seedcode-cli 6.1.5__py3-none-any.whl
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.
- seedcode/__init__.py +14 -0
- seedcode/__main__.py +12 -0
- seedcode/app.py +508 -0
- seedcode/apps/__init__.py +32 -0
- seedcode/apps/discovery.py +241 -0
- seedcode/apps/installer.py +164 -0
- seedcode/apps/launcher.py +156 -0
- seedcode/apps/verifier.py +119 -0
- seedcode/assets/logo.txt +15 -0
- seedcode/cli.py +95 -0
- seedcode/commands/__init__.py +81 -0
- seedcode/commands/about.py +34 -0
- seedcode/commands/agent.py +94 -0
- seedcode/commands/assist.py +201 -0
- seedcode/commands/clear.py +20 -0
- seedcode/commands/desktop.py +104 -0
- seedcode/commands/doctor.py +152 -0
- seedcode/commands/help.py +61 -0
- seedcode/commands/history.py +365 -0
- seedcode/commands/palette.py +100 -0
- seedcode/commands/provider.py +451 -0
- seedcode/commands/theme.py +76 -0
- seedcode/computer/__init__.py +98 -0
- seedcode/computer/browser.py +276 -0
- seedcode/computer/browser_cdp.py +567 -0
- seedcode/computer/browser_engine.py +546 -0
- seedcode/computer/browser_extract.py +301 -0
- seedcode/computer/browser_popups.py +329 -0
- seedcode/computer/browser_selenium.py +209 -0
- seedcode/computer/browser_skills.py +245 -0
- seedcode/computer/catalog.py +200 -0
- seedcode/computer/controller.py +324 -0
- seedcode/computer/dispatcher.py +272 -0
- seedcode/computer/dpi.py +185 -0
- seedcode/computer/engine.py +105 -0
- seedcode/computer/keyboard.py +101 -0
- seedcode/computer/logbook.py +104 -0
- seedcode/computer/mouse.py +48 -0
- seedcode/computer/ocr.py +213 -0
- seedcode/computer/operator_skills.py +577 -0
- seedcode/computer/permissions.py +203 -0
- seedcode/computer/recovery.py +115 -0
- seedcode/computer/registry.py +107 -0
- seedcode/computer/resolver.py +434 -0
- seedcode/computer/screen.py +130 -0
- seedcode/computer/screen_state.py +412 -0
- seedcode/computer/selfguard.py +197 -0
- seedcode/computer/semantic.py +100 -0
- seedcode/computer/skills.py +139 -0
- seedcode/computer/state.py +199 -0
- seedcode/computer/verifier.py +177 -0
- seedcode/computer/vision.py +327 -0
- seedcode/computer/windows.py +217 -0
- seedcode/config/__init__.py +8 -0
- seedcode/config/defaults.py +22 -0
- seedcode/config/manager.py +62 -0
- seedcode/core/__init__.py +31 -0
- seedcode/core/agent.py +534 -0
- seedcode/core/chat.py +128 -0
- seedcode/core/client.py +9 -0
- seedcode/core/errors.py +199 -0
- seedcode/core/identity.py +66 -0
- seedcode/core/identity_store.py +119 -0
- seedcode/core/lifecycle.py +240 -0
- seedcode/core/limits.py +35 -0
- seedcode/core/models.py +347 -0
- seedcode/core/project.py +96 -0
- seedcode/core/providers/__init__.py +58 -0
- seedcode/core/providers/aerolink.py +324 -0
- seedcode/core/providers/base.py +230 -0
- seedcode/core/providers/freemodel.py +931 -0
- seedcode/core/providers/ollama.py +262 -0
- seedcode/core/providers/openrouter.py +393 -0
- seedcode/core/streaming.py +21 -0
- seedcode/memory/__init__.py +8 -0
- seedcode/memory/manager.py +47 -0
- seedcode/memory/storage.py +38 -0
- seedcode/memory/store.py +257 -0
- seedcode/tools/__init__.py +35 -0
- seedcode/tools/base.py +179 -0
- seedcode/tools/desktop.py +371 -0
- seedcode/tools/filesystem.py +309 -0
- seedcode/tools/git.py +72 -0
- seedcode/tools/patch.py +170 -0
- seedcode/tools/permissions.py +288 -0
- seedcode/tools/search.py +137 -0
- seedcode/tools/terminal.py +200 -0
- seedcode/tools/textio.py +59 -0
- seedcode/ui/__init__.py +164 -0
- seedcode/ui/badges.py +64 -0
- seedcode/ui/banner.py +78 -0
- seedcode/ui/dashboard.py +197 -0
- seedcode/ui/dialog.py +62 -0
- seedcode/ui/fuzzy.py +128 -0
- seedcode/ui/layout.py +54 -0
- seedcode/ui/menu.py +61 -0
- seedcode/ui/palette.py +40 -0
- seedcode/ui/progress.py +41 -0
- seedcode/ui/prompts.py +16 -0
- seedcode/ui/renderer.py +36 -0
- seedcode/ui/searchbox.py +70 -0
- seedcode/ui/selector.py +514 -0
- seedcode/ui/statusbar.py +38 -0
- seedcode/ui/textbox.py +61 -0
- seedcode/ui/theme.py +204 -0
- seedcode/ui/tree.py +91 -0
- seedcode/utils/__init__.py +22 -0
- seedcode/utils/helpers.py +97 -0
- seedcode/utils/logger.py +65 -0
- seedcode_cli-6.1.5.dist-info/METADATA +368 -0
- seedcode_cli-6.1.5.dist-info/RECORD +114 -0
- seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
- seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
- seedcode_cli-6.1.5.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
"""DOM inspection over the Chrome DevTools Protocol — the browser's own eyes.
|
|
2
|
+
|
|
3
|
+
This is the **DOM tier** of the detection ladder: when the user's default
|
|
4
|
+
browser is Chromium-based and exposes a DevTools endpoint, Seed Code can ask
|
|
5
|
+
the page directly ("is there a cookie banner?", "where is the first video
|
|
6
|
+
link?") instead of guessing from pixels. That is exact, theme-independent, and
|
|
7
|
+
needs no OCR.
|
|
8
|
+
|
|
9
|
+
Two deliberate design choices:
|
|
10
|
+
|
|
11
|
+
* **Opportunistic, never required.** Attaching needs the browser to have been
|
|
12
|
+
started with ``--remote-debugging-port``. When it wasn't — the common case
|
|
13
|
+
for an already-running browser — every function here returns ``None`` or
|
|
14
|
+
``False`` and the caller falls through to the next ladder tier. Nothing in
|
|
15
|
+
Seed Code *depends* on CDP being live; the URL-first workflows in
|
|
16
|
+
:mod:`.browser_engine` reach their goal without touching the DOM at all.
|
|
17
|
+
* **No new dependencies.** Target discovery is plain HTTP over ``urllib`` and
|
|
18
|
+
the DevTools socket is a ~100-line RFC 6455 client over a stdlib socket.
|
|
19
|
+
Adding ``websockets``/``selenium`` to the install for this would violate the
|
|
20
|
+
"works offline, nothing extra to install" contract.
|
|
21
|
+
|
|
22
|
+
The module holds no AI code and never sees a model.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import base64
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import socket
|
|
31
|
+
import struct
|
|
32
|
+
import threading
|
|
33
|
+
import time
|
|
34
|
+
import urllib.error
|
|
35
|
+
import urllib.request
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
# The DevTools port Seed Code asks the browser to expose. Fixed so a browser
|
|
40
|
+
# launched by an earlier session is still reachable by a later one.
|
|
41
|
+
DEFAULT_PORT = 9222
|
|
42
|
+
|
|
43
|
+
# Everything here is best-effort against a live browser: keep timeouts short so
|
|
44
|
+
# a missing/hung endpoint costs the workflow a moment, not a stall.
|
|
45
|
+
_HTTP_TIMEOUT_S = 1.5
|
|
46
|
+
_WS_TIMEOUT_S = 4.0
|
|
47
|
+
|
|
48
|
+
# How long an availability verdict stays good. A hit is re-checked often enough
|
|
49
|
+
# to notice the browser closing; a miss is cached longer because the expensive
|
|
50
|
+
# case — no DevTools at all — is also the case that must stay cheap.
|
|
51
|
+
_AVAIL_TTL_S = 2.0
|
|
52
|
+
_AVAIL_MISS_TTL_S = 10.0
|
|
53
|
+
|
|
54
|
+
# port -> (available, expires_at)
|
|
55
|
+
_avail_cache: dict[int, tuple[bool, float]] = {}
|
|
56
|
+
|
|
57
|
+
# Guards the shared connection: skills may run from different threads and a
|
|
58
|
+
# DevTools socket cannot interleave request/response pairs safely.
|
|
59
|
+
_lock = threading.Lock()
|
|
60
|
+
_conn: "_Connection | None" = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(slots=True)
|
|
64
|
+
class Tab:
|
|
65
|
+
"""One open browser tab, as DevTools reports it."""
|
|
66
|
+
|
|
67
|
+
target_id: str
|
|
68
|
+
title: str
|
|
69
|
+
url: str
|
|
70
|
+
ws_url: str
|
|
71
|
+
|
|
72
|
+
def describe(self) -> str:
|
|
73
|
+
return f'"{self.title or "(untitled)"}" — {self.url}'
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(slots=True)
|
|
77
|
+
class DomBox:
|
|
78
|
+
"""A DOM element's position, already converted to screen coordinates."""
|
|
79
|
+
|
|
80
|
+
x: int # center, screen space
|
|
81
|
+
y: int # center, screen space
|
|
82
|
+
width: int
|
|
83
|
+
height: int
|
|
84
|
+
text: str
|
|
85
|
+
|
|
86
|
+
def as_tuple(self) -> tuple[int, int, int, int]:
|
|
87
|
+
"""(left, top, width, height) — the resolver's box convention."""
|
|
88
|
+
return (self.x - self.width // 2, self.y - self.height // 2, self.width, self.height)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# --- target discovery (plain HTTP, no socket needed) -------------------------
|
|
92
|
+
|
|
93
|
+
def _endpoint(port: int, path: str) -> str:
|
|
94
|
+
return f"http://127.0.0.1:{port}/json{path}"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _http(port: int, path: str, timeout: float = _HTTP_TIMEOUT_S) -> Any:
|
|
98
|
+
"""GET a DevTools HTTP endpoint, returning parsed JSON or None."""
|
|
99
|
+
try:
|
|
100
|
+
with urllib.request.urlopen(_endpoint(port, path), timeout=timeout) as resp:
|
|
101
|
+
body = resp.read().decode("utf-8", "replace")
|
|
102
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
103
|
+
return None
|
|
104
|
+
if not body.strip():
|
|
105
|
+
return None
|
|
106
|
+
try:
|
|
107
|
+
return json.loads(body)
|
|
108
|
+
except json.JSONDecodeError:
|
|
109
|
+
return body
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def is_available(port: int = DEFAULT_PORT) -> bool:
|
|
113
|
+
"""Whether a DevTools endpoint is answering on ``port``.
|
|
114
|
+
|
|
115
|
+
The verdict is cached briefly. Callers like the popup manager ask this
|
|
116
|
+
(directly or via ``exists``) dozens of times per sweep, and without a cache
|
|
117
|
+
every miss costs a full connection timeout — turning an "is the page
|
|
118
|
+
clean?" check into a multi-second stall on the overwhelmingly common
|
|
119
|
+
no-DevTools machine.
|
|
120
|
+
"""
|
|
121
|
+
global _avail_cache
|
|
122
|
+
now = time.monotonic()
|
|
123
|
+
cached = _avail_cache.get(port)
|
|
124
|
+
if cached is not None and now < cached[1]:
|
|
125
|
+
return cached[0]
|
|
126
|
+
ok = _http(port, "/version") is not None
|
|
127
|
+
ttl = _AVAIL_TTL_S if ok else _AVAIL_MISS_TTL_S
|
|
128
|
+
_avail_cache[port] = (ok, now + ttl)
|
|
129
|
+
return ok
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def invalidate_availability() -> None:
|
|
133
|
+
"""Forget the cached availability verdict (browser started/stopped)."""
|
|
134
|
+
_avail_cache.clear()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def list_tabs(port: int = DEFAULT_PORT) -> list[Tab]:
|
|
138
|
+
"""Every open page tab (DevTools also lists workers/extensions; skipped)."""
|
|
139
|
+
data = _http(port, "/list")
|
|
140
|
+
if not isinstance(data, list):
|
|
141
|
+
return []
|
|
142
|
+
tabs = []
|
|
143
|
+
for entry in data:
|
|
144
|
+
if not isinstance(entry, dict) or entry.get("type") != "page":
|
|
145
|
+
continue
|
|
146
|
+
tabs.append(
|
|
147
|
+
Tab(
|
|
148
|
+
target_id=str(entry.get("id", "")),
|
|
149
|
+
title=str(entry.get("title", "")),
|
|
150
|
+
url=str(entry.get("url", "")),
|
|
151
|
+
ws_url=str(entry.get("webSocketDebuggerUrl", "")),
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
return tabs
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def active_tab(port: int = DEFAULT_PORT) -> Tab | None:
|
|
158
|
+
"""The tab DevTools lists first — the one most recently in the foreground."""
|
|
159
|
+
tabs = list_tabs(port)
|
|
160
|
+
return tabs[0] if tabs else None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def new_tab(url: str = "about:blank", port: int = DEFAULT_PORT) -> Tab | None:
|
|
164
|
+
"""Open a tab via DevTools. Returns None when the endpoint is absent."""
|
|
165
|
+
data = _http(port, f"/new?{url}")
|
|
166
|
+
if not isinstance(data, dict):
|
|
167
|
+
return None
|
|
168
|
+
_reset_connection()
|
|
169
|
+
return Tab(
|
|
170
|
+
target_id=str(data.get("id", "")),
|
|
171
|
+
title=str(data.get("title", "")),
|
|
172
|
+
url=str(data.get("url", "")),
|
|
173
|
+
ws_url=str(data.get("webSocketDebuggerUrl", "")),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def activate_tab(target_id: str, port: int = DEFAULT_PORT) -> bool:
|
|
178
|
+
"""Bring a tab to the foreground."""
|
|
179
|
+
ok = _http(port, f"/activate/{target_id}") is not None
|
|
180
|
+
if ok:
|
|
181
|
+
_reset_connection()
|
|
182
|
+
return ok
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def close_tab(target_id: str, port: int = DEFAULT_PORT) -> bool:
|
|
186
|
+
"""Close a tab."""
|
|
187
|
+
ok = _http(port, f"/close/{target_id}") is not None
|
|
188
|
+
if ok:
|
|
189
|
+
_reset_connection()
|
|
190
|
+
return ok
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# --- JavaScript evaluation (the workhorse) -----------------------------------
|
|
194
|
+
|
|
195
|
+
def evaluate(expression: str, port: int = DEFAULT_PORT) -> Any:
|
|
196
|
+
"""Evaluate JS in the active tab and return the JSON-decoded result.
|
|
197
|
+
|
|
198
|
+
Returns ``None`` when DevTools is unreachable or the expression threw —
|
|
199
|
+
callers treat that as "the DOM tier has nothing to say" and fall through.
|
|
200
|
+
"""
|
|
201
|
+
# Short-circuit on the cached verdict so a machine with no DevTools pays
|
|
202
|
+
# one probe per sweep rather than one per query.
|
|
203
|
+
if not is_available(port):
|
|
204
|
+
return None
|
|
205
|
+
with _lock:
|
|
206
|
+
conn = _get_connection(port)
|
|
207
|
+
if conn is None:
|
|
208
|
+
return None
|
|
209
|
+
try:
|
|
210
|
+
reply = conn.call(
|
|
211
|
+
"Runtime.evaluate",
|
|
212
|
+
{
|
|
213
|
+
"expression": expression,
|
|
214
|
+
"returnByValue": True,
|
|
215
|
+
"awaitPromise": True,
|
|
216
|
+
# Consent banners and player controls are only clickable
|
|
217
|
+
# when the page believes a real user gesture occurred.
|
|
218
|
+
"userGesture": True,
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
except Exception:
|
|
222
|
+
_reset_connection_locked()
|
|
223
|
+
return None
|
|
224
|
+
if not isinstance(reply, dict):
|
|
225
|
+
return None
|
|
226
|
+
if reply.get("exceptionDetails"):
|
|
227
|
+
return None
|
|
228
|
+
return reply.get("result", {}).get("value")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def current_url(port: int = DEFAULT_PORT) -> str | None:
|
|
232
|
+
"""The live address of the active tab, straight from the browser."""
|
|
233
|
+
tab = active_tab(port)
|
|
234
|
+
if tab is not None and tab.url:
|
|
235
|
+
return tab.url
|
|
236
|
+
value = evaluate("location.href", port)
|
|
237
|
+
return str(value) if isinstance(value, str) else None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def current_title(port: int = DEFAULT_PORT) -> str | None:
|
|
241
|
+
"""The live title of the active tab."""
|
|
242
|
+
tab = active_tab(port)
|
|
243
|
+
if tab is not None and tab.title:
|
|
244
|
+
return tab.title
|
|
245
|
+
value = evaluate("document.title", port)
|
|
246
|
+
return str(value) if isinstance(value, str) else None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def navigate(url: str, port: int = DEFAULT_PORT) -> bool:
|
|
250
|
+
"""Drive the active tab to ``url`` through DevTools."""
|
|
251
|
+
return bool(evaluate(f"(location.assign({json.dumps(url)}), true)", port))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def wait_for_load(timeout_s: float = 10.0, port: int = DEFAULT_PORT) -> bool:
|
|
255
|
+
"""Block until the active tab finishes loading (or the timeout elapses)."""
|
|
256
|
+
import time
|
|
257
|
+
|
|
258
|
+
deadline = time.monotonic() + timeout_s
|
|
259
|
+
while time.monotonic() < deadline:
|
|
260
|
+
if evaluate("document.readyState === 'complete'", port) is True:
|
|
261
|
+
return True
|
|
262
|
+
time.sleep(0.25)
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# --- DOM querying (the resolver's DOM tier) ----------------------------------
|
|
267
|
+
|
|
268
|
+
# Built in JS so the *page* does the matching: one round trip, and the result
|
|
269
|
+
# is already in screen space. Chromium exposes the window's screen origin and
|
|
270
|
+
# the chrome height (outerHeight - innerHeight), which together convert a
|
|
271
|
+
# viewport rect into a coordinate the mouse driver can click.
|
|
272
|
+
_BOX_JS = """
|
|
273
|
+
(() => {
|
|
274
|
+
const el = %(finder)s;
|
|
275
|
+
if (!el) return null;
|
|
276
|
+
const r = el.getBoundingClientRect();
|
|
277
|
+
if (!r || r.width <= 0 || r.height <= 0) return null;
|
|
278
|
+
const chrome = window.outerHeight - window.innerHeight;
|
|
279
|
+
return {
|
|
280
|
+
x: Math.round(window.screenX + r.left + r.width / 2),
|
|
281
|
+
y: Math.round(window.screenY + chrome + r.top + r.height / 2),
|
|
282
|
+
width: Math.round(r.width),
|
|
283
|
+
height: Math.round(r.height),
|
|
284
|
+
text: (el.innerText || el.textContent || el.value || '').trim().slice(0, 120)
|
|
285
|
+
};
|
|
286
|
+
})()
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
# Find a visible element whose text/label/aria-label contains a phrase. Used by
|
|
290
|
+
# both the resolver's DOM tier and the popup manager's dismiss buttons.
|
|
291
|
+
_BY_TEXT_JS = """
|
|
292
|
+
(() => {
|
|
293
|
+
const want = %(needle)s.toLowerCase();
|
|
294
|
+
const tags = %(tags)s;
|
|
295
|
+
const nodes = document.querySelectorAll(tags.join(','));
|
|
296
|
+
for (const el of nodes) {
|
|
297
|
+
const r = el.getBoundingClientRect();
|
|
298
|
+
if (!r || r.width <= 0 || r.height <= 0) continue;
|
|
299
|
+
const style = window.getComputedStyle(el);
|
|
300
|
+
if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') continue;
|
|
301
|
+
const label = ((el.innerText || el.textContent || '') + ' ' +
|
|
302
|
+
(el.getAttribute('aria-label') || '') + ' ' +
|
|
303
|
+
(el.getAttribute('title') || '') + ' ' +
|
|
304
|
+
(el.value || '')).toLowerCase();
|
|
305
|
+
if (label.includes(want)) return el;
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
})()
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
# Element kinds a user can actually act on. Kept narrow so a phrase does not
|
|
312
|
+
# match a giant wrapper <div> that happens to contain the text.
|
|
313
|
+
_CLICKABLE_TAGS = [
|
|
314
|
+
"button", "a", "input[type=submit]", "input[type=button]",
|
|
315
|
+
"[role=button]", "[role=link]", "[role=menuitem]", "[role=tab]",
|
|
316
|
+
"[onclick]", "label",
|
|
317
|
+
]
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _finder_by_text(needle: str, tags: list[str] | None = None) -> str:
|
|
321
|
+
return _BY_TEXT_JS % {
|
|
322
|
+
"needle": json.dumps(needle),
|
|
323
|
+
"tags": json.dumps(tags or _CLICKABLE_TAGS),
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _box_from(result: Any) -> DomBox | None:
|
|
328
|
+
if not isinstance(result, dict):
|
|
329
|
+
return None
|
|
330
|
+
try:
|
|
331
|
+
return DomBox(
|
|
332
|
+
x=int(result["x"]), y=int(result["y"]),
|
|
333
|
+
width=int(result["width"]), height=int(result["height"]),
|
|
334
|
+
text=str(result.get("text", "")),
|
|
335
|
+
)
|
|
336
|
+
except (KeyError, TypeError, ValueError):
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def locate_text(phrase: str, port: int = DEFAULT_PORT) -> DomBox | None:
|
|
341
|
+
"""Find a clickable element matching ``phrase``; screen-space box or None."""
|
|
342
|
+
phrase = (phrase or "").strip()
|
|
343
|
+
if not phrase:
|
|
344
|
+
return None
|
|
345
|
+
js = _BOX_JS % {"finder": _finder_by_text(phrase)}
|
|
346
|
+
return _box_from(evaluate(js, port))
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def locate_selector(selector: str, port: int = DEFAULT_PORT) -> DomBox | None:
|
|
350
|
+
"""Find an element by CSS selector; screen-space box or None."""
|
|
351
|
+
selector = (selector or "").strip()
|
|
352
|
+
if not selector:
|
|
353
|
+
return None
|
|
354
|
+
js = _BOX_JS % {"finder": f"document.querySelector({json.dumps(selector)})"}
|
|
355
|
+
return _box_from(evaluate(js, port))
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def click_text(phrase: str, port: int = DEFAULT_PORT) -> bool:
|
|
359
|
+
"""Click an element by its visible text, inside the page.
|
|
360
|
+
|
|
361
|
+
A real in-page ``.click()`` — no mouse movement, so it cannot miss and is
|
|
362
|
+
unaffected by window position, scroll, or z-order.
|
|
363
|
+
"""
|
|
364
|
+
phrase = (phrase or "").strip()
|
|
365
|
+
if not phrase:
|
|
366
|
+
return False
|
|
367
|
+
js = f"(() => {{ const el = {_finder_by_text(phrase)}; if (!el) return false; el.click(); return true; }})()"
|
|
368
|
+
return evaluate(js, port) is True
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def click_selector(selector: str, port: int = DEFAULT_PORT) -> bool:
|
|
372
|
+
"""Click the first element matching a CSS selector, inside the page."""
|
|
373
|
+
selector = (selector or "").strip()
|
|
374
|
+
if not selector:
|
|
375
|
+
return False
|
|
376
|
+
js = (
|
|
377
|
+
f"(() => {{ const el = document.querySelector({json.dumps(selector)}); "
|
|
378
|
+
"if (!el) return false; el.click(); return true; })()"
|
|
379
|
+
)
|
|
380
|
+
return evaluate(js, port) is True
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def exists(selector: str, port: int = DEFAULT_PORT) -> bool:
|
|
384
|
+
"""Whether any visible element matches a CSS selector."""
|
|
385
|
+
js = (
|
|
386
|
+
f"(() => {{ const el = document.querySelector({json.dumps(selector)}); "
|
|
387
|
+
"if (!el) return false; const r = el.getBoundingClientRect(); "
|
|
388
|
+
"return r.width > 0 && r.height > 0; })()"
|
|
389
|
+
)
|
|
390
|
+
return evaluate(js, port) is True
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# --- connection management ---------------------------------------------------
|
|
394
|
+
|
|
395
|
+
def _get_connection(port: int) -> "_Connection | None":
|
|
396
|
+
"""The live DevTools socket for the active tab, opening one if needed."""
|
|
397
|
+
global _conn
|
|
398
|
+
if _conn is not None and _conn.alive:
|
|
399
|
+
return _conn
|
|
400
|
+
tab = active_tab(port)
|
|
401
|
+
if tab is None or not tab.ws_url:
|
|
402
|
+
return None
|
|
403
|
+
try:
|
|
404
|
+
_conn = _Connection(tab.ws_url)
|
|
405
|
+
except Exception:
|
|
406
|
+
_conn = None
|
|
407
|
+
return _conn
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _reset_connection() -> None:
|
|
411
|
+
with _lock:
|
|
412
|
+
_reset_connection_locked()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _reset_connection_locked() -> None:
|
|
416
|
+
"""Drop the cached socket. Caller must already hold ``_lock``."""
|
|
417
|
+
global _conn
|
|
418
|
+
if _conn is not None:
|
|
419
|
+
try:
|
|
420
|
+
_conn.close()
|
|
421
|
+
except Exception:
|
|
422
|
+
pass
|
|
423
|
+
_conn = None
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def reset() -> None:
|
|
427
|
+
"""Forget any attached tab — called when the browser is closed/restarted."""
|
|
428
|
+
_reset_connection()
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
class _Connection:
|
|
432
|
+
"""A minimal DevTools WebSocket client (RFC 6455, client role).
|
|
433
|
+
|
|
434
|
+
Only what CDP needs: a text-frame request/response pair with an incrementing
|
|
435
|
+
message id. Server frames are never masked; client frames always are.
|
|
436
|
+
"""
|
|
437
|
+
|
|
438
|
+
def __init__(self, ws_url: str, timeout: float = _WS_TIMEOUT_S) -> None:
|
|
439
|
+
host, port, path = _split_ws_url(ws_url)
|
|
440
|
+
self._sock = socket.create_connection((host, port), timeout=timeout)
|
|
441
|
+
self._sock.settimeout(timeout)
|
|
442
|
+
self._handshake(host, port, path)
|
|
443
|
+
self._next_id = 0
|
|
444
|
+
self.alive = True
|
|
445
|
+
|
|
446
|
+
def _handshake(self, host: str, port: int, path: str) -> None:
|
|
447
|
+
key = base64.b64encode(os.urandom(16)).decode()
|
|
448
|
+
request = (
|
|
449
|
+
f"GET {path} HTTP/1.1\r\n"
|
|
450
|
+
f"Host: {host}:{port}\r\n"
|
|
451
|
+
"Upgrade: websocket\r\n"
|
|
452
|
+
"Connection: Upgrade\r\n"
|
|
453
|
+
f"Sec-WebSocket-Key: {key}\r\n"
|
|
454
|
+
"Sec-WebSocket-Version: 13\r\n"
|
|
455
|
+
# Chromium >= 111 rejects DevTools sockets from unknown origins
|
|
456
|
+
# unless the browser was started with --remote-allow-origins.
|
|
457
|
+
# Sending no Origin header at all keeps us in the allowed case.
|
|
458
|
+
"\r\n"
|
|
459
|
+
)
|
|
460
|
+
self._sock.sendall(request.encode())
|
|
461
|
+
header = b""
|
|
462
|
+
while b"\r\n\r\n" not in header:
|
|
463
|
+
chunk = self._sock.recv(4096)
|
|
464
|
+
if not chunk:
|
|
465
|
+
raise ConnectionError("DevTools closed the connection during handshake")
|
|
466
|
+
header += chunk
|
|
467
|
+
if len(header) > 65536:
|
|
468
|
+
raise ConnectionError("DevTools handshake response was implausibly large")
|
|
469
|
+
if b" 101 " not in header.split(b"\r\n", 1)[0]:
|
|
470
|
+
raise ConnectionError("DevTools refused the WebSocket upgrade")
|
|
471
|
+
# Anything after the header belongs to the frame stream.
|
|
472
|
+
self._buffer = header.split(b"\r\n\r\n", 1)[1]
|
|
473
|
+
|
|
474
|
+
def call(self, method: str, params: dict[str, Any] | None = None) -> Any:
|
|
475
|
+
"""Send a CDP command and return its ``result`` payload."""
|
|
476
|
+
self._next_id += 1
|
|
477
|
+
message_id = self._next_id
|
|
478
|
+
self._send(json.dumps({"id": message_id, "method": method, "params": params or {}}))
|
|
479
|
+
# CDP interleaves unsolicited events with replies; skip to ours.
|
|
480
|
+
for _ in range(50):
|
|
481
|
+
reply = json.loads(self._recv())
|
|
482
|
+
if reply.get("id") == message_id:
|
|
483
|
+
if "error" in reply:
|
|
484
|
+
raise RuntimeError(str(reply["error"]))
|
|
485
|
+
return reply.get("result")
|
|
486
|
+
raise TimeoutError(f"no DevTools reply for {method}")
|
|
487
|
+
|
|
488
|
+
def _send(self, text: str) -> None:
|
|
489
|
+
payload = text.encode("utf-8")
|
|
490
|
+
header = bytearray([0x81]) # FIN + text opcode
|
|
491
|
+
length = len(payload)
|
|
492
|
+
if length < 126:
|
|
493
|
+
header.append(0x80 | length)
|
|
494
|
+
elif length < (1 << 16):
|
|
495
|
+
header.append(0x80 | 126)
|
|
496
|
+
header += struct.pack(">H", length)
|
|
497
|
+
else:
|
|
498
|
+
header.append(0x80 | 127)
|
|
499
|
+
header += struct.pack(">Q", length)
|
|
500
|
+
mask = os.urandom(4)
|
|
501
|
+
header += mask
|
|
502
|
+
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
|
503
|
+
self._sock.sendall(bytes(header) + masked)
|
|
504
|
+
|
|
505
|
+
def _recv(self) -> str:
|
|
506
|
+
"""Read one complete (possibly fragmented) text message."""
|
|
507
|
+
chunks: list[bytes] = []
|
|
508
|
+
while True:
|
|
509
|
+
fin, opcode, payload = self._read_frame()
|
|
510
|
+
if opcode == 0x8: # close
|
|
511
|
+
self.alive = False
|
|
512
|
+
raise ConnectionError("DevTools closed the connection")
|
|
513
|
+
if opcode == 0x9: # ping -> pong, then keep reading
|
|
514
|
+
self._pong(payload)
|
|
515
|
+
continue
|
|
516
|
+
if opcode == 0xA: # pong, ignore
|
|
517
|
+
continue
|
|
518
|
+
chunks.append(payload)
|
|
519
|
+
if fin:
|
|
520
|
+
return b"".join(chunks).decode("utf-8", "replace")
|
|
521
|
+
|
|
522
|
+
def _read_frame(self) -> tuple[bool, int, bytes]:
|
|
523
|
+
first, second = self._read_exactly(2)
|
|
524
|
+
fin = bool(first & 0x80)
|
|
525
|
+
opcode = first & 0x0F
|
|
526
|
+
length = second & 0x7F
|
|
527
|
+
if length == 126:
|
|
528
|
+
length = struct.unpack(">H", self._read_exactly(2))[0]
|
|
529
|
+
elif length == 127:
|
|
530
|
+
length = struct.unpack(">Q", self._read_exactly(8))[0]
|
|
531
|
+
# Server-to-client frames are unmasked; if a mask bit is set, honour it.
|
|
532
|
+
mask = self._read_exactly(4) if second & 0x80 else b""
|
|
533
|
+
payload = self._read_exactly(length) if length else b""
|
|
534
|
+
if mask:
|
|
535
|
+
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
|
536
|
+
return fin, opcode, payload
|
|
537
|
+
|
|
538
|
+
def _read_exactly(self, count: int) -> bytes:
|
|
539
|
+
while len(self._buffer) < count:
|
|
540
|
+
chunk = self._sock.recv(max(4096, count - len(self._buffer)))
|
|
541
|
+
if not chunk:
|
|
542
|
+
self.alive = False
|
|
543
|
+
raise ConnectionError("DevTools stream ended")
|
|
544
|
+
self._buffer += chunk
|
|
545
|
+
out, self._buffer = self._buffer[:count], self._buffer[count:]
|
|
546
|
+
return out
|
|
547
|
+
|
|
548
|
+
def _pong(self, payload: bytes) -> None:
|
|
549
|
+
mask = os.urandom(4)
|
|
550
|
+
header = bytearray([0x8A, 0x80 | len(payload)]) + mask
|
|
551
|
+
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
|
552
|
+
self._sock.sendall(bytes(header) + masked)
|
|
553
|
+
|
|
554
|
+
def close(self) -> None:
|
|
555
|
+
self.alive = False
|
|
556
|
+
try:
|
|
557
|
+
self._sock.close()
|
|
558
|
+
except OSError:
|
|
559
|
+
pass
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _split_ws_url(ws_url: str) -> tuple[str, int, str]:
|
|
563
|
+
"""Split ``ws://host:port/path`` into its parts."""
|
|
564
|
+
rest = ws_url.split("://", 1)[-1]
|
|
565
|
+
netloc, _, path = rest.partition("/")
|
|
566
|
+
host, _, port_text = netloc.partition(":")
|
|
567
|
+
return host or "127.0.0.1", int(port_text or 80), "/" + path
|