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.
Files changed (55) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +608 -0
  3. package/backends/chromium/index.ts +50 -0
  4. package/backends/chromium-py/bridge.py +67 -0
  5. package/backends/firefox/index.ts +60 -0
  6. package/backends/firefox-py/bridge.py +64 -0
  7. package/backends/playwright-base/playwright-plugin.ts +1294 -0
  8. package/backends/python-adapter.ts +1141 -0
  9. package/backends/python-base/pi_browser_bridge/__init__.py +71 -0
  10. package/backends/python-base/pi_browser_bridge/accessibility.py +408 -0
  11. package/backends/python-base/pi_browser_bridge/bot_detection.py +115 -0
  12. package/backends/python-base/pi_browser_bridge/bridge.py +598 -0
  13. package/backends/python-base/pi_browser_bridge/playwright_base.py +1222 -0
  14. package/backends/python-base/pi_browser_bridge/transport.py +167 -0
  15. package/backends/python-base/pyproject.toml +15 -0
  16. package/browser-cookies.ts +88 -0
  17. package/browser-profile.ts +260 -0
  18. package/browser-status.ts +84 -0
  19. package/browser-toggle.ts +527 -0
  20. package/core/fetch-backend.ts +466 -0
  21. package/core/guides.ts +467 -0
  22. package/core/plugin-api.ts +302 -0
  23. package/core/plugin-config.ts +388 -0
  24. package/core/plugin-registry.ts +263 -0
  25. package/core/router.ts +1186 -0
  26. package/core/shared/accessibility-tree.ts +408 -0
  27. package/core/shared/bot-detection.ts +187 -0
  28. package/core/shared/browser-events.ts +111 -0
  29. package/core/shared/dom-extractor.ts +550 -0
  30. package/core/shared/nav-settle.ts +187 -0
  31. package/core/shared/paths.ts +56 -0
  32. package/core/shared/session-manager.ts +258 -0
  33. package/core/shared/settings-reader.ts +63 -0
  34. package/core/shared/snapshot-cache.ts +231 -0
  35. package/core/shared/storage-state.ts +560 -0
  36. package/core/shared/task-id.ts +77 -0
  37. package/core/shared/url-safety.ts +164 -0
  38. package/index.ts +253 -0
  39. package/package.json +63 -0
  40. package/ship-manifest.test.ts +12 -0
  41. package/tools/browser-back.ts +50 -0
  42. package/tools/browser-click.ts +74 -0
  43. package/tools/browser-console.ts +160 -0
  44. package/tools/browser-inspect.ts +136 -0
  45. package/tools/browser-navigate.ts +254 -0
  46. package/tools/browser-press.ts +80 -0
  47. package/tools/browser-scroll.ts +56 -0
  48. package/tools/browser-snapshot.ts +90 -0
  49. package/tools/browser-type.ts +60 -0
  50. package/tools/index.ts +19 -0
  51. package/tools/utils.ts +157 -0
  52. package/tools/web-fetch.ts +147 -0
  53. package/tools/web-guide.ts +55 -0
  54. package/tools/web-learn.ts +128 -0
  55. 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
+ }