ricibrowser 0.2.1__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.
@@ -0,0 +1,38 @@
1
+ """ricibrowser — a lightweight two-engine browser automation module.
2
+
3
+ No Playwright, no Puppeteer, no selenium. Built entirely on the public Chrome
4
+ DevTools Protocol specification (chromedevtools.github.io/devtools-protocol/).
5
+
6
+ Two engines:
7
+ - **Lightpanda** (fast path): Zig-based headless engine, CDP at ws://127.0.0.1:9222.
8
+ For crawl/recon/endpoint discovery — ~16× less memory, ~9× faster than Chromium.
9
+ - **CDP-Chrome** (thorough path): custom CDP client driving the user's real
10
+ installed Chrome. For DAST/JS-heavy targets/auth flow testing/anti-bot.
11
+
12
+ Extensions:
13
+ - **fingerprint**: Canvas/WebGL/audio fingerprint randomization (opt-in).
14
+ - **geolocation**: Timezone/locale/geolocation override + proxy rotation pool.
15
+ - **captcha**: CAPTCHA detection, Cloudflare auto-resolve, solver hooks.
16
+ - **input**: Human-like mouse movement simulation (bezier curves + timing).
17
+ - **headers**: Sec-CH-UA consistency + request header ordering.
18
+
19
+ Usage::
20
+
21
+ from ricibrowser import Engine, EngineConfig, EngineType
22
+
23
+ engine = Engine(EngineConfig(fast_engine=EngineType.AUTO))
24
+ page = await engine.fast_browse("https://example.com")
25
+ print(page.title, page.text[:200])
26
+
27
+ session = await engine.create_session()
28
+ await session.navigate("https://example.com")
29
+ result = await session.evaluate("document.title")
30
+ await session.close()
31
+ """
32
+
33
+ from ricibrowser.config import EngineConfig, EngineType
34
+ from ricibrowser.engine import Engine
35
+ from ricibrowser.session import Page, Session
36
+
37
+ __version__ = "0.2.0"
38
+ __all__ = ["Engine", "EngineConfig", "EngineType", "Page", "Session"]
ricibrowser/captcha.py ADDED
@@ -0,0 +1,395 @@
1
+ """CAPTCHA detection, Cloudflare auto-resolve, and solver hook framework.
2
+
3
+ Detection: identifies which CAPTCHA/anti-bot system is blocking navigation
4
+ (Cloudflare JS challenge, Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha).
5
+
6
+ Auto-resolve: for Cloudflare JS challenges ("Just a moment..."), waits for
7
+ the challenge to auto-resolve in real Chrome (the JavaScript runs natively
8
+ and often passes within 5-10 seconds). Captures the ``cf_clearance`` cookie
9
+ for persistence.
10
+
11
+ Solver hooks: for CAPTCHAs that can't be auto-resolved (Turnstile,
12
+ reCAPTCHA, hCaptcha), provides a hook interface for external solver services.
13
+ The operator registers a ``CaptchaSolver`` implementation and ricibrowser
14
+ calls it when a CAPTCHA is detected.
15
+
16
+ Usage::
17
+
18
+ from ricibrowser.captcha import CaptCHAHandler, CloudflareAutoSolver
19
+
20
+ handler = CaptchaHandler(auto_solver=CloudflareAutoSolver())
21
+ result = await handler.detect_and_solve(session)
22
+ if result.solved:
23
+ print("Challenge resolved!") # cf_clearance now in cookie jar
24
+ elif result.captcha_type:
25
+ print(f"Unsupported: {result.captcha_type} — needs external solver")
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import logging
32
+ import time
33
+ from dataclasses import dataclass
34
+ from enum import Enum
35
+ from typing import Any, Protocol, runtime_checkable
36
+
37
+ from ricibrowser.cdp_client import CDPClient, CDPError
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ class CaptchaType(Enum):
43
+ """Detected CAPTCHA / anti-bot challenge type."""
44
+
45
+ NONE = "none"
46
+ """No CAPTCHA detected."""
47
+
48
+ CLOUDFLARE_JS = "cloudflare_js"
49
+ """Cloudflare JS challenge ("Just a moment..."). Often auto-resolves."""
50
+
51
+ CLOUDFLARE_TURNSTILE = "cloudflare_turnstile"
52
+ """Cloudflare Turnstile widget. Needs external solver."""
53
+
54
+ RECAPTCHA_V2 = "recaptcha_v2"
55
+ """Google reCAPTCHA v2 (checkbox or invisible). Needs external solver."""
56
+
57
+ RECAPTCHA_V3 = "recaptcha_v3"
58
+ """Google reCAPTCHA v3 (score-based, invisible). Needs external solver."""
59
+
60
+ HCAPTCHA = "hcaptcha"
61
+ """hCaptcha widget. Needs external solver."""
62
+
63
+ GENERIC = "generic"
64
+ """Unknown anti-bot challenge."""
65
+
66
+
67
+ @dataclass
68
+ class CaptchaResult:
69
+ """Result of a CAPTCHA detection / solve attempt."""
70
+
71
+ captcha_type: CaptchaType
72
+ solved: bool
73
+ cookie_name: str | None = None
74
+ """The cookie that proves the challenge was solved (e.g. cf_clearance)."""
75
+ solver_used: str | None = None
76
+ """Which solver was used (e.g. 'cloudflare_auto', 'external')."""
77
+ error: str | None = None
78
+ duration_seconds: float = 0.0
79
+
80
+
81
+ @runtime_checkable
82
+ class CaptchaSolver(Protocol):
83
+ """Protocol for external CAPTCHA solver implementations.
84
+
85
+ Operators implement this to integrate third-party solver services
86
+ (2captcha, anti-captcha, capsolver, etc.) for CAPTCHAs that can't
87
+ be auto-resolved.
88
+
89
+ Example implementation::
90
+
91
+ class TwoCaptchaSolver:
92
+ async def solve(self, captcha_type, site_key, url):
93
+ # Call the 2captcha API
94
+ token = await call_service(site_key, url)
95
+ return CaptchaToken(token=token)
96
+
97
+ handler = CaptCHAHandler(solver=TwoCaptchaSolver())
98
+ """
99
+
100
+ async def solve(
101
+ self,
102
+ captcha_type: CaptchaType,
103
+ site_key: str | None,
104
+ url: str,
105
+ ) -> str | None:
106
+ """Solve a CAPTCHA and return the token.
107
+
108
+ Args:
109
+ captcha_type: The type of CAPTCHA detected.
110
+ site_key: The site key (for reCAPTCHA/hCaptcha/Turnstile).
111
+ url: The page URL where the CAPTCHA appears.
112
+
113
+ Returns:
114
+ The solver token, or None if the solver can't handle this type.
115
+ """
116
+ ...
117
+
118
+
119
+ @dataclass
120
+ class CaptchaToken:
121
+ """A solved CAPTCHA token."""
122
+ token: str
123
+ captcha_type: CaptchaType
124
+
125
+
126
+ # ── Detection ──────────────────────────────────────────────────────────
127
+
128
+
129
+ async def detect_captcha(session) -> CaptchaType:
130
+ """Detect what type of CAPTCHA or anti-bot challenge is on the current page.
131
+
132
+ Checks the DOM for known CAPTCHA widget signatures and the page text
133
+ for challenge messages.
134
+ """
135
+ # Check for Cloudflare JS challenge ("Just a moment...")
136
+ title = await session.evaluate("document.title") or ""
137
+ body_text = await session.evaluate(
138
+ "document.body ? document.body.innerText.substring(0, 500) : ''"
139
+ ) or ""
140
+
141
+ title_lower = title.lower()
142
+ text_lower = (body_text or "").lower()
143
+
144
+ if any(p in title_lower or p in text_lower for p in(
145
+ "just a moment", "checking your browser", "attention required",
146
+ "cf-challenge", "challenge-platform",
147
+ )):
148
+ return CaptchaType.CLOUDFLARE_JS
149
+
150
+ # Check for Cloudflare Turnstile widget
151
+ turnstile = await session.evaluate_bool(
152
+ "document.querySelector('.cf-turnstile, [data-sitekey]') !== null"
153
+ )
154
+ if turnstile:
155
+ # Verify it's actually a Turnstile widget (not reCAPTCHA)
156
+ is_turnstile = await session.evaluate_bool(
157
+ "document.querySelector('script[src*=\"challenges.cloudflare.com/turnstile\"]') !== null"
158
+ )
159
+ if is_turnstile:
160
+ return CaptchaType.CLOUDFLARE_TURNSTILE
161
+
162
+ # Check for reCAPTCHA
163
+ recaptcha = await session.evaluate_bool(
164
+ "document.querySelector('.g-recaptcha, [data-sitekey], "
165
+ "iframe[src*=\"recaptcha\"]') !== null"
166
+ )
167
+ if recaptcha:
168
+ # Check if it's v2 (visible checkbox) or v3 (invisible)
169
+ visible = await session.evaluate_bool(
170
+ "document.querySelector('.g-recaptcha') && "
171
+ "getComputedStyle(document.querySelector('.g-recaptcha')).display !== 'none'"
172
+ )
173
+ if visible:
174
+ return CaptchaType.RECAPTCHA_V2
175
+ return CaptchaType.RECAPTCHA_V3
176
+
177
+ # Check for hCaptcha
178
+ hcaptcha = await session.evaluate_bool(
179
+ "document.querySelector('.h-captcha, iframe[src*=\"hcaptcha\"]') !== null"
180
+ )
181
+ if hcaptcha:
182
+ return CaptchaType.HCAPTCHA
183
+
184
+ # Generic anti-bot check
185
+ if any(p in text_lower for p in(
186
+ "enable javascript and cookies", "please complete the security check",
187
+ "verify you are human", "are you a robot",
188
+ )):
189
+ return CaptchaType.GENERIC
190
+
191
+ return CaptchaType.NONE
192
+
193
+
194
+ # ── Cloudflare Auto-Solver ────────────────────────────────────────────
195
+
196
+
197
+ class CloudflareAutoSolver:
198
+ """Auto-resolves Cloudflare JS challenges by waiting for native JS execution.
199
+
200
+ Cloudflare's "Just a moment..." challenge runs JavaScript that computes
201
+ a token and auto-submits a form. In a real Chrome browser with real V8
202
+ engine (which ricibrowser uses), this JavaScript executes natively and
203
+ the challenge resolves automatically — usually within 5-10 seconds.
204
+
205
+ This solver:
206
+ 1. Waits for the challenge page to load
207
+ 2. Polls every 1s for the challenge to clear (body text changes,
208
+ challenge elements disappear, content loads)
209
+ 3. Checks for the ``cf_clearance`` cookie which proves the challenge passed
210
+ 4. Returns the result with the cookie name
211
+
212
+ Limitations:
213
+ - Only works with real Chrome (not Lightpanda — Lightpanda's V8 may
214
+ not handle the obfuscated CF JS)
215
+ - Doesn't work if the CF challenge requires a Turnstile click
216
+ - If the IP address or User-Agent is too suspicious, CF may loop forever
217
+ """
218
+
219
+ def __init__(self, max_wait: float = 15.0, poll_interval: float = 1.0):
220
+ self.max_wait = max_wait
221
+ self.poll_interval = poll_interval
222
+
223
+ async def solve(self, session) -> CaptchaResult:
224
+ """Wait for the Cloudflare JS challenge to auto-resolve.
225
+
226
+ Args:
227
+ session: A ricibrowser Session on the challenge page.
228
+
229
+ Returns:
230
+ CaptchaResult with solved=True if the challenge cleared.
231
+ """
232
+ start = time.monotonic()
233
+
234
+ for _ in range(int(self.max_wait / self.poll_interval)):
235
+ elapsed = time.monotonic() - start
236
+
237
+ # Check if the challenge has cleared
238
+ body_text = await session.evaluate(
239
+ "document.body ? document.body.innerText.substring(0, 200) : ''"
240
+ )
241
+
242
+ # If evaluate returned None (context not ready, CDP error),
243
+ # treat as "unknown — keep waiting" rather than "cleared".
244
+ if body_text is None:
245
+ await asyncio.sleep(self.poll_interval)
246
+ continue
247
+
248
+ text_lower = body_text.lower()
249
+
250
+ # Challenge cleared if the "Just a moment" / "Checking" text is gone
251
+ # AND there is some actual body content (not empty).
252
+ challenge_cleared = (
253
+ bool(body_text.strip())
254
+ and not any(p in text_lower for p in(
255
+ "just a moment", "checking your browser", "attention required",
256
+ "enable javascript and cookies",
257
+ ))
258
+ )
259
+
260
+ if challenge_cleared:
261
+ # Verify cf_clearance cookie was set
262
+ cookies = await session.get_cookies()
263
+ cf_clearance = [c for c in cookies if c.get("name") == "cf_clearance"]
264
+ if cf_clearance:
265
+ return CaptchaResult(
266
+ captcha_type=CaptchaType.CLOUDFLARE_JS,
267
+ solved=True,
268
+ cookie_name="cf_clearance",
269
+ solver_used="cloudflare_auto",
270
+ duration_seconds=elapsed,
271
+ )
272
+ # Challenge cleared but no cookie — might be a lighter check
273
+ return CaptchaResult(
274
+ captcha_type=CaptchaType.CLOUDFLARE_JS,
275
+ solved=True,
276
+ cookie_name=None,
277
+ solver_used="cloudflare_auto",
278
+ duration_seconds=elapsed,
279
+ )
280
+
281
+ await asyncio.sleep(self.poll_interval)
282
+
283
+ # Timed out
284
+ return CaptchaResult(
285
+ captcha_type=CaptchaType.CLOUDFLARE_JS,
286
+ solved=False,
287
+ error=f"Cloudflare challenge did not auto-resolve within {self.max_wait}s. "
288
+ "The IP may be flagged or a Turnstile click may be required.",
289
+ duration_seconds=time.monotonic() - start,
290
+ )
291
+
292
+
293
+ # ── Handler ───────────────────────────────────────────────────────────
294
+
295
+
296
+ class CaptchaHandler:
297
+ """Detect and attempt to solve CAPTCHAs automatically.
298
+
299
+ Priority:
300
+ 1. Cloudflare JS challenge → CloudflareAutoSolver (native JS execution)
301
+ 2. Turnstile / reCAPTCHA / hCaptcha → external solver (if registered)
302
+ 3. Generic → report and suggest manual intervention
303
+
304
+ Usage::
305
+
306
+ handler = CaptchaHandler(
307
+ auto_solver=CloudflareAutoSolver(),
308
+ external_solver=MySolver(), # optional
309
+ )
310
+ result = await handler.detect_and_solve(session)
311
+ """
312
+
313
+ def __init__(
314
+ self,
315
+ auto_solver: CloudflareAutoSolver | None = None,
316
+ external_solver: CaptchaSolver | None = None,
317
+ ):
318
+ self.auto_solver = auto_solver or CloudflareAutoSolver()
319
+ self.external_solver = external_solver
320
+
321
+ async def detect_and_solve(self, session) -> CaptchaResult:
322
+ """Detect the CAPTCHA type and attempt resolution.
323
+
324
+ Returns a CaptchaResult. If solved, the session is ready to continue.
325
+ If not solved, the result contains the type and an error message.
326
+ """
327
+ start = time.monotonic()
328
+
329
+ # ── Detect ──────────────────────────────────────────────────
330
+ captcha_type = await detect_captcha(session)
331
+
332
+ if captcha_type == CaptchaType.NONE:
333
+ return CaptchaResult(
334
+ captcha_type=CaptchaType.NONE,
335
+ solved=True,
336
+ duration_seconds=time.monotonic() - start,
337
+ )
338
+
339
+ logger.info("CAPTCHA detected: %s", captcha_type.value)
340
+
341
+ # ── Cloudflare JS challenge → auto-solve ───────────────────
342
+ if captcha_type == CaptchaType.CLOUDFLARE_JS:
343
+ result = await self.auto_solver.solve(session)
344
+ return result
345
+
346
+ # ── External solver for reCAPTCHA/hCaptcha/Turnstile ───────
347
+ if self.external_solver and captcha_type in(
348
+ CaptchaType.RECAPTCHA_V2,
349
+ CaptchaType.RECAPTCHA_V3,
350
+ CaptchaType.HCAPTCHA,
351
+ CaptchaType.CLOUDFLARE_TURNSTILE,
352
+ ):
353
+ # Extract the site key
354
+ site_key = await session.evaluate(
355
+ "document.querySelector('[data-sitekey]')?.getAttribute('data-sitekey') || "
356
+ "document.querySelector('iframe[src*=\"recaptcha\"]')?.src.match(/render=([^&]+)/)?.[1] || "
357
+ "document.querySelector('iframe[src*=\"hcaptcha\"]')?.src.match(/sitekey=([^&]+)/)?.[1] || ''"
358
+ ) or ""
359
+
360
+ url = session._current_url
361
+
362
+ try:
363
+ token = await self.external_solver.solve(captcha_type, site_key or None, url)
364
+ if token:
365
+ # Inject the token into the page
366
+ await session.evaluate(
367
+ f"document.getElementById('g-recaptcha-response').value = '{token}';"
368
+ if captcha_type == CaptchaType.RECAPTCHA_V2 else
369
+ f"window.__captcha_token__ = '{token}';"
370
+ )
371
+ return CaptchaResult(
372
+ captcha_type=captcha_type,
373
+ solved=True,
374
+ solver_used="external",
375
+ duration_seconds=time.monotonic() - start,
376
+ )
377
+ except Exception as exc:
378
+ logger.warning("External solver failed: %s", exc)
379
+ return CaptchaResult(
380
+ captcha_type=captcha_type,
381
+ solved=False,
382
+ error=f"External solver error: {exc}",
383
+ duration_seconds=time.monotonic() - start,
384
+ )
385
+
386
+ # ── No solver available for this type ──────────────────────
387
+ solver_name = "external" if self.external_solver else "none"
388
+ return CaptchaResult(
389
+ captcha_type=captcha_type,
390
+ solved=False,
391
+ error=f"No solver available for {captcha_type.value} "
392
+ f"(registered: {solver_name}). For Cloudflare JS challenges, "
393
+ f"ensure real Chrome is used (not Lightpanda).",
394
+ duration_seconds=time.monotonic() - start,
395
+ )
@@ -0,0 +1,261 @@
1
+ """Minimal CDP (Chrome DevTools Protocol) WebSocket client.
2
+
3
+ Built from the public CDP specification at
4
+ chromedevtools.github.io/devtools-protocol/. No Playwright, Puppeteer, or
5
+ selenium dependency — just WebSocket + JSON-RPC.
6
+
7
+ This client implements:
8
+ - WebSocket connection to a CDP endpoint (Chrome's /devtools/page/<id> or
9
+ Lightpanda's root ws://host:port)
10
+ - JSON-RPC command/response matching by id
11
+ - CDP event subscription via callback
12
+ - Target discovery via HTTP /json endpoint
13
+
14
+ It does NOT implement:
15
+ - Runtime.enable on the main world (we use isolated worlds for JS eval)
16
+ - Console.enable by default (off — detection vector)
17
+ - Any browser-specific logic (that's in chrome_launcher.py / lightpanda.py)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import json
24
+ import logging
25
+ from typing import Any, Awaitable, Callable
26
+
27
+ import httpx
28
+ import websockets
29
+ from websockets.asyncio.client import connect as ws_connect
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ CDPEventCallback = Callable[[dict[str, Any]], Awaitable[None] | None]
34
+
35
+
36
+ class CDPError(Exception):
37
+ """CDP protocol error (method returned an error response)."""
38
+
39
+ def __init__(self, method: str, code: int, message: str, data: Any = None):
40
+ self.method = method
41
+ self.code = code
42
+ self.message = message
43
+ self.data = data
44
+ super().__init__(f"CDP {method} failed ({code}): {message}")
45
+
46
+
47
+ class CDPClient:
48
+ """Low-level CDP WebSocket client.
49
+
50
+ Usage::
51
+
52
+ client = await CDPClient.connect("ws://127.0.0.1:9222")
53
+ result = await client.send("Page.navigate", {"url": "https://example.com"})
54
+ await client.close()
55
+
56
+ For Chrome (which uses target-level WS endpoints), use :meth:`connect_to_target`
57
+ which discovers targets via the HTTP /json API first.
58
+ """
59
+
60
+ def __init__(self, ws: websockets.WebSocketClientProtocol):
61
+ self._ws = ws
62
+ self._msg_id: int = 0
63
+ self._pending: dict[int, asyncio.Future[dict]] = {}
64
+ self._event_handlers: dict[str, list[CDPEventCallback]] = {}
65
+ self._recv_task: asyncio.Task | None = None
66
+ self._closed = False
67
+
68
+ @classmethod
69
+ async def connect(cls, ws_url: str) -> "CDPClient":
70
+ """Connect directly to a CDP WebSocket endpoint.
71
+
72
+ For Lightpanda: ws_url = "ws://127.0.0.1:9222"
73
+ For Chrome: ws_url = "ws://127.0.0.1:9223/devtools/page/<target_id>"
74
+ """
75
+ ws = await ws_connect(ws_url, max_size=50 * 1024 * 1024) # 50 MB max
76
+ client = cls(ws)
77
+ client._recv_task = asyncio.create_task(client._recv_loop())
78
+ return client
79
+
80
+ @classmethod
81
+ async def connect_to_target(
82
+ cls,
83
+ http_url: str,
84
+ target_id: str | None = None,
85
+ ) -> "CDPClient":
86
+ """Connect to a Chrome CDP target discovered via the HTTP /json API.
87
+
88
+ 1. GET /json to list targets (or /json/list)
89
+ 2. Find a page-type target (or the one matching target_id)
90
+ 3. Connect to its webSocketDebuggerUrl
91
+ """
92
+ # Fetch target list
93
+ async with httpx.AsyncClient(timeout=10.0) as http:
94
+ resp = await http.get(f"{http_url}/json")
95
+ if resp.status_code != 200:
96
+ raise CDPError("connect_to_target", resp.status_code, f"HTTP {resp.status_code}")
97
+ targets = resp.json()
98
+
99
+ # Find the target
100
+ target = None
101
+ if target_id:
102
+ # When a specific target_id is requested, search exclusively for it.
103
+ # Don't fall back to "first page target" — that could match an
104
+ # unrelated tab.
105
+ for t in targets:
106
+ if t.get("id") == target_id:
107
+ target = t
108
+ break
109
+ if target is None:
110
+ raise CDPError("connect_to_target", -1,
111
+ f"Target {target_id} not found in CDP target list")
112
+ else:
113
+ for t in targets:
114
+ if t.get("type") == "page":
115
+ target = t
116
+ break
117
+
118
+ if target is None:
119
+ # Create a new tab if none exists
120
+ async with httpx.AsyncClient(timeout=10.0) as http:
121
+ resp = await http.put(f"{http_url}/json/new")
122
+ if resp.status_code in (200, 201):
123
+ target = resp.json()
124
+
125
+ if target is None:
126
+ raise CDPError("connect_to_target", -1, "No CDP page target found and could not create one")
127
+
128
+ ws_url = target.get("webSocketDebuggerUrl")
129
+ if not ws_url:
130
+ raise CDPError("connect_to_target", -1, "Target has no webSocketDebuggerUrl")
131
+
132
+ return await cls.connect(ws_url)
133
+
134
+ async def send(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
135
+ """Send a CDP command and await the response.
136
+
137
+ Args:
138
+ method: CDP method name (e.g. "Page.navigate", "Runtime.evaluate")
139
+ params: Method parameters (default empty dict)
140
+
141
+ Returns:
142
+ The "result" field from the CDP response.
143
+
144
+ Raises:
145
+ CDPError: If the CDP response contains an error.
146
+ """
147
+ if self._closed:
148
+ raise CDPError(method, -1, "CDP client is closed")
149
+
150
+ self._msg_id += 1
151
+ msg_id = self._msg_id
152
+ msg: dict[str, Any] = {"id": msg_id, "method": method}
153
+ if params:
154
+ msg["params"] = params
155
+
156
+ future: asyncio.Future[dict] = asyncio.get_event_loop().create_future()
157
+ self._pending[msg_id] = future
158
+
159
+ try:
160
+ await self._ws.send(json.dumps(msg))
161
+ except Exception as exc:
162
+ self._pending.pop(msg_id, None)
163
+ raise CDPError(method, -1, f"WebSocket send failed: {exc}") from exc
164
+
165
+ try:
166
+ result = await asyncio.wait_for(future, timeout=120.0)
167
+ return result
168
+ except asyncio.TimeoutError:
169
+ self._pending.pop(msg_id, None)
170
+ raise CDPError(method, -1, f"Timeout waiting for response to {method}")
171
+
172
+ async def on_event(self, event_name: str, callback: CDPEventCallback) -> None:
173
+ """Register a callback for a CDP event (e.g. "Page.loadEventFired").
174
+
175
+ Callbacks can be sync or async. Multiple callbacks per event are supported.
176
+ """
177
+ self._event_handlers.setdefault(event_name, []).append(callback)
178
+
179
+ async def _recv_loop(self) -> None:
180
+ """Background task that reads WebSocket messages and dispatches them."""
181
+ try:
182
+ async for raw in self._ws:
183
+ if self._closed:
184
+ break
185
+ try:
186
+ msg = json.loads(raw)
187
+ except json.JSONDecodeError:
188
+ logger.warning("CDP: received non-JSON message: %s", raw[:200])
189
+ continue
190
+
191
+ # Response to a command (has "id")
192
+ if "id" in msg:
193
+ fut = self._pending.pop(msg["id"], None)
194
+ if fut is None:
195
+ logger.warning("CDP: received response for unknown id %s", msg["id"])
196
+ continue
197
+ if "error" in msg:
198
+ err = msg["error"]
199
+ fut.set_exception(CDPError(
200
+ method="<response>",
201
+ code=err.get("code", -1),
202
+ message=err.get("message", "Unknown error"),
203
+ data=err.get("data"),
204
+ ))
205
+ else:
206
+ fut.set_result(msg.get("result", {}))
207
+ continue
208
+
209
+ # Event (has "method", no "id")
210
+ if "method" in msg:
211
+ event_name = msg["method"]
212
+ handlers = self._event_handlers.get(event_name, [])
213
+ for handler in handlers:
214
+ try:
215
+ result = handler(msg.get("params", {}))
216
+ if asyncio.iscoroutine(result):
217
+ asyncio.create_task(result)
218
+ except Exception:
219
+ logger.exception("CDP: event handler for %s raised", event_name)
220
+ except websockets.ConnectionClosed:
221
+ pass
222
+ except Exception:
223
+ logger.exception("CDP: recv loop crashed")
224
+ finally:
225
+ # Mark as closed so send() fast-fails and callers reconnect
226
+ # instead of queuing futures that will never resolve.
227
+ self._closed = True
228
+ # Reject any pending futures
229
+ for fut in list(self._pending.values()):
230
+ if not fut.done():
231
+ fut.set_exception(CDPError("<recv-closed>", -1, "Connection closed"))
232
+ self._pending.clear()
233
+
234
+ async def close(self) -> None:
235
+ """Close the WebSocket connection and stop the recv task."""
236
+ if self._closed:
237
+ return
238
+ self._closed = True
239
+ if self._recv_task and not self._recv_task.done():
240
+ self._recv_task.cancel()
241
+ try:
242
+ await asyncio.wait_for(self._recv_task, timeout=5.0)
243
+ except (asyncio.TimeoutError, asyncio.CancelledError):
244
+ pass
245
+ try:
246
+ await self._ws.close()
247
+ except Exception:
248
+ pass
249
+
250
+ @staticmethod
251
+ async def discover_targets(http_url: str) -> list[dict[str, Any]]:
252
+ """List CDP targets via the HTTP /json endpoint.
253
+
254
+ Returns a list of target dicts with keys: id, type, title, url,
255
+ webSocketDebuggerUrl, etc.
256
+ """
257
+ async with httpx.AsyncClient(timeout=10.0) as http:
258
+ resp = await http.get(f"{http_url}/json")
259
+ if resp.status_code == 200:
260
+ return resp.json()
261
+ return []