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 +912 -0
- modcdp/__init__.py +3 -0
- modcdp/translate.py +262 -0
- modcdp/types.py +170 -0
- modcdp-0.0.2.dist-info/METADATA +12 -0
- modcdp-0.0.2.dist-info/RECORD +7 -0
- modcdp-0.0.2.dist-info/WHEEL +4 -0
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
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,,
|