pi-lean-portal 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +608 -0
- package/backends/chromium/index.ts +50 -0
- package/backends/chromium-py/bridge.py +67 -0
- package/backends/firefox/index.ts +60 -0
- package/backends/firefox-py/bridge.py +64 -0
- package/backends/playwright-base/playwright-plugin.ts +1294 -0
- package/backends/python-adapter.ts +1141 -0
- package/backends/python-base/pi_browser_bridge/__init__.py +71 -0
- package/backends/python-base/pi_browser_bridge/accessibility.py +408 -0
- package/backends/python-base/pi_browser_bridge/bot_detection.py +115 -0
- package/backends/python-base/pi_browser_bridge/bridge.py +598 -0
- package/backends/python-base/pi_browser_bridge/playwright_base.py +1222 -0
- package/backends/python-base/pi_browser_bridge/transport.py +167 -0
- package/backends/python-base/pyproject.toml +15 -0
- package/browser-cookies.ts +88 -0
- package/browser-profile.ts +260 -0
- package/browser-status.ts +84 -0
- package/browser-toggle.ts +527 -0
- package/core/fetch-backend.ts +466 -0
- package/core/guides.ts +467 -0
- package/core/plugin-api.ts +302 -0
- package/core/plugin-config.ts +388 -0
- package/core/plugin-registry.ts +263 -0
- package/core/router.ts +1186 -0
- package/core/shared/accessibility-tree.ts +408 -0
- package/core/shared/bot-detection.ts +187 -0
- package/core/shared/browser-events.ts +111 -0
- package/core/shared/dom-extractor.ts +550 -0
- package/core/shared/nav-settle.ts +187 -0
- package/core/shared/paths.ts +56 -0
- package/core/shared/session-manager.ts +258 -0
- package/core/shared/settings-reader.ts +63 -0
- package/core/shared/snapshot-cache.ts +231 -0
- package/core/shared/storage-state.ts +560 -0
- package/core/shared/task-id.ts +77 -0
- package/core/shared/url-safety.ts +164 -0
- package/index.ts +253 -0
- package/package.json +63 -0
- package/ship-manifest.test.ts +12 -0
- package/tools/browser-back.ts +50 -0
- package/tools/browser-click.ts +74 -0
- package/tools/browser-console.ts +160 -0
- package/tools/browser-inspect.ts +136 -0
- package/tools/browser-navigate.ts +254 -0
- package/tools/browser-press.ts +80 -0
- package/tools/browser-scroll.ts +56 -0
- package/tools/browser-snapshot.ts +90 -0
- package/tools/browser-type.ts +60 -0
- package/tools/index.ts +19 -0
- package/tools/utils.ts +157 -0
- package/tools/web-fetch.ts +147 -0
- package/tools/web-guide.ts +55 -0
- package/tools/web-learn.ts +128 -0
- package/verify-ship-manifest.ts +126 -0
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BrowserBridge — base class for Python browser automation backends.
|
|
3
|
+
|
|
4
|
+
Provides JSON-RPC command routing, session lifecycle, element caching,
|
|
5
|
+
and a ``run()`` main loop. Subclasses override ``create_browser_session()``
|
|
6
|
+
and individual operation methods to implement specific browser backends
|
|
7
|
+
(e.g. Chromium via Playwright, Camoufox, etc.).
|
|
8
|
+
|
|
9
|
+
Protocol
|
|
10
|
+
--------
|
|
11
|
+
All communication is JSON-RPC 2.0 over stdin/stdout with newline-delimited
|
|
12
|
+
framing. See ``transport.py`` for details.
|
|
13
|
+
|
|
14
|
+
Lifecycle
|
|
15
|
+
---------
|
|
16
|
+
1. The TypeScript ``PythonPluginAdapter`` spawns the Python process.
|
|
17
|
+
2. The bridge starts its ``run()`` loop, waiting for commands.
|
|
18
|
+
3. First command is typically ``browser.navigate``, which calls
|
|
19
|
+
``create_browser_session()`` (if no session exists yet for that taskId)
|
|
20
|
+
and then navigates.
|
|
21
|
+
4. Subsequent commands use the existing session.
|
|
22
|
+
5. ``browser.cleanup`` closes the session for a taskId.
|
|
23
|
+
6. ``browser.shutdown`` (sent via cleanupAll) terminates the process.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import inspect
|
|
27
|
+
import sys
|
|
28
|
+
import traceback
|
|
29
|
+
from typing import Any, Optional
|
|
30
|
+
|
|
31
|
+
from .transport import (
|
|
32
|
+
read_request,
|
|
33
|
+
write_response,
|
|
34
|
+
make_success_response,
|
|
35
|
+
make_error_response,
|
|
36
|
+
make_parse_error,
|
|
37
|
+
make_invalid_request,
|
|
38
|
+
make_internal_error,
|
|
39
|
+
make_application_error,
|
|
40
|
+
InvalidRequestError,
|
|
41
|
+
APPLICATION_ERROR,
|
|
42
|
+
METHOD_NOT_FOUND,
|
|
43
|
+
INVALID_PARAMS,
|
|
44
|
+
SESSION_ERROR,
|
|
45
|
+
)
|
|
46
|
+
from .accessibility import parse_snapshot, AriaParseResult
|
|
47
|
+
|
|
48
|
+
# ─── Default timeout ──────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
DEFAULT_NAVIGATION_TIMEOUT_MS: int = 30_000
|
|
51
|
+
DEFAULT_INTERACTION_TIMEOUT_MS: int = 10_000
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BrowserBridge:
|
|
55
|
+
"""Base class for Python browser automation bridges.
|
|
56
|
+
|
|
57
|
+
Subclass this and override:
|
|
58
|
+
|
|
59
|
+
* ``create_browser_session(task_id, config)`` — mandatory
|
|
60
|
+
* ``create_browser_context(config)`` — mandatory (creates isolated context per task)
|
|
61
|
+
* Any operation methods you want to customise (optional)
|
|
62
|
+
* ``close_browser_session(task_id)`` — optional (default unregisters)
|
|
63
|
+
|
|
64
|
+
Call ``run()`` to start the JSON-RPC command loop.
|
|
65
|
+
|
|
66
|
+
Named profiles are fully handled by the TypeScript side via
|
|
67
|
+
``core/shared/storage-state.ts`` (disk persistence). The Python
|
|
68
|
+
bridge receives ``storageState`` in the navigate request and applies
|
|
69
|
+
it when creating a new BrowserContext — it does NOT track shared
|
|
70
|
+
contexts across tasks.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
# ── Session storage ─────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
#: Per-taskId session data: {task_id: {...}}.
|
|
76
|
+
#: The dict contents are backend-specific (page, context, etc.).
|
|
77
|
+
#: You can store whatever you need here.
|
|
78
|
+
sessions: dict[str, dict[str, Any]]
|
|
79
|
+
|
|
80
|
+
#: Per-taskId element cache: {task_id: AriaParseResult}.
|
|
81
|
+
element_caches: dict[str, AriaParseResult]
|
|
82
|
+
|
|
83
|
+
#: Whether the bridge is still running.
|
|
84
|
+
_running: bool
|
|
85
|
+
|
|
86
|
+
def __init__(self) -> None:
|
|
87
|
+
self.sessions = {}
|
|
88
|
+
self.element_caches = {}
|
|
89
|
+
self._running = False
|
|
90
|
+
|
|
91
|
+
# ── Subclass hooks ──────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
def create_browser_session(self, task_id: str, config: dict[str, Any]) -> dict[str, Any]:
|
|
94
|
+
"""Create a new browser session for the given task.
|
|
95
|
+
|
|
96
|
+
Must return a dict that will be stored in ``self.sessions[task_id]``.
|
|
97
|
+
The dict is backend-specific (e.g. containing a Playwright page/context).
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
RuntimeError: if a session cannot be created.
|
|
101
|
+
"""
|
|
102
|
+
raise NotImplementedError(
|
|
103
|
+
f"{type(self).__name__} must implement create_browser_session()"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def close_browser_session(self, task_id: str) -> None:
|
|
107
|
+
"""Close and clean up the session for the given task.
|
|
108
|
+
|
|
109
|
+
Default implementation removes the session from the dict. Override
|
|
110
|
+
to close browser pages/contexts before removal.
|
|
111
|
+
"""
|
|
112
|
+
self.sessions.pop(task_id, None)
|
|
113
|
+
self.element_caches.pop(task_id, None)
|
|
114
|
+
|
|
115
|
+
def create_browser_context(self, config: dict[str, Any]) -> Any:
|
|
116
|
+
"""Create a new isolated BrowserContext for a task session.
|
|
117
|
+
|
|
118
|
+
Each task gets its own BrowserContext with no sharing between
|
|
119
|
+
tasks. Named profiles are handled by the TypeScript side via
|
|
120
|
+
``core/shared/storage-state.ts`` — the ``config`` may contain
|
|
121
|
+
``storageState`` to restore cookies and localStorage.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
config: Configuration dict (may contain ``storageState``,
|
|
125
|
+
``viewport``, ``userAgent``, etc.)
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
A backend-specific BrowserContext object.
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
NotImplementedError: if the subclass doesn't implement this.
|
|
132
|
+
"""
|
|
133
|
+
raise NotImplementedError(
|
|
134
|
+
f"{type(self).__name__} must implement create_browser_context()"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# ── Session helpers ─────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
def get_session(self, task_id: str) -> Optional[dict[str, Any]]:
|
|
140
|
+
"""Get the session data for a task, or None."""
|
|
141
|
+
return self.sessions.get(task_id)
|
|
142
|
+
|
|
143
|
+
def require_session(self, task_id: str) -> dict[str, Any]:
|
|
144
|
+
"""Get the session for a task, raising SESSION_ERROR if absent."""
|
|
145
|
+
session = self.get_session(task_id)
|
|
146
|
+
if session is None:
|
|
147
|
+
raise SessionNotFoundError(
|
|
148
|
+
f"No active session for task '{task_id}'. "
|
|
149
|
+
"Call browser.navigate first."
|
|
150
|
+
)
|
|
151
|
+
return session
|
|
152
|
+
|
|
153
|
+
def ensure_session(self, task_id: str, config: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
|
154
|
+
"""Get or create a session for the given task."""
|
|
155
|
+
session = self.get_session(task_id)
|
|
156
|
+
if session is not None:
|
|
157
|
+
return session
|
|
158
|
+
new_session = self.create_browser_session(task_id, config or {})
|
|
159
|
+
self.sessions[task_id] = new_session
|
|
160
|
+
return new_session
|
|
161
|
+
|
|
162
|
+
# ── Page session setup hook ──────────────────────────────────
|
|
163
|
+
|
|
164
|
+
def _setup_page_session(self, page: Any) -> dict[str, Any]:
|
|
165
|
+
"""Set up event handlers (console capture, dialog dismissal) on a new page.
|
|
166
|
+
|
|
167
|
+
Base implementation returns a minimal session dict. Subclasses
|
|
168
|
+
(e.g. ChromiumPyBridge) override this to attach console-message
|
|
169
|
+
accumulators and dialog handlers.
|
|
170
|
+
"""
|
|
171
|
+
return {"page": page}
|
|
172
|
+
|
|
173
|
+
def get_element_cache(self, task_id: str) -> Optional[AriaParseResult]:
|
|
174
|
+
"""Get the cached element parse result for a task, or None."""
|
|
175
|
+
return self.element_caches.get(task_id)
|
|
176
|
+
|
|
177
|
+
def set_element_cache(self, task_id: str, result: AriaParseResult) -> None:
|
|
178
|
+
"""Store a parsed element cache for a task."""
|
|
179
|
+
self.element_caches[task_id] = result
|
|
180
|
+
|
|
181
|
+
# ── Operation stubs (override in subclasses) ────────────────
|
|
182
|
+
|
|
183
|
+
def do_navigate(
|
|
184
|
+
self,
|
|
185
|
+
task_id: str,
|
|
186
|
+
url: str,
|
|
187
|
+
timeout_ms: int = DEFAULT_NAVIGATION_TIMEOUT_MS,
|
|
188
|
+
storageState: Optional[dict[str, Any]] = None,
|
|
189
|
+
profileName: Optional[str] = None,
|
|
190
|
+
profileMode: Optional[str] = None,
|
|
191
|
+
) -> dict[str, Any]:
|
|
192
|
+
"""Navigate the browser to a URL.
|
|
193
|
+
|
|
194
|
+
Subclasses interpret the extra params:
|
|
195
|
+
|
|
196
|
+
- ``storageState``: Playwright storage state for session restoration.
|
|
197
|
+
- ``profileName``: Named profile name (e.g. "work", "shopping").
|
|
198
|
+
- ``profileMode``: ``"none"`` / ``"session"`` / ``"named"``.
|
|
199
|
+
All modes create task-isolated BrowserContexts; named profiles
|
|
200
|
+
are handled by the TypeScript side via ``storageState``
|
|
201
|
+
(``core/shared/storage-state.ts``, disk persistence).
|
|
202
|
+
|
|
203
|
+
Must return a dict with keys:
|
|
204
|
+
success (bool), url (str), title (str),
|
|
205
|
+
snapshot (str), elementCount (int)
|
|
206
|
+
Optionally: botDetected (bool), profileName (str)
|
|
207
|
+
"""
|
|
208
|
+
raise NotImplementedError(
|
|
209
|
+
f"{type(self).__name__} must implement do_navigate()"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def do_snapshot(self, task_id: str) -> dict[str, Any]:
|
|
213
|
+
"""Take an accessibility snapshot of the current page.
|
|
214
|
+
|
|
215
|
+
Must return a dict with keys:
|
|
216
|
+
success (bool), snapshot (str), elementCount (int)
|
|
217
|
+
"""
|
|
218
|
+
raise NotImplementedError(
|
|
219
|
+
f"{type(self).__name__} must implement do_snapshot()"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def do_click(self, task_id: str, ref: str) -> dict[str, Any]:
|
|
223
|
+
"""Click an element by @e ref.
|
|
224
|
+
|
|
225
|
+
Must return a dict with keys:
|
|
226
|
+
success (bool)
|
|
227
|
+
Optionally: snapshot (str), elementCount (int), newUrl (str), newTitle (str)
|
|
228
|
+
"""
|
|
229
|
+
raise NotImplementedError(
|
|
230
|
+
f"{type(self).__name__} must implement do_click()"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def do_type(self, task_id: str, ref: str, text: str) -> dict[str, Any]:
|
|
234
|
+
"""Type text into an element by @e ref.
|
|
235
|
+
|
|
236
|
+
Must return a dict with keys:
|
|
237
|
+
success (bool)
|
|
238
|
+
Optionally: snapshot (str), elementCount (int), newUrl (str), newTitle (str)
|
|
239
|
+
"""
|
|
240
|
+
raise NotImplementedError(
|
|
241
|
+
f"{type(self).__name__} must implement do_type()"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def do_scroll(self, task_id: str, direction: str) -> dict[str, Any]:
|
|
245
|
+
"""Scroll the page up or down.
|
|
246
|
+
|
|
247
|
+
Must return a dict with keys:
|
|
248
|
+
success (bool)
|
|
249
|
+
Optionally: snapshot (str), elementCount (int), newUrl (str), newTitle (str)
|
|
250
|
+
"""
|
|
251
|
+
raise NotImplementedError(
|
|
252
|
+
f"{type(self).__name__} must implement do_scroll()"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def do_go_back(self, task_id: str) -> dict[str, Any]:
|
|
256
|
+
"""Navigate back in history.
|
|
257
|
+
|
|
258
|
+
Must return a dict with keys:
|
|
259
|
+
success (bool)
|
|
260
|
+
Optionally: snapshot (str), elementCount (int), newUrl (str), newTitle (str)
|
|
261
|
+
"""
|
|
262
|
+
raise NotImplementedError(
|
|
263
|
+
f"{type(self).__name__} must implement do_go_back()"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def do_press(self, task_id: str, key: str) -> dict[str, Any]:
|
|
267
|
+
"""Press a keyboard key on the current page (or focused element).
|
|
268
|
+
|
|
269
|
+
Must return a dict with keys:
|
|
270
|
+
success (bool)
|
|
271
|
+
Optionally: snapshot (str), elementCount (int), newUrl (str), newTitle (str)
|
|
272
|
+
"""
|
|
273
|
+
raise NotImplementedError(
|
|
274
|
+
f"{type(self).__name__} must implement do_press()"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def do_screenshot(
|
|
278
|
+
self,
|
|
279
|
+
task_id: str,
|
|
280
|
+
full_page: bool = False,
|
|
281
|
+
) -> dict[str, Any]:
|
|
282
|
+
"""Take a screenshot of the current page.
|
|
283
|
+
|
|
284
|
+
Must return a dict with keys:
|
|
285
|
+
success (bool), dataUri (str) — JPEG base64 data URI
|
|
286
|
+
"""
|
|
287
|
+
raise NotImplementedError(
|
|
288
|
+
f"{type(self).__name__} must implement do_screenshot()"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def do_get_console_messages(self, task_id: str) -> dict[str, Any]:
|
|
292
|
+
"""Get captured console messages.
|
|
293
|
+
|
|
294
|
+
Must return a dict with keys:
|
|
295
|
+
success (bool), messages (list) — each with type, text
|
|
296
|
+
"""
|
|
297
|
+
raise NotImplementedError(
|
|
298
|
+
f"{type(self).__name__} must implement do_get_console_messages()"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def do_clear_console(self, task_id: str) -> dict[str, Any]:
|
|
302
|
+
"""Clear captured console messages.
|
|
303
|
+
|
|
304
|
+
Must return a dict with keys:
|
|
305
|
+
success (bool)
|
|
306
|
+
"""
|
|
307
|
+
raise NotImplementedError(
|
|
308
|
+
f"{type(self).__name__} must implement do_clear_console()"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
def do_evaluate(self, task_id: str, expression: str) -> dict[str, Any]:
|
|
312
|
+
"""Evaluate JavaScript in the page.
|
|
313
|
+
|
|
314
|
+
Must return a dict with keys:
|
|
315
|
+
success (bool)
|
|
316
|
+
Optionally: result (any)
|
|
317
|
+
"""
|
|
318
|
+
raise NotImplementedError(
|
|
319
|
+
f"{type(self).__name__} must implement do_evaluate()"
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def do_get_cookies(
|
|
323
|
+
self, task_id: str, urls: Optional[list[str]] = None
|
|
324
|
+
) -> dict[str, Any]:
|
|
325
|
+
"""Get all cookies, optionally filtered by URL.
|
|
326
|
+
|
|
327
|
+
Must return a dict with keys:
|
|
328
|
+
success (bool), cookies (list) — each with name, value, domain, ...
|
|
329
|
+
"""
|
|
330
|
+
raise NotImplementedError(
|
|
331
|
+
f"{type(self).__name__} must implement do_get_cookies()"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def do_add_cookies(
|
|
335
|
+
self, task_id: str, cookies: list[dict[str, Any]]
|
|
336
|
+
) -> dict[str, Any]:
|
|
337
|
+
"""Add cookies to the browser context.
|
|
338
|
+
|
|
339
|
+
Must return a dict with keys:
|
|
340
|
+
success (bool)
|
|
341
|
+
"""
|
|
342
|
+
raise NotImplementedError(
|
|
343
|
+
f"{type(self).__name__} must implement do_add_cookies()"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
def do_clear_cookies(
|
|
347
|
+
self,
|
|
348
|
+
task_id: str,
|
|
349
|
+
name: Optional[str] = None,
|
|
350
|
+
domain: Optional[str] = None,
|
|
351
|
+
path: Optional[str] = None,
|
|
352
|
+
) -> dict[str, Any]:
|
|
353
|
+
"""Clear cookies, optionally filtered by name/domain/path.
|
|
354
|
+
|
|
355
|
+
Must return a dict with keys:
|
|
356
|
+
success (bool)
|
|
357
|
+
"""
|
|
358
|
+
raise NotImplementedError(
|
|
359
|
+
f"{type(self).__name__} must implement do_clear_cookies()"
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
def do_get_storage_state(self, task_id: str) -> dict[str, Any]:
|
|
363
|
+
"""Get full storage state (cookies + localStorage + IndexedDB).
|
|
364
|
+
|
|
365
|
+
Must return a dict with keys:
|
|
366
|
+
success (bool), cookies (list), origins (list)
|
|
367
|
+
"""
|
|
368
|
+
raise NotImplementedError(
|
|
369
|
+
f"{type(self).__name__} must implement do_get_storage_state()"
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def do_cleanup(self, task_id: str) -> dict[str, Any]:
|
|
373
|
+
"""Clean up resources for a specific task.
|
|
374
|
+
|
|
375
|
+
Profile persistence is handled by the TypeScript side
|
|
376
|
+
(``python-adapter.ts`` auto-saves storage state before calling
|
|
377
|
+
cleanup), so this method always calls ``close_browser_session()``.
|
|
378
|
+
|
|
379
|
+
Must return a dict with keys:
|
|
380
|
+
success (bool)
|
|
381
|
+
"""
|
|
382
|
+
self.close_browser_session(task_id)
|
|
383
|
+
return {"success": True}
|
|
384
|
+
|
|
385
|
+
# ── Command routing ─────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
def handle_command(self, method: str, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
|
|
388
|
+
"""Route a JSON-RPC method to the appropriate operation handler.
|
|
389
|
+
|
|
390
|
+
Returns a JSON-RPC response dict (either result or error).
|
|
391
|
+
"""
|
|
392
|
+
try:
|
|
393
|
+
if method == "ping":
|
|
394
|
+
return make_success_response(cmd_id, "pong")
|
|
395
|
+
|
|
396
|
+
if method == "shutdown":
|
|
397
|
+
self._running = False
|
|
398
|
+
return make_success_response(cmd_id, "shutting_down")
|
|
399
|
+
|
|
400
|
+
if method == "browser.navigate":
|
|
401
|
+
url = self._require_param(params, "url", str, cmd_id)
|
|
402
|
+
task_id = self._require_param(params, "taskId", str, cmd_id)
|
|
403
|
+
timeout_ms = params.get("timeoutMs", DEFAULT_NAVIGATION_TIMEOUT_MS)
|
|
404
|
+
storage_state = params.get("storageState")
|
|
405
|
+
profile_name = params.get("profileName")
|
|
406
|
+
profile_mode = params.get("profileMode")
|
|
407
|
+
result = self.do_navigate(
|
|
408
|
+
task_id, url, timeout_ms,
|
|
409
|
+
storageState=storage_state,
|
|
410
|
+
profileName=profile_name,
|
|
411
|
+
profileMode=profile_mode,
|
|
412
|
+
)
|
|
413
|
+
return make_success_response(cmd_id, result)
|
|
414
|
+
|
|
415
|
+
if method == "browser.snapshot":
|
|
416
|
+
task_id = self._require_param(params, "taskId", str, cmd_id)
|
|
417
|
+
result = self.do_snapshot(task_id)
|
|
418
|
+
return make_success_response(cmd_id, result)
|
|
419
|
+
|
|
420
|
+
if method == "browser.click":
|
|
421
|
+
task_id = self._require_param(params, "taskId", str, cmd_id)
|
|
422
|
+
ref = self._require_param(params, "ref", str, cmd_id)
|
|
423
|
+
result = self.do_click(task_id, ref)
|
|
424
|
+
return make_success_response(cmd_id, result)
|
|
425
|
+
|
|
426
|
+
if method == "browser.type":
|
|
427
|
+
task_id = self._require_param(params, "taskId", str, cmd_id)
|
|
428
|
+
ref = self._require_param(params, "ref", str, cmd_id)
|
|
429
|
+
text = self._require_param(params, "text", str, cmd_id)
|
|
430
|
+
result = self.do_type(task_id, ref, text)
|
|
431
|
+
return make_success_response(cmd_id, result)
|
|
432
|
+
|
|
433
|
+
if method == "browser.scroll":
|
|
434
|
+
task_id = self._require_param(params, "taskId", str, cmd_id)
|
|
435
|
+
direction = self._require_param(params, "direction", str, cmd_id)
|
|
436
|
+
if direction not in ("up", "down"):
|
|
437
|
+
return make_error_response(
|
|
438
|
+
cmd_id, INVALID_PARAMS,
|
|
439
|
+
'direction must be "up" or "down"',
|
|
440
|
+
)
|
|
441
|
+
result = self.do_scroll(task_id, direction)
|
|
442
|
+
return make_success_response(cmd_id, result)
|
|
443
|
+
|
|
444
|
+
if method == "browser.goBack":
|
|
445
|
+
task_id = self._require_param(params, "taskId", str, cmd_id)
|
|
446
|
+
result = self.do_go_back(task_id)
|
|
447
|
+
return make_success_response(cmd_id, result)
|
|
448
|
+
|
|
449
|
+
if method == "browser.press":
|
|
450
|
+
task_id = self._require_param(params, "taskId", str, cmd_id)
|
|
451
|
+
key = self._require_param(params, "key", str, cmd_id)
|
|
452
|
+
result = self.do_press(task_id, key)
|
|
453
|
+
return make_success_response(cmd_id, result)
|
|
454
|
+
|
|
455
|
+
if method == "browser.screenshot":
|
|
456
|
+
task_id = self._require_param(params, "taskId", str, cmd_id)
|
|
457
|
+
full_page = params.get("fullPage", False)
|
|
458
|
+
result = self.do_screenshot(task_id, full_page)
|
|
459
|
+
return make_success_response(cmd_id, result)
|
|
460
|
+
|
|
461
|
+
if method == "browser.getConsoleMessages":
|
|
462
|
+
task_id = self._require_param(params, "taskId", str, cmd_id)
|
|
463
|
+
result = self.do_get_console_messages(task_id)
|
|
464
|
+
return make_success_response(cmd_id, result)
|
|
465
|
+
|
|
466
|
+
if method == "browser.clearConsole":
|
|
467
|
+
task_id = self._require_param(params, "taskId", str, cmd_id)
|
|
468
|
+
result = self.do_clear_console(task_id)
|
|
469
|
+
return make_success_response(cmd_id, result)
|
|
470
|
+
|
|
471
|
+
if method == "browser.evaluate":
|
|
472
|
+
task_id = self._require_param(params, "taskId", str, cmd_id)
|
|
473
|
+
expression = self._require_param(params, "expression", str, cmd_id)
|
|
474
|
+
result = self.do_evaluate(task_id, expression)
|
|
475
|
+
return make_success_response(cmd_id, result)
|
|
476
|
+
|
|
477
|
+
if method == "browser.getCookies":
|
|
478
|
+
task_id = self._require_param(params, "taskId", str, cmd_id)
|
|
479
|
+
urls = params.get("urls")
|
|
480
|
+
result = self.do_get_cookies(task_id, urls)
|
|
481
|
+
return make_success_response(cmd_id, result)
|
|
482
|
+
|
|
483
|
+
if method == "browser.addCookies":
|
|
484
|
+
task_id = self._require_param(params, "taskId", str, cmd_id)
|
|
485
|
+
cookies = self._require_param(params, "cookies", list, cmd_id)
|
|
486
|
+
result = self.do_add_cookies(task_id, cookies)
|
|
487
|
+
return make_success_response(cmd_id, result)
|
|
488
|
+
|
|
489
|
+
if method == "browser.clearCookies":
|
|
490
|
+
task_id = self._require_param(params, "taskId", str, cmd_id)
|
|
491
|
+
name = params.get("name")
|
|
492
|
+
domain = params.get("domain")
|
|
493
|
+
path = params.get("path")
|
|
494
|
+
# No required params beyond taskId — empty call clears ALL cookies
|
|
495
|
+
result = self.do_clear_cookies(task_id, name, domain, path)
|
|
496
|
+
return make_success_response(cmd_id, result)
|
|
497
|
+
|
|
498
|
+
if method == "browser.getStorageState":
|
|
499
|
+
task_id = self._require_param(params, "taskId", str, cmd_id)
|
|
500
|
+
result = self.do_get_storage_state(task_id)
|
|
501
|
+
return make_success_response(cmd_id, result)
|
|
502
|
+
|
|
503
|
+
if method == "browser.cleanup":
|
|
504
|
+
task_id = self._require_param(params, "taskId", str, cmd_id)
|
|
505
|
+
result = self.do_cleanup(task_id)
|
|
506
|
+
return make_success_response(cmd_id, result)
|
|
507
|
+
|
|
508
|
+
# Unknown method
|
|
509
|
+
return make_error_response(
|
|
510
|
+
cmd_id,
|
|
511
|
+
METHOD_NOT_FOUND,
|
|
512
|
+
f"Method not found: {method}",
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
except SessionNotFoundError as exc:
|
|
516
|
+
return make_error_response(cmd_id, SESSION_ERROR, str(exc))
|
|
517
|
+
except NotImplementedError as exc:
|
|
518
|
+
return make_application_error(cmd_id, str(exc))
|
|
519
|
+
except Exception as exc:
|
|
520
|
+
tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
|
521
|
+
return make_application_error(cmd_id, str(exc), traceback_str=tb)
|
|
522
|
+
|
|
523
|
+
# ── Main loop ─────────────────────────────────────────────
|
|
524
|
+
|
|
525
|
+
def run(self) -> None:
|
|
526
|
+
"""Start the main JSON-RPC command loop.
|
|
527
|
+
|
|
528
|
+
Reads requests from stdin, dispatches them, and writes responses
|
|
529
|
+
to stdout. Runs until EOF or a ``shutdown`` command.
|
|
530
|
+
"""
|
|
531
|
+
self._running = True
|
|
532
|
+
while self._running:
|
|
533
|
+
try:
|
|
534
|
+
request = read_request()
|
|
535
|
+
if request is None:
|
|
536
|
+
break # EOF
|
|
537
|
+
|
|
538
|
+
cmd_id = request.get("id")
|
|
539
|
+
method = request.get("method", "")
|
|
540
|
+
params = request.get("params", {})
|
|
541
|
+
|
|
542
|
+
if not isinstance(params, dict):
|
|
543
|
+
write_response(make_error_response(
|
|
544
|
+
cmd_id, INVALID_PARAMS,
|
|
545
|
+
'"params" must be a JSON object',
|
|
546
|
+
))
|
|
547
|
+
continue
|
|
548
|
+
|
|
549
|
+
response = self.handle_command(method, params, cmd_id)
|
|
550
|
+
write_response(response)
|
|
551
|
+
|
|
552
|
+
except InvalidRequestError:
|
|
553
|
+
# Valid JSON but not a valid JSON-RPC Request object
|
|
554
|
+
write_response(make_invalid_request(None))
|
|
555
|
+
except ValueError:
|
|
556
|
+
# JSON parse error
|
|
557
|
+
write_response(make_parse_error(None))
|
|
558
|
+
except EOFError:
|
|
559
|
+
break
|
|
560
|
+
except KeyboardInterrupt:
|
|
561
|
+
break
|
|
562
|
+
except Exception as exc:
|
|
563
|
+
tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
|
564
|
+
write_response(make_application_error(None, str(exc), traceback_str=tb))
|
|
565
|
+
|
|
566
|
+
# ── Internal helpers ──────────────────────────────────────
|
|
567
|
+
|
|
568
|
+
@staticmethod
|
|
569
|
+
def _require_param(
|
|
570
|
+
params: dict[str, Any],
|
|
571
|
+
key: str,
|
|
572
|
+
expected_type: type,
|
|
573
|
+
cmd_id: Any,
|
|
574
|
+
) -> Any:
|
|
575
|
+
"""Require a param to exist and be of the expected type.
|
|
576
|
+
|
|
577
|
+
Raises a JSON-RPC invalid params error if the param is missing
|
|
578
|
+
or has the wrong type.
|
|
579
|
+
"""
|
|
580
|
+
if key not in params:
|
|
581
|
+
raise InvalidParamsError(f'Missing required parameter: "{key}"')
|
|
582
|
+
value = params[key]
|
|
583
|
+
if not isinstance(value, expected_type):
|
|
584
|
+
raise InvalidParamsError(
|
|
585
|
+
f'Parameter "{key}" must be of type {expected_type.__name__}, '
|
|
586
|
+
f"got {type(value).__name__}"
|
|
587
|
+
)
|
|
588
|
+
return value
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
# ─── Custom exceptions ────────────────────────────────────────────────
|
|
592
|
+
|
|
593
|
+
class SessionNotFoundError(Exception):
|
|
594
|
+
"""Raised when an operation requires a session but none exists."""
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
class InvalidParamsError(Exception):
|
|
598
|
+
"""Raised when a required parameter is missing or has the wrong type."""
|