pi-lean-dimension 0.1.0 → 0.2.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/README.md +96 -36
- package/node_modules/pi-lean-portal/AGENTS.md +146 -4
- package/node_modules/pi-lean-portal/README.md +112 -50
- package/node_modules/pi-lean-portal/__tests__/browser-data.test.ts +124 -0
- package/node_modules/pi-lean-portal/__tests__/browser-inspect.test.ts +192 -16
- package/node_modules/pi-lean-portal/__tests__/browser-toggle-profile.test.ts +3 -3
- package/node_modules/pi-lean-portal/__tests__/browser-toggle.test.ts +49 -35
- package/node_modules/pi-lean-portal/__tests__/chromium-py-persistence.test.ts +21 -195
- package/node_modules/pi-lean-portal/__tests__/chromium-py.test.ts +17 -81
- package/node_modules/pi-lean-portal/__tests__/chromium.test.ts +25 -0
- package/node_modules/pi-lean-portal/__tests__/contributed/invisible-py/invisible-py.test.ts +299 -0
- package/node_modules/pi-lean-portal/__tests__/cookie-persistence.test.ts +22 -182
- package/node_modules/pi-lean-portal/__tests__/fetch-backend.test.ts +1 -1
- package/node_modules/pi-lean-portal/__tests__/firefox-py-persistence.test.ts +21 -184
- package/node_modules/pi-lean-portal/__tests__/firefox-py.test.ts +17 -101
- package/node_modules/pi-lean-portal/__tests__/firefox.test.ts +2 -18
- package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/create-py-backend-harness.ts +105 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/load-plugin-config-from-file.ts +53 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/mock-plugin.ts +11 -7
- package/node_modules/pi-lean-portal/__tests__/helpers/mock-python-bridge.py +4 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/persistence-suite.ts +218 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/plugin-contract.ts +198 -318
- package/node_modules/pi-lean-portal/__tests__/helpers/probe-user-backend.ts +198 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/test-server.ts +14 -0
- package/node_modules/pi-lean-portal/__tests__/plugin-config-browser.test.ts +150 -15
- package/node_modules/pi-lean-portal/__tests__/plugin-loading.test.ts +120 -18
- package/node_modules/pi-lean-portal/__tests__/plugin-registry.test.ts +6 -67
- package/node_modules/pi-lean-portal/__tests__/probe-user-backend.test.ts +236 -0
- package/node_modules/pi-lean-portal/__tests__/python-adapter.test.ts +401 -11
- package/node_modules/pi-lean-portal/__tests__/router-session.test.ts +4 -1
- package/node_modules/pi-lean-portal/__tests__/run-contributed-suites.test.ts +318 -0
- package/node_modules/pi-lean-portal/__tests__/session-manager.test.ts +50 -0
- package/node_modules/pi-lean-portal/__tests__/snapshot-cache.test.ts +2 -2
- package/node_modules/pi-lean-portal/__tests__/url-safety.test.ts +1 -1
- package/node_modules/pi-lean-portal/backends/chromium/index.ts +5 -13
- package/node_modules/pi-lean-portal/backends/chromium-py/__pycache__/bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/chromium-py/bridge.py +0 -2
- package/node_modules/pi-lean-portal/backends/firefox/index.ts +6 -5
- package/node_modules/pi-lean-portal/backends/firefox-py/__pycache__/bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/firefox-py/bridge.py +7 -6
- package/node_modules/pi-lean-portal/backends/playwright-base/playwright-plugin.ts +241 -398
- package/node_modules/pi-lean-portal/backends/python-adapter.ts +182 -83
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__init__.py +1 -42
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/__init__.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/__init__.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/accessibility.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/accessibility.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bot_detection.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bot_detection.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bridge.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/browser_data.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/browser_data.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/patch_playwright.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/patch_playwright.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/accessibility.py +12 -147
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/bot_detection.py +12 -37
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/bridge.py +249 -322
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/browser_data.py +92 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/patch_playwright.py +321 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/playwright_base.py +511 -299
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_accessibility.cpython-313-pytest-9.1.0.pyc → test_accessibility.cpython-313-pytest-9.1.1.pyc} +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_accessibility.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_browser_data.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_browser_data.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_chromium_py_bridge.cpython-313-pytest-9.1.0.pyc → test_chromium_py_bridge.cpython-313-pytest-9.1.1.pyc} +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_firefox_py_bridge.cpython-313-pytest-9.1.0.pyc → test_firefox_py_bridge.cpython-313-pytest-9.1.1.pyc} +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_py_bridges.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_transport.cpython-313-pytest-9.1.0.pyc → test_transport.cpython-313-pytest-9.1.1.pyc} +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/conftest.py +95 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_accessibility.py +8 -132
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_bot_detection.py +8 -147
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_browser_data.py +131 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_playwright_base_quirks.py +768 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_py_bridges.py +198 -0
- package/node_modules/pi-lean-portal/browser-toggle.ts +33 -69
- package/node_modules/pi-lean-portal/contributed/CHOOSING.md +126 -0
- package/node_modules/pi-lean-portal/contributed/README.md +304 -0
- package/node_modules/pi-lean-portal/contributed/camoufox-py/bridge.py +216 -0
- package/node_modules/pi-lean-portal/contributed/invisible-py/__pycache__/bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/contributed/invisible-py/bridge.py +434 -0
- package/node_modules/pi-lean-portal/core/fetch-backend.ts +0 -5
- package/node_modules/pi-lean-portal/core/plugin-api.ts +6 -33
- package/node_modules/pi-lean-portal/core/plugin-config.ts +75 -58
- package/node_modules/pi-lean-portal/core/plugin-registry.ts +11 -49
- package/node_modules/pi-lean-portal/core/router.ts +59 -98
- package/node_modules/pi-lean-portal/core/shared/accessibility-tree.ts +10 -143
- package/node_modules/pi-lean-portal/core/shared/bot-detection.ts +31 -77
- package/node_modules/pi-lean-portal/core/shared/browser-data.json +183 -0
- package/node_modules/pi-lean-portal/core/shared/browser-data.ts +50 -0
- package/node_modules/pi-lean-portal/core/shared/browser-events.ts +6 -6
- package/node_modules/pi-lean-portal/core/shared/dom-extractor.ts +97 -36
- package/node_modules/pi-lean-portal/core/shared/nav-settle.ts +12 -15
- package/node_modules/pi-lean-portal/core/shared/paths.ts +3 -0
- package/node_modules/pi-lean-portal/core/shared/session-manager.ts +7 -21
- package/node_modules/pi-lean-portal/{verify-ship-manifest.ts → core/shared/ship-manifest.ts} +34 -9
- package/node_modules/pi-lean-portal/core/shared/snapshot-cache.ts +7 -6
- package/node_modules/pi-lean-portal/core/shared/storage-state.ts +40 -9
- package/node_modules/pi-lean-portal/index.ts +42 -7
- package/node_modules/pi-lean-portal/package.json +8 -3
- package/node_modules/pi-lean-portal/ship-manifest.test.ts +8 -3
- package/node_modules/pi-lean-portal/tools/browser-inspect.ts +2 -6
- package/node_modules/pi-lean-portal/tools/browser-navigate.ts +7 -5
- package/node_modules/pi-lean-portal/tools/browser-snapshot.ts +2 -5
- package/node_modules/pi-lean-portal/tools/utils.ts +22 -4
- package/node_modules/pi-lean-portal/tools/web-fetch.ts +3 -4
- package/node_modules/pi-lean-search/README.md +6 -2
- package/node_modules/pi-lean-search/__tests__/web-search.test.ts +11 -0
- package/node_modules/pi-lean-search/index.ts +4 -21
- package/node_modules/pi-lean-search/package.json +2 -2
- package/node_modules/pi-lean-search/verify-ship-manifest.ts +6 -92
- package/node_modules/pi-lean-search/web-search-tool.ts +19 -13
- package/package.json +4 -4
- package/node_modules/pi-lean-portal/__tests__/helpers/reddit-fixture.ts +0 -264
- package/node_modules/pi-lean-portal/__tests__/helpers/toggle-test-utils.ts +0 -31
- package/node_modules/pi-lean-portal/__tests__/reddit-dialog.test.ts +0 -302
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/occlusion.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313-pytest-9.1.0.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_chromium_py_bridge.py +0 -281
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_firefox_py_bridge.py +0 -212
package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/playwright_base.py
CHANGED
|
@@ -21,10 +21,12 @@ import re
|
|
|
21
21
|
import sys
|
|
22
22
|
import time
|
|
23
23
|
from typing import Any, Optional
|
|
24
|
+
from urllib.parse import unquote as _urlunquote
|
|
24
25
|
|
|
25
26
|
from .bridge import BrowserBridge, SessionNotFoundError
|
|
26
27
|
from .bot_detection import check_bot_detection
|
|
27
28
|
from .accessibility import parse_snapshot, build_locator_args
|
|
29
|
+
from .browser_data import NAV_SETTLE
|
|
28
30
|
|
|
29
31
|
# ─── Playwright import (lazy, for better error messages) ──────────────
|
|
30
32
|
|
|
@@ -37,6 +39,11 @@ except ImportError:
|
|
|
37
39
|
PlaywrightTimeout = TimeoutError # type: ignore[misc]
|
|
38
40
|
|
|
39
41
|
|
|
42
|
+
# ─── Nav-settle constants (loaded from shared browser-data.json) ──────
|
|
43
|
+
|
|
44
|
+
_DOM_STABILIZE_JS: str = NAV_SETTLE["domStabilizationJs"]
|
|
45
|
+
|
|
46
|
+
|
|
40
47
|
# ─── Shared helper for bridge entry points ───────────────────────────
|
|
41
48
|
|
|
42
49
|
|
|
@@ -102,6 +109,133 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
102
109
|
#: Engine-specific install hint (shown when browser executable missing).
|
|
103
110
|
_install_hint: str = ""
|
|
104
111
|
|
|
112
|
+
# ── Stealth quirks (Phase 0) ──────────────────────────────────
|
|
113
|
+
#
|
|
114
|
+
# These opt-in flags let stealth subclasses (Camoufox and other stealth engines)
|
|
115
|
+
# disable the base's hard-coded Playwright defaults that would otherwise
|
|
116
|
+
# clobber a fingerprint-managed browser context. All default to ``off``
|
|
117
|
+
# so the shipped ``chromium-py`` / ``firefox-py`` bridges are bit-identical.
|
|
118
|
+
#
|
|
119
|
+
# See stealth-browser-plan-v3.md "Quirks schema" for the concrete
|
|
120
|
+
# correctness problem each flag fixes.
|
|
121
|
+
|
|
122
|
+
#: When True, ``create_browser_context()`` does NOT pass ``viewport`` or
|
|
123
|
+
#: ``user_agent`` to ``new_context`` — the fingerprint package (e.g.
|
|
124
|
+
#: Camoufox at browser launch via ``NewBrowser``) generates those from
|
|
125
|
+
#: the fingerprint, and the base's hard-coded
|
|
126
|
+
#: values would override them with a detectable mismatch.
|
|
127
|
+
_fingerprint_managed_context: bool = False
|
|
128
|
+
|
|
129
|
+
#: Prefix prepended to every ``page.evaluate`` expression in ``do_evaluate``.
|
|
130
|
+
#: Camoufox runs eval in an isolated world with read-only page access by
|
|
131
|
+
#: default; ``"mw:"`` routes the script to the main world where writes work.
|
|
132
|
+
#: Reads work with the prefix too, so it's safe to apply unconditionally.
|
|
133
|
+
#: Empty string = no prefix (the shipped bridges).
|
|
134
|
+
_eval_prefix: str = ""
|
|
135
|
+
|
|
136
|
+
#: When True, ``do_scroll`` uses ``page.mouse.wheel`` instead of
|
|
137
|
+
#: ``page.evaluate("window.scrollBy")``. The eval-based scroll is a
|
|
138
|
+
#: write that silently no-ops under Camoufox's isolated world; the wheel
|
|
139
|
+
#: event performs the scroll via input events instead.
|
|
140
|
+
_scroll_via_wheel: bool = False
|
|
141
|
+
|
|
142
|
+
#: When True, ``create_browser_context`` passes ``no_viewport=True`` to
|
|
143
|
+
#: ``browser.new_context()``, telling Playwright to skip the
|
|
144
|
+
#: ``Browser.setDefaultViewport`` CDP call. The Camoufox patched Firefox
|
|
145
|
+
#: binary does not accept the ``isMobile`` property that Playwright Firefox
|
|
146
|
+
#: includes in ``setDefaultViewport``, which would otherwise cause a
|
|
147
|
+
#: ``Protocol error`` on context creation. Other backends that patch
|
|
148
|
+
#: ``new_context`` do not need this flag;
|
|
149
|
+
#: only set it when the binary rejects the default viewport call.
|
|
150
|
+
#: Default ``False`` so the shipped bridges stay bit-identical.
|
|
151
|
+
_skip_default_viewport: bool = False
|
|
152
|
+
|
|
153
|
+
#: When True, navigation-settle waits skip the ``networkidle`` load state.
|
|
154
|
+
#: The patched Firefox binaries used by some stealth backends
|
|
155
|
+
#: do not fire ``networkidle`` reliably, so waiting for it either
|
|
156
|
+
#: times out (``do_go_back``'s 30s default) or loiters in the Playwright sync
|
|
157
|
+
#: greenlet's event loop long enough to deadlock the Juggler driver when a
|
|
158
|
+
#: subsequent BrowserContext's ``new_page()`` is created. When True,
|
|
159
|
+
#: ``do_go_back`` uses ``wait_until="load"`` and ``_wait_for_page_ready``
|
|
160
|
+
#: skips its ``networkidle`` wait — matching ``do_navigate``'s load-based
|
|
161
|
+
#: settle, which works reliably on the patched binaries. Default ``False``
|
|
162
|
+
#: so the shipped chromium-py / firefox-py bridges keep their networkidle
|
|
163
|
+
#: settle behaviour bit-identical.
|
|
164
|
+
_skip_networkidle: bool = False
|
|
165
|
+
|
|
166
|
+
#: When True, ``do_evaluate`` wraps the expression in ``eval(<json>)``
|
|
167
|
+
#: before prepending :attr:`_eval_prefix` **and** enables a one-retry
|
|
168
|
+
#: recovery on genuine ``Execution context was destroyed`` errors.
|
|
169
|
+
#: Default ``False`` so the shipped ``chromium-py`` / ``firefox-py``
|
|
170
|
+
#: bridges (which have ``_eval_prefix = ""``) stay bit-identical.
|
|
171
|
+
#:
|
|
172
|
+
#: **Layer 1 — SyntaxError class (``eval(<json>)`` wrap).** Camoufox's
|
|
173
|
+
#: patched Juggler main-world eval path
|
|
174
|
+
#: (``MainWorldContext.executeInGlobal`` in the binary's ``omni.ja``)
|
|
175
|
+
#: wraps every ``mw:``-prefixed script as
|
|
176
|
+
#: ``(() => { let _s = (${script}); ... })()``. That wrapper requires
|
|
177
|
+
#: ``${script}`` to be a single *expression*; any *statement* (``let`` /
|
|
178
|
+
#: ``var`` / multiple ``;``-separated statements) is a ``SyntaxError``
|
|
179
|
+
#: (``missing ) in parenthetical``) that surfaces through Playwright as
|
|
180
|
+
#: ``"Execution context was destroyed, most likely because of a
|
|
181
|
+
#: navigation."`` — an uncatchable-looking error that is NOT a navigation
|
|
182
|
+
#: race at all. Rewriting the script as ``eval(<JSON-string of script>)``
|
|
183
|
+
#: makes it a single expression (valid inside ``let _s = (...)``) while
|
|
184
|
+
#: ``eval`` itself correctly handles both expressions and multi-statement
|
|
185
|
+
#: scripts, returning the completion value of the last statement.
|
|
186
|
+
#: This class is **terminal** — a SyntaxError after the wrap means the
|
|
187
|
+
#: wrap itself is broken and there is no retry.
|
|
188
|
+
#:
|
|
189
|
+
#: **Layer 2 — Genuine context-destruction class (retry).** On a
|
|
190
|
+
#: bot-detection challenge page the page may settle-navigate after
|
|
191
|
+
#: ``browser.navigate`` returns, destroying the ``mw:`` eval context
|
|
192
|
+
#: between the snapshot and a subsequent ``browser.inspect`` or
|
|
193
|
+
#: ``browser-console`` call. The error string is identical to Layer 1
|
|
194
|
+
#: (both produce ``"Execution context was destroyed…"`` through
|
|
195
|
+
#: Playwright) but the class is **transient** — a single
|
|
196
|
+
#: ``wait_for_load_state("load")`` + one ``page.evaluate`` retry
|
|
197
|
+
#: succeeds once the challenge settles. ``do_evaluate`` distinguishes
|
|
198
|
+
#: the two classes in its ``except`` handler: a retry is attempted
|
|
199
|
+
#: only when ``_wrap_mw_eval_in_eval`` is True (Camoufox-only; shipped
|
|
200
|
+
#: bridges never hit the ``mw:`` path) and only for the transient class.
|
|
201
|
+
#: See ``do_evaluate`` for the implementation.
|
|
202
|
+
_wrap_mw_eval_in_eval: bool = False
|
|
203
|
+
|
|
204
|
+
#: When True, read-only evals (``do_evaluate(read_only=True)``, used
|
|
205
|
+
#: only for the EXTRACTOR_SCRIPT) bypass ``page.evaluate`` entirely and
|
|
206
|
+
#: read a result that an ``add_init_script`` stashed in the DOM.
|
|
207
|
+
#:
|
|
208
|
+
#: **Why this exists.** Some patched-Firefox stealth binaries route
|
|
209
|
+
#: ``page.evaluate`` through ``eval()`` in the page's *main* world (a
|
|
210
|
+
#: stealth measure that kills Juggler's isolated-world debugger
|
|
211
|
+
#: signature). The page's CSP then applies, so on CSP-strict sites
|
|
212
|
+
#: (e.g. Reddit, which forbids ``unsafe-eval``) every ``page.evaluate``
|
|
213
|
+
#: fails with ``"call to eval() blocked by CSP"``. Camoufox is NOT
|
|
214
|
+
#: affected — its binary keeps Juggler's CSP-free isolated-world and
|
|
215
|
+
#: ``MainWorldContext.executeInGlobal`` paths; other patched-Firefox
|
|
216
|
+
#: stealth binaries are affected.
|
|
217
|
+
#:
|
|
218
|
+
#: **How it works.** ``create_browser_context`` calls
|
|
219
|
+
#: :meth:`_register_readonly_extractor_init_script`, which wraps the
|
|
220
|
+
#: EXTRACTOR_SCRIPT (plumbed from the TypeScript side via the
|
|
221
|
+
#: ``browser.init`` config key ``readOnlyExtractorScript``) in a
|
|
222
|
+
#: ``DOMContentLoaded``-deferred IIFE that writes its JSON result to
|
|
223
|
+
#: ``<meta id="__pi-extract" content="<urlencoded JSON>">``. The init
|
|
224
|
+
#: script runs in the isolated world, which is CSP-free.
|
|
225
|
+
#: ``do_evaluate(read_only=True)`` then reads that meta via
|
|
226
|
+
#: ``page.query_selector`` + ``get_attribute`` — both native Juggler
|
|
227
|
+
#: element commands, also CSP-free — instead of calling
|
|
228
|
+
#: ``page.evaluate``.
|
|
229
|
+
#:
|
|
230
|
+
#: **Limitation.** The meta is repopulated only on a full document
|
|
231
|
+
#: load (``page.goto``); SPA-style in-page route changes do NOT re-run
|
|
232
|
+
#: the init script, so the stashed result goes stale until the next
|
|
233
|
+
#: ``browser-navigate``. For the navigate→inspect agent flow this is
|
|
234
|
+
#: fine; document with a ``ponytail:`` note if you lift it.
|
|
235
|
+
#: Default ``False`` so shipped bridges stay bit-identical.
|
|
236
|
+
_csp_safe_readonly_via_init_script: bool = False
|
|
237
|
+
|
|
238
|
+
|
|
105
239
|
# ── Shared Playwright state ─────────────────────────────────
|
|
106
240
|
|
|
107
241
|
_pw: Any # Playwright instance (lazy, shared)
|
|
@@ -112,6 +246,13 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
112
246
|
self._pw = None
|
|
113
247
|
self._browser = None
|
|
114
248
|
self._cached_ua: str = ""
|
|
249
|
+
# The read-only extractor script plumbed from the TypeScript side
|
|
250
|
+
# via the ``browser.init`` config key ``readOnlyExtractorScript``.
|
|
251
|
+
# Populated lazily by _register_readonly_extractor_init_script;
|
|
252
|
+
# used by do_evaluate to gate the CSP-free meta handoff so a
|
|
253
|
+
# non-extractor read_only eval (none today) can't silently receive
|
|
254
|
+
# the extractor's stale result.
|
|
255
|
+
self._readonly_extractor_script: str = ""
|
|
115
256
|
|
|
116
257
|
# ── Subclass extension point ───────────────────────────────
|
|
117
258
|
|
|
@@ -163,6 +304,7 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
163
304
|
except Exception:
|
|
164
305
|
pass
|
|
165
306
|
|
|
307
|
+
|
|
166
308
|
# ── Debug logging ───────────────────────────────────────────
|
|
167
309
|
|
|
168
310
|
@property
|
|
@@ -179,6 +321,16 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
179
321
|
flush=True,
|
|
180
322
|
)
|
|
181
323
|
|
|
324
|
+
def _log_op(self, event: str, ctx: dict, result: dict) -> dict:
|
|
325
|
+
"""Log result and return it. Replaces success/failure _log pairs."""
|
|
326
|
+
if self._debug:
|
|
327
|
+
log_data = dict(ctx)
|
|
328
|
+
log_data["success"] = result.get("success", False)
|
|
329
|
+
if result.get("error"):
|
|
330
|
+
log_data["error"] = result["error"]
|
|
331
|
+
self._log(event, **log_data)
|
|
332
|
+
return result
|
|
333
|
+
|
|
182
334
|
# ── Shared Playwright lifecycle ────────────────────────────
|
|
183
335
|
|
|
184
336
|
def _ensure_playwright(self) -> tuple[Any, Any]:
|
|
@@ -232,17 +384,38 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
232
384
|
"""Create a new isolated BrowserContext for a task session.
|
|
233
385
|
|
|
234
386
|
Applies the default viewport, :attr:`effective_user_agent`, and
|
|
235
|
-
``storageState`` from config
|
|
236
|
-
|
|
387
|
+
``storageState`` from config — **unless**
|
|
388
|
+
:attr:`_fingerprint_managed_context` is True, in which case viewport
|
|
389
|
+
and user_agent are omitted so the stealth fingerprint package can
|
|
390
|
+
generate them from the fingerprint without being clobbered.
|
|
391
|
+
(Camoufox v135.x injects the fingerprint at browser launch via
|
|
392
|
+
``NewBrowser``; some stealth engines patch ``browser.new_context``.)
|
|
393
|
+
|
|
394
|
+
Context creation always goes through ``browser.new_context(**kwargs)``.
|
|
395
|
+
Stealth backends that need fingerprint injection at context creation
|
|
396
|
+
time can override this method; the shipped Camoufox bridge injects at
|
|
397
|
+
browser launch time and needs no override.
|
|
398
|
+
|
|
399
|
+
Starts Playwright tracing if ``BROWSER_TRACE_DIR`` is set.
|
|
237
400
|
|
|
238
401
|
Returns a Playwright ``BrowserContext`` (no Page yet).
|
|
239
402
|
"""
|
|
240
403
|
_pw, browser = self._ensure_playwright()
|
|
241
404
|
|
|
242
|
-
context_kwargs: dict[str, Any] = {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
405
|
+
context_kwargs: dict[str, Any] = {}
|
|
406
|
+
if not self._fingerprint_managed_context:
|
|
407
|
+
# Shipped bridges: hard-coded defaults.
|
|
408
|
+
context_kwargs["viewport"] = {"width": 1280, "height": 720}
|
|
409
|
+
context_kwargs["user_agent"] = self.effective_user_agent
|
|
410
|
+
elif self._skip_default_viewport:
|
|
411
|
+
# The Camoufox patched Firefox binary does not accept the
|
|
412
|
+
# ``isMobile`` property that Playwright Firefox includes in
|
|
413
|
+
# ``Browser.setDefaultViewport``. Skip the call entirely.
|
|
414
|
+
context_kwargs["no_viewport"] = True
|
|
415
|
+
# When fingerprint-managed, ONLY pass storage_state (and let the
|
|
416
|
+
# fingerprint package set viewport/UA/screen/dpr). proxy/geolocation
|
|
417
|
+
# are forwarded by stealth subclasses at browser launch via their
|
|
418
|
+
# ``_launch_browser`` override (e.g. ``camoufox.NewBrowser``).
|
|
246
419
|
storage_state = config.get("storageState")
|
|
247
420
|
if storage_state is not None:
|
|
248
421
|
context_kwargs["storage_state"] = storage_state
|
|
@@ -263,8 +436,94 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
263
436
|
except Exception:
|
|
264
437
|
pass # Best-effort
|
|
265
438
|
|
|
439
|
+
# CSP-safe read-only eval path (see _csp_safe_readonly_via_init_script).
|
|
440
|
+
# Registers an init script that stashes the EXTRACTOR_SCRIPT result in
|
|
441
|
+
# the DOM so do_evaluate(read_only=True) can read it without
|
|
442
|
+
# page.evaluate (which is CSP-blocked on some patched binaries).
|
|
443
|
+
if self._csp_safe_readonly_via_init_script:
|
|
444
|
+
self._register_readonly_extractor_init_script(context)
|
|
445
|
+
|
|
266
446
|
return context
|
|
267
447
|
|
|
448
|
+
def _register_readonly_extractor_init_script(self, context: Any) -> None:
|
|
449
|
+
"""Register a CSP-free init script that stashes the read-only
|
|
450
|
+
extractor's JSON result in the DOM.
|
|
451
|
+
|
|
452
|
+
Reads the EXTRACTOR_SCRIPT from ``self.plugin_config[
|
|
453
|
+
"readOnlyExtractorScript"]`` (plumbed from the TypeScript adapter via
|
|
454
|
+
the ``browser.init`` RPC). Wraps it in a ``DOMContentLoaded``-deferred
|
|
455
|
+
IIFE that runs in the isolated world (CSP-free) and writes its JSON
|
|
456
|
+
return value to ``<meta id="__pi-extract" content="<urlencoded JSON>">``
|
|
457
|
+
once the DOM is parsed.
|
|
458
|
+
|
|
459
|
+
No-op (with a debug log) when the script is absent — e.g. an older
|
|
460
|
+
adapter that doesn't forward ``readOnlyExtractorScript``. In that
|
|
461
|
+
case ``do_evaluate(read_only=True)`` falls back to ``page.evaluate``,
|
|
462
|
+
which is the pre-fix behaviour (CSP-fails on strict sites, works on
|
|
463
|
+
lax sites). Idempotent: re-registering on a fresh context is fine
|
|
464
|
+
(init scripts are per-context, not global).
|
|
465
|
+
"""
|
|
466
|
+
script = (self.plugin_config or {}).get("readOnlyExtractorScript")
|
|
467
|
+
if not script:
|
|
468
|
+
self._log(
|
|
469
|
+
"registerReadonlyExtractor",
|
|
470
|
+
taskId="shared",
|
|
471
|
+
success=False,
|
|
472
|
+
reason="readOnlyExtractorScript not present in plugin config",
|
|
473
|
+
)
|
|
474
|
+
return
|
|
475
|
+
self._readonly_extractor_script = script
|
|
476
|
+
# The EXTRACTOR_SCRIPT is a self-contained IIFE that returns a JSON
|
|
477
|
+
# string. It ends with a trailing ``;``; strip it so the assignment
|
|
478
|
+
# ``__r = <script>`` is a single expression statement (wrapping it as
|
|
479
|
+
# ``__r = (<script>);`` would put the script's ``;`` *inside* parens
|
|
480
|
+
# → ``__r = (...})(););`` → SyntaxError, and the whole init script
|
|
481
|
+
# then silently no-ops at document-start).
|
|
482
|
+
script_body = script
|
|
483
|
+
while script_body.endswith(";"):
|
|
484
|
+
script_body = script_body[:-1]
|
|
485
|
+
# Run the extractor at ``DOMContentLoaded`` (NOT ``load``) and write
|
|
486
|
+
# its JSON result to a meta tag. Two reasons specific to the
|
|
487
|
+
# affected patched-Firefox binaries force this shape:
|
|
488
|
+
# 1. ``document.head`` is null at document-start, so a synchronous
|
|
489
|
+
# write at init time would throw — defer until DCL when the head
|
|
490
|
+
# exists and the DOM is parsed.
|
|
491
|
+
# 2. A ``window.addEventListener('load', ...)`` listener registered
|
|
492
|
+
# from the isolated world does NOT fire on this patched binary
|
|
493
|
+
# (the isolated world's window doesn't receive the page's load
|
|
494
|
+
# event), while ``document.addEventListener('DOMContentLoaded'
|
|
495
|
+
# , ...)`` does fire. DCL is also early enough that the
|
|
496
|
+
# extractor's offsetParent / getClientRects / computed-visibility
|
|
497
|
+
# checks are accurate for text elements (layout is done at DCL;
|
|
498
|
+
# only images are still loading, and they don't affect text
|
|
499
|
+
# element layout).
|
|
500
|
+
# The init script runs in the isolated world, so it is NOT subject to
|
|
501
|
+
# the page's CSP (the whole reason this path exists).
|
|
502
|
+
# encodeURIComponent keeps the JSON safe inside an HTML attribute.
|
|
503
|
+
wrapper = (
|
|
504
|
+
"(() => {\n"
|
|
505
|
+
" const __piRun = () => {\n"
|
|
506
|
+
" let __r;\n"
|
|
507
|
+
" try { __r = " + script_body + "; }\n"
|
|
508
|
+
" catch (e) { __r = JSON.stringify({ error: (e && e.message) || String(e) }); }\n"
|
|
509
|
+
" let __m = document.getElementById('__pi-extract');\n"
|
|
510
|
+
" if (!__m) { __m = document.createElement('meta'); __m.id = '__pi-extract'; document.head.appendChild(__m); }\n"
|
|
511
|
+
" __m.setAttribute('content', encodeURIComponent(__r));\n"
|
|
512
|
+
" };\n"
|
|
513
|
+
" if (document.readyState !== 'loading') { __piRun(); }\n"
|
|
514
|
+
" else { document.addEventListener('DOMContentLoaded', __piRun, { once: true }); }\n"
|
|
515
|
+
"})();"
|
|
516
|
+
)
|
|
517
|
+
try:
|
|
518
|
+
context.add_init_script(wrapper)
|
|
519
|
+
except Exception as exc:
|
|
520
|
+
self._log(
|
|
521
|
+
"registerReadonlyExtractor",
|
|
522
|
+
taskId="shared",
|
|
523
|
+
success=False,
|
|
524
|
+
error=str(exc),
|
|
525
|
+
)
|
|
526
|
+
|
|
268
527
|
def _setup_page_session(self, page: Any) -> dict[str, Any]:
|
|
269
528
|
"""Attach console capture and dialog handlers to a new page.
|
|
270
529
|
|
|
@@ -367,15 +626,24 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
367
626
|
# ── Internal helpers ───────────────────────────────────────────
|
|
368
627
|
|
|
369
628
|
def _get_page(self, task_id: str) -> Any:
|
|
370
|
-
"""Get the Playwright Page for a task, or raise SessionNotFoundError."""
|
|
371
629
|
session = self.require_session(task_id)
|
|
372
630
|
return session["page"]
|
|
373
631
|
|
|
374
|
-
def
|
|
375
|
-
|
|
632
|
+
def _get_page_or_error(
|
|
633
|
+
self, task_id: str, op: str
|
|
634
|
+
) -> tuple[Any, dict[str, Any] | None]:
|
|
635
|
+
"""Resolve the page for a task, or return an error dict for the caller to emit."""
|
|
636
|
+
try:
|
|
637
|
+
return self._get_page(task_id), None
|
|
638
|
+
except SessionNotFoundError:
|
|
639
|
+
raise
|
|
640
|
+
except Exception as exc:
|
|
641
|
+
return None, {
|
|
642
|
+
"success": False,
|
|
643
|
+
"error": f"{op[0].upper()}{op[1:]} failed: {exc}",
|
|
644
|
+
}
|
|
376
645
|
|
|
377
|
-
|
|
378
|
-
"""
|
|
646
|
+
def _get_dialog_events(self, task_id: str) -> list[dict[str, str]]:
|
|
379
647
|
session = self.get_session(task_id)
|
|
380
648
|
if not session:
|
|
381
649
|
return []
|
|
@@ -388,7 +656,6 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
388
656
|
def _take_snapshot_and_cache(
|
|
389
657
|
self, task_id: str, page: Any
|
|
390
658
|
) -> tuple[str, int, dict[str, dict[str, Any]]]:
|
|
391
|
-
"""Take snapshot, cache elements, return formatted text + count."""
|
|
392
659
|
try:
|
|
393
660
|
snap_text: str = page.aria_snapshot()
|
|
394
661
|
except Exception:
|
|
@@ -413,6 +680,31 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
413
680
|
for ref, node in parsed.elements.items()
|
|
414
681
|
}
|
|
415
682
|
|
|
683
|
+
def _build_interaction_result(
|
|
684
|
+
self, task_id: str, page: Any, **extra: Any
|
|
685
|
+
) -> dict[str, Any]:
|
|
686
|
+
snap_text, element_count, elements = self._take_snapshot_and_cache(
|
|
687
|
+
task_id, page
|
|
688
|
+
)
|
|
689
|
+
result: dict[str, Any] = {
|
|
690
|
+
"success": True,
|
|
691
|
+
"snapshot": snap_text,
|
|
692
|
+
"elementCount": element_count,
|
|
693
|
+
"elements": elements,
|
|
694
|
+
"dialogEvents": self._get_dialog_events(task_id),
|
|
695
|
+
}
|
|
696
|
+
result.update(extra)
|
|
697
|
+
return result
|
|
698
|
+
|
|
699
|
+
def _ref_debug_info(self, task_id: str, ref: str) -> tuple[str, str]:
|
|
700
|
+
key = ref[1:] if ref.startswith("@") else ref
|
|
701
|
+
cache = self.get_element_cache(task_id)
|
|
702
|
+
node = cache.elements.get(key) if cache else None
|
|
703
|
+
return (
|
|
704
|
+
getattr(node, "role", "unknown") if node else "unknown",
|
|
705
|
+
getattr(node, "name", "unknown") if node else "unknown",
|
|
706
|
+
)
|
|
707
|
+
|
|
416
708
|
def _locate_element(
|
|
417
709
|
self, page: Any, task_id: str, ref: str
|
|
418
710
|
) -> Any:
|
|
@@ -454,7 +746,7 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
454
746
|
# ── Navigation settle helpers ───────────────────────────────
|
|
455
747
|
|
|
456
748
|
@staticmethod
|
|
457
|
-
def _wait_for_page_ready(page: Any, timeout_ms: int) -> None:
|
|
749
|
+
def _wait_for_page_ready(page: Any, timeout_ms: int, skip_networkidle: bool = False) -> None:
|
|
458
750
|
"""Wait for page readiness after a navigation.
|
|
459
751
|
|
|
460
752
|
Mirrors the TypeScript ``waitForPageReady`` helper — each load
|
|
@@ -466,15 +758,19 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
466
758
|
Args:
|
|
467
759
|
page: Playwright Page.
|
|
468
760
|
timeout_ms: Timeout for each wait_for_load_state call.
|
|
761
|
+
skip_networkidle: When True, skip the ``networkidle`` wait —
|
|
762
|
+
stealth patched-Firefox binaries don't fire it reliably and
|
|
763
|
+
loitering for it can deadlock the Juggler driver.
|
|
469
764
|
"""
|
|
470
765
|
try:
|
|
471
766
|
page.wait_for_load_state("load", timeout=timeout_ms)
|
|
472
767
|
except Exception:
|
|
473
768
|
pass
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
769
|
+
if not skip_networkidle:
|
|
770
|
+
try:
|
|
771
|
+
page.wait_for_load_state("networkidle", timeout=timeout_ms)
|
|
772
|
+
except Exception:
|
|
773
|
+
pass
|
|
478
774
|
|
|
479
775
|
@staticmethod
|
|
480
776
|
def _wait_for_navigation_settle(
|
|
@@ -482,6 +778,7 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
482
778
|
url_before: str,
|
|
483
779
|
nav_timeout_ms: int = 5000,
|
|
484
780
|
settle_timeout_ms: int = 400,
|
|
781
|
+
skip_networkidle: bool = False,
|
|
485
782
|
) -> tuple[bool, str]:
|
|
486
783
|
"""Wait for navigation to settle after a user interaction.
|
|
487
784
|
|
|
@@ -517,11 +814,11 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
517
814
|
|
|
518
815
|
waited_for_load = False
|
|
519
816
|
if navigated:
|
|
520
|
-
PlaywrightBridge._wait_for_page_ready(page, nav_timeout_ms)
|
|
817
|
+
PlaywrightBridge._wait_for_page_ready(page, nav_timeout_ms, skip_networkidle)
|
|
521
818
|
waited_for_load = True
|
|
522
819
|
elif page.url != url_before:
|
|
523
820
|
# URL changed without framenavigated event
|
|
524
|
-
PlaywrightBridge._wait_for_page_ready(page, nav_timeout_ms)
|
|
821
|
+
PlaywrightBridge._wait_for_page_ready(page, nav_timeout_ms, skip_networkidle)
|
|
525
822
|
waited_for_load = True
|
|
526
823
|
else:
|
|
527
824
|
# No navigation — settle for client-side rerenders
|
|
@@ -529,7 +826,7 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
529
826
|
|
|
530
827
|
# Late-arrival gate: catch navigations that started during settle
|
|
531
828
|
if not waited_for_load and (navigated or page.url != url_before):
|
|
532
|
-
PlaywrightBridge._wait_for_page_ready(page, nav_timeout_ms)
|
|
829
|
+
PlaywrightBridge._wait_for_page_ready(page, nav_timeout_ms, skip_networkidle)
|
|
533
830
|
|
|
534
831
|
finally:
|
|
535
832
|
page.remove_listener("framenavigated", _on_nav)
|
|
@@ -591,9 +888,16 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
591
888
|
re.IGNORECASE,
|
|
592
889
|
)
|
|
593
890
|
)
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
891
|
+
# Nested ifs (rather than `is_transient and attempt == 0`)
|
|
892
|
+
# keep the no-boolean-in-except lint calm; behavior is
|
|
893
|
+
# identical: retry once on a transient error, then give up.
|
|
894
|
+
if is_transient:
|
|
895
|
+
if attempt == 0:
|
|
896
|
+
time.sleep(2)
|
|
897
|
+
last_error = msg
|
|
898
|
+
else:
|
|
899
|
+
last_error = msg
|
|
900
|
+
break
|
|
597
901
|
else:
|
|
598
902
|
last_error = msg
|
|
599
903
|
break
|
|
@@ -601,16 +905,8 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
601
905
|
# ── DOM stabilization wait ──────────────────────────────
|
|
602
906
|
try:
|
|
603
907
|
page.wait_for_function(
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
setTimeout(() => {
|
|
607
|
-
resolve(
|
|
608
|
-
document.querySelectorAll("*").length === count
|
|
609
|
-
|| count > 5000
|
|
610
|
-
);
|
|
611
|
-
}, 400);
|
|
612
|
-
})""",
|
|
613
|
-
timeout=5_000,
|
|
908
|
+
_DOM_STABILIZE_JS,
|
|
909
|
+
timeout=NAV_SETTLE["navTimeoutMs"],
|
|
614
910
|
)
|
|
615
911
|
except Exception:
|
|
616
912
|
pass # Stabilization timed out — proceed
|
|
@@ -675,46 +971,19 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
675
971
|
}
|
|
676
972
|
|
|
677
973
|
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
974
|
self.close_browser_session(task_id)
|
|
685
975
|
return {"success": True}
|
|
686
976
|
|
|
687
977
|
def do_snapshot(self, task_id: str) -> dict[str, Any]:
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
}
|
|
978
|
+
page, err = self._get_page_or_error(task_id, "snapshot")
|
|
979
|
+
if err:
|
|
980
|
+
return {**err, "snapshot": "", "elementCount": 0}
|
|
704
981
|
|
|
705
982
|
try:
|
|
706
983
|
snap_text, element_count, elements = self._take_snapshot_and_cache(
|
|
707
984
|
task_id, page
|
|
708
985
|
)
|
|
709
|
-
|
|
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 {
|
|
986
|
+
result = {
|
|
718
987
|
"success": True,
|
|
719
988
|
"snapshot": snap_text,
|
|
720
989
|
"elementCount": element_count,
|
|
@@ -722,216 +991,118 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
722
991
|
"dialogEvents": self._get_dialog_events(task_id),
|
|
723
992
|
}
|
|
724
993
|
except Exception as exc:
|
|
725
|
-
|
|
726
|
-
elementCount=0, dialogBlocks=0, fingerprint="",
|
|
727
|
-
time=round((time.time() - _t_start) * 1000))
|
|
728
|
-
return {
|
|
994
|
+
result = {
|
|
729
995
|
"success": False,
|
|
730
996
|
"snapshot": "",
|
|
731
997
|
"elementCount": 0,
|
|
732
998
|
"error": str(exc),
|
|
733
999
|
}
|
|
1000
|
+
return self._log_op("snapshot", {"taskId": task_id}, result)
|
|
734
1001
|
|
|
735
1002
|
# ── Interaction ─────────────────────────────────────────────────
|
|
736
1003
|
|
|
737
1004
|
def do_click(self, task_id: str, ref: str) -> dict[str, Any]:
|
|
738
|
-
|
|
739
|
-
_t_start = time.time()
|
|
1005
|
+
_role, _name = self._ref_debug_info(task_id, ref)
|
|
740
1006
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
-
}
|
|
1007
|
+
page, err = self._get_page_or_error(task_id, "click")
|
|
1008
|
+
if err:
|
|
1009
|
+
return err
|
|
760
1010
|
|
|
761
1011
|
try:
|
|
762
1012
|
locator = self._locate_element(page, task_id, ref)
|
|
763
1013
|
except RuntimeError as exc:
|
|
764
1014
|
self._log("click", taskId=task_id, ref=ref, role=_role,
|
|
765
|
-
name=_name, result="fail"
|
|
766
|
-
time=round((time.time() - _t_start) * 1000))
|
|
1015
|
+
name=_name, result="fail")
|
|
767
1016
|
return {"success": False, "error": str(exc)}
|
|
768
1017
|
|
|
769
1018
|
try:
|
|
770
1019
|
url_before = page.url
|
|
771
1020
|
locator.click(timeout=5_000)
|
|
772
1021
|
|
|
773
|
-
navigated, _ = self._wait_for_navigation_settle(
|
|
1022
|
+
navigated, _ = self._wait_for_navigation_settle(
|
|
1023
|
+
page, url_before, skip_networkidle=self._skip_networkidle,
|
|
1024
|
+
)
|
|
774
1025
|
|
|
775
1026
|
new_url = page.url
|
|
776
1027
|
new_title = page.title()
|
|
777
1028
|
|
|
778
|
-
|
|
779
|
-
task_id, page
|
|
1029
|
+
result = self._build_interaction_result(
|
|
1030
|
+
task_id, page, newUrl=new_url, newTitle=new_title,
|
|
780
1031
|
)
|
|
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
1032
|
except Exception as exc:
|
|
799
|
-
|
|
800
|
-
name=_name, result="fail",
|
|
801
|
-
time=round((time.time() - _t_start) * 1000))
|
|
802
|
-
return {
|
|
1033
|
+
result = {
|
|
803
1034
|
"success": False,
|
|
804
1035
|
"error": f"Click failed: {exc}",
|
|
805
1036
|
}
|
|
1037
|
+
return self._log_op("click", {"taskId": task_id, "ref": ref, "role": _role, "name": _name}, result)
|
|
806
1038
|
|
|
807
1039
|
def do_type(self, task_id: str, ref: str, text: str) -> dict[str, Any]:
|
|
808
|
-
|
|
809
|
-
_t_start = time.time()
|
|
1040
|
+
_role, _name = self._ref_debug_info(task_id, ref)
|
|
810
1041
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
-
}
|
|
1042
|
+
page, err = self._get_page_or_error(task_id, "type")
|
|
1043
|
+
if err:
|
|
1044
|
+
return err
|
|
830
1045
|
|
|
831
1046
|
try:
|
|
832
1047
|
locator = self._locate_element(page, task_id, ref)
|
|
833
1048
|
except RuntimeError as exc:
|
|
834
1049
|
self._log("type", taskId=task_id, ref=ref, role=_role,
|
|
835
|
-
name=_name, result="fail"
|
|
836
|
-
time=round((time.time() - _t_start) * 1000))
|
|
1050
|
+
name=_name, result="fail")
|
|
837
1051
|
return {"success": False, "error": str(exc)}
|
|
838
1052
|
|
|
839
1053
|
try:
|
|
840
1054
|
locator.click(timeout=5_000) # Focus first
|
|
841
1055
|
locator.fill(text)
|
|
842
1056
|
|
|
843
|
-
|
|
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
|
-
|
|
1057
|
+
result = self._build_interaction_result(task_id, page)
|
|
860
1058
|
except Exception as exc:
|
|
861
|
-
|
|
862
|
-
name=_name, result="fail",
|
|
863
|
-
time=round((time.time() - _t_start) * 1000))
|
|
864
|
-
return {
|
|
1059
|
+
result = {
|
|
865
1060
|
"success": False,
|
|
866
1061
|
"error": f"Type failed: {exc}",
|
|
867
1062
|
}
|
|
1063
|
+
return self._log_op("type", {"taskId": task_id, "ref": ref, "role": _role, "name": _name}, result)
|
|
868
1064
|
|
|
869
1065
|
def do_scroll(self, task_id: str, direction: str) -> dict[str, Any]:
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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
|
-
}
|
|
1066
|
+
page, err = self._get_page_or_error(task_id, "scroll")
|
|
1067
|
+
if err:
|
|
1068
|
+
return err
|
|
884
1069
|
|
|
885
1070
|
try:
|
|
886
1071
|
delta = 800 if direction == "down" else -800
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
1072
|
+
if self._scroll_via_wheel:
|
|
1073
|
+
# Camoufox runs page.evaluate in an isolated world where
|
|
1074
|
+
# the eval-write window.scrollBy silently no-ops; drive the
|
|
1075
|
+
# scroll via input events instead.
|
|
1076
|
+
page.mouse.wheel(0, delta)
|
|
1077
|
+
else:
|
|
1078
|
+
page.evaluate(
|
|
1079
|
+
"""(d) => window.scrollBy({ top: d, behavior: 'smooth' })""",
|
|
1080
|
+
delta,
|
|
1081
|
+
)
|
|
891
1082
|
time.sleep(0.2)
|
|
892
1083
|
|
|
893
|
-
|
|
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
|
-
|
|
1084
|
+
result = self._build_interaction_result(task_id, page)
|
|
909
1085
|
except Exception as exc:
|
|
910
|
-
|
|
911
|
-
success=False,
|
|
912
|
-
time=round((time.time() - _t_start) * 1000))
|
|
913
|
-
return {
|
|
1086
|
+
result = {
|
|
914
1087
|
"success": False,
|
|
915
1088
|
"error": f"Scroll failed: {exc}",
|
|
916
1089
|
}
|
|
1090
|
+
return self._log_op("scroll", {"taskId": task_id, "direction": direction}, result)
|
|
917
1091
|
|
|
918
1092
|
def do_go_back(self, task_id: str) -> dict[str, Any]:
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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
|
-
}
|
|
1093
|
+
page, err = self._get_page_or_error(task_id, "goBack")
|
|
1094
|
+
if err:
|
|
1095
|
+
return err
|
|
932
1096
|
|
|
933
1097
|
try:
|
|
934
|
-
|
|
1098
|
+
if self._skip_networkidle:
|
|
1099
|
+
# Stealth patched Firefox doesn't fire networkidle; waiting for
|
|
1100
|
+
# it times out (30s default) and loitering in the event loop can
|
|
1101
|
+
# deadlock the Juggler driver for subsequent contexts. Match
|
|
1102
|
+
# ``do_navigate``'s load-based settle instead.
|
|
1103
|
+
page.go_back(wait_until="load", timeout=15_000)
|
|
1104
|
+
else:
|
|
1105
|
+
page.go_back(wait_until="networkidle")
|
|
935
1106
|
time.sleep(0.3)
|
|
936
1107
|
|
|
937
1108
|
new_url: Optional[str] = None
|
|
@@ -942,88 +1113,45 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
942
1113
|
except Exception:
|
|
943
1114
|
pass
|
|
944
1115
|
|
|
945
|
-
|
|
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
|
-
}
|
|
1116
|
+
extra: dict[str, Any] = {}
|
|
961
1117
|
if new_url is not None:
|
|
962
|
-
|
|
1118
|
+
extra["newUrl"] = new_url
|
|
963
1119
|
if new_title is not None:
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1120
|
+
extra["newTitle"] = new_title
|
|
1121
|
+
result = self._build_interaction_result(task_id, page, **extra)
|
|
967
1122
|
except Exception as exc:
|
|
968
|
-
|
|
969
|
-
time=round((time.time() - _t_start) * 1000))
|
|
970
|
-
return {
|
|
1123
|
+
result = {
|
|
971
1124
|
"success": False,
|
|
972
1125
|
"error": f"GoBack failed: {exc}",
|
|
973
1126
|
}
|
|
1127
|
+
return self._log_op("goBack", {"taskId": task_id}, result)
|
|
974
1128
|
|
|
975
1129
|
def do_press(self, task_id: str, key: str) -> dict[str, Any]:
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
-
}
|
|
1130
|
+
page, err = self._get_page_or_error(task_id, "press")
|
|
1131
|
+
if err:
|
|
1132
|
+
return err
|
|
989
1133
|
|
|
990
1134
|
try:
|
|
991
1135
|
url_before = page.url
|
|
992
1136
|
page.keyboard.press(key)
|
|
993
1137
|
|
|
994
1138
|
navigated, _ = self._wait_for_navigation_settle(
|
|
995
|
-
page, url_before, nav_timeout_ms=3000
|
|
1139
|
+
page, url_before, nav_timeout_ms=3000,
|
|
1140
|
+
skip_networkidle=self._skip_networkidle,
|
|
996
1141
|
)
|
|
997
1142
|
|
|
998
1143
|
new_url = page.url
|
|
999
1144
|
new_title = page.title()
|
|
1000
1145
|
|
|
1001
|
-
|
|
1002
|
-
task_id, page
|
|
1146
|
+
result = self._build_interaction_result(
|
|
1147
|
+
task_id, page, newUrl=new_url, newTitle=new_title,
|
|
1003
1148
|
)
|
|
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
1149
|
except Exception as exc:
|
|
1021
|
-
|
|
1022
|
-
time=round((time.time() - _t_start) * 1000))
|
|
1023
|
-
return {
|
|
1150
|
+
result = {
|
|
1024
1151
|
"success": False,
|
|
1025
1152
|
"error": f"Press failed: {exc}",
|
|
1026
1153
|
}
|
|
1154
|
+
return self._log_op("press", {"taskId": task_id, "key": key}, result)
|
|
1027
1155
|
|
|
1028
1156
|
# ── Media ───────────────────────────────────────────────────────
|
|
1029
1157
|
|
|
@@ -1032,17 +1160,9 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
1032
1160
|
task_id: str,
|
|
1033
1161
|
full_page: bool = False,
|
|
1034
1162
|
) -> dict[str, Any]:
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
except SessionNotFoundError:
|
|
1039
|
-
raise
|
|
1040
|
-
except Exception as exc:
|
|
1041
|
-
return {
|
|
1042
|
-
"success": False,
|
|
1043
|
-
"dataUri": "",
|
|
1044
|
-
"error": str(exc),
|
|
1045
|
-
}
|
|
1163
|
+
page, err = self._get_page_or_error(task_id, "screenshot")
|
|
1164
|
+
if err:
|
|
1165
|
+
return {**err, "dataUri": ""}
|
|
1046
1166
|
|
|
1047
1167
|
try:
|
|
1048
1168
|
buffer: bytes = page.screenshot(
|
|
@@ -1068,7 +1188,6 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
1068
1188
|
# ── Console & eval ──────────────────────────────────────────────
|
|
1069
1189
|
|
|
1070
1190
|
def do_get_console_messages(self, task_id: str) -> dict[str, Any]:
|
|
1071
|
-
"""Return captured console messages for the task."""
|
|
1072
1191
|
try:
|
|
1073
1192
|
session = self.get_session(task_id)
|
|
1074
1193
|
if session is None:
|
|
@@ -1092,7 +1211,6 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
1092
1211
|
}
|
|
1093
1212
|
|
|
1094
1213
|
def do_clear_console(self, task_id: str) -> dict[str, Any]:
|
|
1095
|
-
"""Clear captured console messages for the task."""
|
|
1096
1214
|
try:
|
|
1097
1215
|
session = self.get_session(task_id)
|
|
1098
1216
|
if session is not None:
|
|
@@ -1105,29 +1223,127 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
1105
1223
|
"error": str(exc),
|
|
1106
1224
|
}
|
|
1107
1225
|
|
|
1108
|
-
def do_evaluate(
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1226
|
+
def do_evaluate(
|
|
1227
|
+
self, task_id: str, expression: str, *, read_only: bool = False
|
|
1228
|
+
) -> dict[str, Any]:
|
|
1229
|
+
"""Evaluate JavaScript in the page context.
|
|
1230
|
+
|
|
1231
|
+
When :attr:`_eval_prefix` is non-empty (e.g. Camoufox's ``"mw:"``),
|
|
1232
|
+
it is prepended to the expression so the script runs in the main
|
|
1233
|
+
world where writes work. Reads work with the prefix too, so it is
|
|
1234
|
+
safe to apply unconditionally.
|
|
1235
|
+
|
|
1236
|
+
When :attr:`_wrap_mw_eval_in_eval` is True (Camoufox), the expression
|
|
1237
|
+
is first wrapped as ``eval(<JSON-string of expression>)`` so that
|
|
1238
|
+
multi-statement scripts survive Camoufox's
|
|
1239
|
+
``let _s = (${script})`` main-world wrapper (see the quirk's
|
|
1240
|
+
docstring for the full rationale).
|
|
1241
|
+
|
|
1242
|
+
When *read_only* is True (used for the EXTRACTOR_SCRIPT), the
|
|
1243
|
+
``_eval_prefix`` and ``_wrap_mw_eval_in_eval`` are both bypassed
|
|
1244
|
+
— the expression targets the isolated-world context, which survives
|
|
1245
|
+
in-page JS churn on Camoufox challenge pages. Writes still need
|
|
1246
|
+
the ``mw:`` prefix; pure DOM reads don't.
|
|
1247
|
+
"""
|
|
1248
|
+
page, err = self._get_page_or_error(task_id, "evaluate")
|
|
1249
|
+
if err:
|
|
1250
|
+
return err
|
|
1251
|
+
|
|
1252
|
+
# CSP-safe read-only handoff (patched-Firefox stealth binaries).
|
|
1253
|
+
# On binaries that route page.evaluate through eval() in the main
|
|
1254
|
+
# world, the EXTRACTOR_SCRIPT is CSP-blocked on strict sites. An init
|
|
1255
|
+
# script stashed the result in <meta id="__pi-extract"> at load; read
|
|
1256
|
+
# it via native query_selector + get_attribute (both CSP-free). Only
|
|
1257
|
+
# honored when the expression is exactly the registered extractor — a
|
|
1258
|
+
# future non-extractor read_only eval falls through to page.evaluate.
|
|
1259
|
+
# ponytail: result is stale across SPA route changes (no new load);
|
|
1260
|
+
# fine for navigate→inspect. Re-run on demand if SPA freshness matters.
|
|
1261
|
+
if (
|
|
1262
|
+
read_only
|
|
1263
|
+
and self._csp_safe_readonly_via_init_script
|
|
1264
|
+
and self._readonly_extractor_script
|
|
1265
|
+
and expression == self._readonly_extractor_script
|
|
1266
|
+
):
|
|
1267
|
+
try:
|
|
1268
|
+
meta = page.query_selector("meta#__pi-extract")
|
|
1269
|
+
if meta is not None:
|
|
1270
|
+
raw = meta.get_attribute("content")
|
|
1271
|
+
if raw:
|
|
1272
|
+
return {"success": True, "result": _urlunquote(raw)}
|
|
1273
|
+
except Exception as exc:
|
|
1274
|
+
# Fall through to page.evaluate below — on CSP-strict pages
|
|
1275
|
+
# that will also fail, but the error message is then the
|
|
1276
|
+
# truthful CSP one rather than a meta-read exception.
|
|
1277
|
+
self._log(
|
|
1278
|
+
"evaluate",
|
|
1279
|
+
taskId=task_id,
|
|
1280
|
+
success=False,
|
|
1281
|
+
via="csp_safe_meta",
|
|
1282
|
+
error=str(exc),
|
|
1283
|
+
)
|
|
1284
|
+
# Meta missing (page navigated before load fired, or init script
|
|
1285
|
+
# not registered). Fall through to page.evaluate as best-effort.
|
|
1286
|
+
|
|
1287
|
+
# Build effective expression before the try block so the
|
|
1288
|
+
# retry path can reference it regardless of where the exception
|
|
1289
|
+
# came from.
|
|
1290
|
+
#
|
|
1291
|
+
# Read-only evals (EXTRACTOR_SCRIPT) skip the mw: prefix and the
|
|
1292
|
+
# eval() wrap entirely — they run in the isolated-world context,
|
|
1293
|
+
# which survives in-page JS churn on Camoufox challenge pages.
|
|
1294
|
+
# Writes still need mw:; pure DOM reads don't.
|
|
1295
|
+
if read_only:
|
|
1296
|
+
effective_expression = expression
|
|
1297
|
+
elif self._wrap_mw_eval_in_eval:
|
|
1298
|
+
# ``eval(<json>)`` is a single expression (valid inside
|
|
1299
|
+
# Camoufox's ``let _s = (...)`` wrapper) that runs the script
|
|
1300
|
+
# verbatim and returns its completion value. ``json.dumps``
|
|
1301
|
+
# safely escapes the script into a JS string literal.
|
|
1302
|
+
inner = "eval(" + json.dumps(expression) + ")"
|
|
1303
|
+
effective_expression = (
|
|
1304
|
+
self._eval_prefix + inner if self._eval_prefix else inner
|
|
1305
|
+
)
|
|
1306
|
+
else:
|
|
1307
|
+
effective_expression = (
|
|
1308
|
+
self._eval_prefix + expression if self._eval_prefix else expression
|
|
1309
|
+
)
|
|
1119
1310
|
|
|
1120
1311
|
try:
|
|
1121
|
-
result: Any = page.evaluate(
|
|
1312
|
+
result: Any = page.evaluate(effective_expression)
|
|
1122
1313
|
return {
|
|
1123
1314
|
"success": True,
|
|
1124
1315
|
"result": result,
|
|
1125
1316
|
}
|
|
1126
1317
|
|
|
1127
1318
|
except Exception as exc:
|
|
1319
|
+
err_msg = str(exc)
|
|
1320
|
+
# Genuine context-destruction recovery (Camoufox mw: path only).
|
|
1321
|
+
# When _wrap_mw_eval_in_eval is True (Camoufox), distinguish:
|
|
1322
|
+
# (1) "Execution context was destroyed" — a transient page
|
|
1323
|
+
# navigation/challenge-settle — recover with one
|
|
1324
|
+
# wait_for_load_state + retry.
|
|
1325
|
+
# (2) Any other error (including a SyntaxError through the eval
|
|
1326
|
+
# wrap, which means the wrap itself is broken) — terminal.
|
|
1327
|
+
# ponytail: single retry, no backoff — challenge pages settle in
|
|
1328
|
+
# one load cycle; add exponential backoff if a real challenge
|
|
1329
|
+
# needs >1 retry.
|
|
1330
|
+
# Nested ifs (rather than
|
|
1331
|
+
# `self._wrap_mw_eval_in_eval and "Execution context was destroyed" in err_msg`)
|
|
1332
|
+
# keep the no-boolean-in-except lint calm; behavior is identical.
|
|
1333
|
+
if self._wrap_mw_eval_in_eval:
|
|
1334
|
+
if "Execution context was destroyed" in err_msg:
|
|
1335
|
+
try:
|
|
1336
|
+
page.wait_for_load_state("load")
|
|
1337
|
+
except Exception:
|
|
1338
|
+
pass # Best-effort: proceed to retry even if wait fails
|
|
1339
|
+
try:
|
|
1340
|
+
result = page.evaluate(effective_expression)
|
|
1341
|
+
return {"success": True, "result": result}
|
|
1342
|
+
except Exception as retry_exc:
|
|
1343
|
+
return {"success": False, "error": str(retry_exc)}
|
|
1128
1344
|
return {
|
|
1129
1345
|
"success": False,
|
|
1130
|
-
"error":
|
|
1346
|
+
"error": err_msg,
|
|
1131
1347
|
}
|
|
1132
1348
|
|
|
1133
1349
|
# ── Cookies & storage state ─────────────────────────────────
|
|
@@ -1135,7 +1351,6 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
1135
1351
|
def do_get_cookies(
|
|
1136
1352
|
self, task_id: str, urls: Optional[list[str]] = None
|
|
1137
1353
|
) -> dict[str, Any]:
|
|
1138
|
-
"""Get cookies, optionally filtered by URL."""
|
|
1139
1354
|
try:
|
|
1140
1355
|
session = self.require_session(task_id)
|
|
1141
1356
|
context: Any = session["context"]
|
|
@@ -1163,7 +1378,6 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
1163
1378
|
def do_add_cookies(
|
|
1164
1379
|
self, task_id: str, cookies: list[dict[str, Any]]
|
|
1165
1380
|
) -> dict[str, Any]:
|
|
1166
|
-
"""Add cookies to the browser context."""
|
|
1167
1381
|
try:
|
|
1168
1382
|
session = self.require_session(task_id)
|
|
1169
1383
|
context: Any = session["context"]
|
|
@@ -1181,7 +1395,6 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
1181
1395
|
domain: Optional[str] = None,
|
|
1182
1396
|
path: Optional[str] = None,
|
|
1183
1397
|
) -> dict[str, Any]:
|
|
1184
|
-
"""Clear cookies, optionally filtered by name/domain/path."""
|
|
1185
1398
|
try:
|
|
1186
1399
|
session = self.require_session(task_id)
|
|
1187
1400
|
context: Any = session["context"]
|
|
@@ -1201,7 +1414,6 @@ class PlaywrightBridge(BrowserBridge):
|
|
|
1201
1414
|
return {"success": False, "error": str(exc)}
|
|
1202
1415
|
|
|
1203
1416
|
def do_get_storage_state(self, task_id: str) -> dict[str, Any]:
|
|
1204
|
-
"""Get full storage state (cookies + localStorage + IndexedDB)."""
|
|
1205
1417
|
try:
|
|
1206
1418
|
session = self.require_session(task_id)
|
|
1207
1419
|
context: Any = session["context"]
|