browser-use--browser-harness
2ce5b870c1
http_get called urllib.request.urlopen with no explicit SSL context, so it fell back to whatever trust store the interpreter shipped with. On python.org's Python 3.11 for macOS the CA keychain is empty until the user runs Install Certificates.command, which nobody does — every HTTPS call raised CERTIFICATE_VERIFY_FAILED. Agents were then writing domain-skills that suggested subprocess curl or, worse, disabling TLS verification entirely. Load certifi's bundle explicitly via ssl.create_default_context. Same approach requests and httpx use, works across macOS/Linux/Windows and across Python installers.
197 行
8.0 KiB
Python
197 行
8.0 KiB
Python
"""Browser control via CDP. Read, edit, extend -- this file is yours."""
|
|
import base64, json, os, socket, time, urllib.request
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
def _load_env():
|
|
p = Path(__file__).parent / ".env"
|
|
if not p.exists():
|
|
return
|
|
for line in p.read_text().splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
k, v = line.split("=", 1)
|
|
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
|
|
|
|
|
_load_env()
|
|
|
|
NAME = os.environ.get("BU_NAME", "default")
|
|
SOCK = f"/tmp/bu-{NAME}.sock"
|
|
INTERNAL = ("chrome://", "chrome-untrusted://", "devtools://", "chrome-extension://", "about:")
|
|
|
|
|
|
def _send(req):
|
|
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
s.connect(SOCK)
|
|
s.sendall((json.dumps(req) + "\n").encode())
|
|
data = b""
|
|
while not data.endswith(b"\n"):
|
|
chunk = s.recv(1 << 20)
|
|
if not chunk: break
|
|
data += chunk
|
|
s.close()
|
|
r = json.loads(data)
|
|
if "error" in r: raise RuntimeError(r["error"])
|
|
return r
|
|
|
|
|
|
def cdp(method, session_id=None, **params):
|
|
"""Raw CDP. cdp('Page.navigate', url='...'), cdp('DOM.getDocument', depth=-1)."""
|
|
return _send({"method": method, "params": params, "session_id": session_id}).get("result", {})
|
|
|
|
|
|
def drain_events(): return _send({"meta": "drain_events"})["events"]
|
|
|
|
|
|
# --- navigation / page ---
|
|
def goto(url):
|
|
r = cdp("Page.navigate", url=url)
|
|
d = (Path(__file__).parent / "domain-skills" / (urlparse(url).hostname or "").removeprefix("www.").split(".")[0])
|
|
return {**r, "domain_skills": sorted(p.name for p in d.rglob("*.md"))[:10]} if d.is_dir() else r
|
|
|
|
def page_info():
|
|
"""{url, title, w, h, sx, sy, pw, ph} — viewport + scroll + page size."""
|
|
r = cdp("Runtime.evaluate",
|
|
expression="JSON.stringify({url:location.href,title:document.title,w:innerWidth,h:innerHeight,sx:scrollX,sy:scrollY,pw:document.documentElement.scrollWidth,ph:document.documentElement.scrollHeight})",
|
|
returnByValue=True)
|
|
return json.loads(r["result"]["value"])
|
|
|
|
# --- input ---
|
|
def click(x, y, button="left", clicks=1):
|
|
cdp("Input.dispatchMouseEvent", type="mousePressed", x=x, y=y, button=button, clickCount=clicks)
|
|
cdp("Input.dispatchMouseEvent", type="mouseReleased", x=x, y=y, button=button, clickCount=clicks)
|
|
|
|
def type_text(text):
|
|
cdp("Input.insertText", text=text)
|
|
|
|
_KEYS = { # key → (windowsVirtualKeyCode, code, text)
|
|
"Enter": (13, "Enter", "\r"), "Tab": (9, "Tab", "\t"), "Backspace": (8, "Backspace", ""),
|
|
"Escape": (27, "Escape", ""), "Delete": (46, "Delete", ""), " ": (32, "Space", " "),
|
|
"ArrowLeft": (37, "ArrowLeft", ""), "ArrowUp": (38, "ArrowUp", ""),
|
|
"ArrowRight": (39, "ArrowRight", ""), "ArrowDown": (40, "ArrowDown", ""),
|
|
"Home": (36, "Home", ""), "End": (35, "End", ""),
|
|
"PageUp": (33, "PageUp", ""), "PageDown": (34, "PageDown", ""),
|
|
}
|
|
def press_key(key, modifiers=0):
|
|
"""Modifiers bitfield: 1=Alt, 2=Ctrl, 4=Meta(Cmd), 8=Shift.
|
|
Special keys (Enter, Tab, Arrow*, Backspace, etc.) carry their virtual key codes
|
|
so listeners checking e.keyCode / e.key all fire."""
|
|
vk, code, text = _KEYS.get(key, (ord(key[0]) if len(key) == 1 else 0, key, key if len(key) == 1 else ""))
|
|
base = {"key": key, "code": code, "modifiers": modifiers, "windowsVirtualKeyCode": vk, "nativeVirtualKeyCode": vk}
|
|
cdp("Input.dispatchKeyEvent", type="keyDown", **base, **({"text": text} if text else {}))
|
|
if text and len(text) == 1:
|
|
cdp("Input.dispatchKeyEvent", type="char", text=text, **{k: v for k, v in base.items() if k != "text"})
|
|
cdp("Input.dispatchKeyEvent", type="keyUp", **base)
|
|
|
|
def scroll(x, y, dy=-300, dx=0):
|
|
cdp("Input.dispatchMouseEvent", type="mouseWheel", x=x, y=y, deltaX=dx, deltaY=dy)
|
|
|
|
|
|
# --- visual ---
|
|
def screenshot(path="/tmp/shot.png", full=False):
|
|
r = cdp("Page.captureScreenshot", format="png", captureBeyondViewport=full)
|
|
open(path, "wb").write(base64.b64decode(r["data"]))
|
|
return path
|
|
|
|
|
|
# --- tabs ---
|
|
def list_tabs(include_chrome=True):
|
|
out = []
|
|
for t in cdp("Target.getTargets")["targetInfos"]:
|
|
if t["type"] != "page": continue
|
|
url = t.get("url", "")
|
|
if not include_chrome and url.startswith(INTERNAL): continue
|
|
out.append({"targetId": t["targetId"], "title": t.get("title", ""), "url": url})
|
|
return out
|
|
|
|
def current_tab():
|
|
t = cdp("Target.getTargetInfo").get("targetInfo", {})
|
|
return {"targetId": t.get("targetId"), "url": t.get("url", ""), "title": t.get("title", "")}
|
|
|
|
def switch_tab(target_id):
|
|
cdp("Target.activateTarget", targetId=target_id)
|
|
sid = cdp("Target.attachToTarget", targetId=target_id, flatten=True)["sessionId"]
|
|
_send({"meta": "set_session", "session_id": sid})
|
|
return sid
|
|
|
|
def new_tab(url="about:blank"):
|
|
tid = cdp("Target.createTarget", url=url)["targetId"]
|
|
switch_tab(tid)
|
|
return tid
|
|
|
|
def ensure_real_tab():
|
|
"""Switch to a real user tab if current is chrome:// / internal / stale."""
|
|
tabs = list_tabs(include_chrome=False)
|
|
if not tabs:
|
|
return None
|
|
try:
|
|
cur = current_tab()
|
|
if cur["url"] and not cur["url"].startswith(INTERNAL):
|
|
return cur
|
|
except Exception:
|
|
pass
|
|
switch_tab(tabs[0]["targetId"])
|
|
return tabs[0]
|
|
|
|
def iframe_target(url_substr):
|
|
"""First iframe target whose URL contains `url_substr`. Use with js(..., target_id=...)."""
|
|
for t in cdp("Target.getTargets")["targetInfos"]:
|
|
if t["type"] == "iframe" and url_substr in t.get("url", ""):
|
|
return t["targetId"]
|
|
return None
|
|
|
|
|
|
# --- utility ---
|
|
def wait(seconds=1.0):
|
|
time.sleep(seconds)
|
|
|
|
def wait_for_load(timeout=15.0):
|
|
"""Poll document.readyState == 'complete' or timeout."""
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
if js("document.readyState") == "complete": return True
|
|
time.sleep(0.3)
|
|
return False
|
|
|
|
def js(expression, target_id=None):
|
|
"""Run JS in the attached tab (default) or inside an iframe target (via iframe_target())."""
|
|
sid = cdp("Target.attachToTarget", targetId=target_id, flatten=True)["sessionId"] if target_id else None
|
|
r = cdp("Runtime.evaluate", session_id=sid, expression=expression, returnByValue=True, awaitPromise=True)
|
|
return r.get("result", {}).get("value")
|
|
|
|
|
|
_KC = {"Enter": 13, "Tab": 9, "Escape": 27, "Backspace": 8, " ": 32, "ArrowLeft": 37, "ArrowUp": 38, "ArrowRight": 39, "ArrowDown": 40}
|
|
|
|
|
|
def dispatch_key(selector, key="Enter", event="keypress"):
|
|
"""Dispatch a DOM KeyboardEvent on the matched element.
|
|
|
|
Use this when a site reacts to synthetic DOM key events on an element more reliably
|
|
than to raw CDP input events.
|
|
"""
|
|
kc = _KC.get(key, ord(key) if len(key) == 1 else 0)
|
|
js(
|
|
f"(()=>{{const e=document.querySelector({json.dumps(selector)});if(e){{e.focus();e.dispatchEvent(new KeyboardEvent({json.dumps(event)},{{key:{json.dumps(key)},code:{json.dumps(key)},keyCode:{kc},which:{kc},bubbles:true}}));}}}})()"
|
|
)
|
|
|
|
def upload_file(selector, path):
|
|
"""Set files on a file input via CDP DOM.setFileInputFiles. `path` is an absolute filepath (use tempfile.mkstemp if needed)."""
|
|
doc = cdp("DOM.getDocument", depth=-1)
|
|
nid = cdp("DOM.querySelector", nodeId=doc["root"]["nodeId"], selector=selector)["nodeId"]
|
|
if not nid: raise RuntimeError(f"no element for {selector}")
|
|
cdp("DOM.setFileInputFiles", files=[path] if isinstance(path, str) else list(path), nodeId=nid)
|
|
|
|
def http_get(url, headers=None, timeout=20.0):
|
|
"""Pure HTTP — no browser. Use for static pages / APIs. Wrap in ThreadPoolExecutor for bulk."""
|
|
import urllib.request, gzip, ssl, certifi
|
|
h = {"User-Agent": "Mozilla/5.0", "Accept-Encoding": "gzip"}
|
|
if headers: h.update(headers)
|
|
ctx = ssl.create_default_context(cafile=certifi.where())
|
|
with urllib.request.urlopen(urllib.request.Request(url, headers=h), timeout=timeout, context=ctx) as r:
|
|
data = r.read()
|
|
if r.headers.get("Content-Encoding") == "gzip": data = gzip.decompress(data)
|
|
return data.decode()
|