518 lines
21 KiB
Python
518 lines
21 KiB
Python
"""Browser control via CDP.
|
||||
|
|
|
|||
|
|
Core helpers live here. Agent-editable helpers live in
|
|||
|
|
BH_AGENT_WORKSPACE/agent_helpers.py.
|
|||
|
|
"""
|
|||
|
|
import base64, importlib.util, json, math, os, sys, time, urllib.request
|
|||
|
|
from pathlib import Path
|
|||
|
|
from urllib.parse import urlparse
|
|||
|
|
|
|||
|
|
from . import _ipc as ipc
|
|||
|
|
from . import paths
|
|||
|
|
|
|||
|
|
|
|||
|
|
CORE_DIR = Path(__file__).resolve().parent
|
|||
|
|
REPO_ROOT = CORE_DIR.parent.parent
|
|||
|
|
AGENT_WORKSPACE = paths.workspace_dir()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _load_env():
|
|||
|
|
paths = [REPO_ROOT / ".env", AGENT_WORKSPACE / ".env"]
|
|||
|
|
for p in paths:
|
|||
|
|
if not p.exists():
|
|||
|
|
continue
|
|||
|
|
_load_env_file(p)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _load_env_file(p):
|
|||
|
|
for line in p.read_text(encoding="utf-8-sig", errors="replace").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 = ipc.sock_addr(NAME)
|
|||
|
|
INTERNAL = ("chrome://", "chrome-untrusted://", "devtools://", "chrome-extension://", "about:")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _send(req):
|
|||
|
|
c, token = ipc.connect(NAME, timeout=5.0)
|
|||
|
|
try:
|
|||
|
|
r = ipc.request(c, token, req)
|
|||
|
|
finally:
|
|||
|
|
c.close()
|
|||
|
|
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"]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _js_snippet(expression, limit=160):
|
|||
|
|
snippet = expression.strip().replace("\n", "\\n")
|
|||
|
|
return snippet[:limit - 3] + "..." if len(snippet) > limit else snippet
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _js_exception_description(result, details):
|
|||
|
|
desc = result.get("description")
|
|||
|
|
exc = details.get("exception") if details else None
|
|||
|
|
if not desc and isinstance(exc, dict):
|
|||
|
|
desc = exc.get("description")
|
|||
|
|
if desc is None and "value" in exc:
|
|||
|
|
desc = str(exc["value"])
|
|||
|
|
if desc is None:
|
|||
|
|
desc = exc.get("className")
|
|||
|
|
if not desc and details:
|
|||
|
|
desc = details.get("text")
|
|||
|
|
return desc or "JavaScript evaluation failed"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _decode_unserializable_js_value(value):
|
|||
|
|
if value == "NaN":
|
|||
|
|
return math.nan
|
|||
|
|
if value == "Infinity":
|
|||
|
|
return math.inf
|
|||
|
|
if value == "-Infinity":
|
|||
|
|
return -math.inf
|
|||
|
|
if value == "-0":
|
|||
|
|
return -0.0
|
|||
|
|
if value.endswith("n"):
|
|||
|
|
return int(value[:-1])
|
|||
|
|
return value
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _runtime_value(response, expression):
|
|||
|
|
result = response.get("result", {})
|
|||
|
|
details = response.get("exceptionDetails")
|
|||
|
|
if details or result.get("subtype") == "error":
|
|||
|
|
desc = _js_exception_description(result, details)
|
|||
|
|
if details:
|
|||
|
|
line = details.get("lineNumber")
|
|||
|
|
col = details.get("columnNumber")
|
|||
|
|
loc = f" at line {line}, column {col}" if line is not None and col is not None else ""
|
|||
|
|
else:
|
|||
|
|
loc = ""
|
|||
|
|
raise RuntimeError(f"JavaScript evaluation failed{loc}: {desc}; expression: {_js_snippet(expression)}")
|
|||
|
|
if "value" in result:
|
|||
|
|
return result["value"]
|
|||
|
|
if "unserializableValue" in result:
|
|||
|
|
return _decode_unserializable_js_value(result["unserializableValue"])
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _runtime_evaluate(expression, session_id=None, await_promise=False):
|
|||
|
|
try:
|
|||
|
|
r = cdp("Runtime.evaluate", session_id=session_id, expression=expression, returnByValue=True, awaitPromise=await_promise)
|
|||
|
|
except TimeoutError as e:
|
|||
|
|
raise RuntimeError(f"Runtime.evaluate timed out; expression: {_js_snippet(expression)}") from e
|
|||
|
|
return _runtime_value(r, expression)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _wrap_js_function(expression):
|
|||
|
|
return f"(function(){{{expression}}})()"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _is_illegal_return_error(exc):
|
|||
|
|
return "Illegal return statement" in str(exc)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --- navigation / page ---
|
|||
|
|
def goto_url(url):
|
|||
|
|
r = cdp("Page.navigate", url=url)
|
|||
|
|
if os.environ.get("BH_DOMAIN_SKILLS") != "1":
|
|||
|
|
return r
|
|||
|
|
d = (AGENT_WORKSPACE / "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.
|
|||
|
|
|
|||
|
|
If a native dialog (alert/confirm/prompt/beforeunload) is open, returns
|
|||
|
|
{dialog: {type, message, ...}} instead — the page's JS thread is frozen
|
|||
|
|
until the dialog is handled (see interaction-skills/dialogs.md)."""
|
|||
|
|
dialog = _send({"meta": "pending_dialog"}).get("dialog")
|
|||
|
|
if dialog:
|
|||
|
|
return {"dialog": dialog}
|
|||
|
|
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})"
|
|||
|
|
return json.loads(_runtime_evaluate(expression))
|
|||
|
|
|
|||
|
|
# --- input ---
|
|||
|
|
_debug_click_counter = 0
|
|||
|
|
|
|||
|
|
def click_at_xy(x, y, button="left", clicks=1):
|
|||
|
|
if os.environ.get("BH_DEBUG_CLICKS"):
|
|||
|
|
global _debug_click_counter
|
|||
|
|
try:
|
|||
|
|
from PIL import Image, ImageDraw
|
|||
|
|
dpr = js("window.devicePixelRatio") or 1
|
|||
|
|
path = capture_screenshot(str(ipc._TMP / f"debug_click_{_debug_click_counter}.png"))
|
|||
|
|
img = Image.open(path)
|
|||
|
|
draw = ImageDraw.Draw(img)
|
|||
|
|
px, py = int(x * dpr), int(y * dpr)
|
|||
|
|
r = int(15 * dpr)
|
|||
|
|
draw.ellipse([px - r, py - r, px + r, py + r], outline="red", width=int(3 * dpr))
|
|||
|
|
draw.line([px - r - int(5 * dpr), py, px + r + int(5 * dpr), py], fill="red", width=int(2 * dpr))
|
|||
|
|
draw.line([px, py - r - int(5 * dpr), px, py + r + int(5 * dpr)], fill="red", width=int(2 * dpr))
|
|||
|
|
img.save(path)
|
|||
|
|
print(f"[debug_click] saved {path} (x={x}, y={y}, dpr={dpr})")
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"[debug_click] overlay failed: {e}")
|
|||
|
|
_debug_click_counter += 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)
|
|||
|
|
|
|||
|
|
def fill_input(selector, text, clear_first=True, timeout=0.0):
|
|||
|
|
"""Fill a framework-managed input (React controlled, Vue v-model, Ember tracked).
|
|||
|
|
|
|||
|
|
type_text() uses Input.insertText which bypasses framework event listeners and leaves
|
|||
|
|
submit buttons disabled. This helper focuses the element, clears it, types via real
|
|||
|
|
key events, then fires synthetic input+change events so the framework sees the update.
|
|||
|
|
|
|||
|
|
Raises RuntimeError if the element is not found. Pass timeout>0 to wait for
|
|||
|
|
late-rendered elements (e.g. after a route change) before typing.
|
|||
|
|
"""
|
|||
|
|
if timeout > 0:
|
|||
|
|
if not wait_for_element(selector, timeout=timeout):
|
|||
|
|
raise RuntimeError(f"fill_input: element not found: {selector!r}")
|
|||
|
|
focused = js(
|
|||
|
|
f"(()=>{{const e=document.querySelector({json.dumps(selector)});"
|
|||
|
|
f"if(!e)return false;e.focus();return true;}})()"
|
|||
|
|
)
|
|||
|
|
if not focused:
|
|||
|
|
raise RuntimeError(f"fill_input: element not found: {selector!r}")
|
|||
|
|
if clear_first:
|
|||
|
|
# Dispatch select-all directly — NOT via press_key, which always emits a
|
|||
|
|
# `char` event for single-char keys. With Ctrl/Cmd held, that `char`
|
|||
|
|
# makes Chrome treat the input as a printable "a" instead of firing the
|
|||
|
|
# select-all shortcut, leaving the field uncleared.
|
|||
|
|
mods = 4 if sys.platform == "darwin" else 2 # Cmd on macOS, Ctrl elsewhere
|
|||
|
|
select_all = {"key": "a", "code": "KeyA", "modifiers": mods,
|
|||
|
|
"windowsVirtualKeyCode": 65, "nativeVirtualKeyCode": 65}
|
|||
|
|
cdp("Input.dispatchKeyEvent", type="rawKeyDown", **select_all)
|
|||
|
|
cdp("Input.dispatchKeyEvent", type="keyUp", **select_all)
|
|||
|
|
press_key("Backspace")
|
|||
|
|
for ch in text:
|
|||
|
|
press_key(ch)
|
|||
|
|
js(
|
|||
|
|
f"(()=>{{const e=document.querySelector({json.dumps(selector)});"
|
|||
|
|
f"if(!e)return;"
|
|||
|
|
f"e.dispatchEvent(new Event('input',{{bubbles:true}}));"
|
|||
|
|
f"e.dispatchEvent(new Event('change',{{bubbles:true}}));}})();"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
_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}
|
|||
|
|
shortcut_modifiers = modifiers & (1 | 2 | 4) # Alt/Ctrl/Meta turn single keys into shortcuts.
|
|||
|
|
printable_char = len(key) == 1 and bool(text) and not shortcut_modifiers
|
|||
|
|
cdp("Input.dispatchKeyEvent", type="keyDown", **base, **({} if printable_char or not text else {"text": text}))
|
|||
|
|
if printable_char:
|
|||
|
|
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 capture_screenshot(path=None, full=False, max_dim=None):
|
|||
|
|
"""Save a PNG of the current viewport. Set max_dim=1800 on a 2× display to
|
|||
|
|
keep the file under the 2000px-per-side limit some image-aware LLMs enforce."""
|
|||
|
|
path = path or str(ipc._TMP / "shot.png")
|
|||
|
|
r = cdp("Page.captureScreenshot", format="png", captureBeyondViewport=full)
|
|||
|
|
open(path, "wb").write(base64.b64decode(r["data"]))
|
|||
|
|
if max_dim:
|
|||
|
|
from PIL import Image
|
|||
|
|
img = Image.open(path)
|
|||
|
|
if max(img.size) > max_dim:
|
|||
|
|
img.thumbnail((max_dim, max_dim))
|
|||
|
|
img.save(path)
|
|||
|
|
return path
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --- tabs ---
|
|||
|
|
def _is_agent_startup_placeholder(title, url):
|
|||
|
|
url = str(url or "")
|
|||
|
|
return str(title or "").startswith("Starting agent ") and (
|
|||
|
|
url in ("", "about:blank") or url.startswith("about:blank#")
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def list_tabs(include_chrome=True):
|
|||
|
|
out = []
|
|||
|
|
for t in cdp("Target.getTargets")["targetInfos"]:
|
|||
|
|
if t["type"] != "page": continue
|
|||
|
|
url = t.get("url", "")
|
|||
|
|
if _is_agent_startup_placeholder(t.get("title", ""), url): continue
|
|||
|
|
if not include_chrome and url.startswith(INTERNAL): continue
|
|||
|
|
out.append({
|
|||
|
|
"targetId": t["targetId"],
|
|||
|
|
"target_id": t["targetId"],
|
|||
|
|
"title": t.get("title", ""),
|
|||
|
|
"url": url,
|
|||
|
|
})
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
def current_tab():
|
|||
|
|
r = _send({"meta": "current_tab"})
|
|||
|
|
return {
|
|||
|
|
"targetId": r["targetId"],
|
|||
|
|
"target_id": r["targetId"],
|
|||
|
|
"url": r["url"],
|
|||
|
|
"title": r["title"],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def _mark_tab():
|
|||
|
|
"""Prepend horse emoji to tab title so the user can see which tab the agent controls."""
|
|||
|
|
try: cdp("Runtime.evaluate", expression="if(!document.title.startsWith('\U0001F434'))document.title='\U0001F434 '+document.title")
|
|||
|
|
except Exception: pass
|
|||
|
|
|
|||
|
|
def switch_tab(target):
|
|||
|
|
# Accept either a raw targetId string or the dict returned by current_tab() / list_tabs(),
|
|||
|
|
# so `switch_tab(current_tab())` works without a manual ["targetId"] dance.
|
|||
|
|
target_id = (target.get("targetId") or target.get("target_id")) if isinstance(target, dict) else target
|
|||
|
|
# Unmark old tab. Horse emoji is a surrogate pair in JS UTF-16 strings (2 code units),
|
|||
|
|
# plus the trailing space = 3 code units, so slice(3) cleanly removes the prefix.
|
|||
|
|
try: cdp("Runtime.evaluate", expression="if(document.title.startsWith('\U0001F434 '))document.title=document.title.slice(3)")
|
|||
|
|
except Exception: pass
|
|||
|
|
cdp("Target.activateTarget", targetId=target_id)
|
|||
|
|
sid = cdp("Target.attachToTarget", targetId=target_id, flatten=True)["sessionId"]
|
|||
|
|
_send({"meta": "set_session", "session_id": sid, "target_id": target_id})
|
|||
|
|
_mark_tab()
|
|||
|
|
return sid
|
|||
|
|
|
|||
|
|
def new_tab(url="about:blank"):
|
|||
|
|
# Always create blank, then goto: passing url to createTarget races with
|
|||
|
|
# attach, so the brief about:blank is "complete" by the time the caller
|
|||
|
|
# polls and wait_for_load() returns before navigation actually starts.
|
|||
|
|
if url != "about:blank":
|
|||
|
|
try:
|
|||
|
|
cur = current_tab()
|
|||
|
|
cur_url = cur.get("url") or ""
|
|||
|
|
# Reuse attached tab when it's blank
|
|||
|
|
if (
|
|||
|
|
cur_url in ("", "about:blank")
|
|||
|
|
or cur_url.startswith("about:blank#")
|
|||
|
|
or cur_url.startswith(("chrome://newtab", "chrome://new-tab-page", "edge://newtab", "about:newtab"))
|
|||
|
|
):
|
|||
|
|
goto_url(url)
|
|||
|
|
return cur.get("targetId") or cur.get("target_id")
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
tid = cdp("Target.createTarget", url="about:blank")["targetId"]
|
|||
|
|
switch_tab(tid)
|
|||
|
|
if url != "about:blank":
|
|||
|
|
goto_url(url)
|
|||
|
|
return tid
|
|||
|
|
|
|||
|
|
def close_tab(target=None):
|
|||
|
|
"""Close a tab. If `target` is omitted, closes the currently attached tab.
|
|||
|
|
Accepts a raw targetId string or a dict from list_tabs()/current_tab()."""
|
|||
|
|
target_id = (target.get("targetId") or target.get("target_id")) if isinstance(target, dict) else target
|
|||
|
|
if target_id is None:
|
|||
|
|
target_id = current_tab()["targetId"]
|
|||
|
|
cdp("Target.closeTarget", targetId=target_id)
|
|||
|
|
|
|||
|
|
|
|||
|
|
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 wait_for_element(selector, timeout=10.0, visible=False):
|
|||
|
|
"""Poll until querySelector(selector) exists in the DOM, or timeout.
|
|||
|
|
|
|||
|
|
wait_for_load() misses SPAs — the document is 'complete' before the framework renders.
|
|||
|
|
Use this after actions that trigger async rendering (route changes, data fetches).
|
|||
|
|
Set visible=True to also require the element to be non-hidden and in-layout.
|
|||
|
|
Returns True if found, False on timeout.
|
|||
|
|
"""
|
|||
|
|
if visible:
|
|||
|
|
# checkVisibility walks the ancestor chain and respects display:none /
|
|||
|
|
# visibility:hidden / opacity:0 on parents, which a getComputedStyle
|
|||
|
|
# check on the element alone misses (it returns the descendant's own
|
|||
|
|
# style, not the inherited "is this rendered" state). Falls back to
|
|||
|
|
# the per-element CSS check on older Chrome that lacks checkVisibility.
|
|||
|
|
check = (
|
|||
|
|
f"(()=>{{const e=document.querySelector({json.dumps(selector)});"
|
|||
|
|
f"if(!e)return false;"
|
|||
|
|
f"if(typeof e.checkVisibility==='function')"
|
|||
|
|
f"return e.checkVisibility({{checkOpacity:true,checkVisibilityCSS:true}});"
|
|||
|
|
f"const s=getComputedStyle(e);"
|
|||
|
|
f"return s.display!=='none'&&s.visibility!=='hidden'&&s.opacity!=='0'}})()"
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
check = f"!!document.querySelector({json.dumps(selector)})"
|
|||
|
|
deadline = time.time() + timeout
|
|||
|
|
while time.time() < deadline:
|
|||
|
|
if js(check): return True
|
|||
|
|
time.sleep(0.3)
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def wait_for_network_idle(timeout=10.0, idle_ms=500):
|
|||
|
|
"""Wait until all in-flight requests finish and no Network.* events arrive for idle_ms ms.
|
|||
|
|
|
|||
|
|
Useful after form submits, SPA route transitions, and any action that triggers
|
|||
|
|
XHR/fetch without a visible DOM change. Builds on drain_events() — no daemon changes.
|
|||
|
|
Returns True if idle window reached, False on timeout.
|
|||
|
|
|
|||
|
|
Events are filtered to the active session — a previously-attached background
|
|||
|
|
tab (e.g. a polling/SSE page the agent switched away from) keeps emitting
|
|||
|
|
Network events into the daemon's global event buffer; without this filter
|
|||
|
|
they would poison the idle check on the current tab.
|
|||
|
|
"""
|
|||
|
|
deadline = time.time() + timeout
|
|||
|
|
last_activity = time.time()
|
|||
|
|
inflight = set()
|
|||
|
|
active_session = _send({"meta": "session"}).get("session_id")
|
|||
|
|
while time.time() < deadline:
|
|||
|
|
for e in drain_events():
|
|||
|
|
if e.get("session_id") != active_session:
|
|||
|
|
continue
|
|||
|
|
method = e.get("method", "")
|
|||
|
|
params = e.get("params", {})
|
|||
|
|
if method == "Network.requestWillBeSent":
|
|||
|
|
inflight.add(params.get("requestId"))
|
|||
|
|
last_activity = time.time()
|
|||
|
|
elif method in ("Network.loadingFinished", "Network.loadingFailed"):
|
|||
|
|
inflight.discard(params.get("requestId"))
|
|||
|
|
last_activity = time.time()
|
|||
|
|
elif method.startswith("Network."):
|
|||
|
|
last_activity = time.time()
|
|||
|
|
if not inflight and (time.time() - last_activity) * 1000 >= idle_ms:
|
|||
|
|
return True
|
|||
|
|
time.sleep(0.1)
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def js(expression, target_id=None):
|
|||
|
|
"""Run JS in the attached tab (default) or inside an iframe target (via iframe_target()).
|
|||
|
|
|
|||
|
|
Expressions are evaluated as-is first. If Chrome reports an illegal top-level
|
|||
|
|
`return`, the snippet is retried inside a function wrapper, so both
|
|||
|
|
`document.title` and `const x = 1; return x` work without mis-wrapping nested
|
|||
|
|
functions that contain their own returns.
|
|||
|
|
"""
|
|||
|
|
sid = cdp("Target.attachToTarget", targetId=target_id, flatten=True)["sessionId"] if target_id else None
|
|||
|
|
try:
|
|||
|
|
return _runtime_evaluate(expression, session_id=sid, await_promise=True)
|
|||
|
|
except RuntimeError as e:
|
|||
|
|
if _is_illegal_return_error(e):
|
|||
|
|
return _runtime_evaluate(_wrap_js_function(expression), session_id=sid, await_promise=True)
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
|
|||
|
|
_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.
|
|||
|
|
|
|||
|
|
When BROWSER_USE_API_KEY is set, routes through the fetch-use proxy (handles bot
|
|||
|
|
detection, residential proxies, retries). Falls back to local urllib otherwise."""
|
|||
|
|
if os.environ.get("BROWSER_USE_API_KEY"):
|
|||
|
|
try:
|
|||
|
|
from fetch_use import fetch_sync
|
|||
|
|
return fetch_sync(url, headers=headers, timeout_ms=int(timeout * 1000)).text
|
|||
|
|
except ImportError:
|
|||
|
|
pass
|
|||
|
|
import gzip
|
|||
|
|
h = {"User-Agent": "Mozilla/5.0", "Accept-Encoding": "gzip"}
|
|||
|
|
if headers: h.update(headers)
|
|||
|
|
with urllib.request.urlopen(urllib.request.Request(url, headers=h), timeout=timeout) as r:
|
|||
|
|
data = r.read()
|
|||
|
|
if r.headers.get("Content-Encoding") == "gzip": data = gzip.decompress(data)
|
|||
|
|
return data.decode()
|
|||
|
|
|
|||
|
|
|
|||
|
|
# Imported at the bottom so recorder's own `from . import helpers` sees a
|
|||
|
|
# fully-defined module. Exposes the recording helpers via `from .helpers import *`.
|
|||
|
|
from .recorder import start_recording, stop_recording, recording_dir
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _load_agent_helpers():
|
|||
|
|
p = AGENT_WORKSPACE / "agent_helpers.py"
|
|||
|
|
if not p.exists():
|
|||
|
|
return
|
|||
|
|
spec = importlib.util.spec_from_file_location("browser_harness_agent_helpers", p)
|
|||
|
|
if not spec or not spec.loader:
|
|||
|
|
return
|
|||
|
|
module = importlib.util.module_from_spec(spec)
|
|||
|
|
spec.loader.exec_module(module)
|
|||
|
|
for name, value in vars(module).items():
|
|||
|
|
if name.startswith("_"):
|
|||
|
|
continue
|
|||
|
|
globals()[name] = value
|
|||
|
|
|
|||
|
|
|
|||
|
|
_load_agent_helpers()
|