browser_use/remote_upload.py
Rama main · réplica de lectura del repositorio original.
"""upload_file and DOM.setFileInputFiles with local paths, also on a remote browser (cloud, a remote cdp_url), where a
local path means nothing on the browser host and the bytes go through the page. Used by the agent's REPL worker and by
the CLI / MCP server."""
import base64
import concurrent.futures
import contextlib
import json
import os
from collections.abc import Callable
from pathlib import Path
from typing import Any
UPLOAD_PART = 24_000 # bytes per js() call: 32,000 base64 characters, well under browser-harness's 64 KiB per request
def local_paths(path) -> list[str]:
"""Absolute paths of existing files (relative ones from the working directory, ~ expanded). Raises otherwise:
DOM.setFileInputFiles takes any string, and a missing, relative or directory path became a 0-byte upload."""
out = []
for p in [path] if isinstance(path, (str, os.PathLike)) else path:
full = Path(p).expanduser().resolve()
if not full.is_file():
raise FileNotFoundError(f'upload_file: {str(p)!r} is not a file ({full})')
out.append(str(full))
return out
def find(selector) -> str:
"""JS for the first match of selector in the page, its open shadow roots and same-origin frames."""
return f"""(() => {{
const q = r => {{ let el = r.querySelector({json.dumps(selector)});
for (const e of r.querySelectorAll('*')) el ||= (e.shadowRoot && q(e.shadowRoot)) || (e.contentDocument && q(e.contentDocument));
return el; }};
return q(document);
}})()"""
SIZES = 'function () { return Array.from(this.files || [], f => f.size) }'
PUT = """function (key, i, n, b64) {
const w = this.ownerDocument.defaultView;
((w[key] ||= [])[i] ||= [])[n] = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
}"""
SET = """function (key, meta) {
const w = this.ownerDocument.defaultView, parts = w[key] || [];
delete w[key];
const dt = new DataTransfer();
meta.forEach(([name, type], i) => dt.items.add(new File(parts[i] || [], name, {type})));
this.files = dt.files;
this.dispatchEvent(new Event('input', {bubbles: true}));
this.dispatchEvent(new Event('change', {bubbles: true}));
return this.files.length;
}"""
def set_files(cdp, object_id: str, path, remote: bool) -> int:
"""Set local file(s) on the file input object_id; returns how many it holds. A remote browser cannot read a local
path: Browser Use cloud rejects it ('Files must exist in the downloads directory'), another host's Chrome sets empty
or missing files. There, and whenever the input does not then hold files of the local sizes (Browser(cdp_url=) to a
remote host), the bytes go through the page, in parts (one browser-harness request takes at most 64 KiB), several
at a time. cdp(method, **params) runs on the element's session."""
import mimetypes
paths = local_paths(path)
def call(fn: str, *args) -> Any:
r = cdp(
'Runtime.callFunctionOn',
objectId=object_id,
functionDeclaration=fn,
arguments=[{'value': a} for a in args],
returnByValue=True,
)
if r.get('exceptionDetails'):
raise RuntimeError(f'upload_file: {r["exceptionDetails"].get("text", "the page refused the files")}')
return r.get('result', {}).get('value')
if not remote:
with contextlib.suppress(RuntimeError): # rejected: a remote browser behind cdp_url
cdp('DOM.setFileInputFiles', objectId=object_id, files=paths)
if call(SIZES) == [os.path.getsize(p) for p in paths]:
return len(paths)
files = [Path(p).read_bytes() for p in paths]
key = f'__buUpload{os.urandom(4).hex()}'
parts = [(i, n, k) for i, data in enumerate(files) for n, k in enumerate(range(0, len(data), UPLOAD_PART))]
def put(part: tuple[int, int, int]) -> None: # part n of file i, decoded in the page
i, n, k = part
call(PUT, key, i, n, base64.b64encode(files[i][k : k + UPLOAD_PART]).decode())
try:
with concurrent.futures.ThreadPoolExecutor(8) as pool: # each part is one round trip to the browser
list(pool.map(put, parts))
return call(SET, key, [[Path(p).name, mimetypes.guess_type(p)[0] or 'application/octet-stream'] for p in paths])
finally: # SET deletes the parts; a failed part must not leave them in the page either
with contextlib.suppress(Exception):
call('function (key) { delete this.ownerDocument.defaultView[key] }', key)
def uploader(cdp, remote: Callable[[], bool]):
"""upload_file(selector, path) on the attached tab; remote(): whether the browser is a cloud browser."""
def upload_file(selector, path):
"""Set local file(s) on a file input. path: a file path or a list of them (relative to the working directory).
On a remote browser the bytes go through the page."""
el = cdp('Runtime.evaluate', expression=find(selector))['result']
if 'objectId' not in el:
raise RuntimeError(f'no element for {selector}')
return set_files(cdp, el['objectId'], path, remote())
return upload_file