zenpilot 0.2.0__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.
- zenpilot/__init__.py +3 -0
- zenpilot/bidi.py +109 -0
- zenpilot/browser.py +497 -0
- zenpilot/cli.py +231 -0
- zenpilot/dialogs.py +41 -0
- zenpilot/safety.py +157 -0
- zenpilot/server.py +308 -0
- zenpilot/tree.py +97 -0
- zenpilot/walker.js +173 -0
- zenpilot/zen.py +152 -0
- zenpilot-0.2.0.dist-info/METADATA +248 -0
- zenpilot-0.2.0.dist-info/RECORD +15 -0
- zenpilot-0.2.0.dist-info/WHEEL +4 -0
- zenpilot-0.2.0.dist-info/entry_points.txt +3 -0
- zenpilot-0.2.0.dist-info/licenses/LICENSE +21 -0
zenpilot/__init__.py
ADDED
zenpilot/bidi.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Minimal WebDriver BiDi client: command/response matching and event listeners."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import itertools
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
import websockets
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BiDiError(Exception):
|
|
12
|
+
"""An error response from the browser, e.g. error="no such node"."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, error: str, message: str):
|
|
15
|
+
super().__init__(f"{error}: {message}")
|
|
16
|
+
self.error = error
|
|
17
|
+
self.message = message
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BiDi:
|
|
21
|
+
def __init__(self, url: str):
|
|
22
|
+
self.url = url
|
|
23
|
+
self._ws = None
|
|
24
|
+
self._ids = itertools.count(1)
|
|
25
|
+
self._pending: dict[int, asyncio.Future] = {}
|
|
26
|
+
self._listeners: list[tuple[str, Callable[[dict], bool], asyncio.Future]] = []
|
|
27
|
+
self._reader: asyncio.Task | None = None
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def connected(self) -> bool:
|
|
31
|
+
return self._reader is not None and not self._reader.done()
|
|
32
|
+
|
|
33
|
+
async def connect(self) -> dict:
|
|
34
|
+
"""Open the socket and create a session. Returns session.new's result."""
|
|
35
|
+
# websockets sends no Origin header, which the remote agent requires.
|
|
36
|
+
self._ws = await websockets.connect(self.url, max_size=None, ping_interval=None)
|
|
37
|
+
self._reader = asyncio.create_task(self._read_loop())
|
|
38
|
+
try:
|
|
39
|
+
return await self.send("session.new", {"capabilities": {}})
|
|
40
|
+
except BaseException:
|
|
41
|
+
await self._ws.close()
|
|
42
|
+
raise
|
|
43
|
+
|
|
44
|
+
async def close(self) -> None:
|
|
45
|
+
"""End the session (Firefox keeps orphaned sessions alive) and close."""
|
|
46
|
+
if not self.connected:
|
|
47
|
+
return
|
|
48
|
+
try:
|
|
49
|
+
await self.send("session.end", timeout=2)
|
|
50
|
+
except (BiDiError, ConnectionError, TimeoutError):
|
|
51
|
+
pass
|
|
52
|
+
await self._ws.close()
|
|
53
|
+
|
|
54
|
+
async def send(self, method: str, params: dict | None = None, timeout: float = 30) -> dict:
|
|
55
|
+
if not self.connected:
|
|
56
|
+
raise ConnectionError("not connected to the browser")
|
|
57
|
+
id_ = next(self._ids)
|
|
58
|
+
fut = asyncio.get_running_loop().create_future()
|
|
59
|
+
self._pending[id_] = fut
|
|
60
|
+
try:
|
|
61
|
+
await self._ws.send(json.dumps({"id": id_, "method": method, "params": params or {}}))
|
|
62
|
+
return await asyncio.wait_for(fut, timeout)
|
|
63
|
+
finally:
|
|
64
|
+
self._pending.pop(id_, None)
|
|
65
|
+
|
|
66
|
+
def expect(self, method: str, predicate: Callable[[dict], bool] = lambda p: True) -> asyncio.Future:
|
|
67
|
+
"""Future resolving with the params of the next matching event.
|
|
68
|
+
|
|
69
|
+
Register it *before* triggering the action that emits the event. Cancel it
|
|
70
|
+
if you stop waiting; cancelled futures are dropped on the next event.
|
|
71
|
+
"""
|
|
72
|
+
fut = asyncio.get_running_loop().create_future()
|
|
73
|
+
self._listeners.append((method, predicate, fut))
|
|
74
|
+
return fut
|
|
75
|
+
|
|
76
|
+
async def _read_loop(self) -> None:
|
|
77
|
+
try:
|
|
78
|
+
async for raw in self._ws:
|
|
79
|
+
self._dispatch(json.loads(raw))
|
|
80
|
+
except websockets.ConnectionClosed:
|
|
81
|
+
pass
|
|
82
|
+
finally:
|
|
83
|
+
err = ConnectionError("connection to the browser closed")
|
|
84
|
+
for fut in [*self._pending.values(), *(f for _, _, f in self._listeners)]:
|
|
85
|
+
if not fut.done():
|
|
86
|
+
fut.set_exception(err)
|
|
87
|
+
self._listeners.clear()
|
|
88
|
+
|
|
89
|
+
def _dispatch(self, msg: dict) -> None:
|
|
90
|
+
if msg.get("type") == "event":
|
|
91
|
+
method, params = msg["method"], msg["params"]
|
|
92
|
+
keep = []
|
|
93
|
+
for listener in self._listeners:
|
|
94
|
+
m, pred, fut = listener
|
|
95
|
+
if fut.done():
|
|
96
|
+
continue
|
|
97
|
+
if m == method and pred(params):
|
|
98
|
+
fut.set_result(params)
|
|
99
|
+
else:
|
|
100
|
+
keep.append(listener)
|
|
101
|
+
self._listeners = keep
|
|
102
|
+
return
|
|
103
|
+
fut = self._pending.get(msg.get("id"))
|
|
104
|
+
if fut is None or fut.done():
|
|
105
|
+
return
|
|
106
|
+
if msg.get("type") == "error":
|
|
107
|
+
fut.set_exception(BiDiError(msg.get("error", "unknown error"), msg.get("message", "")))
|
|
108
|
+
else:
|
|
109
|
+
fut.set_result(msg.get("result", {}))
|
zenpilot/browser.py
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
"""Browser operations over WebDriver BiDi: tabs, page reading and trusted input."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
|
|
8
|
+
import websockets
|
|
9
|
+
|
|
10
|
+
from .bidi import BiDi, BiDiError
|
|
11
|
+
from .tree import MAX_ITEMS, WALKER_JS, Item, Ref, Refs, render, unwrap
|
|
12
|
+
|
|
13
|
+
# BiDi errors that mean "this node or frame no longer exists".
|
|
14
|
+
STALE_ERRORS = {"no such node", "no such element", "stale element reference", "detached shadow root", "no such frame"}
|
|
15
|
+
ENTER, BACKSPACE = "\ue007", "\ue003" # WebDriver key codes
|
|
16
|
+
SPECIAL_KEYS = {"\n": ENTER, "\t": "\ue004"}
|
|
17
|
+
# Firefox allows one BiDi session. Releasing it when idle lets Claude Desktop and
|
|
18
|
+
# several Claude Code sessions take turns; refs and tab ids expire with it.
|
|
19
|
+
IDLE_RELEASE_SECONDS = 120
|
|
20
|
+
|
|
21
|
+
# Scrolls the element into view and reports anything covering its center.
|
|
22
|
+
PREPARE_JS = """(el) => {
|
|
23
|
+
if (!el.isConnected) return ["stale", "", ""];
|
|
24
|
+
el.scrollIntoView({block: "center", inline: "center", behavior: "instant"});
|
|
25
|
+
const r = el.getBoundingClientRect();
|
|
26
|
+
if (r.width === 0 || r.height === 0) return ["hidden", el.tagName, ""];
|
|
27
|
+
const top = el.getRootNode().elementFromPoint?.(r.left + r.width / 2, r.top + r.height / 2);
|
|
28
|
+
const ok = !top || el === top || el.contains(top) || top.contains(el) || top.control === el;
|
|
29
|
+
const classes = typeof top?.className === "string" ? top.className.trim().split(/\\s+/).filter(Boolean) : [];
|
|
30
|
+
const cover = ok ? "" : top.tagName.toLowerCase() + (top.id ? "#" + top.id : "") +
|
|
31
|
+
(classes.length ? "." + classes.slice(0, 2).join(".") : "");
|
|
32
|
+
return ["ok", el.tagName, cover];
|
|
33
|
+
}"""
|
|
34
|
+
|
|
35
|
+
SELECT_CONTENT_JS = """(el) => {
|
|
36
|
+
if (typeof el.select === "function" && el.type !== "file") el.select();
|
|
37
|
+
else if (el.isContentEditable) getSelection().selectAllChildren(el);
|
|
38
|
+
}"""
|
|
39
|
+
|
|
40
|
+
GET_TEXT_JS = """(max) => {
|
|
41
|
+
const articles = document.querySelectorAll("article");
|
|
42
|
+
const root = document.querySelector("main, [role=main]") || (articles.length === 1 ? articles[0] : document.body);
|
|
43
|
+
const text = (root?.innerText || "").replace(/[ \\t]+\\n/g, "\\n").replace(/\\n{3,}/g, "\\n\\n").trim();
|
|
44
|
+
return [document.title, text.length, text.slice(0, max)];
|
|
45
|
+
}"""
|
|
46
|
+
|
|
47
|
+
TAB_STATE_JS = "() => [document.title, document.visibilityState, document.hasFocus()]"
|
|
48
|
+
|
|
49
|
+
SCROLL_INFO_JS = """() => [innerWidth, innerHeight, scrollX, scrollY,
|
|
50
|
+
document.documentElement.scrollWidth, document.documentElement.scrollHeight]"""
|
|
51
|
+
|
|
52
|
+
# Scroll offsets of the window and everything under the viewport's center.
|
|
53
|
+
SCROLL_STATE_JS = """() => {
|
|
54
|
+
const out = [scrollX, scrollY];
|
|
55
|
+
for (let el = document.elementFromPoint(innerWidth / 2, innerHeight / 2); el; el = el.parentElement)
|
|
56
|
+
out.push(el.scrollLeft, el.scrollTop);
|
|
57
|
+
return out.join(",");
|
|
58
|
+
}"""
|
|
59
|
+
|
|
60
|
+
# Fallback for when wheel events don't scroll (Zen's window is hidden): scroll the
|
|
61
|
+
# nearest scrollable ancestor of the viewport's center, like the wheel would.
|
|
62
|
+
SCROLL_JS_FALLBACK = """(dx, dy) => {
|
|
63
|
+
let el = document.elementFromPoint(innerWidth / 2, innerHeight / 2);
|
|
64
|
+
for (; el && el !== document.documentElement; el = el.parentElement) {
|
|
65
|
+
const s = getComputedStyle(el);
|
|
66
|
+
const canY = dy && /(auto|scroll)/.test(s.overflowY) && el.scrollHeight > el.clientHeight;
|
|
67
|
+
const canX = dx && /(auto|scroll)/.test(s.overflowX) && el.scrollWidth > el.clientWidth;
|
|
68
|
+
if (canY || canX) break;
|
|
69
|
+
}
|
|
70
|
+
const target = el && el !== document.documentElement ? el : document.scrollingElement;
|
|
71
|
+
target.scrollBy({left: dx, top: dy, behavior: "instant"});
|
|
72
|
+
}"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class BrowserError(Exception):
|
|
76
|
+
"""A failure with a message written for the model."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class BrowserTimeout(BrowserError):
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _arg(v) -> dict:
|
|
84
|
+
if isinstance(v, dict): # {"sharedId": ...}
|
|
85
|
+
return v
|
|
86
|
+
if v is None:
|
|
87
|
+
return {"type": "null"}
|
|
88
|
+
if isinstance(v, bool):
|
|
89
|
+
return {"type": "boolean", "value": v}
|
|
90
|
+
if isinstance(v, (int, float)):
|
|
91
|
+
return {"type": "number", "value": v}
|
|
92
|
+
return {"type": "string", "value": str(v)}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _stale(key: str) -> BrowserError:
|
|
96
|
+
return BrowserError(f"Ref {key} is stale: the page changed since read_page. Call read_page again for fresh refs.")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Browser:
|
|
100
|
+
def __init__(self):
|
|
101
|
+
self._bidi: BiDi | None = None
|
|
102
|
+
self._lock = asyncio.Lock()
|
|
103
|
+
self._next_tab = 1 # never reset, so old tab ids can't alias new tabs
|
|
104
|
+
self._tab_ids: dict[str, str] = {} # context -> "t1"
|
|
105
|
+
self._contexts: dict[str, str] = {} # "t1" -> context
|
|
106
|
+
self._last_used: str | None = None # context zenpilot last opened, switched to or navigated
|
|
107
|
+
self._busy = 0
|
|
108
|
+
self._idle_timer: asyncio.TimerHandle | None = None
|
|
109
|
+
self.refs = Refs()
|
|
110
|
+
|
|
111
|
+
# -- connection -------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
async def connect(self, port: int) -> None:
|
|
114
|
+
async with self._lock:
|
|
115
|
+
if self._bidi and self._bidi.connected:
|
|
116
|
+
return
|
|
117
|
+
# Firefox issues new context ids per session, so old ids and refs are void.
|
|
118
|
+
self._tab_ids.clear()
|
|
119
|
+
self._contexts.clear()
|
|
120
|
+
self._last_used = None
|
|
121
|
+
self.refs.clear()
|
|
122
|
+
bidi = BiDi(f"ws://127.0.0.1:{port}/session")
|
|
123
|
+
try:
|
|
124
|
+
await bidi.connect()
|
|
125
|
+
except (OSError, websockets.InvalidHandshake) as e:
|
|
126
|
+
raise BrowserError(
|
|
127
|
+
f"Can't reach Zen's remote agent on 127.0.0.1:{port} ({e}). "
|
|
128
|
+
"Ask the user to run `zenpilot launch`, or `zenpilot doctor` to see what's wrong."
|
|
129
|
+
) from None
|
|
130
|
+
except BiDiError as e:
|
|
131
|
+
if e.error == "session not created":
|
|
132
|
+
raise BrowserError(
|
|
133
|
+
"Zen already has an active automation session. Either another zenpilot "
|
|
134
|
+
"(Claude Desktop or Claude Code) is using it, or a previous one was killed "
|
|
135
|
+
"without ending its session; in that case Zen must be restarted."
|
|
136
|
+
) from None
|
|
137
|
+
raise
|
|
138
|
+
try:
|
|
139
|
+
await bidi.send(
|
|
140
|
+
"session.subscribe",
|
|
141
|
+
{
|
|
142
|
+
"events": [
|
|
143
|
+
"browsingContext.navigationStarted",
|
|
144
|
+
"browsingContext.load",
|
|
145
|
+
"browsingContext.contextCreated",
|
|
146
|
+
]
|
|
147
|
+
},
|
|
148
|
+
)
|
|
149
|
+
except BaseException:
|
|
150
|
+
await bidi.close() # don't leave an orphaned session behind
|
|
151
|
+
raise
|
|
152
|
+
self._bidi = bidi
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def connected(self) -> bool:
|
|
156
|
+
return bool(self._bidi and self._bidi.connected)
|
|
157
|
+
|
|
158
|
+
async def close(self) -> None:
|
|
159
|
+
if self._bidi:
|
|
160
|
+
await self._bidi.close()
|
|
161
|
+
|
|
162
|
+
@asynccontextmanager
|
|
163
|
+
async def session(self, port: int):
|
|
164
|
+
"""Hold the BiDi session for one tool call; release it after a quiet period."""
|
|
165
|
+
if self._idle_timer:
|
|
166
|
+
self._idle_timer.cancel()
|
|
167
|
+
self._busy += 1
|
|
168
|
+
try:
|
|
169
|
+
await self.connect(port)
|
|
170
|
+
yield
|
|
171
|
+
finally:
|
|
172
|
+
self._busy -= 1
|
|
173
|
+
loop = asyncio.get_running_loop()
|
|
174
|
+
self._idle_timer = loop.call_later(IDLE_RELEASE_SECONDS, lambda: loop.create_task(self._release_if_idle()))
|
|
175
|
+
|
|
176
|
+
async def _release_if_idle(self) -> None:
|
|
177
|
+
async with self._lock:
|
|
178
|
+
if self._busy == 0:
|
|
179
|
+
await self.close()
|
|
180
|
+
|
|
181
|
+
async def call(self, method: str, params: dict | None = None, timeout: float = 30) -> dict:
|
|
182
|
+
try:
|
|
183
|
+
return await self._bidi.send(method, params, timeout)
|
|
184
|
+
except ConnectionError:
|
|
185
|
+
raise BrowserError(
|
|
186
|
+
"Lost the connection to Zen. Retry; if it persists, check that Zen is running."
|
|
187
|
+
) from None
|
|
188
|
+
except TimeoutError:
|
|
189
|
+
raise BrowserTimeout(f"Zen did not answer {method} within {timeout:g}s.") from None
|
|
190
|
+
|
|
191
|
+
async def run(self, context: str, fn: str, *args, timeout: float = 15):
|
|
192
|
+
"""Call a JS function in a browsing context and return its unwrapped result."""
|
|
193
|
+
r = await self.call(
|
|
194
|
+
"script.callFunction",
|
|
195
|
+
{
|
|
196
|
+
"functionDeclaration": fn,
|
|
197
|
+
"target": {"context": context},
|
|
198
|
+
"arguments": [_arg(a) for a in args],
|
|
199
|
+
"awaitPromise": True,
|
|
200
|
+
"resultOwnership": "none",
|
|
201
|
+
"serializationOptions": {"maxDomDepth": 0},
|
|
202
|
+
},
|
|
203
|
+
timeout,
|
|
204
|
+
)
|
|
205
|
+
if r["type"] == "exception":
|
|
206
|
+
raise BrowserError(f"Script error in page: {r['exceptionDetails'].get('text', '')}")
|
|
207
|
+
return unwrap(r["result"])
|
|
208
|
+
|
|
209
|
+
# -- tabs ---------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
def _tab_id(self, context: str) -> str:
|
|
212
|
+
if context not in self._tab_ids:
|
|
213
|
+
tab = f"t{self._next_tab}"
|
|
214
|
+
self._next_tab += 1
|
|
215
|
+
self._tab_ids[context] = tab
|
|
216
|
+
self._contexts[tab] = context
|
|
217
|
+
return self._tab_ids[context]
|
|
218
|
+
|
|
219
|
+
async def tabs(self, probe: bool = True) -> list[dict]:
|
|
220
|
+
"""Top-level tabs. With probe, also fetch titles and work out the active tab."""
|
|
221
|
+
contexts = (await self.call("browsingContext.getTree", {"maxDepth": 0}))["contexts"]
|
|
222
|
+
tabs = [
|
|
223
|
+
{
|
|
224
|
+
"id": self._tab_id(c["context"]),
|
|
225
|
+
"context": c["context"],
|
|
226
|
+
"url": c["url"],
|
|
227
|
+
"window": c.get("clientWindow"),
|
|
228
|
+
"title": "",
|
|
229
|
+
"active": False,
|
|
230
|
+
}
|
|
231
|
+
for c in contexts
|
|
232
|
+
]
|
|
233
|
+
live = {t["context"] for t in tabs}
|
|
234
|
+
for ctx in [c for c in self._tab_ids if c not in live]:
|
|
235
|
+
self.refs.drop_tab(self._tab_ids[ctx])
|
|
236
|
+
del self._contexts[self._tab_ids.pop(ctx)]
|
|
237
|
+
if not probe or not tabs:
|
|
238
|
+
return tabs
|
|
239
|
+
|
|
240
|
+
async def state(tab):
|
|
241
|
+
try:
|
|
242
|
+
return await self.run(tab["context"], TAB_STATE_JS, timeout=3)
|
|
243
|
+
except (BrowserError, BiDiError):
|
|
244
|
+
return ["", "hidden", False]
|
|
245
|
+
|
|
246
|
+
states = await asyncio.gather(*(state(t) for t in tabs))
|
|
247
|
+
windows = (await self.call("browser.getClientWindows"))["clientWindows"]
|
|
248
|
+
focused_window = next((w["clientWindow"] for w in windows if w.get("active")), None)
|
|
249
|
+
|
|
250
|
+
# Zen has no "selected tab" API. Focus and visibility identify it while the window
|
|
251
|
+
# is on screen; when it's covered they go stale, so the tab zenpilot last used breaks ties.
|
|
252
|
+
def rank(t, state):
|
|
253
|
+
_, visibility, focus = state
|
|
254
|
+
visible = visibility == "visible"
|
|
255
|
+
in_focused_window = visible and t["window"] == focused_window
|
|
256
|
+
return 4 * focus + 2 * visible + in_focused_window + 2.5 * (t["context"] == self._last_used)
|
|
257
|
+
|
|
258
|
+
for t, state in zip(tabs, states, strict=True):
|
|
259
|
+
t["title"] = state[0] or ""
|
|
260
|
+
max(zip(tabs, states, strict=True), key=lambda ts: rank(*ts))[0]["active"] = True
|
|
261
|
+
return tabs
|
|
262
|
+
|
|
263
|
+
async def tab(self, tab_id: str | None) -> dict:
|
|
264
|
+
tabs = await self.tabs(probe=tab_id is None)
|
|
265
|
+
if tab_id is None:
|
|
266
|
+
if not tabs:
|
|
267
|
+
raise BrowserError("Zen has no open tabs. Use new_tab.")
|
|
268
|
+
return next(t for t in tabs if t["active"])
|
|
269
|
+
tab = next((t for t in tabs if t["id"] == tab_id.strip().lower()), None)
|
|
270
|
+
if tab is None:
|
|
271
|
+
raise BrowserError(f"Unknown tab id '{tab_id}'. Call list_tabs for current ids.")
|
|
272
|
+
return tab
|
|
273
|
+
|
|
274
|
+
async def url_of(self, context: str) -> str:
|
|
275
|
+
return (await self.call("browsingContext.getTree", {"root": context, "maxDepth": 0}))["contexts"][0]["url"]
|
|
276
|
+
|
|
277
|
+
async def new_tab(self) -> str:
|
|
278
|
+
# A foreground create waits for the tab to become visible, which never happens
|
|
279
|
+
# while Zen's window is covered. Create in the background, then activate.
|
|
280
|
+
context = (await self.call("browsingContext.create", {"type": "tab", "background": True}))["context"]
|
|
281
|
+
await self.activate({"context": context})
|
|
282
|
+
return self._tab_id(context)
|
|
283
|
+
|
|
284
|
+
async def activate(self, tab: dict) -> None:
|
|
285
|
+
self._last_used = tab["context"]
|
|
286
|
+
try:
|
|
287
|
+
# Selects the tab at once, but the reply waits on window focus and
|
|
288
|
+
# visibility changes that a covered window never delivers.
|
|
289
|
+
await self.call("browsingContext.activate", {"context": tab["context"]}, timeout=1)
|
|
290
|
+
except BrowserTimeout:
|
|
291
|
+
pass
|
|
292
|
+
|
|
293
|
+
async def close_tab(self, tab: dict) -> None:
|
|
294
|
+
await self.call("browsingContext.close", {"context": tab["context"]})
|
|
295
|
+
self.refs.drop_tab(tab["id"])
|
|
296
|
+
|
|
297
|
+
async def navigate(self, tab: dict, url: str) -> tuple[str, bool]:
|
|
298
|
+
"""Navigate and wait for load. Returns (final url, still_loading)."""
|
|
299
|
+
self.refs.drop_tab(tab["id"])
|
|
300
|
+
self._last_used = tab["context"]
|
|
301
|
+
try:
|
|
302
|
+
await self.call("browsingContext.navigate", {"context": tab["context"], "url": url, "wait": "complete"})
|
|
303
|
+
loading = False
|
|
304
|
+
except BrowserTimeout:
|
|
305
|
+
loading = True
|
|
306
|
+
except BiDiError as e:
|
|
307
|
+
raise BrowserError(f"Navigation to {url} failed: {e.message or e.error}") from None
|
|
308
|
+
return await self.url_of(tab["context"]), loading
|
|
309
|
+
|
|
310
|
+
# -- page content -----------------------------------------------------
|
|
311
|
+
|
|
312
|
+
async def read_page(self, tab: dict, frame_allowed: Callable[[str], str | None]) -> str:
|
|
313
|
+
"""Outline of the page; frame_allowed(url) returns a reason string to skip a frame."""
|
|
314
|
+
self.refs.drop_tab(tab["id"])
|
|
315
|
+
items: list[Item] = []
|
|
316
|
+
truncated = False
|
|
317
|
+
|
|
318
|
+
async def collect(context: str, base: int):
|
|
319
|
+
nonlocal truncated
|
|
320
|
+
out, trunc = await self.run(context, WALKER_JS, MAX_ITEMS, timeout=20)
|
|
321
|
+
truncated |= trunc
|
|
322
|
+
for depth, line, node, frame in out:
|
|
323
|
+
item = Item(base + depth, line, context, node and node["sharedId"])
|
|
324
|
+
items.append(item)
|
|
325
|
+
if not frame:
|
|
326
|
+
continue
|
|
327
|
+
try:
|
|
328
|
+
url = await self.url_of(frame["context"])
|
|
329
|
+
if reason := frame_allowed(url):
|
|
330
|
+
item.line += f" [skipped: {reason}]"
|
|
331
|
+
else:
|
|
332
|
+
await collect(frame["context"], base + depth + 1)
|
|
333
|
+
except (BrowserError, BiDiError):
|
|
334
|
+
item.line += " [not readable]"
|
|
335
|
+
|
|
336
|
+
await collect(tab["context"], 0)
|
|
337
|
+
return render(items, self.refs, tab["id"], truncated)
|
|
338
|
+
|
|
339
|
+
async def get_text(self, tab: dict, max_chars: int) -> tuple[str, str]:
|
|
340
|
+
title, length, text = await self.run(tab["context"], GET_TEXT_JS, max_chars)
|
|
341
|
+
if length > max_chars:
|
|
342
|
+
text += f"\n[truncated: showing {max_chars} of {length} characters]"
|
|
343
|
+
return title, text or "[no text]"
|
|
344
|
+
|
|
345
|
+
async def screenshot(self, tab: dict) -> bytes:
|
|
346
|
+
r = await self.call(
|
|
347
|
+
"browsingContext.captureScreenshot",
|
|
348
|
+
{
|
|
349
|
+
"context": tab["context"],
|
|
350
|
+
"format": {"type": "image/jpeg", "quality": 0.8},
|
|
351
|
+
},
|
|
352
|
+
)
|
|
353
|
+
return base64.b64decode(r["data"])
|
|
354
|
+
|
|
355
|
+
# -- input ----------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
def ref(self, key: str) -> Ref:
|
|
358
|
+
ref = self.refs.get(key)
|
|
359
|
+
if ref is None:
|
|
360
|
+
raise BrowserError(
|
|
361
|
+
f"Unknown ref '{key}'. Refs come from the latest read_page of a tab and expire "
|
|
362
|
+
"when read_page runs again or the page navigates. Call read_page again."
|
|
363
|
+
)
|
|
364
|
+
return ref
|
|
365
|
+
|
|
366
|
+
def top_context(self, ref: Ref) -> str:
|
|
367
|
+
return self._contexts.get(ref.tab, ref.context)
|
|
368
|
+
|
|
369
|
+
async def ref_url(self, key: str) -> str:
|
|
370
|
+
"""URL of the tab a ref belongs to."""
|
|
371
|
+
try:
|
|
372
|
+
return await self.url_of(self.top_context(self.ref(key)))
|
|
373
|
+
except BiDiError:
|
|
374
|
+
raise BrowserError(f"The tab ref {key} belonged to is gone. Call list_tabs, then read_page.") from None
|
|
375
|
+
|
|
376
|
+
async def _prepare(self, key: str, ref: Ref) -> tuple[str, str]:
|
|
377
|
+
"""Scroll the node into view. Returns (tagName, covering element or "")."""
|
|
378
|
+
try:
|
|
379
|
+
status, tag, cover = await self.run(ref.context, PREPARE_JS, {"sharedId": ref.shared_id})
|
|
380
|
+
except BiDiError as e:
|
|
381
|
+
if e.error in STALE_ERRORS:
|
|
382
|
+
raise _stale(key) from None
|
|
383
|
+
raise
|
|
384
|
+
if status == "stale":
|
|
385
|
+
raise _stale(key)
|
|
386
|
+
if status == "hidden":
|
|
387
|
+
raise BrowserError(f"Ref {key} is no longer visible. Call read_page again.")
|
|
388
|
+
return tag, cover
|
|
389
|
+
|
|
390
|
+
async def _actions(self, ref: Ref, actions: list[dict]) -> None:
|
|
391
|
+
await self.call("input.performActions", {"context": ref.context, "actions": actions})
|
|
392
|
+
|
|
393
|
+
async def _click(self, ref: Ref) -> None:
|
|
394
|
+
origin = {"type": "element", "element": {"sharedId": ref.shared_id}}
|
|
395
|
+
await self._actions(
|
|
396
|
+
ref,
|
|
397
|
+
[
|
|
398
|
+
{
|
|
399
|
+
"type": "pointer",
|
|
400
|
+
"id": "mouse",
|
|
401
|
+
"parameters": {"pointerType": "mouse"},
|
|
402
|
+
"actions": [
|
|
403
|
+
{"type": "pointerMove", "x": 0, "y": 0, "origin": origin},
|
|
404
|
+
{"type": "pointerDown", "button": 0},
|
|
405
|
+
{"type": "pointerUp", "button": 0},
|
|
406
|
+
],
|
|
407
|
+
}
|
|
408
|
+
],
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
async def _keys(self, ref: Ref, keys: list[str]) -> None:
|
|
412
|
+
for i in range(0, len(keys), 100):
|
|
413
|
+
chunk = [{"type": t, "value": k} for k in keys[i : i + 100] for t in ("keyDown", "keyUp")]
|
|
414
|
+
await self._actions(ref, [{"type": "key", "id": "keyboard", "actions": chunk}])
|
|
415
|
+
|
|
416
|
+
async def _settle(self, ref: Ref, action) -> str:
|
|
417
|
+
"""Run an input action; if it starts a navigation or opens a tab, wait and report it."""
|
|
418
|
+
top = self.top_context(ref)
|
|
419
|
+
started = self._bidi.expect("browsingContext.navigationStarted", lambda p: p["context"] == top)
|
|
420
|
+
loaded = self._bidi.expect("browsingContext.load", lambda p: p["context"] == top)
|
|
421
|
+
created = self._bidi.expect("browsingContext.contextCreated", lambda p: p.get("parent") is None)
|
|
422
|
+
try:
|
|
423
|
+
await action()
|
|
424
|
+
await asyncio.wait({started, created}, timeout=0.4)
|
|
425
|
+
notes = []
|
|
426
|
+
if started.done() and not started.exception():
|
|
427
|
+
try:
|
|
428
|
+
await asyncio.wait_for(loaded, 15)
|
|
429
|
+
notes.append(f"Page navigated to {await self.url_of(top)}")
|
|
430
|
+
except TimeoutError:
|
|
431
|
+
notes.append("A navigation started and is still loading")
|
|
432
|
+
if created.done() and not created.exception():
|
|
433
|
+
tab = self._tab_id(created.result()["context"])
|
|
434
|
+
notes.append(f"A new tab {tab} opened")
|
|
435
|
+
return ". ".join(notes)
|
|
436
|
+
finally:
|
|
437
|
+
for fut in (started, loaded, created):
|
|
438
|
+
fut.cancel()
|
|
439
|
+
|
|
440
|
+
async def click(self, key: str) -> str:
|
|
441
|
+
ref = self.ref(key)
|
|
442
|
+
_, cover = await self._prepare(key, ref)
|
|
443
|
+
result = f"Clicked {key}."
|
|
444
|
+
if cover:
|
|
445
|
+
result += f" Note: its center was covered by <{cover}>, which received the click."
|
|
446
|
+
if nav := await self._settle(ref, lambda: self._click(ref)):
|
|
447
|
+
result += f" {nav}."
|
|
448
|
+
return result
|
|
449
|
+
|
|
450
|
+
async def type(self, key: str, text: str, submit: bool) -> str:
|
|
451
|
+
ref = self.ref(key)
|
|
452
|
+
tag, _ = await self._prepare(key, ref)
|
|
453
|
+
if tag == "SELECT":
|
|
454
|
+
# Clicking would open the native popup; focus and type to pick an option instead.
|
|
455
|
+
await self.run(ref.context, "(el) => el.focus()", {"sharedId": ref.shared_id})
|
|
456
|
+
else:
|
|
457
|
+
await self._click(ref)
|
|
458
|
+
await self.run(ref.context, SELECT_CONTENT_JS, {"sharedId": ref.shared_id})
|
|
459
|
+
await self._keys(ref, [SPECIAL_KEYS.get(ch, ch) for ch in text] or [BACKSPACE])
|
|
460
|
+
result = f"Typed {len(text)} characters into {key}."
|
|
461
|
+
if submit and (nav := await self._settle(ref, lambda: self._keys(ref, [ENTER]))):
|
|
462
|
+
result += f" Pressed Enter. {nav}."
|
|
463
|
+
elif submit:
|
|
464
|
+
result += " Pressed Enter."
|
|
465
|
+
return result
|
|
466
|
+
|
|
467
|
+
async def scroll(self, tab: dict, direction: str, amount: int) -> str:
|
|
468
|
+
ctx = tab["context"]
|
|
469
|
+
w, h, *_ = await self.run(ctx, SCROLL_INFO_JS)
|
|
470
|
+
dx, dy = {"down": (0, amount), "up": (0, -amount), "right": (amount, 0), "left": (-amount, 0)}[direction]
|
|
471
|
+
before = await self.run(ctx, SCROLL_STATE_JS)
|
|
472
|
+
await self.call(
|
|
473
|
+
"input.performActions",
|
|
474
|
+
{
|
|
475
|
+
"context": ctx,
|
|
476
|
+
"actions": [
|
|
477
|
+
{
|
|
478
|
+
"type": "wheel",
|
|
479
|
+
"id": "wheel",
|
|
480
|
+
"actions": [{"type": "scroll", "x": int(w // 2), "y": int(h // 2), "deltaX": dx, "deltaY": dy}],
|
|
481
|
+
}
|
|
482
|
+
],
|
|
483
|
+
},
|
|
484
|
+
)
|
|
485
|
+
# Smooth scrolling takes a while: wait until the position settles (up to 2s),
|
|
486
|
+
# or give up after 0.4s if nothing moved at all.
|
|
487
|
+
state = before
|
|
488
|
+
for i in range(20):
|
|
489
|
+
await asyncio.sleep(0.1)
|
|
490
|
+
now = await self.run(ctx, SCROLL_STATE_JS)
|
|
491
|
+
if now == state and (now != before or i >= 3):
|
|
492
|
+
break
|
|
493
|
+
state = now
|
|
494
|
+
if state == before:
|
|
495
|
+
await self.run(ctx, SCROLL_JS_FALLBACK, dx, dy)
|
|
496
|
+
_, h, x, y, sw, sh = await self.run(ctx, SCROLL_INFO_JS)
|
|
497
|
+
return f"Scrolled {direction}. Viewport now at y={y:.0f} of {sh:.0f} (height {h:.0f}), x={x:.0f} of {sw:.0f}."
|