modcdp 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
modcdp/ModCDPClient.py ADDED
@@ -0,0 +1,912 @@
1
+ """ModCDPClient (Python): importable, no CLI, no demo code.
2
+
3
+ Constructor parameter names mirror the JS / Go ports:
4
+ cdp_url upstream CDP URL (str)
5
+ extension_path extension directory (str)
6
+ routes client-side routing dict
7
+ server { 'loopback_cdp_url'?, 'routes'? } passed to ModCDPServer.configure
8
+ scan_for_existing_localhost_9222
9
+ when true and cdp_url is unset, attach to localhost:9222 before autolaunching
10
+ mirror_upstream_events
11
+ when false, do not mirror server-side upstream CDP events back through Runtime bindings
12
+ *_timeout_ms / *_interval_ms
13
+ override default CDP send, service-worker probe, event, and poll timings
14
+
15
+ Public methods: connect(), send(method, params), on(event, handler), close(), _cdp.send(), _cdp.on().
16
+ Synchronous (blocking) API; one background thread reads frames off the WS.
17
+ """
18
+
19
+ import json
20
+ import os
21
+ import re
22
+ import shutil
23
+ import subprocess
24
+ import sys
25
+ import threading
26
+ import time
27
+ import tempfile
28
+ import urllib.error
29
+ import urllib.parse
30
+ import urllib.request
31
+ import socket
32
+ import zipfile
33
+ from collections.abc import Mapping, Sequence
34
+ from pathlib import Path
35
+ from queue import Queue, Empty
36
+ from typing import cast
37
+
38
+ from websocket import create_connection
39
+
40
+ from .translate import (
41
+ CUSTOM_EVENT_BINDING_NAME,
42
+ DEFAULT_CLIENT_ROUTES,
43
+ UPSTREAM_EVENT_BINDING_NAME,
44
+ wrap_command_if_needed,
45
+ unwrap_event_if_needed,
46
+ unwrap_response_if_needed,
47
+ )
48
+ from .types import (
49
+ ModCDPAddCustomCommandParams,
50
+ ModCDPAddCustomEventObjectParams,
51
+ ModCDPAddCustomEventParams,
52
+ ModCDPAddMiddlewareParams,
53
+ ModCDPCommandTiming,
54
+ ModCDPConnectTiming,
55
+ ModCDPPingLatency,
56
+ ModCDPRawTiming,
57
+ ModCDPRoutes,
58
+ ModCDPServerConfig,
59
+ BorrowedExtensionInfo,
60
+ CdpFrame,
61
+ ExtensionInfo,
62
+ ExtensionProbe,
63
+ FrameParams,
64
+ Handler,
65
+ JsonValue,
66
+ LaunchOptions,
67
+ PendingEntry,
68
+ ProtocolParams,
69
+ ProtocolPayload,
70
+ ProtocolResult,
71
+ TargetInfo,
72
+ TranslatedCommand,
73
+ WebSocketLike,
74
+ )
75
+
76
+ EXT_ID_FROM_URL_RE = re.compile(r"^chrome-extension://([a-z]+)/")
77
+ MODCDP_READY_EXPRESSION = (
78
+ "Boolean(globalThis.ModCDP?.__ModCDPServerVersion === 1 && "
79
+ "globalThis.ModCDP?.handleCommand && globalThis.ModCDP?.addCustomEvent)"
80
+ )
81
+ DEFAULT_SERVER = object()
82
+ DEFAULT_LIVE_CDP_URL = "http://127.0.0.1:9222"
83
+ DEFAULT_CDP_SEND_TIMEOUT_MS = 10_000
84
+ DEFAULT_EVENT_WAIT_TIMEOUT_MS = 10_000
85
+ DEFAULT_EXECUTION_CONTEXT_TIMEOUT_MS = 10_000
86
+ DEFAULT_CHROME_READY_TIMEOUT_MS = 45_000
87
+ DEFAULT_SERVICE_WORKER_PROBE_TIMEOUT_MS = 10_000
88
+ DEFAULT_SERVICE_WORKER_READY_TIMEOUT_MS = 60_000
89
+ DEFAULT_SERVICE_WORKER_POLL_INTERVAL_MS = 100
90
+ DEFAULT_TARGET_SESSION_POLL_INTERVAL_MS = 20
91
+ DEFAULT_WS_CONNECT_ERROR_SETTLE_TIMEOUT_MS = 250
92
+
93
+
94
+ class _DomainMethods:
95
+ def __init__(self, client: "ModCDPClient", domain: str) -> None:
96
+ self._client = client
97
+ self._domain = domain
98
+
99
+ def __getattr__(self, method: str):
100
+ def call(*args: ProtocolParams, **kwargs: JsonValue) -> JsonValue:
101
+ if len(args) > 1:
102
+ raise TypeError(f"{self._domain}.{method} accepts at most one positional params object")
103
+ params: ProtocolParams = dict(args[0]) if args else {}
104
+ params.update(kwargs)
105
+ return self._client.send(f"{self._domain}.{method}", params)
106
+
107
+ return call
108
+
109
+
110
+ class _RawCDP:
111
+ def __init__(self, client: "ModCDPClient") -> None:
112
+ self._client = client
113
+
114
+ def send(
115
+ self,
116
+ method: str,
117
+ params: ProtocolParams | None = None,
118
+ session_id: str | None = None,
119
+ ) -> ProtocolResult:
120
+ return self._client._send_frame(method, params or {}, session_id=session_id, record_raw_timing=True)
121
+
122
+ def on(self, event: str, handler: Handler) -> "ModCDPClient":
123
+ return self._client.on(event, handler)
124
+
125
+
126
+ def websocket_url_for(endpoint: str) -> str:
127
+ if re.match(r"^wss?://", endpoint, re.I):
128
+ return endpoint
129
+ http_endpoint = endpoint if re.match(r"^[a-z][a-z\d+\-.]*://", endpoint, re.I) else f"http://{endpoint}"
130
+ try:
131
+ r = urllib.request.urlopen(f"{http_endpoint}/json/version", timeout=5)
132
+ except urllib.error.HTTPError as e:
133
+ if e.code != 404:
134
+ raise
135
+ parsed = urllib.parse.urlparse(http_endpoint)
136
+ scheme = "wss" if parsed.scheme == "https" else "ws"
137
+ return urllib.parse.urlunparse((scheme, parsed.netloc, "/devtools/browser", "", "", ""))
138
+ with r:
139
+ parsed: object = json.loads(r.read())
140
+ if not isinstance(parsed, dict):
141
+ raise RuntimeError(f"HTTP discovery for {endpoint} returned invalid JSON")
142
+ parsed_obj = cast(Mapping[str, object], parsed)
143
+ ws_url = parsed_obj.get("webSocketDebuggerUrl")
144
+ if not ws_url:
145
+ raise RuntimeError(f"HTTP discovery for {endpoint} returned no webSocketDebuggerUrl")
146
+ if not isinstance(ws_url, str):
147
+ raise RuntimeError(f"HTTP discovery for {endpoint} returned a non-string webSocketDebuggerUrl")
148
+ return ws_url
149
+
150
+
151
+ def live_websocket_url_for(endpoint: str = DEFAULT_LIVE_CDP_URL) -> str | None:
152
+ try:
153
+ return websocket_url_for(endpoint)
154
+ except Exception:
155
+ return None
156
+
157
+
158
+ def _free_port() -> int:
159
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
160
+ sock.bind(("127.0.0.1", 0))
161
+ return sock.getsockname()[1]
162
+
163
+
164
+ def _json_object(value: JsonValue | None) -> ProtocolResult:
165
+ return value if isinstance(value, dict) else {}
166
+
167
+
168
+ def modcdp_server_path(extension_path: str) -> Path:
169
+ candidates = [Path(extension_path) / "ModCDPServer.js"]
170
+ for parent in Path(__file__).resolve().parents:
171
+ candidates.append(parent / "dist" / "extension" / "ModCDPServer.js")
172
+ for candidate in candidates:
173
+ if candidate.exists():
174
+ return candidate
175
+ checked = ", ".join(str(candidate) for candidate in candidates)
176
+ raise FileNotFoundError(f"Unable to locate ModCDPServer.js; checked: {checked}")
177
+
178
+
179
+ def modcdp_server_bootstrap_expression(extension_path: str) -> str:
180
+ server_path = modcdp_server_path(extension_path)
181
+ source = server_path.read_text()
182
+ start = source.index("export function installModCDPServer")
183
+ end = source.index("export const ModCDPServer")
184
+ installer = source[start:end].replace("export function", "function", 1)
185
+ return (
186
+ "(() => {\n"
187
+ f"{installer}\n"
188
+ "const ModCDP = installModCDPServer(globalThis);\n"
189
+ "return {\n"
190
+ " ok: Boolean(ModCDP?.__ModCDPServerVersion === 1 && ModCDP?.handleCommand && ModCDP?.addCustomEvent),\n"
191
+ " extension_id: globalThis.chrome?.runtime?.id ?? null,\n"
192
+ " has_tabs: Boolean(globalThis.chrome?.tabs?.query),\n"
193
+ " has_debugger: Boolean(globalThis.chrome?.debugger?.sendCommand),\n"
194
+ "};\n"
195
+ "})()"
196
+ )
197
+
198
+
199
+ class ModCDPClient:
200
+ def __init__(
201
+ self,
202
+ cdp_url: str | None = None,
203
+ extension_path: str | None = None,
204
+ routes: Mapping[str, str] | None = None,
205
+ server: Mapping[str, JsonValue] | None | object = DEFAULT_SERVER,
206
+ custom_commands: Sequence[ModCDPAddCustomCommandParams] | None = None,
207
+ custom_events: Sequence[ModCDPAddCustomEventParams] | None = None,
208
+ custom_middlewares: Sequence[ModCDPAddMiddlewareParams] | None = None,
209
+ service_worker_url_includes: Sequence[str] | None = None,
210
+ service_worker_url_suffixes: Sequence[str] | None = None,
211
+ trust_service_worker_target: bool = False,
212
+ require_service_worker_target: bool = False,
213
+ service_worker_ready_expression: str | None = None,
214
+ mirror_upstream_events: bool = True,
215
+ scan_for_existing_localhost_9222: bool = False,
216
+ cdp_send_timeout_ms: int = DEFAULT_CDP_SEND_TIMEOUT_MS,
217
+ event_wait_timeout_ms: int = DEFAULT_EVENT_WAIT_TIMEOUT_MS,
218
+ execution_context_timeout_ms: int = DEFAULT_EXECUTION_CONTEXT_TIMEOUT_MS,
219
+ service_worker_probe_timeout_ms: int = DEFAULT_SERVICE_WORKER_PROBE_TIMEOUT_MS,
220
+ service_worker_ready_timeout_ms: int = DEFAULT_SERVICE_WORKER_READY_TIMEOUT_MS,
221
+ service_worker_poll_interval_ms: int = DEFAULT_SERVICE_WORKER_POLL_INTERVAL_MS,
222
+ target_session_poll_interval_ms: int = DEFAULT_TARGET_SESSION_POLL_INTERVAL_MS,
223
+ ws_connect_error_settle_timeout_ms: int = DEFAULT_WS_CONNECT_ERROR_SETTLE_TIMEOUT_MS,
224
+ launch_options: LaunchOptions | None = None,
225
+ ) -> None:
226
+ self.cdp_url: str | None = cdp_url
227
+ self.extension_path: str | None = extension_path
228
+ self.routes: ModCDPRoutes = {**DEFAULT_CLIENT_ROUTES, **dict(routes or {})}
229
+ if server is DEFAULT_SERVER:
230
+ self.server: ModCDPServerConfig | None = {}
231
+ elif server is None:
232
+ self.server = None
233
+ elif isinstance(server, Mapping):
234
+ self.server = cast(ModCDPServerConfig, dict(server))
235
+ else:
236
+ raise TypeError("server must be a mapping, None, or omitted")
237
+ self.custom_commands: list[ModCDPAddCustomCommandParams] = list(custom_commands or [])
238
+ self.custom_events: list[ModCDPAddCustomEventParams] = list(custom_events or [])
239
+ self.custom_middlewares: list[ModCDPAddMiddlewareParams] = list(custom_middlewares or [])
240
+ self.service_worker_url_includes: list[str] = list(service_worker_url_includes or [])
241
+ self.service_worker_url_suffixes: list[str] = list(service_worker_url_suffixes or ["/service_worker.js", "/background.js"])
242
+ self.trust_service_worker_target = trust_service_worker_target
243
+ self.require_service_worker_target = require_service_worker_target
244
+ self.service_worker_ready_expression = service_worker_ready_expression
245
+ self.mirror_upstream_events = mirror_upstream_events
246
+ self.scan_for_existing_localhost_9222 = scan_for_existing_localhost_9222
247
+ self.cdp_send_timeout_ms = cdp_send_timeout_ms
248
+ self.event_wait_timeout_ms = event_wait_timeout_ms
249
+ self.execution_context_timeout_ms = execution_context_timeout_ms
250
+ self.service_worker_probe_timeout_ms = service_worker_probe_timeout_ms
251
+ self.service_worker_ready_timeout_ms = service_worker_ready_timeout_ms
252
+ self.service_worker_poll_interval_ms = service_worker_poll_interval_ms
253
+ self.target_session_poll_interval_ms = target_session_poll_interval_ms
254
+ self.ws_connect_error_settle_timeout_ms = ws_connect_error_settle_timeout_ms
255
+ self.server_options: ModCDPServerConfig = {
256
+ "cdp_send_timeout_ms": cdp_send_timeout_ms,
257
+ "loopback_execution_context_timeout_ms": execution_context_timeout_ms,
258
+ "ws_connect_error_settle_timeout_ms": ws_connect_error_settle_timeout_ms,
259
+ }
260
+ self.launch_options = cast(LaunchOptions, dict(launch_options or {}))
261
+
262
+ self.extension_id: str | None = None
263
+ self.ext_target_id: str | None = None
264
+ self.ext_session_id: str | None = None
265
+ self.latency: ModCDPPingLatency | None = None
266
+ self.connect_timing: ModCDPConnectTiming | None = None
267
+ self.last_command_timing: ModCDPCommandTiming | None = None
268
+ self.last_raw_timing: ModCDPRawTiming | None = None
269
+
270
+ self._ws: WebSocketLike | None = None
271
+ self._next_id = 0
272
+ self._pending: dict[int, PendingEntry] = {}
273
+ self._handlers: dict[str, list[Handler]] = {}
274
+ self._lock = threading.Lock()
275
+ self._target_sessions: dict[str, str] = {}
276
+ self._session_targets: dict[str, TargetInfo] = {}
277
+ self._reader_thread: threading.Thread | None = None
278
+ self._closed = False
279
+ self._launched_process: subprocess.Popen[bytes] | None = None
280
+ self._profile_dir: tempfile.TemporaryDirectory[str] | None = None
281
+ self._prepared_extension_dir: tempfile.TemporaryDirectory[str] | None = None
282
+ self._cdp = _RawCDP(self)
283
+
284
+ def connect(self) -> "ModCDPClient":
285
+ connect_started_at = int(time.time() * 1000)
286
+ if self.cdp_url is None:
287
+ self.cdp_url = live_websocket_url_for() if self.scan_for_existing_localhost_9222 else None
288
+ if self.cdp_url is None:
289
+ self._prepare_extension_path()
290
+ launched = self._launch_chrome()
291
+ self.cdp_url = launched["cdp_url"]
292
+ input_cdp_url = self.cdp_url
293
+ self.cdp_url = websocket_url_for(input_cdp_url)
294
+ if self.server is not None and "loopback_cdp_url" not in self.server:
295
+ self.server = {**self.server, "loopback_cdp_url": self.cdp_url}
296
+ elif self.server and isinstance(self.server.get("loopback_cdp_url"), str):
297
+ loopback_url = self.server["loopback_cdp_url"]
298
+ if loopback_url == input_cdp_url or loopback_url == self.cdp_url:
299
+ self.server = {**self.server, "loopback_cdp_url": self.cdp_url}
300
+ self._ws = cast(WebSocketLike, create_connection(self.cdp_url, timeout=self.cdp_send_timeout_ms / 1000))
301
+ self._reader_thread = threading.Thread(target=self._reader, daemon=True)
302
+ self._reader_thread.start()
303
+
304
+ self._send_frame("Target.setAutoAttach", {
305
+ "autoAttach": True,
306
+ "waitForDebuggerOnStart": False,
307
+ "flatten": True,
308
+ })
309
+ self._send_frame("Target.setDiscoverTargets", {"discover": True})
310
+
311
+ extension_started_at = int(time.time() * 1000)
312
+ self._prepare_extension_path()
313
+ ext = self._ensure_extension()
314
+ extension_completed_at = int(time.time() * 1000)
315
+ self.extension_id = ext["extension_id"]
316
+ self.ext_target_id = ext["target_id"]
317
+ self.ext_session_id = ext["session_id"]
318
+ self._send_frame("Runtime.enable", {}, self.ext_session_id)
319
+ self._send_frame("Runtime.addBinding", {"name": CUSTOM_EVENT_BINDING_NAME}, self.ext_session_id)
320
+ if self.mirror_upstream_events:
321
+ self._send_frame("Runtime.addBinding", {"name": UPSTREAM_EVENT_BINDING_NAME}, self.ext_session_id)
322
+
323
+ if self.server is not None:
324
+ custom_events: list[ModCDPAddCustomEventObjectParams] = []
325
+ for event in self.custom_events:
326
+ custom_events.append({"name": event} if isinstance(event, str) else event)
327
+ custom_commands: list[ModCDPAddCustomCommandParams] = [
328
+ command
329
+ for command in self.custom_commands
330
+ if isinstance(command.get("expression"), str) and command.get("expression")
331
+ ]
332
+ custom_middlewares: list[ModCDPAddMiddlewareParams] = list(self.custom_middlewares)
333
+ configure_params: ModCDPServerConfig = {
334
+ **self.server_options,
335
+ **self.server,
336
+ "custom_events": custom_events,
337
+ "custom_commands": custom_commands,
338
+ "custom_middlewares": custom_middlewares,
339
+ }
340
+ self._send_raw(wrap_command_if_needed(
341
+ "Mod.configure",
342
+ cast(ProtocolParams, configure_params),
343
+ routes=self.routes,
344
+ cdp_session_id=self.ext_session_id,
345
+ ))
346
+ threading.Thread(target=self._measure_ping_latency, daemon=True).start()
347
+ connected_at = int(time.time() * 1000)
348
+ self.connect_timing = {
349
+ "started_at": connect_started_at,
350
+ "extension_source": ext.get("source"),
351
+ "extension_started_at": extension_started_at,
352
+ "extension_completed_at": extension_completed_at,
353
+ "extension_duration_ms": extension_completed_at - extension_started_at,
354
+ "connected_at": connected_at,
355
+ "duration_ms": connected_at - connect_started_at,
356
+ }
357
+ return self
358
+
359
+ def send(self, method: str, params: ProtocolParams | None = None) -> JsonValue:
360
+ started_at = int(time.time() * 1000)
361
+ command = wrap_command_if_needed(
362
+ method,
363
+ params or {},
364
+ routes=self.routes,
365
+ cdp_session_id=self.ext_session_id,
366
+ )
367
+ result = self._send_raw(command)
368
+ completed_at = int(time.time() * 1000)
369
+ self.last_command_timing = {
370
+ "method": method,
371
+ "target": command["target"],
372
+ "started_at": started_at,
373
+ "completed_at": completed_at,
374
+ "duration_ms": completed_at - started_at,
375
+ }
376
+ return result
377
+
378
+ def raw_send(self, method: str, params: ProtocolParams | None = None) -> ProtocolResult:
379
+ return self._send_frame(method, params or {}, record_raw_timing=True)
380
+
381
+ def on(self, event: str, handler: Handler) -> "ModCDPClient":
382
+ self._handlers.setdefault(event, []).append(handler)
383
+ return self
384
+
385
+ def __getattr__(self, domain: str) -> _DomainMethods:
386
+ if domain.startswith("_"):
387
+ raise AttributeError(domain)
388
+ return _DomainMethods(self, domain)
389
+
390
+ def close(self) -> None:
391
+ if self._closed:
392
+ return
393
+ if self._ws is not None:
394
+ try:
395
+ with self._lock:
396
+ self._next_id += 1
397
+ msg_id = self._next_id
398
+ self._ws.send(json.dumps({"id": msg_id, "method": "Browser.close", "params": {}}))
399
+ except Exception:
400
+ pass
401
+ self._closed = True
402
+ try:
403
+ if self._ws:
404
+ self._ws.close()
405
+ except Exception:
406
+ pass
407
+ if self._reader_thread is not None and self._reader_thread.is_alive():
408
+ self._reader_thread.join(timeout=1)
409
+ self._ws = None
410
+ if self._launched_process is not None:
411
+ self._launched_process.terminate()
412
+ try:
413
+ self._launched_process.wait(timeout=2)
414
+ except subprocess.TimeoutExpired:
415
+ self._launched_process.kill()
416
+ self._launched_process.wait(timeout=2)
417
+ self._launched_process = None
418
+ if self._profile_dir is not None:
419
+ self._cleanup_temp_dir(self._profile_dir)
420
+ self._profile_dir = None
421
+ if self._prepared_extension_dir is not None:
422
+ self._cleanup_temp_dir(self._prepared_extension_dir)
423
+ self._prepared_extension_dir = None
424
+
425
+ def _cleanup_temp_dir(self, temp_dir: tempfile.TemporaryDirectory[str]) -> None:
426
+ for attempt in range(20):
427
+ try:
428
+ temp_dir.cleanup()
429
+ return
430
+ except OSError:
431
+ if attempt == 19:
432
+ shutil.rmtree(temp_dir.name, ignore_errors=True)
433
+ return
434
+ time.sleep(0.1)
435
+
436
+ def _ready_expression(self) -> str:
437
+ if not self.service_worker_ready_expression:
438
+ return MODCDP_READY_EXPRESSION
439
+ return f"({MODCDP_READY_EXPRESSION}) && Boolean({self.service_worker_ready_expression})"
440
+
441
+ def _session_id_for_target(self, target_id: str, timeout: float = 0) -> str | None:
442
+ if timeout <= 0:
443
+ return self._target_sessions.get(target_id)
444
+ deadline = time.time() + timeout
445
+ while time.time() <= deadline:
446
+ session_id = self._target_sessions.get(target_id)
447
+ if session_id:
448
+ return session_id
449
+ time.sleep(self.target_session_poll_interval_ms / 1000)
450
+ return None
451
+
452
+ def _ensure_session_id_for_target(self, target_id: str, timeout: float = 0, allow_attach: bool = False) -> str | None:
453
+ session_id = self._target_sessions.get(target_id)
454
+ if session_id:
455
+ return session_id
456
+ if allow_attach:
457
+ result = self._send_frame(
458
+ "Target.attachToTarget",
459
+ {"targetId": target_id, "flatten": True},
460
+ timeout=max(timeout, self.cdp_send_timeout_ms / 1000),
461
+ )
462
+ attached_session_id = result.get("sessionId")
463
+ if isinstance(attached_session_id, str) and attached_session_id:
464
+ self._target_sessions[target_id] = attached_session_id
465
+ return attached_session_id
466
+ return self._session_id_for_target(target_id, timeout=timeout)
467
+
468
+ def _launch_chrome(self) -> dict[str, str]:
469
+ executable_path = self.launch_options.get("executable_path") or os.environ.get("CHROME_PATH")
470
+ candidates = [
471
+ executable_path,
472
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
473
+ "/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
474
+ str(Path.home() / "Library/Caches/ms-playwright/chromium-1217/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"),
475
+ "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
476
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
477
+ "/usr/bin/chromium",
478
+ "/usr/bin/chromium-browser",
479
+ "/usr/bin/google-chrome-canary",
480
+ "/usr/bin/google-chrome-stable",
481
+ "/usr/bin/google-chrome",
482
+ ]
483
+ executable_path = next((candidate for candidate in candidates if candidate and Path(candidate).exists()), None)
484
+ if executable_path is None:
485
+ raise RuntimeError("No Chrome/Chromium binary found. Set CHROME_PATH or pass launch_options.executable_path.")
486
+ port = int(self.launch_options.get("port") or _free_port())
487
+ self._profile_dir = tempfile.TemporaryDirectory(prefix="modcdp.")
488
+ args = [
489
+ "--enable-unsafe-extension-debugging",
490
+ "--remote-allow-origins=*",
491
+ "--no-first-run",
492
+ "--no-default-browser-check",
493
+ "--disable-default-apps",
494
+ "--disable-dev-shm-usage",
495
+ "--disable-background-networking",
496
+ "--disable-backgrounding-occluded-windows",
497
+ "--disable-renderer-backgrounding",
498
+ "--disable-background-timer-throttling",
499
+ "--disable-sync",
500
+ "--disable-features=DisableLoadExtensionCommandLineSwitch",
501
+ "--password-store=basic",
502
+ "--use-mock-keychain",
503
+ "--disable-gpu",
504
+ f"--user-data-dir={self._profile_dir.name}",
505
+ "--remote-debugging-address=127.0.0.1",
506
+ f"--remote-debugging-port={port}",
507
+ ]
508
+ default_headless = sys.platform.startswith("linux") and not os.environ.get("DISPLAY")
509
+ if self.launch_options.get("headless", default_headless):
510
+ args.append("--headless=new")
511
+ if self.launch_options.get("sandbox", False) is False:
512
+ args.append("--no-sandbox")
513
+ if self.extension_path:
514
+ args.append(f"--load-extension={self.extension_path}")
515
+ extra_args = self.launch_options.get("extra_args") or []
516
+ args.extend(extra_args)
517
+ args.append("about:blank")
518
+ self._launched_process = subprocess.Popen([executable_path, *args], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
519
+ cdp_url = f"http://127.0.0.1:{port}"
520
+ chrome_ready_timeout_s = DEFAULT_CHROME_READY_TIMEOUT_MS / 1000
521
+ deadline = time.time() + chrome_ready_timeout_s
522
+ while time.time() < deadline:
523
+ try:
524
+ with urllib.request.urlopen(f"{cdp_url}/json/version", timeout=0.5) as response:
525
+ json.loads(response.read())
526
+ return {"cdp_url": cdp_url}
527
+ except Exception:
528
+ time.sleep(0.1)
529
+ self.close()
530
+ raise RuntimeError(f"Chrome at {cdp_url} did not become ready within {chrome_ready_timeout_s}s")
531
+
532
+ # --- internals ---------------------------------------------------------
533
+
534
+ def _prepare_extension_path(self) -> None:
535
+ if not self.extension_path or not self.extension_path.endswith(".zip"):
536
+ return
537
+ self._prepared_extension_dir = tempfile.TemporaryDirectory(prefix="modcdp-extension.")
538
+ with zipfile.ZipFile(self.extension_path) as archive:
539
+ archive.extractall(self._prepared_extension_dir.name)
540
+ self.extension_path = self._prepared_extension_dir.name
541
+
542
+ def _send_raw(self, wrapped: TranslatedCommand) -> JsonValue:
543
+ if wrapped["target"] == "direct_cdp":
544
+ step = wrapped["steps"][0]
545
+ return self._send_frame(step["method"], step.get("params") or {})
546
+ if wrapped["target"] != "service_worker":
547
+ raise RuntimeError(f"Unsupported command target {wrapped['target']!r}")
548
+
549
+ result: ProtocolResult = {}
550
+ unwrap: str | None = None
551
+ for step in wrapped["steps"]:
552
+ result = self._send_frame(step["method"], step.get("params") or {}, self.ext_session_id)
553
+ unwrap = step.get("unwrap")
554
+ return unwrap_response_if_needed(result, unwrap)
555
+
556
+ def _measure_ping_latency(self) -> ModCDPPingLatency:
557
+ sent_at = int(time.time() * 1000)
558
+ done: Queue[ProtocolPayload] = Queue()
559
+
560
+ def on_pong(payload: ProtocolPayload) -> None:
561
+ done.put(payload or {})
562
+
563
+ self._handlers.setdefault("Mod.pong", []).append(on_pong)
564
+ try:
565
+ self.send("Mod.ping", {"sentAt": sent_at})
566
+ payload = done.get(timeout=10)
567
+ except Empty:
568
+ raise RuntimeError("Mod.pong timed out")
569
+ finally:
570
+ handlers = self._handlers.get("Mod.pong") or []
571
+ if on_pong in handlers:
572
+ handlers.remove(on_pong)
573
+
574
+ returned_at = int(time.time() * 1000)
575
+ raw_received_at = payload.get("receivedAt")
576
+ received_at = raw_received_at if isinstance(raw_received_at, (int, float)) else None
577
+ latency: ModCDPPingLatency = {
578
+ "sentAt": sent_at,
579
+ "receivedAt": received_at,
580
+ "returnedAt": returned_at,
581
+ "roundTripMs": returned_at - sent_at,
582
+ "serviceWorkerMs": received_at - sent_at if received_at is not None else None,
583
+ "returnPathMs": returned_at - received_at if received_at is not None else None,
584
+ }
585
+ self.latency = latency
586
+ return latency
587
+
588
+ def _send_frame(
589
+ self,
590
+ method: str,
591
+ params: FrameParams | None = None,
592
+ session_id: str | None = None,
593
+ timeout: float | None = None,
594
+ record_raw_timing: bool = False,
595
+ ) -> ProtocolResult:
596
+ effective_timeout = self.cdp_send_timeout_ms / 1000 if timeout is None else timeout
597
+ with self._lock:
598
+ self._next_id += 1
599
+ msg_id = self._next_id
600
+ done: Queue[CdpFrame] = Queue()
601
+ self._pending[msg_id] = (method, done)
602
+ started_at = int(time.time() * 1000)
603
+ msg: CdpFrame = {"id": msg_id, "method": method, "params": params or {}}
604
+ if session_id:
605
+ msg["sessionId"] = session_id
606
+ ws = self._ws
607
+ if ws is None:
608
+ with self._lock:
609
+ self._pending.pop(msg_id, None)
610
+ raise RuntimeError("CDP websocket is not connected")
611
+ try:
612
+ ws.send(json.dumps(msg))
613
+ except Exception:
614
+ with self._lock:
615
+ self._pending.pop(msg_id, None)
616
+ raise
617
+ try:
618
+ response = done.get(timeout=effective_timeout)
619
+ except Empty:
620
+ with self._lock:
621
+ self._pending.pop(msg_id, None)
622
+ raise RuntimeError(f"{method} timed out after {int(effective_timeout * 1000)}ms")
623
+ err = response.get("error")
624
+ if err:
625
+ raise RuntimeError(f"{method} failed: {err.get('message', err)}")
626
+ if record_raw_timing:
627
+ completed_at = int(time.time() * 1000)
628
+ self.last_raw_timing = {
629
+ "method": method,
630
+ "started_at": started_at,
631
+ "completed_at": completed_at,
632
+ "duration_ms": completed_at - started_at,
633
+ }
634
+ return _json_object(response.get("result"))
635
+
636
+ def _reader(self) -> None:
637
+ ws = self._ws
638
+ if ws is None:
639
+ return
640
+ try:
641
+ while True:
642
+ raw = ws.recv()
643
+ if not raw:
644
+ break
645
+ if isinstance(raw, bytes):
646
+ raw = raw.decode()
647
+ parsed: object = json.loads(raw)
648
+ if not isinstance(parsed, dict):
649
+ continue
650
+ msg = cast(CdpFrame, parsed)
651
+ if "id" in msg and msg["id"] is not None:
652
+ with self._lock:
653
+ entry = self._pending.pop(msg["id"], None)
654
+ if entry:
655
+ entry[1].put(msg)
656
+ continue
657
+ method = msg.get("method")
658
+ raw_params = msg.get("params")
659
+ params = cast(ProtocolParams, raw_params) if isinstance(raw_params, Mapping) else {}
660
+ if method == "Target.attachedToTarget":
661
+ session_id = params.get("sessionId")
662
+ raw_target_info = params.get("targetInfo")
663
+ target_info = cast(TargetInfo, raw_target_info) if isinstance(raw_target_info, dict) else None
664
+ target_id = target_info.get("targetId") if target_info else None
665
+ if isinstance(session_id, str) and isinstance(target_id, str) and target_info:
666
+ self._target_sessions[target_id] = session_id
667
+ self._session_targets[session_id] = target_info
668
+ elif method == "Target.detachedFromTarget":
669
+ session_id = params.get("sessionId")
670
+ target_info = self._session_targets.pop(session_id, None) if isinstance(session_id, str) else None
671
+ target_id = target_info.get("targetId") if target_info else None
672
+ if target_id:
673
+ self._target_sessions.pop(target_id, None)
674
+ if method and msg.get("sessionId") == self.ext_session_id:
675
+ session_id = msg.get("sessionId")
676
+ u = unwrap_event_if_needed(
677
+ method,
678
+ params,
679
+ session_id if isinstance(session_id, str) else None,
680
+ self.ext_session_id,
681
+ )
682
+ if u:
683
+ for h in self._handlers.get(u["event"], []):
684
+ def run_wrapped_event(handler=h, payload=u["data"], event_name=u["event"]):
685
+ try: handler(payload)
686
+ except Exception as e: print(f"[ModCDPClient] handler error for {event_name}: {e}")
687
+ threading.Thread(target=run_wrapped_event, daemon=True).start()
688
+ continue
689
+ if method:
690
+ for h in self._handlers.get(method, []):
691
+ def run_method_event(handler=h, payload=dict(params), event_name=method):
692
+ try: handler(payload)
693
+ except Exception as e: print(f"[ModCDPClient] handler error for {event_name}: {e}")
694
+ threading.Thread(target=run_method_event, daemon=True).start()
695
+ except Exception as e:
696
+ if not self._closed:
697
+ print(f"[ModCDPClient] reader exited: {e}")
698
+ finally:
699
+ with self._lock:
700
+ pending = list(self._pending.values())
701
+ self._pending.clear()
702
+ for _, done in pending:
703
+ done.put({"error": {"message": "connection closed"}})
704
+
705
+ def _target_infos(self) -> list[TargetInfo]:
706
+ value = self._send_frame("Target.getTargets").get("targetInfos")
707
+ if not isinstance(value, list):
708
+ return []
709
+ targets: list[TargetInfo] = []
710
+ for item in value:
711
+ if not isinstance(item, dict):
712
+ continue
713
+ target_id = item.get("targetId")
714
+ target_type = item.get("type")
715
+ target_url = item.get("url")
716
+ if isinstance(target_id, str) and isinstance(target_type, str) and isinstance(target_url, str):
717
+ targets.append({"targetId": target_id, "type": target_type, "url": target_url})
718
+ return targets
719
+
720
+ def _ensure_extension(self) -> ExtensionInfo:
721
+ ready_expression = self._ready_expression()
722
+ trust_service_worker_target = (
723
+ self.trust_service_worker_target
724
+ or len(self.service_worker_url_includes) > 0
725
+ or any(len([part for part in suffix.split("/") if part]) > 1 for suffix in self.service_worker_url_suffixes)
726
+ )
727
+
728
+ def probe_target(target: TargetInfo, timeout: float = 0, allow_attach: bool = False) -> ExtensionProbe | None:
729
+ target_id = target.get("targetId")
730
+ target_url = target.get("url")
731
+ if not target_id or not target_url:
732
+ return None
733
+ session_id = self._ensure_session_id_for_target(target_id, timeout=timeout, allow_attach=allow_attach)
734
+ if not session_id:
735
+ return None
736
+ self._send_frame("Runtime.enable", {}, session_id, timeout=self.cdp_send_timeout_ms / 1000)
737
+ probe = self._send_frame("Runtime.evaluate", {
738
+ "expression": ready_expression,
739
+ "returnByValue": True,
740
+ }, session_id, timeout=self.cdp_send_timeout_ms / 1000)
741
+ if _json_object(probe.get("result")).get("value") is not True:
742
+ return None
743
+ match = EXT_ID_FROM_URL_RE.match(target_url)
744
+ if match is None:
745
+ return None
746
+ return {
747
+ "extension_id": match.group(1),
748
+ "target_id": target_id,
749
+ "url": target_url,
750
+ "session_id": session_id,
751
+ }
752
+
753
+ def discover_ready_service_worker(matched_only: bool = False) -> ExtensionInfo | None:
754
+ target_infos = self._target_infos()
755
+ if trust_service_worker_target:
756
+ for t in target_infos:
757
+ if self._service_worker_target_matches(t):
758
+ try:
759
+ result = probe_target(t, timeout=self.service_worker_probe_timeout_ms / 1000, allow_attach=True)
760
+ except Exception:
761
+ result = None
762
+ if result:
763
+ return {"source": "trusted", **result}
764
+ if trust_service_worker_target or matched_only:
765
+ return None
766
+ for t in target_infos:
767
+ if t["type"] != "service_worker": continue
768
+ if not t["url"].startswith("chrome-extension://"): continue
769
+ try:
770
+ result = probe_target(t, timeout=self.service_worker_probe_timeout_ms / 1000)
771
+ except Exception:
772
+ continue
773
+ if result:
774
+ return {"source": "discovered", **result}
775
+ return None
776
+
777
+ def wait_for_ready_service_worker(timeout: float, matched_only: bool = False) -> ExtensionInfo | None:
778
+ deadline = time.monotonic() + timeout
779
+ while time.monotonic() < deadline:
780
+ result = discover_ready_service_worker(matched_only=matched_only)
781
+ if result:
782
+ return result
783
+ time.sleep(self.service_worker_poll_interval_ms / 1000)
784
+ return None
785
+
786
+ # 1. Discover an existing ModCDP service worker. Browserbase loads the
787
+ # extension for the session, but the service-worker target can appear a
788
+ # moment after the browser CDP websocket accepts connections.
789
+ discovered = discover_ready_service_worker()
790
+ if discovered:
791
+ return discovered
792
+ if self.require_service_worker_target:
793
+ discovered = wait_for_ready_service_worker(
794
+ self.service_worker_probe_timeout_ms / 1000,
795
+ matched_only=trust_service_worker_target,
796
+ )
797
+ if discovered:
798
+ return discovered
799
+ raise RuntimeError(
800
+ "Required ModCDP service worker target did not become ready "
801
+ f"({', '.join([*self.service_worker_url_includes, *self.service_worker_url_suffixes]) or 'no matcher'})."
802
+ )
803
+ if self.extension_path is None:
804
+ raise RuntimeError("extension_path is required when no existing ModCDP service worker can be discovered.")
805
+
806
+ # 2. Try Extensions.loadUnpacked.
807
+ try:
808
+ r = self._send_frame("Extensions.loadUnpacked", {"path": self.extension_path})
809
+ extension_id = r.get("id") or r.get("extensionId")
810
+ except RuntimeError as e:
811
+ if "Method not available" in str(e) or "wasn't found" in str(e):
812
+ discovered = wait_for_ready_service_worker(
813
+ self.service_worker_probe_timeout_ms / 1000,
814
+ matched_only=trust_service_worker_target,
815
+ )
816
+ if discovered:
817
+ return discovered
818
+ return self._borrow_extension_worker(str(e))
819
+ raise
820
+ if not isinstance(extension_id, str) or not extension_id:
821
+ raise RuntimeError(f"Extensions.loadUnpacked returned no id: {r}")
822
+
823
+ # 3. Wait for the loaded extension's SW.
824
+ sw_url_prefix = f"chrome-extension://{extension_id}/"
825
+ deadline = time.monotonic() + self.service_worker_ready_timeout_ms / 1000
826
+ while time.monotonic() < deadline:
827
+ for t in self._target_infos():
828
+ target_url = t.get("url") or ""
829
+ if t.get("type") == "service_worker" and target_url.startswith(sw_url_prefix):
830
+ result = probe_target(t, timeout=self.service_worker_probe_timeout_ms / 1000, allow_attach=True)
831
+ if result:
832
+ return {
833
+ "source": "injected", "extension_id": extension_id,
834
+ "target_id": t["targetId"], "url": target_url, "session_id": result["session_id"],
835
+ }
836
+ time.sleep(self.service_worker_poll_interval_ms / 1000)
837
+ raise RuntimeError(
838
+ f"Timed out after {self.service_worker_ready_timeout_ms}ms waiting for service worker target for extension {extension_id}."
839
+ )
840
+
841
+ def _service_worker_target_matches(self, target: TargetInfo) -> bool:
842
+ url = target.get("url") or ""
843
+ if target.get("type") != "service_worker" or not url.startswith("chrome-extension://"):
844
+ return False
845
+ if self.service_worker_url_includes and not all(part in url for part in self.service_worker_url_includes):
846
+ return False
847
+ if self.service_worker_url_suffixes and not any(url.endswith(suffix) for suffix in self.service_worker_url_suffixes):
848
+ return False
849
+ return bool(self.service_worker_url_includes or self.service_worker_url_suffixes)
850
+
851
+ def _borrow_extension_worker(self, load_error: str) -> ExtensionInfo:
852
+ if self.extension_path is None:
853
+ raise RuntimeError("extension_path is required to bootstrap a borrowed extension worker.")
854
+ borrowed: list[BorrowedExtensionInfo] = []
855
+ bootstrap = modcdp_server_bootstrap_expression(self.extension_path)
856
+ for t in self._target_infos():
857
+ target_id = t.get("targetId")
858
+ target_url = t.get("url") or ""
859
+ if t.get("type") != "service_worker": continue
860
+ if not target_id or not target_url.startswith("chrome-extension://"): continue
861
+ session_id = None
862
+ try:
863
+ session_id = self._session_id_for_target(target_id, timeout=2)
864
+ if not session_id:
865
+ continue
866
+ try: self._send_frame("Runtime.enable", {}, session_id, timeout=2)
867
+ except Exception: pass
868
+ result = self._send_frame("Runtime.evaluate", {
869
+ "expression": bootstrap,
870
+ "awaitPromise": True,
871
+ "returnByValue": True,
872
+ "allowUnsafeEvalBlockedByCSP": True,
873
+ }, session_id, timeout=3)
874
+ result_object = result.get("result")
875
+ value = result_object.get("value") if isinstance(result_object, dict) else {}
876
+ if not isinstance(value, dict):
877
+ value = {}
878
+ ready = bool(value.get("ok"))
879
+ if ready and self.service_worker_ready_expression:
880
+ probe = self._send_frame("Runtime.evaluate", {
881
+ "expression": self._ready_expression(),
882
+ "returnByValue": True,
883
+ }, session_id, timeout=2)
884
+ ready = _json_object(probe.get("result")).get("value") is True
885
+ if ready:
886
+ m = EXT_ID_FROM_URL_RE.match(target_url)
887
+ extension_id = value.get("extension_id") or (m.group(1) if m else None)
888
+ if not isinstance(extension_id, str):
889
+ continue
890
+ borrowed.append({
891
+ "source": "borrowed",
892
+ "extension_id": extension_id,
893
+ "target_id": target_id,
894
+ "url": target_url,
895
+ "session_id": session_id,
896
+ "has_tabs": bool(value.get("has_tabs")),
897
+ "has_debugger": bool(value.get("has_debugger")),
898
+ })
899
+ except Exception:
900
+ pass
901
+ borrowed.sort(key=lambda item: (item.get("has_debugger", False), item.get("has_tabs", False)), reverse=True)
902
+ if borrowed:
903
+ selected = borrowed[0]
904
+ selected.pop("has_tabs", None)
905
+ selected.pop("has_debugger", None)
906
+ return selected
907
+ raise RuntimeError(
908
+ "Cannot install or borrow ModCDP in the running browser.\n"
909
+ " - No existing service worker with globalThis.ModCDP was found.\n"
910
+ f" - Extensions.loadUnpacked is unavailable ({load_error}).\n"
911
+ " - No running chrome-extension:// service worker accepted the ModCDP bootstrap."
912
+ )
modcdp/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .ModCDPClient import ModCDPClient
2
+
3
+ __all__ = ["ModCDPClient"]
modcdp/translate.py ADDED
@@ -0,0 +1,262 @@
1
+ """Pure ModCDP <-> CDP translation helpers for the Python client."""
2
+
3
+ import json
4
+ import time
5
+ from typing import cast
6
+
7
+ from .types import (
8
+ ModCDPRoutes,
9
+ JsonObject,
10
+ JsonValue,
11
+ ProtocolParams,
12
+ ProtocolPayload,
13
+ ProtocolResult,
14
+ RuntimeEvaluateParams,
15
+ TranslatedCommand,
16
+ TranslatedStep,
17
+ UnwrappedModCDPEvent,
18
+ )
19
+
20
+ UPSTREAM_EVENT_BINDING_NAME = "__ModCDP_event_from_upstream__"
21
+ CUSTOM_EVENT_BINDING_NAME = "__ModCDP_custom_event__"
22
+
23
+ DEFAULT_CLIENT_ROUTES: ModCDPRoutes = {
24
+ "Mod.*": "service_worker",
25
+ "Custom.*": "service_worker",
26
+ "*.*": "service_worker",
27
+ }
28
+
29
+
30
+ def route_for(method: str, routes: ModCDPRoutes) -> str:
31
+ routes = routes or {}
32
+ if method in routes:
33
+ return routes[method]
34
+ best_prefix_len = -1
35
+ best_route = None
36
+ for pattern, route in routes.items():
37
+ if pattern == "*.*" or not pattern.endswith(".*"):
38
+ continue
39
+ prefix = pattern[:-1]
40
+ if method.startswith(prefix) and len(prefix) > best_prefix_len:
41
+ best_prefix_len = len(prefix)
42
+ best_route = route
43
+ if best_route is not None:
44
+ return best_route
45
+ if "*.*" in routes:
46
+ return routes["*.*"]
47
+ return "direct_cdp"
48
+
49
+
50
+ def _required_string(params: ProtocolParams, name: str) -> str:
51
+ value = params.get(name)
52
+ if not isinstance(value, str) or not value:
53
+ raise TypeError(f"{name} must be a non-empty string")
54
+ return value
55
+
56
+
57
+ def _optional_string(params: ProtocolParams, name: str) -> str | None:
58
+ value = params.get(name)
59
+ if value is None:
60
+ return None
61
+ if not isinstance(value, str):
62
+ raise TypeError(f"{name} must be a string")
63
+ return value
64
+
65
+
66
+ def _object_or_empty(value: JsonValue | None) -> JsonObject:
67
+ return value if isinstance(value, dict) else {}
68
+
69
+
70
+ def _eval_params(expression: str) -> RuntimeEvaluateParams:
71
+ return {
72
+ "expression": expression,
73
+ "awaitPromise": True,
74
+ "returnByValue": True,
75
+ "allowUnsafeEvalBlockedByCSP": True,
76
+ }
77
+
78
+
79
+ def _wrap_modcdp_evaluate(params: ProtocolParams, session_id: str) -> RuntimeEvaluateParams:
80
+ expression = _required_string(params, "expression")
81
+ user_params = params.get("params", {})
82
+ cdp_session_id = _optional_string(params, "cdpSessionId") or session_id
83
+ return _eval_params(
84
+ "(async () => {\n"
85
+ f" const params = {json.dumps(user_params)};\n"
86
+ f" const cdp = globalThis.ModCDP.attachToSession({json.dumps(cdp_session_id)});\n"
87
+ " const ModCDP = globalThis.ModCDP;\n"
88
+ " const chrome = globalThis.chrome;\n"
89
+ f" const value = ({expression});\n"
90
+ " return typeof value === 'function' ? await value(params) : value;\n"
91
+ "})()"
92
+ )
93
+
94
+
95
+ def _wrap_modcdp_add_custom_command(params: ProtocolParams) -> RuntimeEvaluateParams:
96
+ name = _required_string(params, "name")
97
+ expression = _required_string(params, "expression")
98
+ return _eval_params(
99
+ "(() => {\n"
100
+ " return globalThis.ModCDP.addCustomCommand({\n"
101
+ f" name: {json.dumps(name)},\n"
102
+ f" paramsSchema: {json.dumps(params.get('paramsSchema'))},\n"
103
+ f" resultSchema: {json.dumps(params.get('resultSchema'))},\n"
104
+ f" expression: {json.dumps(expression)},\n"
105
+ " handler: async (params, cdpSessionId, method) => {\n"
106
+ " const cdp = globalThis.ModCDP.attachToSession(cdpSessionId);\n"
107
+ " const ModCDP = globalThis.ModCDP;\n"
108
+ " const chrome = globalThis.chrome;\n"
109
+ f" const handler = ({expression});\n"
110
+ " return await handler(params || {}, method);\n"
111
+ " },\n"
112
+ " });\n"
113
+ "})()"
114
+ )
115
+
116
+
117
+ def _wrap_modcdp_add_custom_event(params: ProtocolParams) -> RuntimeEvaluateParams:
118
+ name = _required_string(params, "name")
119
+ return _eval_params(
120
+ "globalThis.ModCDP.addCustomEvent({\n"
121
+ f" name: {json.dumps(name)},\n"
122
+ f" eventSchema: {json.dumps(params.get('eventSchema'))},\n"
123
+ "})"
124
+ )
125
+
126
+
127
+ def _wrap_modcdp_add_middleware(params: ProtocolParams) -> RuntimeEvaluateParams:
128
+ phase = _required_string(params, "phase")
129
+ expression = _required_string(params, "expression")
130
+ name = _optional_string(params, "name") or "*"
131
+ return _eval_params(
132
+ "(() => {\n"
133
+ " return globalThis.ModCDP.addMiddleware({\n"
134
+ f" name: {json.dumps(name)},\n"
135
+ f" phase: {json.dumps(phase)},\n"
136
+ f" expression: {json.dumps(expression)},\n"
137
+ " handler: async (payload, next, context = {}) => {\n"
138
+ " const cdp = globalThis.ModCDP.attachToSession(context.cdpSessionId ?? null);\n"
139
+ " const ModCDP = globalThis.ModCDP;\n"
140
+ " const chrome = globalThis.chrome;\n"
141
+ f" const middleware = ({expression});\n"
142
+ " return await middleware(payload, next, context);\n"
143
+ " },\n"
144
+ " });\n"
145
+ "})()"
146
+ )
147
+
148
+
149
+ def _wrap_custom_command(method: str, params: ProtocolParams, session_id: str) -> RuntimeEvaluateParams:
150
+ return _eval_params(
151
+ f"globalThis.ModCDP.handleCommand("
152
+ f"{json.dumps(method)}, {json.dumps(params)}, "
153
+ f"{json.dumps(session_id)})"
154
+ )
155
+
156
+
157
+ def _wrap_service_worker_command(method: str, params: ProtocolParams, session_id: str) -> list[TranslatedStep]:
158
+ if method == "Mod.ping" and "sentAt" not in params:
159
+ params = {**params, "sentAt": int(time.time() * 1000)}
160
+
161
+ if method == "Mod.addCustomEvent":
162
+ return [
163
+ {
164
+ "method": "Runtime.evaluate",
165
+ "params": _wrap_modcdp_add_custom_event(params),
166
+ "unwrap": "evaluate",
167
+ },
168
+ ]
169
+ if method == "Mod.evaluate":
170
+ runtime_params = _wrap_modcdp_evaluate(params, session_id)
171
+ elif method == "Mod.addCustomCommand":
172
+ runtime_params = _wrap_modcdp_add_custom_command(params)
173
+ elif method == "Mod.addMiddleware":
174
+ runtime_params = _wrap_modcdp_add_middleware(params)
175
+ else:
176
+ runtime_params = _wrap_custom_command(method, params, _optional_string(params, "cdpSessionId") or session_id)
177
+ return [{"method": "Runtime.evaluate", "params": runtime_params, "unwrap": "evaluate"}]
178
+
179
+
180
+ def wrap_command_if_needed(
181
+ method: str,
182
+ params: ProtocolParams | None = None,
183
+ *,
184
+ routes: ModCDPRoutes | None = None,
185
+ cdp_session_id: str | None = None,
186
+ ) -> TranslatedCommand:
187
+ params = params or {}
188
+ route = route_for(method, routes or DEFAULT_CLIENT_ROUTES)
189
+ if route == "direct_cdp":
190
+ return {"route": route, "target": "direct_cdp", "steps": [{"method": method, "params": params}]}
191
+ if route == "service_worker":
192
+ if cdp_session_id is None:
193
+ raise RuntimeError(f"service_worker route requires a CDP session id for {method}")
194
+ return {
195
+ "route": route,
196
+ "target": "service_worker",
197
+ "steps": _wrap_service_worker_command(method, params, cdp_session_id),
198
+ }
199
+ raise RuntimeError(f"Unsupported client route '{route}' for {method}")
200
+
201
+
202
+ def _unwrap_evaluate_response(result: ProtocolResult) -> JsonValue:
203
+ if result.get("exceptionDetails"):
204
+ ex = _object_or_empty(result.get("exceptionDetails"))
205
+ exception = _object_or_empty(ex.get("exception"))
206
+ description = exception.get("description")
207
+ text = ex.get("text")
208
+ msg = (
209
+ description
210
+ if isinstance(description, str)
211
+ else text
212
+ if isinstance(text, str)
213
+ else "Runtime.evaluate failed"
214
+ )
215
+ raise RuntimeError(msg)
216
+ inner = _object_or_empty(result.get("result"))
217
+ return inner.get("value")
218
+
219
+
220
+ def unwrap_response_if_needed(result: ProtocolResult, unwrap: str | None = None) -> JsonValue:
221
+ return _unwrap_evaluate_response(result) if unwrap == "evaluate" else (result or {})
222
+
223
+
224
+ def unwrap_event_if_needed(
225
+ method: str,
226
+ params: ProtocolParams,
227
+ session_id: str | None = None,
228
+ our_session_id: str | None = None,
229
+ ) -> UnwrappedModCDPEvent | None:
230
+ if method != "Runtime.bindingCalled":
231
+ return None
232
+ binding_name = params.get("name")
233
+ if not isinstance(binding_name, str):
234
+ return None
235
+ raw_payload = params.get("payload")
236
+ if not isinstance(raw_payload, str):
237
+ return None
238
+ try:
239
+ parsed: object = json.loads(raw_payload)
240
+ except json.JSONDecodeError:
241
+ return None
242
+ if not isinstance(parsed, dict):
243
+ return None
244
+ payload = cast(ProtocolPayload, parsed)
245
+ is_upstream_event_binding = binding_name == UPSTREAM_EVENT_BINDING_NAME
246
+ is_custom_event_binding = binding_name == CUSTOM_EVENT_BINDING_NAME
247
+ if not is_upstream_event_binding and not is_custom_event_binding:
248
+ return None
249
+ payload_event = payload.get("event")
250
+ if not isinstance(payload_event, str) or not payload_event:
251
+ payload_event = None
252
+ if payload_event is None:
253
+ return None
254
+ resolved_event = payload_event
255
+ if resolved_event == UPSTREAM_EVENT_BINDING_NAME or resolved_event == CUSTOM_EVENT_BINDING_NAME:
256
+ return None
257
+ data_value = payload["data"] if "data" in payload else payload
258
+ data: ProtocolPayload = data_value if isinstance(data_value, dict) else {"value": data_value}
259
+ raw_source_session_id = payload.get("cdpSessionId")
260
+ source_session_id = raw_source_session_id if isinstance(raw_source_session_id, str) else session_id
261
+ unwrapped: UnwrappedModCDPEvent = {"event": resolved_event, "data": data, "sessionId": source_session_id}
262
+ return unwrapped
modcdp/types.py ADDED
@@ -0,0 +1,170 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Mapping
4
+ from queue import Queue
5
+ from typing import Literal, Protocol, TypeAlias, TypedDict
6
+
7
+ JsonPrimitive: TypeAlias = None | bool | int | float | str
8
+ JsonValue: TypeAlias = JsonPrimitive | list["JsonValue"] | dict[str, "JsonValue"]
9
+ JsonObject: TypeAlias = dict[str, JsonValue]
10
+
11
+ ProtocolParams: TypeAlias = Mapping[str, JsonValue]
12
+ ProtocolResult: TypeAlias = dict[str, JsonValue]
13
+ ProtocolPayload: TypeAlias = dict[str, JsonValue]
14
+ FrameParams: TypeAlias = Mapping[str, object]
15
+ ModCDPRoutes: TypeAlias = dict[str, str]
16
+
17
+
18
+ class _ModCDPAddCustomCommandRequired(TypedDict):
19
+ name: str
20
+ expression: str
21
+
22
+
23
+ class ModCDPAddCustomCommandParams(_ModCDPAddCustomCommandRequired, total=False):
24
+ paramsSchema: JsonValue
25
+ resultSchema: JsonValue
26
+
27
+
28
+ class _ModCDPAddCustomEventObjectRequired(TypedDict):
29
+ name: str
30
+
31
+
32
+ class ModCDPAddCustomEventObjectParams(_ModCDPAddCustomEventObjectRequired, total=False):
33
+ eventSchema: JsonValue
34
+
35
+
36
+ ModCDPAddCustomEventParams: TypeAlias = str | ModCDPAddCustomEventObjectParams
37
+
38
+
39
+ class _ModCDPAddMiddlewareRequired(TypedDict):
40
+ phase: Literal["request", "response", "event"]
41
+ expression: str
42
+
43
+
44
+ class ModCDPAddMiddlewareParams(_ModCDPAddMiddlewareRequired, total=False):
45
+ name: str
46
+
47
+
48
+ class ModCDPPingLatency(TypedDict):
49
+ sentAt: int
50
+ receivedAt: int | float | None
51
+ returnedAt: int
52
+ roundTripMs: int
53
+ serviceWorkerMs: int | float | None
54
+ returnPathMs: int | float | None
55
+
56
+
57
+ class ModCDPConnectTiming(TypedDict):
58
+ started_at: int
59
+ extension_source: str | None
60
+ extension_started_at: int
61
+ extension_completed_at: int
62
+ extension_duration_ms: int
63
+ connected_at: int
64
+ duration_ms: int
65
+
66
+
67
+ class ModCDPCommandTiming(TypedDict):
68
+ method: str
69
+ target: str
70
+ started_at: int
71
+ completed_at: int
72
+ duration_ms: int
73
+
74
+
75
+ class ModCDPRawTiming(TypedDict):
76
+ method: str
77
+ started_at: int
78
+ completed_at: int
79
+ duration_ms: int
80
+
81
+
82
+ class ModCDPServerConfig(TypedDict, total=False):
83
+ loopback_cdp_url: str | None
84
+ routes: ModCDPRoutes
85
+ cdp_send_timeout_ms: int
86
+ loopback_execution_context_timeout_ms: int
87
+ ws_connect_error_settle_timeout_ms: int
88
+ browserToken: str | None
89
+ custom_commands: list[ModCDPAddCustomCommandParams]
90
+ custom_events: list[ModCDPAddCustomEventObjectParams]
91
+ custom_middlewares: list[ModCDPAddMiddlewareParams]
92
+
93
+
94
+ class LaunchOptions(TypedDict, total=False):
95
+ executable_path: str
96
+ port: int
97
+ headless: bool
98
+ sandbox: bool
99
+ extra_args: list[str]
100
+
101
+
102
+ RuntimeEvaluateParams: TypeAlias = dict[str, JsonValue]
103
+
104
+
105
+ class _TranslatedStepRequired(TypedDict):
106
+ method: str
107
+
108
+
109
+ class TranslatedStep(_TranslatedStepRequired, total=False):
110
+ params: FrameParams
111
+ unwrap: Literal["evaluate"]
112
+
113
+
114
+ class TranslatedCommand(TypedDict):
115
+ route: str
116
+ target: Literal["direct_cdp", "service_worker"]
117
+ steps: list[TranslatedStep]
118
+
119
+
120
+ class CdpError(TypedDict, total=False):
121
+ message: str
122
+
123
+
124
+ class CdpFrame(TypedDict, total=False):
125
+ id: int
126
+ method: str
127
+ params: FrameParams
128
+ sessionId: str
129
+ result: ProtocolResult
130
+ error: CdpError
131
+
132
+
133
+ class TargetInfo(TypedDict):
134
+ targetId: str
135
+ type: str
136
+ url: str
137
+
138
+
139
+ class ExtensionProbe(TypedDict):
140
+ extension_id: str
141
+ target_id: str
142
+ url: str
143
+ session_id: str
144
+
145
+
146
+ class ExtensionInfo(ExtensionProbe):
147
+ source: str
148
+
149
+
150
+ class BorrowedExtensionInfo(ExtensionInfo, total=False):
151
+ has_tabs: bool
152
+ has_debugger: bool
153
+
154
+
155
+ class UnwrappedModCDPEvent(TypedDict):
156
+ event: str
157
+ data: ProtocolPayload
158
+ sessionId: str | None
159
+
160
+
161
+ Handler: TypeAlias = Callable[[ProtocolPayload], None]
162
+ PendingEntry: TypeAlias = tuple[str, Queue[CdpFrame]]
163
+
164
+
165
+ class WebSocketLike(Protocol):
166
+ def send(self, payload: str) -> object: ...
167
+
168
+ def recv(self) -> str | bytes | None: ...
169
+
170
+ def close(self) -> object: ...
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: modcdp
3
+ Version: 0.0.2
4
+ Summary: Python client for ModCDP.
5
+ Project-URL: Repository, https://github.com/pirate/ModCDP
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: websocket-client>=1.8.0
8
+ Description-Content-Type: text/markdown
9
+
10
+ # ModCDP Python Client
11
+
12
+ Python client for ModCDP.
@@ -0,0 +1,7 @@
1
+ modcdp/ModCDPClient.py,sha256=LfVIcJ7pRhqRYAO-_fFB8_AHBtkdVqBWdU_HwXZIx5M,42172
2
+ modcdp/__init__.py,sha256=A6MKAdgscmH-bu4a5kvoo-GpTJkM8r_07D1zXrATVgc,67
3
+ modcdp/translate.py,sha256=rVWwJR5H5N4zvKHOnwW39k_cF26evOjRHS5BsEUzj4Y,9572
4
+ modcdp/types.py,sha256=SFwzKGp_fNiE8POp_G_BqqGu_54-iR3NejQsPBqpXdc,3879
5
+ modcdp-0.0.2.dist-info/METADATA,sha256=ATTwttHvPntecDd67FrJsYihlnkQn2-xfmPRIRp1nJc,297
6
+ modcdp-0.0.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
7
+ modcdp-0.0.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any