pi-lean-portal 0.1.0
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/LICENSE +661 -0
- package/README.md +608 -0
- package/backends/chromium/index.ts +50 -0
- package/backends/chromium-py/bridge.py +67 -0
- package/backends/firefox/index.ts +60 -0
- package/backends/firefox-py/bridge.py +64 -0
- package/backends/playwright-base/playwright-plugin.ts +1294 -0
- package/backends/python-adapter.ts +1141 -0
- package/backends/python-base/pi_browser_bridge/__init__.py +71 -0
- package/backends/python-base/pi_browser_bridge/accessibility.py +408 -0
- package/backends/python-base/pi_browser_bridge/bot_detection.py +115 -0
- package/backends/python-base/pi_browser_bridge/bridge.py +598 -0
- package/backends/python-base/pi_browser_bridge/playwright_base.py +1222 -0
- package/backends/python-base/pi_browser_bridge/transport.py +167 -0
- package/backends/python-base/pyproject.toml +15 -0
- package/browser-cookies.ts +88 -0
- package/browser-profile.ts +260 -0
- package/browser-status.ts +84 -0
- package/browser-toggle.ts +527 -0
- package/core/fetch-backend.ts +466 -0
- package/core/guides.ts +467 -0
- package/core/plugin-api.ts +302 -0
- package/core/plugin-config.ts +388 -0
- package/core/plugin-registry.ts +263 -0
- package/core/router.ts +1186 -0
- package/core/shared/accessibility-tree.ts +408 -0
- package/core/shared/bot-detection.ts +187 -0
- package/core/shared/browser-events.ts +111 -0
- package/core/shared/dom-extractor.ts +550 -0
- package/core/shared/nav-settle.ts +187 -0
- package/core/shared/paths.ts +56 -0
- package/core/shared/session-manager.ts +258 -0
- package/core/shared/settings-reader.ts +63 -0
- package/core/shared/snapshot-cache.ts +231 -0
- package/core/shared/storage-state.ts +560 -0
- package/core/shared/task-id.ts +77 -0
- package/core/shared/url-safety.ts +164 -0
- package/index.ts +253 -0
- package/package.json +63 -0
- package/ship-manifest.test.ts +12 -0
- package/tools/browser-back.ts +50 -0
- package/tools/browser-click.ts +74 -0
- package/tools/browser-console.ts +160 -0
- package/tools/browser-inspect.ts +136 -0
- package/tools/browser-navigate.ts +254 -0
- package/tools/browser-press.ts +80 -0
- package/tools/browser-scroll.ts +56 -0
- package/tools/browser-snapshot.ts +90 -0
- package/tools/browser-type.ts +60 -0
- package/tools/index.ts +19 -0
- package/tools/utils.ts +157 -0
- package/tools/web-fetch.ts +147 -0
- package/tools/web-guide.ts +55 -0
- package/tools/web-learn.ts +128 -0
- package/verify-ship-manifest.ts +126 -0
|
@@ -0,0 +1,1222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Playwright Bridge Base — shared Playwright logic for Python browser bridges.
|
|
3
|
+
|
|
4
|
+
Extracts the Playwright-specific implementation from chromium-py/bridge.py
|
|
5
|
+
into a parameterized base class. Subclasses override:
|
|
6
|
+
|
|
7
|
+
* ``_plugin_name`` — e.g. ``"chromium-py"``, ``"firefox-py"``
|
|
8
|
+
* ``_user_agent`` — fallback UA string (when dynamic probe is disabled or fails)
|
|
9
|
+
* ``_capture_user_agent`` — bool; if True the real UA is probed at lazy browser init
|
|
10
|
+
* ``_install_hint`` — engine-specific install instruction
|
|
11
|
+
* ``_launch_browser()`` — calls ``self._pw.chromium.launch()`` or ``self._pw.firefox.launch()``
|
|
12
|
+
|
|
13
|
+
All navigation, interaction, console, cookie, and storage operations
|
|
14
|
+
are shared across engines.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
from typing import Any, Optional
|
|
24
|
+
|
|
25
|
+
from .bridge import BrowserBridge, SessionNotFoundError
|
|
26
|
+
from .bot_detection import check_bot_detection
|
|
27
|
+
from .accessibility import parse_snapshot, build_locator_args
|
|
28
|
+
|
|
29
|
+
# ─── Playwright import (lazy, for better error messages) ──────────────
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout # type: ignore[import-unresolved]
|
|
33
|
+
HAS_PLAYWRIGHT = True
|
|
34
|
+
except ImportError:
|
|
35
|
+
HAS_PLAYWRIGHT = False
|
|
36
|
+
sync_playwright = None # type: ignore[assignment]
|
|
37
|
+
PlaywrightTimeout = TimeoutError # type: ignore[misc]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ─── Shared helper for bridge entry points ───────────────────────────
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def check_playwright_or_exit(browser: str) -> None:
|
|
44
|
+
"""Check Playwright is installed; print JSON-RPC error and exit if not.
|
|
45
|
+
|
|
46
|
+
Intended for use in bridge ``if __name__ == "__main__"`` blocks to give
|
|
47
|
+
the TypeScript ``PythonPluginAdapter`` an immediate, parseable error
|
|
48
|
+
before the bridge tries to start.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
browser: The browser channel name (e.g. ``"chromium"``, ``"firefox"``).
|
|
52
|
+
"""
|
|
53
|
+
if not HAS_PLAYWRIGHT:
|
|
54
|
+
msg = (
|
|
55
|
+
f"ERROR: Playwright {browser} is not installed.\n"
|
|
56
|
+
"Run the following commands to install:\n"
|
|
57
|
+
" pip install playwright\n"
|
|
58
|
+
f" playwright install {browser}\n"
|
|
59
|
+
)
|
|
60
|
+
print(json.dumps({
|
|
61
|
+
"jsonrpc": "2.0",
|
|
62
|
+
"id": None,
|
|
63
|
+
"error": {"code": -32000, "message": msg.strip()},
|
|
64
|
+
}))
|
|
65
|
+
sys.stdout.flush()
|
|
66
|
+
sys.exit(1)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PlaywrightBridge(BrowserBridge):
|
|
70
|
+
"""Base class for Playwright-based browser bridges.
|
|
71
|
+
|
|
72
|
+
Subclasses must set these class/instance attributes:
|
|
73
|
+
|
|
74
|
+
* ``_plugin_name`` — log identifier (e.g. ``"chromium-py"``,
|
|
75
|
+
``"firefox-py"``)
|
|
76
|
+
* ``_user_agent`` — fallback UA string (used when dynamic probe
|
|
77
|
+
is disabled or fails)
|
|
78
|
+
* ``_capture_user_agent`` — bool; when True the real UA is
|
|
79
|
+
dynamically probed from an about:blank page at lazy browser init
|
|
80
|
+
* ``_install_hint`` — shown when the browser executable is missing
|
|
81
|
+
* ``_launch_browser()`` — factory for creating the Playwright
|
|
82
|
+
Browser; called from ``_ensure_playwright()`` with the
|
|
83
|
+
``sync_playwright`` context already started (``self._pw`` is
|
|
84
|
+
available).
|
|
85
|
+
|
|
86
|
+
``_plugin_name`` appears in ``_log()`` calls as ``plugin=self._plugin_name``
|
|
87
|
+
so the debug output identifies which plugin is logging.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
# ── Subclass contract ─────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
#: Plugin identifier for log output (e.g. "chromium-py", "firefox-py").
|
|
93
|
+
_plugin_name: str = ""
|
|
94
|
+
|
|
95
|
+
#: Fallback user-agent string (used when ``_capture_user_agent`` is False or the dynamic probe fails).
|
|
96
|
+
_user_agent: str = ""
|
|
97
|
+
|
|
98
|
+
#: When True, dynamically probes the real UA from an about:blank page at lazy browser init.
|
|
99
|
+
#: Set for engines (like Firefox) whose UA may change across Playwright versions.
|
|
100
|
+
_capture_user_agent: bool = False
|
|
101
|
+
|
|
102
|
+
#: Engine-specific install hint (shown when browser executable missing).
|
|
103
|
+
_install_hint: str = ""
|
|
104
|
+
|
|
105
|
+
# ── Shared Playwright state ─────────────────────────────────
|
|
106
|
+
|
|
107
|
+
_pw: Any # Playwright instance (lazy, shared)
|
|
108
|
+
_browser: Any # Browser instance (lazy, shared)
|
|
109
|
+
|
|
110
|
+
def __init__(self) -> None:
|
|
111
|
+
super().__init__()
|
|
112
|
+
self._pw = None
|
|
113
|
+
self._browser = None
|
|
114
|
+
self._cached_ua: str = ""
|
|
115
|
+
|
|
116
|
+
# ── Subclass extension point ───────────────────────────────
|
|
117
|
+
|
|
118
|
+
def _launch_browser(self) -> Any:
|
|
119
|
+
"""Create and return a Playwright Browser.
|
|
120
|
+
|
|
121
|
+
Called from ``_ensure_playwright()`` after ``self._pw`` has been
|
|
122
|
+
started. Subclasses must call ``self._pw.chromium.launch(...)``
|
|
123
|
+
or ``self._pw.firefox.launch(...)`` with engine-specific args.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
Exception: if the browser executable cannot be found or
|
|
127
|
+
launched. The base class catches this and re-raises with
|
|
128
|
+
the engine-specific ``_install_hint``.
|
|
129
|
+
"""
|
|
130
|
+
raise NotImplementedError("Subclass must override _launch_browser")
|
|
131
|
+
|
|
132
|
+
# ── User-agent capture (probe-then-cache) ──────────────────────
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def effective_user_agent(self) -> str:
|
|
136
|
+
"""Return the cached dynamic UA, or the hardcoded fallback.
|
|
137
|
+
|
|
138
|
+
When ``_capture_user_agent`` is True and the probe succeeded,
|
|
139
|
+
returns the UA captured from an about:blank page. Otherwise
|
|
140
|
+
falls back to ``_user_agent``.
|
|
141
|
+
"""
|
|
142
|
+
return self._cached_ua or self._user_agent
|
|
143
|
+
|
|
144
|
+
def _capture_ua(self) -> None:
|
|
145
|
+
"""Dynamically probe the real user-agent from a throwaway about:blank page.
|
|
146
|
+
|
|
147
|
+
Called once at lazy browser init when ``_capture_user_agent`` is True.
|
|
148
|
+
Silently falls back to ``_user_agent`` on failure.
|
|
149
|
+
"""
|
|
150
|
+
if self._cached_ua:
|
|
151
|
+
return
|
|
152
|
+
page = None
|
|
153
|
+
try:
|
|
154
|
+
page = self._browser.new_page()
|
|
155
|
+
self._cached_ua = page.evaluate("() => navigator.userAgent")
|
|
156
|
+
self._log("captureUA", success=True, ua=self._cached_ua)
|
|
157
|
+
except Exception:
|
|
158
|
+
self._log("captureUA", success=False, ua="(fallback)")
|
|
159
|
+
finally:
|
|
160
|
+
if page is not None:
|
|
161
|
+
try:
|
|
162
|
+
page.close()
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
# ── Debug logging ───────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def _debug(self) -> bool:
|
|
170
|
+
"""Whether structured debug logging is enabled."""
|
|
171
|
+
return os.environ.get("BROWSER_DEBUG") == "1"
|
|
172
|
+
|
|
173
|
+
def _log(self, event: str, **data: Any) -> None:
|
|
174
|
+
"""Structured debug log to stderr when BROWSER_DEBUG=1."""
|
|
175
|
+
if self._debug:
|
|
176
|
+
print(
|
|
177
|
+
f"[browser] {event}: {json.dumps(data, default=str)}",
|
|
178
|
+
file=sys.stderr,
|
|
179
|
+
flush=True,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# ── Shared Playwright lifecycle ────────────────────────────
|
|
183
|
+
|
|
184
|
+
def _ensure_playwright(self) -> tuple[Any, Any]:
|
|
185
|
+
"""Return the shared ``(pw, browser)`` pair, starting if needed.
|
|
186
|
+
|
|
187
|
+
Wraps ``_launch_browser()`` with install-error detection: if the
|
|
188
|
+
executable is missing, re-raises with the engine-specific
|
|
189
|
+
``_install_hint``.
|
|
190
|
+
"""
|
|
191
|
+
if self._pw is None:
|
|
192
|
+
if not HAS_PLAYWRIGHT:
|
|
193
|
+
raise RuntimeError(
|
|
194
|
+
"Playwright is not installed. "
|
|
195
|
+
"Run: pip install playwright && "
|
|
196
|
+
+ (self._install_hint.lower() if self._install_hint else "playwright install <browser>")
|
|
197
|
+
)
|
|
198
|
+
self._pw = sync_playwright().start() # type: ignore[union-attr]
|
|
199
|
+
try:
|
|
200
|
+
self._browser = self._launch_browser()
|
|
201
|
+
except Exception as _exc:
|
|
202
|
+
if re.search(
|
|
203
|
+
r"Executable doesn't exist|browserType\.launch",
|
|
204
|
+
str(_exc),
|
|
205
|
+
re.IGNORECASE,
|
|
206
|
+
):
|
|
207
|
+
raise RuntimeError(self._install_hint) from _exc
|
|
208
|
+
raise
|
|
209
|
+
# Probe UA at first launch (Firefox opt-in)
|
|
210
|
+
if self._capture_user_agent:
|
|
211
|
+
self._capture_ua()
|
|
212
|
+
return self._pw, self._browser
|
|
213
|
+
|
|
214
|
+
def _maybe_stop_playwright(self) -> None:
|
|
215
|
+
"""Stop the shared Playwright if no sessions remain."""
|
|
216
|
+
if not self.sessions and self._pw is not None:
|
|
217
|
+
try:
|
|
218
|
+
if self._browser:
|
|
219
|
+
self._browser.close()
|
|
220
|
+
except Exception:
|
|
221
|
+
pass
|
|
222
|
+
try:
|
|
223
|
+
self._pw.stop()
|
|
224
|
+
except Exception:
|
|
225
|
+
pass
|
|
226
|
+
self._pw = None
|
|
227
|
+
self._browser = None
|
|
228
|
+
|
|
229
|
+
# ── Session lifecycle ──────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
def create_browser_context(self, config: dict[str, Any]) -> Any:
|
|
232
|
+
"""Create a new isolated BrowserContext for a task session.
|
|
233
|
+
|
|
234
|
+
Applies the default viewport, :attr:`effective_user_agent`, and
|
|
235
|
+
``storageState`` from config. Starts Playwright tracing if
|
|
236
|
+
``BROWSER_TRACE_DIR`` is set.
|
|
237
|
+
|
|
238
|
+
Returns a Playwright ``BrowserContext`` (no Page yet).
|
|
239
|
+
"""
|
|
240
|
+
_pw, browser = self._ensure_playwright()
|
|
241
|
+
|
|
242
|
+
context_kwargs: dict[str, Any] = {
|
|
243
|
+
"viewport": {"width": 1280, "height": 720},
|
|
244
|
+
"user_agent": self.effective_user_agent,
|
|
245
|
+
}
|
|
246
|
+
storage_state = config.get("storageState")
|
|
247
|
+
if storage_state is not None:
|
|
248
|
+
context_kwargs["storage_state"] = storage_state
|
|
249
|
+
|
|
250
|
+
context = browser.new_context(**context_kwargs)
|
|
251
|
+
|
|
252
|
+
# Start Playwright trace capture if BROWSER_TRACE_DIR is set.
|
|
253
|
+
_trace_dir = os.environ.get("BROWSER_TRACE_DIR")
|
|
254
|
+
if _trace_dir:
|
|
255
|
+
try:
|
|
256
|
+
context.tracing.start(
|
|
257
|
+
screenshots=True,
|
|
258
|
+
snapshots=True,
|
|
259
|
+
sources=True,
|
|
260
|
+
)
|
|
261
|
+
self._log("tracing", taskId=config.get("_task_id", "shared"),
|
|
262
|
+
action="start", dir=_trace_dir)
|
|
263
|
+
except Exception:
|
|
264
|
+
pass # Best-effort
|
|
265
|
+
|
|
266
|
+
return context
|
|
267
|
+
|
|
268
|
+
def _setup_page_session(self, page: Any) -> dict[str, Any]:
|
|
269
|
+
"""Attach console capture and dialog handlers to a new page.
|
|
270
|
+
|
|
271
|
+
Returns a session dict with ``page``, ``console_messages``, and
|
|
272
|
+
``dialog_log``.
|
|
273
|
+
"""
|
|
274
|
+
# ── Console capture (ring buffer, capped at 500) ────────
|
|
275
|
+
console_messages: list[dict[str, str]] = []
|
|
276
|
+
|
|
277
|
+
def _capture_console(msg: Any) -> None:
|
|
278
|
+
console_messages.append({"type": msg.type, "text": msg.text})
|
|
279
|
+
if len(console_messages) > 500:
|
|
280
|
+
console_messages.pop(0)
|
|
281
|
+
|
|
282
|
+
page.on("console", _capture_console)
|
|
283
|
+
|
|
284
|
+
# ── Dialog auto-dismissal ───────────────────────────────
|
|
285
|
+
dialog_log: list[dict[str, str]] = []
|
|
286
|
+
page.on("dialog", lambda dialog: (
|
|
287
|
+
dialog_log.append({
|
|
288
|
+
"type": dialog.type,
|
|
289
|
+
"message": dialog.message[:200],
|
|
290
|
+
"handledAs": "accepted",
|
|
291
|
+
}),
|
|
292
|
+
dialog.accept(),
|
|
293
|
+
))
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
"page": page,
|
|
297
|
+
"console_messages": console_messages,
|
|
298
|
+
"dialog_log": dialog_log,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
def create_browser_session(
|
|
302
|
+
self, task_id: str, config: dict[str, Any]
|
|
303
|
+
) -> dict[str, Any]:
|
|
304
|
+
"""Create a new BrowserContext + Page for the given task.
|
|
305
|
+
|
|
306
|
+
Reuses the shared Playwright instance and Browser across tasks.
|
|
307
|
+
Returns a session dict containing the page, context, and
|
|
308
|
+
console-message accumulator.
|
|
309
|
+
|
|
310
|
+
If ``config`` contains a ``storageState`` key, it is passed
|
|
311
|
+
to :meth:`create_browser_context` to restore cookies and
|
|
312
|
+
localStorage.
|
|
313
|
+
"""
|
|
314
|
+
context = self.create_browser_context(config)
|
|
315
|
+
page = context.new_page()
|
|
316
|
+
session = self._setup_page_session(page)
|
|
317
|
+
session["context"] = context
|
|
318
|
+
return session
|
|
319
|
+
|
|
320
|
+
def close_browser_session(self, task_id: str) -> None:
|
|
321
|
+
"""Close the BrowserContext (and Page) for the given task.
|
|
322
|
+
|
|
323
|
+
Each task gets its own isolated BrowserContext created by
|
|
324
|
+
:meth:`create_browser_session`, so this always closes the
|
|
325
|
+
context directly. Named profiles are handled on the TypeScript
|
|
326
|
+
side via ``storage-state.ts`` (disk persistence).
|
|
327
|
+
"""
|
|
328
|
+
session = self.sessions.get(task_id)
|
|
329
|
+
if session is not None:
|
|
330
|
+
context: Any = session.get("context")
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
page: Any = session.get("page")
|
|
334
|
+
if page and not page.is_closed():
|
|
335
|
+
page.close()
|
|
336
|
+
except Exception:
|
|
337
|
+
pass
|
|
338
|
+
|
|
339
|
+
# Stop and save Playwright trace if BROWSER_TRACE_DIR is set.
|
|
340
|
+
_trace_dir = os.environ.get("BROWSER_TRACE_DIR")
|
|
341
|
+
if _trace_dir and context:
|
|
342
|
+
try:
|
|
343
|
+
os.makedirs(_trace_dir, exist_ok=True)
|
|
344
|
+
_trace_path = os.path.join(
|
|
345
|
+
_trace_dir,
|
|
346
|
+
f"trace-{task_id}-{int(time.time() * 1000)}.zip",
|
|
347
|
+
)
|
|
348
|
+
context.tracing.stop(path=_trace_path)
|
|
349
|
+
self._log("tracing", taskId=task_id, action="stop",
|
|
350
|
+
dir=_trace_dir)
|
|
351
|
+
except Exception:
|
|
352
|
+
pass
|
|
353
|
+
|
|
354
|
+
try:
|
|
355
|
+
if context:
|
|
356
|
+
context.close()
|
|
357
|
+
except Exception:
|
|
358
|
+
pass
|
|
359
|
+
|
|
360
|
+
# Remove session + element cache
|
|
361
|
+
self.sessions.pop(task_id, None)
|
|
362
|
+
self.element_caches.pop(task_id, None)
|
|
363
|
+
|
|
364
|
+
# Stop shared Playwright if no sessions remain
|
|
365
|
+
self._maybe_stop_playwright()
|
|
366
|
+
|
|
367
|
+
# ── Internal helpers ───────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
def _get_page(self, task_id: str) -> Any:
|
|
370
|
+
"""Get the Playwright Page for a task, or raise SessionNotFoundError."""
|
|
371
|
+
session = self.require_session(task_id)
|
|
372
|
+
return session["page"]
|
|
373
|
+
|
|
374
|
+
def _get_dialog_events(self, task_id: str) -> list[dict[str, str]]:
|
|
375
|
+
"""Get up to 10 most recent auto-dismissed dialog events for a task.
|
|
376
|
+
|
|
377
|
+
Returns a list of ``{type, message, handledAs}`` dicts.
|
|
378
|
+
"""
|
|
379
|
+
session = self.get_session(task_id)
|
|
380
|
+
if not session:
|
|
381
|
+
return []
|
|
382
|
+
log: list[dict[str, str]] = session.get("dialog_log", [])
|
|
383
|
+
return [
|
|
384
|
+
{"type": e["type"], "message": e["message"], "handledAs": e["handledAs"]}
|
|
385
|
+
for e in log[-10:]
|
|
386
|
+
]
|
|
387
|
+
|
|
388
|
+
def _take_snapshot_and_cache(
|
|
389
|
+
self, task_id: str, page: Any
|
|
390
|
+
) -> tuple[str, int, dict[str, dict[str, Any]]]:
|
|
391
|
+
"""Take snapshot, cache elements, return formatted text + count."""
|
|
392
|
+
try:
|
|
393
|
+
snap_text: str = page.aria_snapshot()
|
|
394
|
+
except Exception:
|
|
395
|
+
return "(snapshot not available)", 0, {}
|
|
396
|
+
|
|
397
|
+
if not snap_text:
|
|
398
|
+
return "(no accessibility tree)", 0, {}
|
|
399
|
+
|
|
400
|
+
parsed = parse_snapshot(snap_text)
|
|
401
|
+
self.set_element_cache(task_id, parsed)
|
|
402
|
+
|
|
403
|
+
return parsed.text, parsed.count, {
|
|
404
|
+
ref: {
|
|
405
|
+
"role": node.role,
|
|
406
|
+
"name": node.name,
|
|
407
|
+
"props": list(node.props),
|
|
408
|
+
"depth": node.depth,
|
|
409
|
+
"raw": node.raw,
|
|
410
|
+
"occurrenceIndex": node.occurrence_index,
|
|
411
|
+
"parentRef": node.parent_ref,
|
|
412
|
+
}
|
|
413
|
+
for ref, node in parsed.elements.items()
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
def _locate_element(
|
|
417
|
+
self, page: Any, task_id: str, ref: str
|
|
418
|
+
) -> Any:
|
|
419
|
+
"""Resolve an @e ref to a Playwright locator.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
page: Playwright Page.
|
|
423
|
+
task_id: Task identifier for the element cache.
|
|
424
|
+
ref: Element reference (e.g. "@e5" or "e5").
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
A Playwright ``Locator``.
|
|
428
|
+
|
|
429
|
+
Raises:
|
|
430
|
+
RuntimeError: if the ref is not in the cache.
|
|
431
|
+
"""
|
|
432
|
+
key = ref[1:] if ref.startswith("@") else ref
|
|
433
|
+
cache = self.get_element_cache(task_id)
|
|
434
|
+
if cache is None:
|
|
435
|
+
raise RuntimeError(
|
|
436
|
+
f"No element cache for task '{task_id}'. "
|
|
437
|
+
"Call browser.navigate or browser.snapshot first."
|
|
438
|
+
)
|
|
439
|
+
node = cache.elements.get(key)
|
|
440
|
+
if node is None:
|
|
441
|
+
raise RuntimeError(
|
|
442
|
+
f"Element @{key} not found in accessibility tree. "
|
|
443
|
+
"Refresh with browser.snapshot first."
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
role, kwargs = build_locator_args(node)
|
|
447
|
+
occurrence_index = kwargs.pop("occurrenceIndex", 0)
|
|
448
|
+
locator = page.get_by_role(role, **kwargs)
|
|
449
|
+
# Always use .nth(occurrence_index) to avoid strict-mode violations
|
|
450
|
+
# when multiple elements share the same role+name. For unique elements
|
|
451
|
+
# (occurrence_index=0) this is equivalent to the bare locator.
|
|
452
|
+
return locator.nth(occurrence_index)
|
|
453
|
+
|
|
454
|
+
# ── Navigation settle helpers ───────────────────────────────
|
|
455
|
+
|
|
456
|
+
@staticmethod
|
|
457
|
+
def _wait_for_page_ready(page: Any, timeout_ms: int) -> None:
|
|
458
|
+
"""Wait for page readiness after a navigation.
|
|
459
|
+
|
|
460
|
+
Mirrors the TypeScript ``waitForPageReady`` helper — each load
|
|
461
|
+
state check gets the full timeout budget, and timeouts are
|
|
462
|
+
silently swallowed so the caller always proceeds (matching TS
|
|
463
|
+
``.catch(() => {{}})`` behavior). Required to prevent long-polling
|
|
464
|
+
or streaming sites from failing the entire settle via networkidle.
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
page: Playwright Page.
|
|
468
|
+
timeout_ms: Timeout for each wait_for_load_state call.
|
|
469
|
+
"""
|
|
470
|
+
try:
|
|
471
|
+
page.wait_for_load_state("load", timeout=timeout_ms)
|
|
472
|
+
except Exception:
|
|
473
|
+
pass
|
|
474
|
+
try:
|
|
475
|
+
page.wait_for_load_state("networkidle", timeout=timeout_ms)
|
|
476
|
+
except Exception:
|
|
477
|
+
pass
|
|
478
|
+
|
|
479
|
+
@staticmethod
|
|
480
|
+
def _wait_for_navigation_settle(
|
|
481
|
+
page: Any,
|
|
482
|
+
url_before: str,
|
|
483
|
+
nav_timeout_ms: int = 5000,
|
|
484
|
+
settle_timeout_ms: int = 400,
|
|
485
|
+
) -> tuple[bool, str]:
|
|
486
|
+
"""Wait for navigation to settle after a user interaction.
|
|
487
|
+
|
|
488
|
+
Replaces fixed ``time.sleep()`` calls that race against navigation
|
|
489
|
+
commit. Instead, listens for the ``framenavigated`` event and waits
|
|
490
|
+
for page readiness only when a navigation has actually started.
|
|
491
|
+
|
|
492
|
+
Args:
|
|
493
|
+
page: Playwright Page.
|
|
494
|
+
url_before: The page URL before the interaction.
|
|
495
|
+
nav_timeout_ms: Max time (ms) to wait for each page readiness
|
|
496
|
+
check (load and networkidle each get the full
|
|
497
|
+
budget). Default: 5000.
|
|
498
|
+
settle_timeout_ms: Short settle delay (ms) when no navigation
|
|
499
|
+
occurs (default: 400).
|
|
500
|
+
|
|
501
|
+
Returns:
|
|
502
|
+
``(navigated, url)`` — whether a main-frame navigation was
|
|
503
|
+
detected, and the page URL after settling.
|
|
504
|
+
"""
|
|
505
|
+
navigated = False
|
|
506
|
+
|
|
507
|
+
def _on_nav(frame: Any) -> None:
|
|
508
|
+
nonlocal navigated
|
|
509
|
+
if frame == page.main_frame:
|
|
510
|
+
navigated = True
|
|
511
|
+
|
|
512
|
+
page.on("framenavigated", _on_nav)
|
|
513
|
+
|
|
514
|
+
try:
|
|
515
|
+
# Wait for a potential navigation to start (150 ms window)
|
|
516
|
+
page.wait_for_timeout(150)
|
|
517
|
+
|
|
518
|
+
waited_for_load = False
|
|
519
|
+
if navigated:
|
|
520
|
+
PlaywrightBridge._wait_for_page_ready(page, nav_timeout_ms)
|
|
521
|
+
waited_for_load = True
|
|
522
|
+
elif page.url != url_before:
|
|
523
|
+
# URL changed without framenavigated event
|
|
524
|
+
PlaywrightBridge._wait_for_page_ready(page, nav_timeout_ms)
|
|
525
|
+
waited_for_load = True
|
|
526
|
+
else:
|
|
527
|
+
# No navigation — settle for client-side rerenders
|
|
528
|
+
page.wait_for_timeout(settle_timeout_ms)
|
|
529
|
+
|
|
530
|
+
# Late-arrival gate: catch navigations that started during settle
|
|
531
|
+
if not waited_for_load and (navigated or page.url != url_before):
|
|
532
|
+
PlaywrightBridge._wait_for_page_ready(page, nav_timeout_ms)
|
|
533
|
+
|
|
534
|
+
finally:
|
|
535
|
+
page.remove_listener("framenavigated", _on_nav)
|
|
536
|
+
|
|
537
|
+
return navigated, page.url
|
|
538
|
+
|
|
539
|
+
# ── Navigation & state ─────────────────────────────────────────
|
|
540
|
+
|
|
541
|
+
def do_navigate(
|
|
542
|
+
self,
|
|
543
|
+
task_id: str,
|
|
544
|
+
url: str,
|
|
545
|
+
timeout_ms: int = 30_000,
|
|
546
|
+
storageState: Optional[dict[str, Any]] = None,
|
|
547
|
+
profileName: Optional[str] = None,
|
|
548
|
+
profileMode: Optional[str] = None,
|
|
549
|
+
) -> dict[str, Any]:
|
|
550
|
+
"""Navigate the browser to a URL.
|
|
551
|
+
|
|
552
|
+
Includes retry on transient network errors, DOM stabilisation
|
|
553
|
+
wait, bot detection, and accessibility snapshot.
|
|
554
|
+
|
|
555
|
+
Named profiles are handled by the TypeScript side
|
|
556
|
+
(``python-adapter.ts`` pre-loads ``storageState`` before
|
|
557
|
+
navigate), so the Python bridge always creates isolated
|
|
558
|
+
sessions regardless of ``profileName``/``profileMode``.
|
|
559
|
+
|
|
560
|
+
If ``storageState`` is provided, it is passed to the context
|
|
561
|
+
creation so saved cookies and localStorage are restored.
|
|
562
|
+
"""
|
|
563
|
+
_t_start = time.time()
|
|
564
|
+
config: dict[str, Any] = {}
|
|
565
|
+
if storageState is not None:
|
|
566
|
+
config["storageState"] = storageState
|
|
567
|
+
|
|
568
|
+
session = self.ensure_session(task_id, config)
|
|
569
|
+
page: Any = session["page"]
|
|
570
|
+
|
|
571
|
+
# ── Navigate (with retry on transient errors) ───────────
|
|
572
|
+
last_error: Optional[str] = None
|
|
573
|
+
for attempt in range(2):
|
|
574
|
+
try:
|
|
575
|
+
page.goto(url, wait_until="load", timeout=timeout_ms)
|
|
576
|
+
last_error = None
|
|
577
|
+
break
|
|
578
|
+
except PlaywrightTimeout as exc:
|
|
579
|
+
last_error = str(exc)
|
|
580
|
+
if attempt == 0:
|
|
581
|
+
time.sleep(2)
|
|
582
|
+
else:
|
|
583
|
+
break
|
|
584
|
+
except Exception as exc:
|
|
585
|
+
msg = str(exc)
|
|
586
|
+
is_transient = bool(
|
|
587
|
+
re.search(
|
|
588
|
+
r"net::ERR_|ECONNRESET|ECONNREFUSED|ETIMEDOUT|"
|
|
589
|
+
r"timeout|Interrupted",
|
|
590
|
+
msg,
|
|
591
|
+
re.IGNORECASE,
|
|
592
|
+
)
|
|
593
|
+
)
|
|
594
|
+
if is_transient and attempt == 0:
|
|
595
|
+
time.sleep(2)
|
|
596
|
+
last_error = msg
|
|
597
|
+
else:
|
|
598
|
+
last_error = msg
|
|
599
|
+
break
|
|
600
|
+
|
|
601
|
+
# ── DOM stabilization wait ──────────────────────────────
|
|
602
|
+
try:
|
|
603
|
+
page.wait_for_function(
|
|
604
|
+
"""() => new Promise(resolve => {
|
|
605
|
+
const count = document.querySelectorAll("*").length;
|
|
606
|
+
setTimeout(() => {
|
|
607
|
+
resolve(
|
|
608
|
+
document.querySelectorAll("*").length === count
|
|
609
|
+
|| count > 5000
|
|
610
|
+
);
|
|
611
|
+
}, 400);
|
|
612
|
+
})""",
|
|
613
|
+
timeout=5_000,
|
|
614
|
+
)
|
|
615
|
+
except Exception:
|
|
616
|
+
pass # Stabilization timed out — proceed
|
|
617
|
+
|
|
618
|
+
# ── Bot detection ───────────────────────────────────────
|
|
619
|
+
bot_detected = check_bot_detection(page)
|
|
620
|
+
|
|
621
|
+
# If navigation failed, also check error message keywords —
|
|
622
|
+
# catches challenge pages that failed to render any HTML body.
|
|
623
|
+
if last_error is not None:
|
|
624
|
+
err_lower = last_error.lower()
|
|
625
|
+
if any(
|
|
626
|
+
kw in err_lower
|
|
627
|
+
for kw in ("captcha", "cloudflare", "blocked", "challenge")
|
|
628
|
+
):
|
|
629
|
+
bot_detected = True
|
|
630
|
+
|
|
631
|
+
# ── Snapshot ────────────────────────────────────────────
|
|
632
|
+
try:
|
|
633
|
+
snap_text, element_count, elements = self._take_snapshot_and_cache(
|
|
634
|
+
task_id, page
|
|
635
|
+
)
|
|
636
|
+
except Exception:
|
|
637
|
+
snap_text = "(snapshot not available)"
|
|
638
|
+
element_count = 0
|
|
639
|
+
elements = {}
|
|
640
|
+
|
|
641
|
+
try:
|
|
642
|
+
title: str = page.title()
|
|
643
|
+
except Exception:
|
|
644
|
+
title = ""
|
|
645
|
+
|
|
646
|
+
if last_error is None:
|
|
647
|
+
self._log("navigate", url=url, plugin=self._plugin_name,
|
|
648
|
+
success=True, botDetected=bot_detected,
|
|
649
|
+
elementCount=element_count,
|
|
650
|
+
time=round((time.time() - _t_start) * 1000))
|
|
651
|
+
return {
|
|
652
|
+
"success": True,
|
|
653
|
+
"url": page.url,
|
|
654
|
+
"title": title,
|
|
655
|
+
"snapshot": snap_text,
|
|
656
|
+
"elementCount": element_count,
|
|
657
|
+
"elements": elements,
|
|
658
|
+
"botDetected": bot_detected,
|
|
659
|
+
"dialogEvents": self._get_dialog_events(task_id),
|
|
660
|
+
}
|
|
661
|
+
else:
|
|
662
|
+
self._log("navigate", url=url, plugin=self._plugin_name,
|
|
663
|
+
success=False, botDetected=bot_detected,
|
|
664
|
+
elementCount=element_count, error=last_error,
|
|
665
|
+
time=round((time.time() - _t_start) * 1000))
|
|
666
|
+
return {
|
|
667
|
+
"success": False,
|
|
668
|
+
"url": url,
|
|
669
|
+
"title": title,
|
|
670
|
+
"snapshot": snap_text,
|
|
671
|
+
"elementCount": element_count,
|
|
672
|
+
"elements": elements,
|
|
673
|
+
"botDetected": bot_detected,
|
|
674
|
+
"error": last_error,
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
def do_cleanup(self, task_id: str) -> dict[str, Any]:
|
|
678
|
+
"""Clean up resources for a specific task.
|
|
679
|
+
|
|
680
|
+
Profile persistence is handled by the TypeScript side
|
|
681
|
+
(``python-adapter.ts`` auto-saves storage state before calling
|
|
682
|
+
cleanup), so this always calls :meth:`close_browser_session`.
|
|
683
|
+
"""
|
|
684
|
+
self.close_browser_session(task_id)
|
|
685
|
+
return {"success": True}
|
|
686
|
+
|
|
687
|
+
def do_snapshot(self, task_id: str) -> dict[str, Any]:
|
|
688
|
+
"""Take a fresh accessibility snapshot and refresh element cache."""
|
|
689
|
+
_t_start = time.time()
|
|
690
|
+
try:
|
|
691
|
+
page = self._get_page(task_id)
|
|
692
|
+
except SessionNotFoundError:
|
|
693
|
+
raise
|
|
694
|
+
except Exception as exc:
|
|
695
|
+
self._log("snapshot", taskId=task_id, success=False,
|
|
696
|
+
elementCount=0, dialogBlocks=0, fingerprint="",
|
|
697
|
+
time=round((time.time() - _t_start) * 1000))
|
|
698
|
+
return {
|
|
699
|
+
"success": False,
|
|
700
|
+
"snapshot": "",
|
|
701
|
+
"elementCount": 0,
|
|
702
|
+
"error": str(exc),
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
try:
|
|
706
|
+
snap_text, element_count, elements = self._take_snapshot_and_cache(
|
|
707
|
+
task_id, page
|
|
708
|
+
)
|
|
709
|
+
session = self.get_session(task_id)
|
|
710
|
+
dialog_blocks = len(session.get("dialog_log", [])) if session else 0
|
|
711
|
+
fingerprint = snap_text[:16] if snap_text else ""
|
|
712
|
+
self._log("snapshot", taskId=task_id, success=True,
|
|
713
|
+
elementCount=element_count,
|
|
714
|
+
dialogBlocks=dialog_blocks,
|
|
715
|
+
fingerprint=fingerprint,
|
|
716
|
+
time=round((time.time() - _t_start) * 1000))
|
|
717
|
+
return {
|
|
718
|
+
"success": True,
|
|
719
|
+
"snapshot": snap_text,
|
|
720
|
+
"elementCount": element_count,
|
|
721
|
+
"elements": elements,
|
|
722
|
+
"dialogEvents": self._get_dialog_events(task_id),
|
|
723
|
+
}
|
|
724
|
+
except Exception as exc:
|
|
725
|
+
self._log("snapshot", taskId=task_id, success=False,
|
|
726
|
+
elementCount=0, dialogBlocks=0, fingerprint="",
|
|
727
|
+
time=round((time.time() - _t_start) * 1000))
|
|
728
|
+
return {
|
|
729
|
+
"success": False,
|
|
730
|
+
"snapshot": "",
|
|
731
|
+
"elementCount": 0,
|
|
732
|
+
"error": str(exc),
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
# ── Interaction ─────────────────────────────────────────────────
|
|
736
|
+
|
|
737
|
+
def do_click(self, task_id: str, ref: str) -> dict[str, Any]:
|
|
738
|
+
"""Click an element by @e ref."""
|
|
739
|
+
_t_start = time.time()
|
|
740
|
+
|
|
741
|
+
# Extract role/name from element cache for debug logging
|
|
742
|
+
_key = ref[1:] if ref.startswith("@") else ref
|
|
743
|
+
_cache = self.get_element_cache(task_id)
|
|
744
|
+
_node = _cache.elements.get(_key) if _cache else None
|
|
745
|
+
_role: str = getattr(_node, "role", "unknown") if _node else "unknown"
|
|
746
|
+
_name: str = getattr(_node, "name", "unknown") if _node else "unknown"
|
|
747
|
+
|
|
748
|
+
try:
|
|
749
|
+
page = self._get_page(task_id)
|
|
750
|
+
except SessionNotFoundError:
|
|
751
|
+
raise
|
|
752
|
+
except Exception as exc:
|
|
753
|
+
self._log("click", taskId=task_id, ref=ref, role=_role,
|
|
754
|
+
name=_name, result="fail",
|
|
755
|
+
time=round((time.time() - _t_start) * 1000))
|
|
756
|
+
return {
|
|
757
|
+
"success": False,
|
|
758
|
+
"error": f"Click failed: {exc}",
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
try:
|
|
762
|
+
locator = self._locate_element(page, task_id, ref)
|
|
763
|
+
except RuntimeError as exc:
|
|
764
|
+
self._log("click", taskId=task_id, ref=ref, role=_role,
|
|
765
|
+
name=_name, result="fail",
|
|
766
|
+
time=round((time.time() - _t_start) * 1000))
|
|
767
|
+
return {"success": False, "error": str(exc)}
|
|
768
|
+
|
|
769
|
+
try:
|
|
770
|
+
url_before = page.url
|
|
771
|
+
locator.click(timeout=5_000)
|
|
772
|
+
|
|
773
|
+
navigated, _ = self._wait_for_navigation_settle(page, url_before)
|
|
774
|
+
|
|
775
|
+
new_url = page.url
|
|
776
|
+
new_title = page.title()
|
|
777
|
+
|
|
778
|
+
snap_text, element_count, elements = self._take_snapshot_and_cache(
|
|
779
|
+
task_id, page
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
self._log("click", taskId=task_id, ref=ref, role=_role,
|
|
783
|
+
name=_name, result="success", navigated=navigated,
|
|
784
|
+
time=round((time.time() - _t_start) * 1000))
|
|
785
|
+
|
|
786
|
+
dialog_events = self._get_dialog_events(task_id)
|
|
787
|
+
result: dict[str, Any] = {
|
|
788
|
+
"success": True,
|
|
789
|
+
"snapshot": snap_text,
|
|
790
|
+
"elementCount": element_count,
|
|
791
|
+
"elements": elements,
|
|
792
|
+
"dialogEvents": dialog_events,
|
|
793
|
+
"newUrl": new_url,
|
|
794
|
+
"newTitle": new_title,
|
|
795
|
+
}
|
|
796
|
+
return result
|
|
797
|
+
|
|
798
|
+
except Exception as exc:
|
|
799
|
+
self._log("click", taskId=task_id, ref=ref, role=_role,
|
|
800
|
+
name=_name, result="fail",
|
|
801
|
+
time=round((time.time() - _t_start) * 1000))
|
|
802
|
+
return {
|
|
803
|
+
"success": False,
|
|
804
|
+
"error": f"Click failed: {exc}",
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
def do_type(self, task_id: str, ref: str, text: str) -> dict[str, Any]:
|
|
808
|
+
"""Type text into an element by @e ref."""
|
|
809
|
+
_t_start = time.time()
|
|
810
|
+
|
|
811
|
+
# Extract role/name from element cache for debug logging
|
|
812
|
+
_key = ref[1:] if ref.startswith("@") else ref
|
|
813
|
+
_cache = self.get_element_cache(task_id)
|
|
814
|
+
_node = _cache.elements.get(_key) if _cache else None
|
|
815
|
+
_role: str = getattr(_node, "role", "unknown") if _node else "unknown"
|
|
816
|
+
_name: str = getattr(_node, "name", "unknown") if _node else "unknown"
|
|
817
|
+
|
|
818
|
+
try:
|
|
819
|
+
page = self._get_page(task_id)
|
|
820
|
+
except SessionNotFoundError:
|
|
821
|
+
raise
|
|
822
|
+
except Exception as exc:
|
|
823
|
+
self._log("type", taskId=task_id, ref=ref, role=_role,
|
|
824
|
+
name=_name, result="fail",
|
|
825
|
+
time=round((time.time() - _t_start) * 1000))
|
|
826
|
+
return {
|
|
827
|
+
"success": False,
|
|
828
|
+
"error": f"Type failed: {exc}",
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
try:
|
|
832
|
+
locator = self._locate_element(page, task_id, ref)
|
|
833
|
+
except RuntimeError as exc:
|
|
834
|
+
self._log("type", taskId=task_id, ref=ref, role=_role,
|
|
835
|
+
name=_name, result="fail",
|
|
836
|
+
time=round((time.time() - _t_start) * 1000))
|
|
837
|
+
return {"success": False, "error": str(exc)}
|
|
838
|
+
|
|
839
|
+
try:
|
|
840
|
+
locator.click(timeout=5_000) # Focus first
|
|
841
|
+
locator.fill(text)
|
|
842
|
+
|
|
843
|
+
snap_text, element_count, elements = self._take_snapshot_and_cache(
|
|
844
|
+
task_id, page
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
self._log("type", taskId=task_id, ref=ref, role=_role,
|
|
848
|
+
name=_name, result="success",
|
|
849
|
+
elementCount=element_count,
|
|
850
|
+
time=round((time.time() - _t_start) * 1000))
|
|
851
|
+
|
|
852
|
+
return {
|
|
853
|
+
"success": True,
|
|
854
|
+
"snapshot": snap_text,
|
|
855
|
+
"elementCount": element_count,
|
|
856
|
+
"elements": elements,
|
|
857
|
+
"dialogEvents": self._get_dialog_events(task_id),
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
except Exception as exc:
|
|
861
|
+
self._log("type", taskId=task_id, ref=ref, role=_role,
|
|
862
|
+
name=_name, result="fail",
|
|
863
|
+
time=round((time.time() - _t_start) * 1000))
|
|
864
|
+
return {
|
|
865
|
+
"success": False,
|
|
866
|
+
"error": f"Type failed: {exc}",
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
def do_scroll(self, task_id: str, direction: str) -> dict[str, Any]:
|
|
870
|
+
"""Scroll the page up or down."""
|
|
871
|
+
_t_start = time.time()
|
|
872
|
+
try:
|
|
873
|
+
page = self._get_page(task_id)
|
|
874
|
+
except SessionNotFoundError:
|
|
875
|
+
raise
|
|
876
|
+
except Exception as exc:
|
|
877
|
+
self._log("scroll", taskId=task_id, direction=direction,
|
|
878
|
+
success=False,
|
|
879
|
+
time=round((time.time() - _t_start) * 1000))
|
|
880
|
+
return {
|
|
881
|
+
"success": False,
|
|
882
|
+
"error": f"Scroll failed: {exc}",
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
try:
|
|
886
|
+
delta = 800 if direction == "down" else -800
|
|
887
|
+
page.evaluate(
|
|
888
|
+
"""(d) => window.scrollBy({ top: d, behavior: 'smooth' })""",
|
|
889
|
+
delta,
|
|
890
|
+
)
|
|
891
|
+
time.sleep(0.2)
|
|
892
|
+
|
|
893
|
+
snap_text, element_count, elements = self._take_snapshot_and_cache(
|
|
894
|
+
task_id, page
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
self._log("scroll", taskId=task_id, direction=direction,
|
|
898
|
+
success=True, elementCount=element_count,
|
|
899
|
+
time=round((time.time() - _t_start) * 1000))
|
|
900
|
+
|
|
901
|
+
return {
|
|
902
|
+
"success": True,
|
|
903
|
+
"snapshot": snap_text,
|
|
904
|
+
"elementCount": element_count,
|
|
905
|
+
"elements": elements,
|
|
906
|
+
"dialogEvents": self._get_dialog_events(task_id),
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
except Exception as exc:
|
|
910
|
+
self._log("scroll", taskId=task_id, direction=direction,
|
|
911
|
+
success=False,
|
|
912
|
+
time=round((time.time() - _t_start) * 1000))
|
|
913
|
+
return {
|
|
914
|
+
"success": False,
|
|
915
|
+
"error": f"Scroll failed: {exc}",
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
def do_go_back(self, task_id: str) -> dict[str, Any]:
|
|
919
|
+
"""Navigate back in history."""
|
|
920
|
+
_t_start = time.time()
|
|
921
|
+
try:
|
|
922
|
+
page = self._get_page(task_id)
|
|
923
|
+
except SessionNotFoundError:
|
|
924
|
+
raise
|
|
925
|
+
except Exception as exc:
|
|
926
|
+
self._log("goBack", taskId=task_id, success=False,
|
|
927
|
+
time=round((time.time() - _t_start) * 1000))
|
|
928
|
+
return {
|
|
929
|
+
"success": False,
|
|
930
|
+
"error": f"GoBack failed: {exc}",
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
try:
|
|
934
|
+
page.go_back(wait_until="networkidle")
|
|
935
|
+
time.sleep(0.3)
|
|
936
|
+
|
|
937
|
+
new_url: Optional[str] = None
|
|
938
|
+
new_title: Optional[str] = None
|
|
939
|
+
try:
|
|
940
|
+
new_url = page.url
|
|
941
|
+
new_title = page.title()
|
|
942
|
+
except Exception:
|
|
943
|
+
pass
|
|
944
|
+
|
|
945
|
+
snap_text, element_count, elements = self._take_snapshot_and_cache(
|
|
946
|
+
task_id, page
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
self._log("goBack", taskId=task_id, success=True,
|
|
950
|
+
elementCount=element_count,
|
|
951
|
+
time=round((time.time() - _t_start) * 1000))
|
|
952
|
+
|
|
953
|
+
dialog_events = self._get_dialog_events(task_id)
|
|
954
|
+
result: dict[str, Any] = {
|
|
955
|
+
"success": True,
|
|
956
|
+
"snapshot": snap_text,
|
|
957
|
+
"elementCount": element_count,
|
|
958
|
+
"elements": elements,
|
|
959
|
+
"dialogEvents": dialog_events,
|
|
960
|
+
}
|
|
961
|
+
if new_url is not None:
|
|
962
|
+
result["newUrl"] = new_url
|
|
963
|
+
if new_title is not None:
|
|
964
|
+
result["newTitle"] = new_title
|
|
965
|
+
return result
|
|
966
|
+
|
|
967
|
+
except Exception as exc:
|
|
968
|
+
self._log("goBack", taskId=task_id, success=False,
|
|
969
|
+
time=round((time.time() - _t_start) * 1000))
|
|
970
|
+
return {
|
|
971
|
+
"success": False,
|
|
972
|
+
"error": f"GoBack failed: {exc}",
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
def do_press(self, task_id: str, key: str) -> dict[str, Any]:
|
|
976
|
+
"""Press a keyboard key."""
|
|
977
|
+
_t_start = time.time()
|
|
978
|
+
try:
|
|
979
|
+
page = self._get_page(task_id)
|
|
980
|
+
except SessionNotFoundError:
|
|
981
|
+
raise
|
|
982
|
+
except Exception as exc:
|
|
983
|
+
self._log("press", taskId=task_id, key=key, success=False,
|
|
984
|
+
time=round((time.time() - _t_start) * 1000))
|
|
985
|
+
return {
|
|
986
|
+
"success": False,
|
|
987
|
+
"error": f"Press failed: {exc}",
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
try:
|
|
991
|
+
url_before = page.url
|
|
992
|
+
page.keyboard.press(key)
|
|
993
|
+
|
|
994
|
+
navigated, _ = self._wait_for_navigation_settle(
|
|
995
|
+
page, url_before, nav_timeout_ms=3000
|
|
996
|
+
)
|
|
997
|
+
|
|
998
|
+
new_url = page.url
|
|
999
|
+
new_title = page.title()
|
|
1000
|
+
|
|
1001
|
+
snap_text, element_count, elements = self._take_snapshot_and_cache(
|
|
1002
|
+
task_id, page
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
self._log("press", taskId=task_id, key=key, success=True,
|
|
1006
|
+
navigated=navigated, elementCount=element_count,
|
|
1007
|
+
time=round((time.time() - _t_start) * 1000))
|
|
1008
|
+
|
|
1009
|
+
result: dict[str, Any] = {
|
|
1010
|
+
"success": True,
|
|
1011
|
+
"snapshot": snap_text,
|
|
1012
|
+
"elementCount": element_count,
|
|
1013
|
+
"elements": elements,
|
|
1014
|
+
"dialogEvents": self._get_dialog_events(task_id),
|
|
1015
|
+
"newUrl": new_url,
|
|
1016
|
+
"newTitle": new_title,
|
|
1017
|
+
}
|
|
1018
|
+
return result
|
|
1019
|
+
|
|
1020
|
+
except Exception as exc:
|
|
1021
|
+
self._log("press", taskId=task_id, key=key, success=False,
|
|
1022
|
+
time=round((time.time() - _t_start) * 1000))
|
|
1023
|
+
return {
|
|
1024
|
+
"success": False,
|
|
1025
|
+
"error": f"Press failed: {exc}",
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
# ── Media ───────────────────────────────────────────────────────
|
|
1029
|
+
|
|
1030
|
+
def do_screenshot(
|
|
1031
|
+
self,
|
|
1032
|
+
task_id: str,
|
|
1033
|
+
full_page: bool = False,
|
|
1034
|
+
) -> dict[str, Any]:
|
|
1035
|
+
"""Take a JPEG screenshot and return as a base64 data URI."""
|
|
1036
|
+
try:
|
|
1037
|
+
page = self._get_page(task_id)
|
|
1038
|
+
except SessionNotFoundError:
|
|
1039
|
+
raise
|
|
1040
|
+
except Exception as exc:
|
|
1041
|
+
return {
|
|
1042
|
+
"success": False,
|
|
1043
|
+
"dataUri": "",
|
|
1044
|
+
"error": str(exc),
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
try:
|
|
1048
|
+
buffer: bytes = page.screenshot(
|
|
1049
|
+
type="jpeg",
|
|
1050
|
+
quality=80,
|
|
1051
|
+
full_page=full_page,
|
|
1052
|
+
)
|
|
1053
|
+
b64: str = base64.b64encode(buffer).decode("ascii")
|
|
1054
|
+
data_uri: str = f"data:image/jpeg;base64,{b64}"
|
|
1055
|
+
|
|
1056
|
+
return {
|
|
1057
|
+
"success": True,
|
|
1058
|
+
"dataUri": data_uri,
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
except Exception as exc:
|
|
1062
|
+
return {
|
|
1063
|
+
"success": False,
|
|
1064
|
+
"dataUri": "",
|
|
1065
|
+
"error": str(exc),
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
# ── Console & eval ──────────────────────────────────────────────
|
|
1069
|
+
|
|
1070
|
+
def do_get_console_messages(self, task_id: str) -> dict[str, Any]:
|
|
1071
|
+
"""Return captured console messages for the task."""
|
|
1072
|
+
try:
|
|
1073
|
+
session = self.get_session(task_id)
|
|
1074
|
+
if session is None:
|
|
1075
|
+
return {
|
|
1076
|
+
"success": True,
|
|
1077
|
+
"messages": [],
|
|
1078
|
+
}
|
|
1079
|
+
messages: list[dict[str, str]] = session.get(
|
|
1080
|
+
"console_messages", []
|
|
1081
|
+
)
|
|
1082
|
+
return {
|
|
1083
|
+
"success": True,
|
|
1084
|
+
"messages": messages,
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
except Exception as exc:
|
|
1088
|
+
return {
|
|
1089
|
+
"success": False,
|
|
1090
|
+
"messages": [],
|
|
1091
|
+
"error": str(exc),
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
def do_clear_console(self, task_id: str) -> dict[str, Any]:
|
|
1095
|
+
"""Clear captured console messages for the task."""
|
|
1096
|
+
try:
|
|
1097
|
+
session = self.get_session(task_id)
|
|
1098
|
+
if session is not None:
|
|
1099
|
+
session["console_messages"] = []
|
|
1100
|
+
return {"success": True}
|
|
1101
|
+
|
|
1102
|
+
except Exception as exc:
|
|
1103
|
+
return {
|
|
1104
|
+
"success": False,
|
|
1105
|
+
"error": str(exc),
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
def do_evaluate(self, task_id: str, expression: str) -> dict[str, Any]:
|
|
1109
|
+
"""Evaluate JavaScript in the page context."""
|
|
1110
|
+
try:
|
|
1111
|
+
page = self._get_page(task_id)
|
|
1112
|
+
except SessionNotFoundError:
|
|
1113
|
+
raise
|
|
1114
|
+
except Exception as exc:
|
|
1115
|
+
return {
|
|
1116
|
+
"success": False,
|
|
1117
|
+
"error": str(exc),
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
try:
|
|
1121
|
+
result: Any = page.evaluate(expression)
|
|
1122
|
+
return {
|
|
1123
|
+
"success": True,
|
|
1124
|
+
"result": result,
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
except Exception as exc:
|
|
1128
|
+
return {
|
|
1129
|
+
"success": False,
|
|
1130
|
+
"error": str(exc),
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
# ── Cookies & storage state ─────────────────────────────────
|
|
1134
|
+
|
|
1135
|
+
def do_get_cookies(
|
|
1136
|
+
self, task_id: str, urls: Optional[list[str]] = None
|
|
1137
|
+
) -> dict[str, Any]:
|
|
1138
|
+
"""Get cookies, optionally filtered by URL."""
|
|
1139
|
+
try:
|
|
1140
|
+
session = self.require_session(task_id)
|
|
1141
|
+
context: Any = session["context"]
|
|
1142
|
+
raw = context.cookies(urls or [])
|
|
1143
|
+
# Normalise to our Cookie shape
|
|
1144
|
+
cookies = [
|
|
1145
|
+
{
|
|
1146
|
+
"name": c["name"],
|
|
1147
|
+
"value": c["value"],
|
|
1148
|
+
"domain": c.get("domain"),
|
|
1149
|
+
"path": c.get("path"),
|
|
1150
|
+
"expires": c.get("expires"),
|
|
1151
|
+
"httpOnly": c.get("httpOnly"),
|
|
1152
|
+
"secure": c.get("secure"),
|
|
1153
|
+
"sameSite": c.get("sameSite"),
|
|
1154
|
+
}
|
|
1155
|
+
for c in raw
|
|
1156
|
+
]
|
|
1157
|
+
return {"success": True, "cookies": cookies}
|
|
1158
|
+
except SessionNotFoundError:
|
|
1159
|
+
raise
|
|
1160
|
+
except Exception as exc:
|
|
1161
|
+
return {"success": False, "cookies": [], "error": str(exc)}
|
|
1162
|
+
|
|
1163
|
+
def do_add_cookies(
|
|
1164
|
+
self, task_id: str, cookies: list[dict[str, Any]]
|
|
1165
|
+
) -> dict[str, Any]:
|
|
1166
|
+
"""Add cookies to the browser context."""
|
|
1167
|
+
try:
|
|
1168
|
+
session = self.require_session(task_id)
|
|
1169
|
+
context: Any = session["context"]
|
|
1170
|
+
context.add_cookies(cookies)
|
|
1171
|
+
return {"success": True}
|
|
1172
|
+
except SessionNotFoundError:
|
|
1173
|
+
raise
|
|
1174
|
+
except Exception as exc:
|
|
1175
|
+
return {"success": False, "error": str(exc)}
|
|
1176
|
+
|
|
1177
|
+
def do_clear_cookies(
|
|
1178
|
+
self,
|
|
1179
|
+
task_id: str,
|
|
1180
|
+
name: Optional[str] = None,
|
|
1181
|
+
domain: Optional[str] = None,
|
|
1182
|
+
path: Optional[str] = None,
|
|
1183
|
+
) -> dict[str, Any]:
|
|
1184
|
+
"""Clear cookies, optionally filtered by name/domain/path."""
|
|
1185
|
+
try:
|
|
1186
|
+
session = self.require_session(task_id)
|
|
1187
|
+
context: Any = session["context"]
|
|
1188
|
+
# Playwright Python accepts name/domain/path as kwargs
|
|
1189
|
+
kwargs: dict[str, Any] = {}
|
|
1190
|
+
if name is not None:
|
|
1191
|
+
kwargs["name"] = name
|
|
1192
|
+
if domain is not None:
|
|
1193
|
+
kwargs["domain"] = domain
|
|
1194
|
+
if path is not None:
|
|
1195
|
+
kwargs["path"] = path
|
|
1196
|
+
context.clear_cookies(**kwargs)
|
|
1197
|
+
return {"success": True}
|
|
1198
|
+
except SessionNotFoundError:
|
|
1199
|
+
raise
|
|
1200
|
+
except Exception as exc:
|
|
1201
|
+
return {"success": False, "error": str(exc)}
|
|
1202
|
+
|
|
1203
|
+
def do_get_storage_state(self, task_id: str) -> dict[str, Any]:
|
|
1204
|
+
"""Get full storage state (cookies + localStorage + IndexedDB)."""
|
|
1205
|
+
try:
|
|
1206
|
+
session = self.require_session(task_id)
|
|
1207
|
+
context: Any = session["context"]
|
|
1208
|
+
state: dict[str, Any] = context.storage_state()
|
|
1209
|
+
return {
|
|
1210
|
+
"success": True,
|
|
1211
|
+
"cookies": state.get("cookies", []),
|
|
1212
|
+
"origins": state.get("origins", []),
|
|
1213
|
+
}
|
|
1214
|
+
except SessionNotFoundError:
|
|
1215
|
+
raise
|
|
1216
|
+
except Exception as exc:
|
|
1217
|
+
return {
|
|
1218
|
+
"success": False,
|
|
1219
|
+
"cookies": [],
|
|
1220
|
+
"origins": [],
|
|
1221
|
+
"error": str(exc),
|
|
1222
|
+
}
|