browser-workspace 0.4.1 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -2
- package/SKILL.md +60 -37
- package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
- package/bin/browser-workspace +174 -13
- package/package.json +1 -1
- package/platform_runner.py +23 -6
- package/platforms/chatgpt/actions/_temporary_submit.py +22 -2
- package/platforms/chatgpt/tests/test_contract.py +7 -0
- package/platforms/wechat-channels/actions/_common.py +1 -1
- package/platforms/wechat-channels/actions/_post.py +37 -38
- package/platforms/wechat-channels/manifest.yaml +19 -13
- package/platforms/xhs/actions/_post.py +109 -20
- package/platforms/xhs/manifest.yaml +14 -5
- package/platforms/xhs/tests/test_contract.py +17 -0
- package/platforms/youtube/actions/_post.py +7 -4
- package/pyproject.toml +6 -2
- package/session_client.py +21 -11
- package/session_daemon.py +115 -9
- package/src/browser_workspace.egg-info/PKG-INFO +2 -1
- package/src/browser_workspace.egg-info/SOURCES.txt +84 -15
- package/src/browser_workspace.egg-info/entry_points.txt +2 -0
- package/src/browser_workspace.egg-info/requires.txt +1 -0
- package/src/browser_workspace.egg-info/top_level.txt +1 -1
- package/src/browser_workspace_app/__init__.py +1 -0
- package/src/browser_workspace_app/agent-workspace/agent_helpers.py +718 -0
- package/src/browser_workspace_app/cli.py +236 -0
- package/src/browser_workspace_app/extension/manifest.json +26 -0
- package/src/browser_workspace_app/extension/service-worker.mjs +126 -0
- package/src/browser_workspace_app/extension/workspace-manager.mjs +680 -0
- package/src/browser_workspace_app/extension/workspace-page.mjs +7 -0
- package/src/browser_workspace_app/extension/workspace.html +8 -0
- package/src/browser_workspace_app/interaction-skills/connection.md +52 -0
- package/src/browser_workspace_app/interaction-skills/cookies.md +3 -0
- package/src/browser_workspace_app/interaction-skills/cross-origin-iframes.md +3 -0
- package/src/browser_workspace_app/interaction-skills/dialogs.md +64 -0
- package/src/browser_workspace_app/interaction-skills/downloads.md +3 -0
- package/src/browser_workspace_app/interaction-skills/drag-and-drop.md +3 -0
- package/src/browser_workspace_app/interaction-skills/dropdowns.md +3 -0
- package/src/browser_workspace_app/interaction-skills/iframes.md +3 -0
- package/src/browser_workspace_app/interaction-skills/network-requests.md +3 -0
- package/src/browser_workspace_app/interaction-skills/print-as-pdf.md +3 -0
- package/src/browser_workspace_app/interaction-skills/profile-sync.md +90 -0
- package/src/browser_workspace_app/interaction-skills/screenshots.md +17 -0
- package/src/browser_workspace_app/interaction-skills/scrolling.md +22 -0
- package/src/browser_workspace_app/interaction-skills/shadow-dom.md +3 -0
- package/src/browser_workspace_app/interaction-skills/tabs.md +73 -0
- package/src/browser_workspace_app/interaction-skills/uploads.md +1 -0
- package/src/browser_workspace_app/interaction-skills/viewport.md +16 -0
- package/src/browser_workspace_app/licenses/browser-harness-MIT.txt +21 -0
- package/src/browser_workspace_app/managed_chrome.py +244 -0
- package/src/browser_workspace_app/node/admin.mjs +44 -0
- package/src/browser_workspace_app/package.json +36 -0
- package/src/browser_workspace_app/platform_runner.py +204 -0
- package/src/browser_workspace_app/platforms/chatgpt/README.md +60 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_bootstrap_start.py +58 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_lifecycle_common.py +119 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_mcp_app.py +80 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_new_thread.py +85 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_new_turn.py +33 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_project_setup.py +353 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_readiness.py +52 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_submit_thread.py +129 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_temporary_submit.py +591 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_thread_turn.py +362 -0
- package/src/browser_workspace_app/platforms/chatgpt/manifest.yaml +34 -0
- package/src/browser_workspace_app/platforms/chatgpt/tests/test_contract.py +183 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/README.md +49 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/actions/_chrome_web_store.py +425 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/check-status.md +7 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/open-item.md +10 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/submit-review.md +10 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/update-listing.md +18 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/upload-package.md +11 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/verify-published.md +9 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/manifest.yaml +33 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/tests/test_contract.py +83 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/README.md +32 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/actions/_meeting.py +210 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/flows/instant-meeting.md +21 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/manifest.yaml +25 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/tests/test_contract.py +24 -0
- package/src/browser_workspace_app/platforms/tiktok/README.md +10 -0
- package/src/browser_workspace_app/platforms/tiktok/actions/_post.py +180 -0
- package/src/browser_workspace_app/platforms/tiktok/manifest.yaml +99 -0
- package/src/browser_workspace_app/platforms/wechat-channels/README.md +10 -0
- package/src/browser_workspace_app/platforms/wechat-channels/actions/_common.py +163 -0
- package/src/browser_workspace_app/platforms/wechat-channels/actions/_manage.py +130 -0
- package/src/browser_workspace_app/platforms/wechat-channels/actions/_post.py +231 -0
- package/src/browser_workspace_app/platforms/wechat-channels/manifest.yaml +124 -0
- package/src/browser_workspace_app/platforms/wechat-channels/tests/test_contract.py +36 -0
- package/src/browser_workspace_app/platforms/xhs/README.md +10 -0
- package/src/browser_workspace_app/platforms/xhs/actions/_manage.py +355 -0
- package/src/browser_workspace_app/platforms/xhs/actions/_post.py +281 -0
- package/src/browser_workspace_app/platforms/xhs/manifest.yaml +107 -0
- package/src/browser_workspace_app/platforms/xhs/tests/test_contract.py +34 -0
- package/src/browser_workspace_app/platforms/youtube/README.md +10 -0
- package/src/browser_workspace_app/platforms/youtube/actions/_post.py +103 -0
- package/src/browser_workspace_app/platforms/youtube/manifest.yaml +94 -0
- package/src/browser_workspace_app/pyproject.toml +33 -0
- package/src/browser_workspace_app/session_client.py +107 -0
- package/src/browser_workspace_app/session_daemon.py +431 -0
- package/src/browser_harness/__init__.py +0 -2
- package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/browser_harness/_ipc.py +0 -201
- package/src/browser_harness/admin.py +0 -1588
- package/src/browser_harness/auth.py +0 -546
- package/src/browser_harness/daemon.py +0 -894
- package/src/browser_harness/helpers.py +0 -668
- package/src/browser_harness/macos.py +0 -131
- package/src/browser_harness/mcp_cli.py +0 -16
- package/src/browser_harness/paths.py +0 -49
- package/src/browser_harness/recorder.py +0 -343
- package/src/browser_harness/run.py +0 -413
- package/src/browser_harness/telemetry.py +0 -308
- package/src/browser_harness/video-template.html +0 -970
- package/src/browser_harness/video.py +0 -749
- package/src/browser_harness/video_render.py +0 -524
|
@@ -1,668 +0,0 @@
|
|
|
1
|
-
"""Browser control via CDP.
|
|
2
|
-
|
|
3
|
-
Core helpers live here. Agent-editable helpers live in
|
|
4
|
-
BH_AGENT_WORKSPACE/agent_helpers.py.
|
|
5
|
-
"""
|
|
6
|
-
import base64, importlib.util, json, math, os, time, urllib.request
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
from urllib.parse import urlparse
|
|
9
|
-
|
|
10
|
-
from . import _ipc as ipc
|
|
11
|
-
from . import paths
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
CORE_DIR = Path(__file__).resolve().parent
|
|
15
|
-
REPO_ROOT = CORE_DIR.parent.parent
|
|
16
|
-
AGENT_WORKSPACE = paths.workspace_dir()
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def _load_env():
|
|
20
|
-
paths = [REPO_ROOT / ".env", AGENT_WORKSPACE / ".env"]
|
|
21
|
-
for p in paths:
|
|
22
|
-
if not p.exists():
|
|
23
|
-
continue
|
|
24
|
-
_load_env_file(p)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def _load_env_file(p):
|
|
28
|
-
for line in p.read_text(encoding="utf-8-sig", errors="replace").splitlines():
|
|
29
|
-
line = line.strip()
|
|
30
|
-
if not line or line.startswith("#") or "=" not in line:
|
|
31
|
-
continue
|
|
32
|
-
k, v = line.split("=", 1)
|
|
33
|
-
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
_load_env()
|
|
37
|
-
|
|
38
|
-
NAME = os.environ.get("BU_NAME", "default")
|
|
39
|
-
SOCK = ipc.sock_addr(NAME)
|
|
40
|
-
INTERNAL = ("chrome://", "chrome-untrusted://", "devtools://", "chrome-extension://", "about:")
|
|
41
|
-
IPC_CONNECT_TIMEOUT_SECONDS = 5.0
|
|
42
|
-
DEFAULT_IPC_RESPONSE_TIMEOUT_SECONDS = 5.0
|
|
43
|
-
# Cloud screenshots routinely take longer than ordinary CDP round trips. Keep
|
|
44
|
-
# their IPC socket alive within the caller's existing 90-second process budget.
|
|
45
|
-
SCREENSHOT_IPC_RESPONSE_TIMEOUT_SECONDS = 60.0
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
class _IPCResponseTimeout(TimeoutError):
|
|
49
|
-
pass
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def _send(req, response_timeout=DEFAULT_IPC_RESPONSE_TIMEOUT_SECONDS):
|
|
53
|
-
c, token = ipc.connect(NAME, timeout=IPC_CONNECT_TIMEOUT_SECONDS)
|
|
54
|
-
try:
|
|
55
|
-
c.settimeout(response_timeout)
|
|
56
|
-
try:
|
|
57
|
-
r = ipc.request(c, token, req)
|
|
58
|
-
except TimeoutError as e:
|
|
59
|
-
# Carry the detail on the exception itself. Raising the bare class
|
|
60
|
-
# left str(exc) empty, so every caller that reported the error had
|
|
61
|
-
# to rebuild the context by hand or print nothing useful.
|
|
62
|
-
label = req.get("method") or req.get("meta") or "request"
|
|
63
|
-
raise _IPCResponseTimeout(
|
|
64
|
-
f"{label} timed out after {response_timeout:g}s waiting for the daemon"
|
|
65
|
-
) from e
|
|
66
|
-
finally:
|
|
67
|
-
c.close()
|
|
68
|
-
if "error" in r: raise RuntimeError(r["error"])
|
|
69
|
-
return r
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def cdp(method, session_id=None, _response_timeout=DEFAULT_IPC_RESPONSE_TIMEOUT_SECONDS, **params):
|
|
73
|
-
"""Raw CDP. cdp('Page.navigate', url='...'), cdp('DOM.getDocument', depth=-1)."""
|
|
74
|
-
return _send(
|
|
75
|
-
{"method": method, "params": params, "session_id": session_id},
|
|
76
|
-
response_timeout=_response_timeout,
|
|
77
|
-
).get("result", {})
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def drain_events(): return _send({"meta": "drain_events"})["events"]
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def _js_snippet(expression, limit=160):
|
|
84
|
-
snippet = expression.strip().replace("\n", "\\n")
|
|
85
|
-
return snippet[:limit - 3] + "..." if len(snippet) > limit else snippet
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def _js_exception_description(result, details):
|
|
89
|
-
desc = result.get("description")
|
|
90
|
-
exc = details.get("exception") if details else None
|
|
91
|
-
if not desc and isinstance(exc, dict):
|
|
92
|
-
desc = exc.get("description")
|
|
93
|
-
if desc is None and "value" in exc:
|
|
94
|
-
desc = str(exc["value"])
|
|
95
|
-
if desc is None:
|
|
96
|
-
desc = exc.get("className")
|
|
97
|
-
if not desc and details:
|
|
98
|
-
desc = details.get("text")
|
|
99
|
-
return desc or "JavaScript evaluation failed"
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def _decode_unserializable_js_value(value):
|
|
103
|
-
if value == "NaN":
|
|
104
|
-
return math.nan
|
|
105
|
-
if value == "Infinity":
|
|
106
|
-
return math.inf
|
|
107
|
-
if value == "-Infinity":
|
|
108
|
-
return -math.inf
|
|
109
|
-
if value == "-0":
|
|
110
|
-
return -0.0
|
|
111
|
-
if value.endswith("n"):
|
|
112
|
-
return int(value[:-1])
|
|
113
|
-
return value
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def _runtime_value(response, expression):
|
|
117
|
-
result = response.get("result", {})
|
|
118
|
-
details = response.get("exceptionDetails")
|
|
119
|
-
if details or result.get("subtype") == "error":
|
|
120
|
-
desc = _js_exception_description(result, details)
|
|
121
|
-
if details:
|
|
122
|
-
line = details.get("lineNumber")
|
|
123
|
-
col = details.get("columnNumber")
|
|
124
|
-
loc = f" at line {line}, column {col}" if line is not None and col is not None else ""
|
|
125
|
-
else:
|
|
126
|
-
loc = ""
|
|
127
|
-
raise RuntimeError(f"JavaScript evaluation failed{loc}: {desc}; expression: {_js_snippet(expression)}")
|
|
128
|
-
if "value" in result:
|
|
129
|
-
return result["value"]
|
|
130
|
-
if "unserializableValue" in result:
|
|
131
|
-
return _decode_unserializable_js_value(result["unserializableValue"])
|
|
132
|
-
return None
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
def _runtime_evaluate(expression, session_id=None, await_promise=False):
|
|
136
|
-
try:
|
|
137
|
-
r = cdp("Runtime.evaluate", session_id=session_id, expression=expression, returnByValue=True, awaitPromise=await_promise)
|
|
138
|
-
except TimeoutError as e:
|
|
139
|
-
raise RuntimeError(f"Runtime.evaluate timed out; expression: {_js_snippet(expression)}") from e
|
|
140
|
-
return _runtime_value(r, expression)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def _wrap_js_function(expression):
|
|
144
|
-
return f"(function(){{{expression}}})()"
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
def _is_illegal_return_error(exc):
|
|
148
|
-
return "Illegal return statement" in str(exc)
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
# --- navigation / page ---
|
|
152
|
-
def goto_url(url):
|
|
153
|
-
r = cdp("Page.navigate", url=url)
|
|
154
|
-
if os.environ.get("BH_DOMAIN_SKILLS") != "1":
|
|
155
|
-
return r
|
|
156
|
-
d = (AGENT_WORKSPACE / "domain-skills" / (urlparse(url).hostname or "").removeprefix("www.").split(".")[0])
|
|
157
|
-
return {**r, "domain_skills": sorted(p.name for p in d.rglob("*.md"))[:10]} if d.is_dir() else r
|
|
158
|
-
|
|
159
|
-
def page_info():
|
|
160
|
-
"""{url, title, w, h, sx, sy, pw, ph} — viewport + scroll + page size.
|
|
161
|
-
|
|
162
|
-
If a native dialog (alert/confirm/prompt/beforeunload) is open, returns
|
|
163
|
-
{dialog: {type, message, ...}} instead — the page's JS thread is frozen
|
|
164
|
-
until the dialog is handled (see interaction-skills/dialogs.md)."""
|
|
165
|
-
dialog = _send({"meta": "pending_dialog"}).get("dialog")
|
|
166
|
-
if dialog:
|
|
167
|
-
return {"dialog": dialog}
|
|
168
|
-
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})"
|
|
169
|
-
return json.loads(_runtime_evaluate(expression))
|
|
170
|
-
|
|
171
|
-
# --- input ---
|
|
172
|
-
_debug_click_counter = 0
|
|
173
|
-
|
|
174
|
-
def click_at_xy(x, y, button="left", clicks=1):
|
|
175
|
-
if os.environ.get("BH_DEBUG_CLICKS"):
|
|
176
|
-
global _debug_click_counter
|
|
177
|
-
try:
|
|
178
|
-
from PIL import Image, ImageDraw
|
|
179
|
-
dpr = js("window.devicePixelRatio") or 1
|
|
180
|
-
path = capture_screenshot(str(ipc._TMP / f"debug_click_{_debug_click_counter}.png"))
|
|
181
|
-
img = Image.open(path)
|
|
182
|
-
draw = ImageDraw.Draw(img)
|
|
183
|
-
px, py = int(x * dpr), int(y * dpr)
|
|
184
|
-
r = int(15 * dpr)
|
|
185
|
-
draw.ellipse([px - r, py - r, px + r, py + r], outline="red", width=int(3 * dpr))
|
|
186
|
-
draw.line([px - r - int(5 * dpr), py, px + r + int(5 * dpr), py], fill="red", width=int(2 * dpr))
|
|
187
|
-
draw.line([px, py - r - int(5 * dpr), px, py + r + int(5 * dpr)], fill="red", width=int(2 * dpr))
|
|
188
|
-
img.save(path)
|
|
189
|
-
print(f"[debug_click] saved {path} (x={x}, y={y}, dpr={dpr})")
|
|
190
|
-
except Exception as e:
|
|
191
|
-
print(f"[debug_click] overlay failed: {e}")
|
|
192
|
-
_debug_click_counter += 1
|
|
193
|
-
cdp("Input.dispatchMouseEvent", type="mousePressed", x=x, y=y, button=button, clickCount=clicks)
|
|
194
|
-
cdp("Input.dispatchMouseEvent", type="mouseReleased", x=x, y=y, button=button, clickCount=clicks)
|
|
195
|
-
|
|
196
|
-
def type_text(text):
|
|
197
|
-
cdp("Input.insertText", text=text)
|
|
198
|
-
|
|
199
|
-
_SELECT_ALL_MODIFIER = None
|
|
200
|
-
def _select_all_modifier():
|
|
201
|
-
"""Select-all modifier by the browser's OS (not this process's): 4=Meta on macOS, else 2=Ctrl."""
|
|
202
|
-
global _SELECT_ALL_MODIFIER
|
|
203
|
-
if _SELECT_ALL_MODIFIER is None:
|
|
204
|
-
ua = cdp("Browser.getVersion").get("userAgent", "")
|
|
205
|
-
_SELECT_ALL_MODIFIER = 4 if "Mac OS X" in ua or "Macintosh" in ua else 2
|
|
206
|
-
return _SELECT_ALL_MODIFIER
|
|
207
|
-
|
|
208
|
-
def fill_input(selector, text, clear_first=True, timeout=0.0):
|
|
209
|
-
"""Fill a framework-managed input (React controlled, Vue v-model, Ember tracked).
|
|
210
|
-
|
|
211
|
-
type_text() uses Input.insertText which bypasses framework event listeners and leaves
|
|
212
|
-
submit buttons disabled. This helper focuses the element, clears it, types via real
|
|
213
|
-
key events, then fires synthetic input+change events so the framework sees the update.
|
|
214
|
-
|
|
215
|
-
Raises RuntimeError if the element is not found. Pass timeout>0 to wait for
|
|
216
|
-
late-rendered elements (e.g. after a route change) before typing.
|
|
217
|
-
"""
|
|
218
|
-
if timeout > 0:
|
|
219
|
-
if not wait_for_element(selector, timeout=timeout):
|
|
220
|
-
raise RuntimeError(f"fill_input: element not found: {selector!r}")
|
|
221
|
-
focused = js(
|
|
222
|
-
f"(()=>{{const e=document.querySelector({json.dumps(selector)});"
|
|
223
|
-
f"if(!e)return false;e.focus();return true;}})()"
|
|
224
|
-
)
|
|
225
|
-
if not focused:
|
|
226
|
-
raise RuntimeError(f"fill_input: element not found: {selector!r}")
|
|
227
|
-
if clear_first:
|
|
228
|
-
# Dispatch select-all directly — NOT via press_key, which always emits a
|
|
229
|
-
# `char` event for single-char keys. With Ctrl/Cmd held, that `char`
|
|
230
|
-
# makes Chrome treat the input as a printable "a" instead of firing the
|
|
231
|
-
# select-all shortcut, leaving the field uncleared.
|
|
232
|
-
mods = _select_all_modifier()
|
|
233
|
-
select_all = {"key": "a", "code": "KeyA", "modifiers": mods,
|
|
234
|
-
"windowsVirtualKeyCode": 65, "nativeVirtualKeyCode": 65,
|
|
235
|
-
"commands": ["SelectAll"]}
|
|
236
|
-
cdp("Input.dispatchKeyEvent", type="rawKeyDown", **select_all)
|
|
237
|
-
cdp("Input.dispatchKeyEvent", type="keyUp",
|
|
238
|
-
**{k: v for k, v in select_all.items() if k != "commands"})
|
|
239
|
-
press_key("Backspace")
|
|
240
|
-
for ch in text:
|
|
241
|
-
press_key(ch)
|
|
242
|
-
js(
|
|
243
|
-
f"(()=>{{const e=document.querySelector({json.dumps(selector)});"
|
|
244
|
-
f"if(!e)return;"
|
|
245
|
-
f"e.dispatchEvent(new Event('input',{{bubbles:true}}));"
|
|
246
|
-
f"e.dispatchEvent(new Event('change',{{bubbles:true}}));}})();"
|
|
247
|
-
)
|
|
248
|
-
|
|
249
|
-
_KEYS = { # key → (windowsVirtualKeyCode, code, text)
|
|
250
|
-
"Enter": (13, "Enter", "\r"), "Tab": (9, "Tab", "\t"), "Backspace": (8, "Backspace", ""),
|
|
251
|
-
"Escape": (27, "Escape", ""), "Delete": (46, "Delete", ""), " ": (32, "Space", " "),
|
|
252
|
-
"ArrowLeft": (37, "ArrowLeft", ""), "ArrowUp": (38, "ArrowUp", ""),
|
|
253
|
-
"ArrowRight": (39, "ArrowRight", ""), "ArrowDown": (40, "ArrowDown", ""),
|
|
254
|
-
"Home": (36, "Home", ""), "End": (35, "End", ""),
|
|
255
|
-
"PageUp": (33, "PageUp", ""), "PageDown": (34, "PageDown", ""),
|
|
256
|
-
}
|
|
257
|
-
# US-layout physical keys for printable ASCII punctuation: char → (code, virtual key).
|
|
258
|
-
# `code` names the physical key, so it is layout-independent and never the
|
|
259
|
-
# character itself; the virtual key code is the Win32 VK_OEM_* value, which is
|
|
260
|
-
# unrelated to ord(char) for everything except A-Z and 0-9.
|
|
261
|
-
_PUNCTUATION_KEYS = {
|
|
262
|
-
"`": ("Backquote", 192), "-": ("Minus", 189), "=": ("Equal", 187),
|
|
263
|
-
"[": ("BracketLeft", 219), "]": ("BracketRight", 221), "\\": ("Backslash", 220),
|
|
264
|
-
";": ("Semicolon", 186), "'": ("Quote", 222), ",": ("Comma", 188),
|
|
265
|
-
".": ("Period", 190), "/": ("Slash", 191),
|
|
266
|
-
}
|
|
267
|
-
# Characters a US layout only produces with Shift held, mapped to the unshifted
|
|
268
|
-
# character that shares their physical key.
|
|
269
|
-
_SHIFTED_CHARS = {
|
|
270
|
-
"~": "`", "!": "1", "@": "2", "#": "3", "$": "4", "%": "5", "^": "6",
|
|
271
|
-
"&": "7", "*": "8", "(": "9", ")": "0", "_": "-", "+": "=",
|
|
272
|
-
"{": "[", "}": "]", "|": "\\", ":": ";", '"': "'", "<": ",", ">": ".", "?": "/",
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
def _printable_key(char):
|
|
277
|
-
"""(code, virtual key, needs_shift) for one printable ASCII char on a US layout.
|
|
278
|
-
|
|
279
|
-
None when the character has no US physical key — accented letters, CJK,
|
|
280
|
-
emoji. Those still insert from the char event's text, and inventing a
|
|
281
|
-
keyboard key for them would just be a different wrong answer.
|
|
282
|
-
"""
|
|
283
|
-
unshifted = _SHIFTED_CHARS.get(char, char)
|
|
284
|
-
needs_shift = char in _SHIFTED_CHARS or char.isupper()
|
|
285
|
-
if unshifted.isascii() and "a" <= unshifted.lower() <= "z":
|
|
286
|
-
return f"Key{unshifted.upper()}", ord(unshifted.upper()), needs_shift
|
|
287
|
-
if unshifted.isdigit() and unshifted.isascii():
|
|
288
|
-
return f"Digit{unshifted}", ord(unshifted), needs_shift
|
|
289
|
-
if unshifted in _PUNCTUATION_KEYS:
|
|
290
|
-
code, vk = _PUNCTUATION_KEYS[unshifted]
|
|
291
|
-
return code, vk, needs_shift
|
|
292
|
-
return None
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
def press_key(key, modifiers=0):
|
|
296
|
-
"""Modifiers bitfield: 1=Alt, 2=Ctrl, 4=Meta(Cmd), 8=Shift.
|
|
297
|
-
|
|
298
|
-
Named keys (Enter, Tab, Arrow*, Backspace, ...) and printable characters alike
|
|
299
|
-
carry the physical `code` and virtual key code a real US keyboard sends, so
|
|
300
|
-
listeners reading e.key, e.code and e.keyCode all agree. A character that
|
|
301
|
-
needs Shift on that layout (uppercase, !@#$...) sets the Shift modifier too,
|
|
302
|
-
unless the caller is already composing a shortcut with Alt/Ctrl/Meta — there,
|
|
303
|
-
the caller's intent wins over the physical truth.
|
|
304
|
-
"""
|
|
305
|
-
if key in _KEYS:
|
|
306
|
-
vk, code, text = _KEYS[key]
|
|
307
|
-
elif len(key) == 1:
|
|
308
|
-
text = key
|
|
309
|
-
resolved = _printable_key(key)
|
|
310
|
-
if resolved:
|
|
311
|
-
code, vk, needs_shift = resolved
|
|
312
|
-
if needs_shift and not modifiers & (1 | 2 | 4):
|
|
313
|
-
modifiers |= 8
|
|
314
|
-
else:
|
|
315
|
-
code, vk = "", 0
|
|
316
|
-
else:
|
|
317
|
-
vk, code, text = 0, key, ""
|
|
318
|
-
base = {"key": key, "code": code, "modifiers": modifiers, "windowsVirtualKeyCode": vk, "nativeVirtualKeyCode": vk}
|
|
319
|
-
shortcut_modifiers = modifiers & (1 | 2 | 4) # Alt/Ctrl/Meta turn single keys into shortcuts.
|
|
320
|
-
printable_char = len(key) == 1 and bool(text) and not shortcut_modifiers
|
|
321
|
-
cdp("Input.dispatchKeyEvent", type="keyDown", **base, **({} if printable_char or not text else {"text": text}))
|
|
322
|
-
if printable_char:
|
|
323
|
-
cdp("Input.dispatchKeyEvent", type="char", text=text, **{k: v for k, v in base.items() if k != "text"})
|
|
324
|
-
cdp("Input.dispatchKeyEvent", type="keyUp", **base)
|
|
325
|
-
|
|
326
|
-
def scroll(x, y, dy=-300, dx=0):
|
|
327
|
-
cdp("Input.dispatchMouseEvent", type="mouseWheel", x=x, y=y, deltaX=dx, deltaY=dy)
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
# --- visual ---
|
|
331
|
-
def capture_screenshot(path=None, full=False, max_dim=None):
|
|
332
|
-
"""Save a PNG of the current viewport. Set max_dim=1800 on a 2× display to
|
|
333
|
-
keep the file under the 2000px-per-side limit some image-aware LLMs enforce."""
|
|
334
|
-
path = path or str(ipc._TMP / "shot.png")
|
|
335
|
-
try:
|
|
336
|
-
r = cdp(
|
|
337
|
-
"Page.captureScreenshot",
|
|
338
|
-
_response_timeout=SCREENSHOT_IPC_RESPONSE_TIMEOUT_SECONDS,
|
|
339
|
-
format="png",
|
|
340
|
-
captureBeyondViewport=full,
|
|
341
|
-
)
|
|
342
|
-
except _IPCResponseTimeout as e:
|
|
343
|
-
raise RuntimeError(
|
|
344
|
-
f"Page.captureScreenshot timed out after {SCREENSHOT_IPC_RESPONSE_TIMEOUT_SECONDS:g}s"
|
|
345
|
-
) from e
|
|
346
|
-
open(path, "wb").write(base64.b64decode(r["data"]))
|
|
347
|
-
if max_dim:
|
|
348
|
-
from PIL import Image
|
|
349
|
-
img = Image.open(path)
|
|
350
|
-
if max(img.size) > max_dim:
|
|
351
|
-
img.thumbnail((max_dim, max_dim))
|
|
352
|
-
img.save(path)
|
|
353
|
-
return path
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
# --- tabs ---
|
|
357
|
-
def _is_agent_startup_placeholder(title, url):
|
|
358
|
-
url = str(url or "")
|
|
359
|
-
return str(title or "").startswith("Starting agent ") and (
|
|
360
|
-
url in ("", "about:blank") or url.startswith("about:blank#")
|
|
361
|
-
)
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
def list_tabs(include_chrome=True):
|
|
365
|
-
out = []
|
|
366
|
-
for t in cdp("Target.getTargets")["targetInfos"]:
|
|
367
|
-
if t["type"] != "page": continue
|
|
368
|
-
url = t.get("url", "")
|
|
369
|
-
if _is_agent_startup_placeholder(t.get("title", ""), url): continue
|
|
370
|
-
if not include_chrome and url.startswith(INTERNAL): continue
|
|
371
|
-
out.append({
|
|
372
|
-
"targetId": t["targetId"],
|
|
373
|
-
"target_id": t["targetId"],
|
|
374
|
-
"title": t.get("title", ""),
|
|
375
|
-
"url": url,
|
|
376
|
-
})
|
|
377
|
-
return out
|
|
378
|
-
|
|
379
|
-
def current_tab():
|
|
380
|
-
r = _send({"meta": "current_tab"})
|
|
381
|
-
return {
|
|
382
|
-
"targetId": r["targetId"],
|
|
383
|
-
"target_id": r["targetId"],
|
|
384
|
-
"url": r["url"],
|
|
385
|
-
"title": r["title"],
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
def _mark_tab():
|
|
389
|
-
"""Prepend horse emoji to tab title so the user can see which tab the agent controls."""
|
|
390
|
-
if os.environ.get("BH_TAB_MARKER", "").strip().lower() in {"0", "false", "no", "off"}:
|
|
391
|
-
return
|
|
392
|
-
try: cdp("Runtime.evaluate", expression="if(!document.title.startsWith('\U0001F434'))document.title='\U0001F434 '+document.title")
|
|
393
|
-
except Exception: pass
|
|
394
|
-
|
|
395
|
-
def _target_id(target):
|
|
396
|
-
"""Accept a raw target id or a tab dict returned by the helpers."""
|
|
397
|
-
return (target.get("targetId") or target.get("target_id")) if isinstance(target, dict) else target
|
|
398
|
-
|
|
399
|
-
def activate_tab(target):
|
|
400
|
-
"""Make a target the visible Chrome tab.
|
|
401
|
-
|
|
402
|
-
This is intentionally separate from switch_tab(): attaching the agent to a
|
|
403
|
-
target does not require taking over the user's visible Chrome tab.
|
|
404
|
-
"""
|
|
405
|
-
target_id = _target_id(target)
|
|
406
|
-
cdp("Target.activateTarget", targetId=target_id)
|
|
407
|
-
return target_id
|
|
408
|
-
|
|
409
|
-
def switch_tab(target, activate=False):
|
|
410
|
-
"""Attach the agent without changing Chrome's visible tab by default.
|
|
411
|
-
|
|
412
|
-
Pass activate=True only when Chrome must visibly show the target. The horse
|
|
413
|
-
marker still moves to the attached target so the user can find it.
|
|
414
|
-
"""
|
|
415
|
-
# Accept either a raw targetId string or the dict returned by current_tab() / list_tabs(),
|
|
416
|
-
# so `switch_tab(current_tab())` works without a manual ["targetId"] dance.
|
|
417
|
-
target_id = _target_id(target)
|
|
418
|
-
# Unmark old tab. Horse emoji is a surrogate pair in JS UTF-16 strings (2 code units),
|
|
419
|
-
# plus the trailing space = 3 code units, so slice(3) cleanly removes the prefix.
|
|
420
|
-
try: cdp("Runtime.evaluate", expression="if(document.title.startsWith('\U0001F434 '))document.title=document.title.slice(3)")
|
|
421
|
-
except Exception: pass
|
|
422
|
-
if activate:
|
|
423
|
-
activate_tab(target_id)
|
|
424
|
-
sid = cdp("Target.attachToTarget", targetId=target_id, flatten=True)["sessionId"]
|
|
425
|
-
_send({"meta": "set_session", "session_id": sid, "target_id": target_id})
|
|
426
|
-
_mark_tab()
|
|
427
|
-
return sid
|
|
428
|
-
|
|
429
|
-
def new_tab(url="about:blank"):
|
|
430
|
-
# Always create blank, then goto: passing url to createTarget races with
|
|
431
|
-
# attach, so the brief about:blank is "complete" by the time the caller
|
|
432
|
-
# polls and wait_for_load() returns before navigation actually starts.
|
|
433
|
-
if url != "about:blank":
|
|
434
|
-
try:
|
|
435
|
-
cur = current_tab()
|
|
436
|
-
cur_url = cur.get("url") or ""
|
|
437
|
-
# Reuse attached tab when it's blank
|
|
438
|
-
if (
|
|
439
|
-
cur_url in ("", "about:blank", "data:text/html,")
|
|
440
|
-
or cur_url.startswith("about:blank#")
|
|
441
|
-
or cur_url.startswith(("chrome://newtab", "chrome://new-tab-page", "edge://newtab", "about:newtab"))
|
|
442
|
-
):
|
|
443
|
-
goto_url(url)
|
|
444
|
-
return cur.get("targetId") or cur.get("target_id")
|
|
445
|
-
except Exception:
|
|
446
|
-
pass
|
|
447
|
-
tid = cdp("Target.createTarget", url="about:blank", background=True)["targetId"]
|
|
448
|
-
switch_tab(tid)
|
|
449
|
-
if url != "about:blank":
|
|
450
|
-
goto_url(url)
|
|
451
|
-
return tid
|
|
452
|
-
|
|
453
|
-
def close_tab(target=None):
|
|
454
|
-
"""Close a tab. If `target` is omitted, closes the currently attached tab.
|
|
455
|
-
Accepts a raw targetId string or a dict from list_tabs()/current_tab()."""
|
|
456
|
-
target_id = _target_id(target)
|
|
457
|
-
if target_id is None:
|
|
458
|
-
target_id = current_tab()["targetId"]
|
|
459
|
-
cdp("Target.closeTarget", targetId=target_id)
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
def ensure_real_tab():
|
|
463
|
-
"""Switch to a real user tab if current is chrome:// / internal / stale."""
|
|
464
|
-
tabs = list_tabs(include_chrome=False)
|
|
465
|
-
if not tabs:
|
|
466
|
-
return None
|
|
467
|
-
try:
|
|
468
|
-
cur = current_tab()
|
|
469
|
-
if cur["url"] and not cur["url"].startswith(INTERNAL):
|
|
470
|
-
return cur
|
|
471
|
-
except Exception:
|
|
472
|
-
pass
|
|
473
|
-
switch_tab(tabs[0]["targetId"])
|
|
474
|
-
return tabs[0]
|
|
475
|
-
|
|
476
|
-
def iframe_target(url_substr):
|
|
477
|
-
"""First iframe target whose URL contains `url_substr`. Use with js(..., target_id=...)."""
|
|
478
|
-
for t in cdp("Target.getTargets")["targetInfos"]:
|
|
479
|
-
if t["type"] == "iframe" and url_substr in t.get("url", ""):
|
|
480
|
-
return t["targetId"]
|
|
481
|
-
return None
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
# --- utility ---
|
|
485
|
-
def wait(seconds=1.0):
|
|
486
|
-
time.sleep(seconds)
|
|
487
|
-
|
|
488
|
-
def wait_for_load(timeout=15.0):
|
|
489
|
-
"""Poll document.readyState == 'complete' or timeout."""
|
|
490
|
-
deadline = time.time() + timeout
|
|
491
|
-
while time.time() < deadline:
|
|
492
|
-
if js("document.readyState") == "complete": return True
|
|
493
|
-
time.sleep(0.3)
|
|
494
|
-
return False
|
|
495
|
-
|
|
496
|
-
def wait_for_element(selector, timeout=10.0, visible=False):
|
|
497
|
-
"""Poll until querySelector(selector) exists in the DOM, or timeout.
|
|
498
|
-
|
|
499
|
-
wait_for_load() misses SPAs — the document is 'complete' before the framework renders.
|
|
500
|
-
Use this after actions that trigger async rendering (route changes, data fetches).
|
|
501
|
-
Set visible=True to also require the element to be non-hidden and in-layout.
|
|
502
|
-
Returns True if found, False on timeout.
|
|
503
|
-
"""
|
|
504
|
-
if visible:
|
|
505
|
-
# checkVisibility walks the ancestor chain and respects display:none /
|
|
506
|
-
# visibility:hidden / opacity:0 on parents, which a getComputedStyle
|
|
507
|
-
# check on the element alone misses (it returns the descendant's own
|
|
508
|
-
# style, not the inherited "is this rendered" state). Falls back to
|
|
509
|
-
# the per-element CSS check on older Chrome that lacks checkVisibility.
|
|
510
|
-
check = (
|
|
511
|
-
f"(()=>{{const e=document.querySelector({json.dumps(selector)});"
|
|
512
|
-
f"if(!e)return false;"
|
|
513
|
-
f"if(typeof e.checkVisibility==='function')"
|
|
514
|
-
f"return e.checkVisibility({{checkOpacity:true,checkVisibilityCSS:true}});"
|
|
515
|
-
f"const s=getComputedStyle(e);"
|
|
516
|
-
f"return s.display!=='none'&&s.visibility!=='hidden'&&s.opacity!=='0'}})()"
|
|
517
|
-
)
|
|
518
|
-
else:
|
|
519
|
-
check = f"!!document.querySelector({json.dumps(selector)})"
|
|
520
|
-
deadline = time.time() + timeout
|
|
521
|
-
while time.time() < deadline:
|
|
522
|
-
if js(check): return True
|
|
523
|
-
time.sleep(0.3)
|
|
524
|
-
return False
|
|
525
|
-
|
|
526
|
-
def wait_for_network_idle(timeout=10.0, idle_ms=500):
|
|
527
|
-
"""Wait until all in-flight requests finish and no Network.* events arrive for idle_ms ms.
|
|
528
|
-
|
|
529
|
-
Useful after form submits, SPA route transitions, and any action that triggers
|
|
530
|
-
XHR/fetch without a visible DOM change. Builds on drain_events() — no daemon changes.
|
|
531
|
-
Returns True if idle window reached, False on timeout.
|
|
532
|
-
|
|
533
|
-
Events are filtered to the active session — a previously-attached background
|
|
534
|
-
tab (e.g. a polling/SSE page the agent switched away from) keeps emitting
|
|
535
|
-
Network events into the daemon's global event buffer; without this filter
|
|
536
|
-
they would poison the idle check on the current tab.
|
|
537
|
-
"""
|
|
538
|
-
deadline = time.time() + timeout
|
|
539
|
-
last_activity = time.time()
|
|
540
|
-
inflight = set()
|
|
541
|
-
active_session = _send({"meta": "session"}).get("session_id")
|
|
542
|
-
while time.time() < deadline:
|
|
543
|
-
for e in drain_events():
|
|
544
|
-
if e.get("session_id") != active_session:
|
|
545
|
-
continue
|
|
546
|
-
method = e.get("method", "")
|
|
547
|
-
params = e.get("params", {})
|
|
548
|
-
if method == "Network.requestWillBeSent":
|
|
549
|
-
inflight.add(params.get("requestId"))
|
|
550
|
-
last_activity = time.time()
|
|
551
|
-
elif method in ("Network.loadingFinished", "Network.loadingFailed"):
|
|
552
|
-
inflight.discard(params.get("requestId"))
|
|
553
|
-
last_activity = time.time()
|
|
554
|
-
elif method.startswith("Network."):
|
|
555
|
-
last_activity = time.time()
|
|
556
|
-
if not inflight and (time.time() - last_activity) * 1000 >= idle_ms:
|
|
557
|
-
return True
|
|
558
|
-
time.sleep(0.1)
|
|
559
|
-
return False
|
|
560
|
-
|
|
561
|
-
def js(expression, target_id=None):
|
|
562
|
-
"""Run JS in the attached tab (default) or inside an iframe target (via iframe_target()).
|
|
563
|
-
|
|
564
|
-
Expressions are evaluated as-is first. If Chrome reports an illegal top-level
|
|
565
|
-
`return`, the snippet is retried inside a function wrapper, so both
|
|
566
|
-
`document.title` and `const x = 1; return x` work without mis-wrapping nested
|
|
567
|
-
functions that contain their own returns.
|
|
568
|
-
"""
|
|
569
|
-
sid = cdp("Target.attachToTarget", targetId=target_id, flatten=True)["sessionId"] if target_id else None
|
|
570
|
-
try:
|
|
571
|
-
result = _js_evaluate(expression, sid)
|
|
572
|
-
except BaseException:
|
|
573
|
-
# Keep the evaluation error; a detach failure here must not replace it.
|
|
574
|
-
if sid:
|
|
575
|
-
try:
|
|
576
|
-
_detach_iframe_session(sid)
|
|
577
|
-
except BaseException:
|
|
578
|
-
pass
|
|
579
|
-
raise
|
|
580
|
-
if sid:
|
|
581
|
-
_detach_iframe_session(sid)
|
|
582
|
-
return result
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
def _js_evaluate(expression, sid):
|
|
586
|
-
try:
|
|
587
|
-
return _runtime_evaluate(expression, session_id=sid, await_promise=True)
|
|
588
|
-
except RuntimeError as e:
|
|
589
|
-
if _is_illegal_return_error(e):
|
|
590
|
-
return _runtime_evaluate(_wrap_js_function(expression), session_id=sid, await_promise=True)
|
|
591
|
-
raise
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
def _detach_iframe_session(sid):
|
|
595
|
-
"""Release the session js(target_id=...) attached so polling loops do not
|
|
596
|
-
accumulate one live session (and one event stream) per call. A session that
|
|
597
|
-
Chrome already dropped (iframe navigated or closed) is not a leak."""
|
|
598
|
-
try:
|
|
599
|
-
cdp("Target.detachFromTarget", sessionId=sid)
|
|
600
|
-
except Exception as e:
|
|
601
|
-
message = str(e).lower()
|
|
602
|
-
if "no session with given id" in message or "session with given id not found" in message:
|
|
603
|
-
return
|
|
604
|
-
raise
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
_KC = {"Enter": 13, "Tab": 9, "Escape": 27, "Backspace": 8, " ": 32, "ArrowLeft": 37, "ArrowUp": 38, "ArrowRight": 39, "ArrowDown": 40}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
def dispatch_key(selector, key="Enter", event="keypress"):
|
|
611
|
-
"""Dispatch a DOM KeyboardEvent on the matched element.
|
|
612
|
-
|
|
613
|
-
Use this when a site reacts to synthetic DOM key events on an element more reliably
|
|
614
|
-
than to raw CDP input events.
|
|
615
|
-
"""
|
|
616
|
-
kc = _KC.get(key, ord(key) if len(key) == 1 else 0)
|
|
617
|
-
js(
|
|
618
|
-
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}}));}}}})()"
|
|
619
|
-
)
|
|
620
|
-
|
|
621
|
-
def upload_file(selector, path):
|
|
622
|
-
"""Set files on a file input via CDP DOM.setFileInputFiles. `path` is an absolute filepath (use tempfile.mkstemp if needed)."""
|
|
623
|
-
doc = cdp("DOM.getDocument", depth=-1)
|
|
624
|
-
nid = cdp("DOM.querySelector", nodeId=doc["root"]["nodeId"], selector=selector)["nodeId"]
|
|
625
|
-
if not nid: raise RuntimeError(f"no element for {selector}")
|
|
626
|
-
cdp("DOM.setFileInputFiles", files=[path] if isinstance(path, str) else list(path), nodeId=nid)
|
|
627
|
-
|
|
628
|
-
def http_get(url, headers=None, timeout=20.0):
|
|
629
|
-
"""Pure HTTP — no browser. Use for static pages / APIs. Wrap in ThreadPoolExecutor for bulk.
|
|
630
|
-
|
|
631
|
-
When BROWSER_USE_API_KEY is set, routes through the fetch-use proxy (handles bot
|
|
632
|
-
detection, residential proxies, retries). Falls back to local urllib otherwise."""
|
|
633
|
-
if os.environ.get("BROWSER_USE_API_KEY"):
|
|
634
|
-
try:
|
|
635
|
-
from fetch_use import fetch_sync
|
|
636
|
-
return fetch_sync(url, headers=headers, timeout_ms=int(timeout * 1000)).text
|
|
637
|
-
except ImportError:
|
|
638
|
-
pass
|
|
639
|
-
import gzip
|
|
640
|
-
h = {"User-Agent": "Mozilla/5.0", "Accept-Encoding": "gzip"}
|
|
641
|
-
if headers: h.update(headers)
|
|
642
|
-
with urllib.request.urlopen(urllib.request.Request(url, headers=h), timeout=timeout) as r:
|
|
643
|
-
data = r.read()
|
|
644
|
-
if r.headers.get("Content-Encoding") == "gzip": data = gzip.decompress(data)
|
|
645
|
-
return data.decode()
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
# Imported at the bottom so recorder's own `from . import helpers` sees a
|
|
649
|
-
# fully-defined module. Exposes the recording helpers via `from .helpers import *`.
|
|
650
|
-
from .recorder import start_recording, stop_recording, recording_dir
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
def _load_agent_helpers():
|
|
654
|
-
p = AGENT_WORKSPACE / "agent_helpers.py"
|
|
655
|
-
if not p.exists():
|
|
656
|
-
return
|
|
657
|
-
spec = importlib.util.spec_from_file_location("browser_harness_agent_helpers", p)
|
|
658
|
-
if not spec or not spec.loader:
|
|
659
|
-
return
|
|
660
|
-
module = importlib.util.module_from_spec(spec)
|
|
661
|
-
spec.loader.exec_module(module)
|
|
662
|
-
for name, value in vars(module).items():
|
|
663
|
-
if name.startswith("_"):
|
|
664
|
-
continue
|
|
665
|
-
globals()[name] = value
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
_load_agent_helpers()
|