ceki-sdk 2.15.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.
ceki_sdk/__init__.py ADDED
@@ -0,0 +1,52 @@
1
+ from ._browser import Browser
2
+ from ._captcha import CaptchaResult
3
+ from ._client import Client
4
+ from ._connect import ConnectOptions, connect
5
+ from ._profile import BrowserProfile
6
+ from ._exceptions import (
7
+ AuthFailed,
8
+ CaptchaError,
9
+ CaptchaTimeoutError,
10
+ CdpUnrecoverable,
11
+ CekiError,
12
+ ConnectionLost,
13
+ InsufficientFunds,
14
+ NotOwner,
15
+ ProviderDisconnected,
16
+ RateLimitExceeded,
17
+ SessionEnded,
18
+ SessionExpired,
19
+ SessionNotFound,
20
+ )
21
+ from ._models import BrowserOption, ChatMessage, Match, ReadReceipt, SessionInfo, Snapshot
22
+ from .humanize import HumanProfile
23
+
24
+ __version__ = "2.15.1"
25
+ __all__ = [
26
+ "connect",
27
+ "ConnectOptions",
28
+ "Client",
29
+ "Browser",
30
+ "BrowserOption",
31
+ "Match",
32
+ "ChatMessage",
33
+ "ReadReceipt",
34
+ "RateLimitExceeded",
35
+ "InsufficientFunds",
36
+ "SessionEnded",
37
+ "CdpUnrecoverable",
38
+ "AuthFailed",
39
+ "ConnectionLost",
40
+ "ProviderDisconnected",
41
+ "SessionNotFound",
42
+ "SessionExpired",
43
+ "NotOwner",
44
+ "SessionInfo",
45
+ "Snapshot",
46
+ "BrowserProfile",
47
+ "CekiError",
48
+ "HumanProfile",
49
+ "CaptchaResult",
50
+ "CaptchaError",
51
+ "CaptchaTimeoutError",
52
+ ]
ceki_sdk/_browser.py ADDED
@@ -0,0 +1,646 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import base64
5
+ import json
6
+ import logging
7
+ import mimetypes
8
+ import os
9
+ from datetime import datetime, timedelta, timezone
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Any, Awaitable, Callable, Coroutine, Literal, cast
12
+
13
+ import httpx
14
+
15
+ from .humanize import HumanProfile, Humanizer
16
+
17
+ if TYPE_CHECKING:
18
+ from ._client import Client
19
+ from ._captcha import CaptchaResult
20
+ from ._exceptions import (
21
+ CaptchaTimeoutError,
22
+ CdpUnrecoverable,
23
+ InsufficientFunds,
24
+ ProviderDisconnected,
25
+ RateLimitExceeded,
26
+ SessionEnded,
27
+ )
28
+ from ._models import Match, Snapshot
29
+
30
+ log = logging.getLogger(__name__)
31
+
32
+ EventCallback = Callable[[str, dict[str, Any]], Awaitable[None]]
33
+ TabOpenedCallback = Callable[[str], Awaitable[None]]
34
+ SimpleCallback = Callable[[], Awaitable[None]]
35
+ UserEventCallback = Callable[[list[dict[str, Any]]], Awaitable[None]]
36
+
37
+ _ERROR_TERMINAL = {-1011, -1012, -1015, -1018}
38
+
39
+
40
+ def _resolve_human(human) -> Humanizer | None:
41
+ if os.environ.get("CEKI_HUMAN_DISABLE", "").lower() in ("1", "true", "yes"):
42
+ return None
43
+ if human is None:
44
+ return None
45
+ if isinstance(human, HumanProfile):
46
+ return Humanizer(human)
47
+ if isinstance(human, dict):
48
+ return Humanizer(HumanProfile.from_dict(human))
49
+ if isinstance(human, (str, Path)):
50
+ s = str(human)
51
+ if s in ("natural", "careful"):
52
+ return Humanizer(HumanProfile.load_preset(s))
53
+ return Humanizer(HumanProfile.load(s))
54
+ raise ValueError(f"Invalid human profile: {human!r}")
55
+
56
+
57
+ class Browser:
58
+ def __init__(self, client: "Client", match: Match, *, human="natural") -> None:
59
+ self._client = client
60
+ self._match = match
61
+ self._cdp_counter = 0
62
+ self._pending_cdp: dict[int, asyncio.Future[Any]] = {}
63
+ self._event_callbacks: list[EventCallback] = []
64
+ self._tab_opened_callbacks: list[TabOpenedCallback] = []
65
+ self._provider_disconnected_callbacks: list[SimpleCallback] = []
66
+ self._provider_reconnected_callbacks: list[SimpleCallback] = []
67
+ self._user_event_callbacks: list[UserEventCallback] = []
68
+ self._ended = asyncio.Event()
69
+ self._ended_reason: str | None = None
70
+
71
+ from ._chat import BrowserChat
72
+ from ._profile import BrowserProfile
73
+
74
+ self.chat = BrowserChat(self)
75
+ self.profile = BrowserProfile(self)
76
+
77
+ env_profile = os.environ.get("CEKI_HUMAN_PROFILE")
78
+ env_path = os.environ.get("CEKI_HUMAN_PROFILE_PATH")
79
+ if human == "natural" and env_profile:
80
+ human = env_profile
81
+ elif human == "natural" and env_path:
82
+ human = env_path
83
+ self._humanizer = _resolve_human(human)
84
+ self._last_pointer: tuple[int, int] | None = None
85
+ self._last_seen_ts: str | None = None
86
+
87
+ @property
88
+ def session_id(self) -> str:
89
+ return self._match.session_id
90
+
91
+ @property
92
+ def browser_id(self) -> int:
93
+ return self._match.schedule_id
94
+
95
+ @property
96
+ def schedule_id(self) -> int:
97
+ return self._match.schedule_id
98
+
99
+ @property
100
+ def chat_topic_id(self) -> str | None:
101
+ return self._match.chat_topic_id
102
+
103
+ @property
104
+ def browser_info(self) -> dict[str, Any]:
105
+ return self._match.browser_info
106
+
107
+ @property
108
+ def provider_user_id(self) -> int | None:
109
+ return self._match.provider_user_id
110
+
111
+ async def send(self, cdp: dict[str, Any], *, timeout: float = 60.0) -> dict[str, Any]:
112
+ if self._ended.is_set():
113
+ raise SessionEnded(self._ended_reason or "ended")
114
+ cdp_id = self._cdp_counter
115
+ self._cdp_counter += 1
116
+ loop = asyncio.get_event_loop()
117
+ fut: asyncio.Future[Any] = loop.create_future()
118
+ self._pending_cdp[cdp_id] = fut
119
+ try:
120
+ await self._client._ws_send(
121
+ {
122
+ "type": "cdp",
123
+ "session_id": self.session_id,
124
+ "id": cdp_id,
125
+ "method": cdp["method"],
126
+ "params": cdp.get("params", {}),
127
+ }
128
+ )
129
+ result = await asyncio.wait_for(asyncio.shield(fut), timeout=timeout)
130
+ return result
131
+ finally:
132
+ self._pending_cdp.pop(cdp_id, None)
133
+
134
+ def on_event(self, callback: EventCallback) -> None:
135
+ self._event_callbacks.append(callback)
136
+
137
+ def on_tab_opened(self, callback: TabOpenedCallback) -> None:
138
+ self._tab_opened_callbacks.append(callback)
139
+
140
+ def on_provider_disconnected(self, callback: SimpleCallback) -> None:
141
+ self._provider_disconnected_callbacks.append(callback)
142
+
143
+ def on_provider_reconnected(self, callback: SimpleCallback) -> None:
144
+ self._provider_reconnected_callbacks.append(callback)
145
+
146
+ def on_user_event(self, callback: UserEventCallback) -> None:
147
+ self._user_event_callbacks.append(callback)
148
+
149
+ async def switch_tab(self) -> None:
150
+ await self._client._ws_send({"type": "switch_tab", "session_id": self.session_id})
151
+
152
+ async def configure(self, *, masking_mode: str | None = None, **kwargs: Any) -> None:
153
+ payload: dict[str, Any] = {"type": "session.configure", "session_id": self.session_id}
154
+ if masking_mode is not None:
155
+ payload["masking_mode"] = masking_mode
156
+ payload.update(kwargs)
157
+ await self._client._ws_send(payload)
158
+
159
+ async def close(self, *, timeout: float = 10.0) -> None:
160
+ if self._ended.is_set():
161
+ return
162
+ try:
163
+ await self._client._ws_send(
164
+ {"type": "session.end", "session_id": self.session_id, "reason": "user_stop"}
165
+ )
166
+ await asyncio.wait_for(self._ended.wait(), timeout=timeout)
167
+ except asyncio.TimeoutError:
168
+ for fut in self._pending_cdp.values():
169
+ if not fut.done():
170
+ fut.cancel()
171
+ self._pending_cdp.clear()
172
+ self._ended.set()
173
+ self._ended_reason = "user_stop"
174
+ finally:
175
+ self._client._active_browsers.pop(self.session_id, None)
176
+
177
+ async def release(self, *, timeout: float = 10.0) -> None:
178
+ """Alias for :meth:`close` — завершить аренду браузера."""
179
+ await self.close(timeout=timeout)
180
+
181
+ async def wait_until_ended(self) -> str:
182
+ await self._ended.wait()
183
+ return self._ended_reason or "unknown"
184
+
185
+ # ──────────────────────────────────────────────────────────────────────────
186
+ # High-level browser actions (with optional human-like timing)
187
+ # ──────────────────────────────────────────────────────────────────────────
188
+
189
+ async def navigate(self, url: str, *, timeout: float = 30.0) -> dict:
190
+ if self._humanizer:
191
+ await self._humanizer.before("navigate")
192
+ result = await self.send({"method": "Page.navigate", "params": {"url": url}}, timeout=timeout)
193
+ if self._humanizer:
194
+ await self._humanizer.after("navigate")
195
+ return result
196
+
197
+ async def click(self, x: int | float, y: int | float) -> None:
198
+ if self._humanizer:
199
+ await self._humanizer.before("click")
200
+ await self.send({"method": "Input.dispatchMouseEvent", "params": {
201
+ "type": "mousePressed", "x": int(x), "y": int(y), "button": "left", "clickCount": 1,
202
+ }})
203
+ await self.send({"method": "Input.dispatchMouseEvent", "params": {
204
+ "type": "mouseReleased", "x": int(x), "y": int(y), "button": "left", "clickCount": 1,
205
+ }})
206
+ self._last_pointer = (int(x), int(y))
207
+ if self._humanizer:
208
+ await self._humanizer.after("click")
209
+
210
+ async def _send_keystroke(self, char: str) -> None:
211
+ from .humanize.keymap import keymap_for_char
212
+ mapping = keymap_for_char(char)
213
+ if mapping is None:
214
+ await self.send({"method": "Input.insertText", "params": {"text": char}})
215
+ log.warning("Non-ASCII char %r: falling back to Input.insertText", char)
216
+ return
217
+ code, key, vk, needs_shift = mapping
218
+ if needs_shift:
219
+ await self.send({"method": "Input.dispatchKeyEvent", "params": {
220
+ "type": "keyDown", "key": "Shift", "code": "ShiftLeft",
221
+ "windowsVirtualKeyCode": 16, "nativeVirtualKeyCode": 16,
222
+ }})
223
+ await self.send({"method": "Input.dispatchKeyEvent", "params": {
224
+ "type": "keyDown", "key": key, "code": code,
225
+ "text": char, "unmodifiedText": char.lower() if needs_shift else char,
226
+ "windowsVirtualKeyCode": vk, "nativeVirtualKeyCode": vk,
227
+ **({"modifiers": 8} if needs_shift else {}),
228
+ }})
229
+ await self.send({"method": "Input.dispatchKeyEvent", "params": {
230
+ "type": "keyUp", "key": key, "code": code,
231
+ "windowsVirtualKeyCode": vk, "nativeVirtualKeyCode": vk,
232
+ **({"modifiers": 8} if needs_shift else {}),
233
+ }})
234
+ if needs_shift:
235
+ await self.send({"method": "Input.dispatchKeyEvent", "params": {
236
+ "type": "keyUp", "key": "Shift", "code": "ShiftLeft",
237
+ "windowsVirtualKeyCode": 16, "nativeVirtualKeyCode": 16,
238
+ }})
239
+
240
+ async def type(self, text: str) -> None:
241
+ if self._humanizer:
242
+ if self._last_pointer is not None:
243
+ await self.click(*self._last_pointer)
244
+ else:
245
+ log.debug("type() called with humanizer but no last_pointer; falling back to plain insertText")
246
+ await self._humanizer.before("type")
247
+ async for char, delay_ms in self._humanizer.humanize_text(text):
248
+ await self._send_keystroke(char)
249
+ if delay_ms > 0:
250
+ await asyncio.sleep(delay_ms / 1000)
251
+ await self._humanizer.after("type")
252
+ else:
253
+ for char in text:
254
+ await self._send_keystroke(char)
255
+
256
+ async def scroll(
257
+ self, x: int = 0, y: int = 0, *, delta_x: int = 0, delta_y: int = -300
258
+ ) -> None:
259
+ if self._humanizer:
260
+ await self._humanizer.before("scroll")
261
+ await self.send({"method": "Input.dispatchMouseEvent", "params": {
262
+ "type": "mouseWheel", "x": x, "y": y, "deltaX": delta_x, "deltaY": delta_y,
263
+ }})
264
+ self._last_pointer = (int(x), int(y))
265
+ if self._humanizer:
266
+ await self._humanizer.after("scroll")
267
+
268
+ async def screenshot(
269
+ self,
270
+ *,
271
+ format: Literal["base64", "png"] = "base64",
272
+ full_page: bool = False,
273
+ ) -> dict | bytes:
274
+ """Take a screenshot.
275
+
276
+ Args:
277
+ format: ``"base64"`` (default) returns CDP-shape dict, ``"png"`` returns raw PNG bytes.
278
+ full_page: If True, capture the entire scrollable page, not just the viewport.
279
+ """
280
+ if format not in ("base64", "png"):
281
+ raise ValueError(f"Unsupported format: {format!r}. Use 'base64' or 'png'.")
282
+ if self._humanizer:
283
+ await self._humanizer.before("screenshot")
284
+
285
+ params: dict[str, Any] = {}
286
+ if full_page:
287
+ metrics = await self.send({"method": "Page.getLayoutMetrics"})
288
+ content = metrics.get("contentSize", {})
289
+ width = int(content.get("width", 0))
290
+ height = int(content.get("height", 0))
291
+ MAX_HEIGHT = 16384
292
+ if height > MAX_HEIGHT:
293
+ log.warning("full_page screenshot height=%d clamped to %d", height, MAX_HEIGHT)
294
+ height = MAX_HEIGHT
295
+ params["captureBeyondViewport"] = True
296
+ params["clip"] = {"x": 0, "y": 0, "width": width, "height": height, "scale": 1}
297
+
298
+ resp = await self.send({"method": "Page.captureScreenshot", "params": params})
299
+ if self._humanizer:
300
+ await self._humanizer.after("screenshot")
301
+ if format == "base64":
302
+ return resp
303
+ import base64 as _b64
304
+ data = resp.get("data", "")
305
+ return _b64.b64decode(data) if data else b""
306
+
307
+ async def snapshot(self) -> Snapshot:
308
+ from datetime import datetime, timezone
309
+ resp = await self.send({"method": "Page.captureScreenshot"})
310
+ screenshot_b64 = resp.get("data", "")
311
+ all_msgs = await self.chat.history(since=self._last_seen_ts)
312
+ if self._last_seen_ts and all_msgs:
313
+ all_msgs = [m for m in all_msgs if m.created_at > self._last_seen_ts]
314
+ if all_msgs:
315
+ self._last_seen_ts = all_msgs[-1].created_at
316
+ return Snapshot(screenshot=screenshot_b64, chat=all_msgs, ts=datetime.now(timezone.utc))
317
+
318
+ async def upload(
319
+ self,
320
+ selector: str,
321
+ source: str | Path | bytes,
322
+ *,
323
+ filename: str | None = None,
324
+ ) -> dict:
325
+ """Upload a file to an ``<input type="file">`` element.
326
+
327
+ Args:
328
+ selector: CSS selector for the file input element.
329
+ source: File path (str/Path) or raw bytes.
330
+ filename: Override the filename (default: basename of path or ``upload.bin``).
331
+
332
+ Returns:
333
+ ``{"ok": True, "filename": "...", "size": N}`` on success.
334
+
335
+ Raises:
336
+ ValueError: If selector matches no element or element is not a file input.
337
+ """
338
+ if isinstance(source, (str, Path)):
339
+ path = Path(source)
340
+ if not path.is_file():
341
+ raise ValueError(f"file not found: {path}")
342
+ data = path.read_bytes()
343
+ if filename is None:
344
+ filename = path.name
345
+ elif isinstance(source, bytes):
346
+ data = source
347
+ if filename is None:
348
+ filename = "upload.bin"
349
+ else:
350
+ raise TypeError(f"source must be str, Path, or bytes, got {type(source).__name__}")
351
+
352
+ mime_type, _ = mimetypes.guess_type(filename)
353
+ if mime_type is None:
354
+ mime_type = "application/octet-stream"
355
+
356
+ b64_data = base64.b64encode(data).decode("ascii")
357
+
358
+ js_selector = json.dumps(selector)
359
+ js_filename = json.dumps(filename)
360
+ js_mimetype = json.dumps(mime_type)
361
+
362
+ js_expr = (
363
+ "(function() {"
364
+ f"var input = document.querySelector({js_selector});"
365
+ "if (!input) return JSON.stringify({error: 'no input matched'});"
366
+ "if (input.type !== 'file') return JSON.stringify({error: 'element is not a file input'});"
367
+ f"var b64 = '{b64_data}';"
368
+ "var bin = atob(b64);"
369
+ "var bytes = new Uint8Array(bin.length);"
370
+ "for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);"
371
+ f"var file = new File([bytes], {js_filename}, {{type: {js_mimetype}}});"
372
+ "var dt = new DataTransfer();"
373
+ "dt.items.add(file);"
374
+ "input.files = dt.files;"
375
+ "input.dispatchEvent(new Event('change', {bubbles: true}));"
376
+ f"return JSON.stringify({{ok: true, filename: {js_filename}, size: bytes.length}});"
377
+ "})()"
378
+ )
379
+
380
+ resp = await self.send(
381
+ {"method": "Runtime.evaluate", "params": {"expression": js_expr, "returnByValue": True}}
382
+ )
383
+
384
+ value = resp.get("result", {}).get("value", "")
385
+ if isinstance(value, str):
386
+ parsed = json.loads(value)
387
+ else:
388
+ parsed = value
389
+
390
+ if "error" in parsed:
391
+ raise ValueError(parsed["error"])
392
+
393
+ return parsed
394
+
395
+ def set_human(self, profile) -> "HumanProfile | None":
396
+ prev = self._humanizer.profile if self._humanizer else None
397
+ self._humanizer = _resolve_human(profile)
398
+ return prev
399
+
400
+ # ──────────────────────────────────────────────────────────────────────────
401
+ # Human action / captcha
402
+ # ──────────────────────────────────────────────────────────────────────────
403
+
404
+ def _api_headers(self) -> dict[str, str]:
405
+ headers: dict[str, str] = {"Authorization": f"Bearer {self._client.api_key}"}
406
+ if self._client._basic_auth:
407
+ creds = base64.b64encode(
408
+ f"{self._client._basic_auth[0]}:{self._client._basic_auth[1]}".encode()
409
+ ).decode()
410
+ headers["X-Basic-Auth"] = f"Basic {creds}"
411
+ return headers
412
+
413
+ async def request_captcha(
414
+ self,
415
+ acceptance_timeout: float = 60,
416
+ completion_timeout: float = 120,
417
+ auto_accept: bool = True,
418
+ ) -> CaptchaResult:
419
+ if acceptance_timeout < 30:
420
+ raise ValueError("acceptance_timeout must be >= 30 seconds")
421
+ if completion_timeout < 30:
422
+ raise ValueError("completion_timeout must be >= 30 seconds")
423
+
424
+ acceptance_timeout = min(acceptance_timeout, 300)
425
+ completion_timeout = min(completion_timeout, 600)
426
+
427
+ child_event_id = await self._create_captcha_event(acceptance_timeout, completion_timeout)
428
+
429
+ queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
430
+ self.chat._action_queues[child_event_id] = queue
431
+
432
+ accepted = False
433
+ completion_deadline = datetime.now(timezone.utc) + timedelta(seconds=completion_timeout)
434
+
435
+ try:
436
+ deadline_accept = asyncio.get_event_loop().time() + acceptance_timeout
437
+ while True:
438
+ remaining = deadline_accept - asyncio.get_event_loop().time()
439
+ if remaining <= 0:
440
+ raise asyncio.TimeoutError()
441
+ action = await asyncio.wait_for(queue.get(), timeout=remaining)
442
+ kind = action.get("kind", "")
443
+ data: dict[str, Any] = action.get("data") or {}
444
+
445
+ if kind == "human_action_accepted":
446
+ accepted = True
447
+ break
448
+ if kind == "human_action_completed":
449
+ return await self._finish_captcha(
450
+ child_event_id, data, auto_accept, solved=True,
451
+ )
452
+ if kind in (
453
+ "human_action_failed",
454
+ "human_action_declined",
455
+ "human_action_withdrew",
456
+ ):
457
+ return CaptchaResult(
458
+ solved=False,
459
+ child_event_id=child_event_id,
460
+ cancel_reason=kind.replace("human_action_", ""),
461
+ browser=self,
462
+ )
463
+
464
+ remaining_completion = (
465
+ completion_deadline - datetime.now(timezone.utc)
466
+ ).total_seconds()
467
+ while True:
468
+ if remaining_completion <= 0:
469
+ raise asyncio.TimeoutError()
470
+ action = await asyncio.wait_for(
471
+ queue.get(), timeout=remaining_completion,
472
+ )
473
+ kind = action.get("kind", "")
474
+ data = action.get("data") or {}
475
+
476
+ if kind == "human_action_completed":
477
+ return await self._finish_captcha(
478
+ child_event_id, data, auto_accept, solved=True,
479
+ )
480
+ if kind in ("human_action_failed", "human_action_withdrew"):
481
+ return CaptchaResult(
482
+ solved=False,
483
+ child_event_id=child_event_id,
484
+ cancel_reason=kind.replace("human_action_", ""),
485
+ browser=self,
486
+ )
487
+ remaining_completion = (
488
+ completion_deadline - datetime.now(timezone.utc)
489
+ ).total_seconds()
490
+
491
+ except asyncio.TimeoutError:
492
+ phase = "completion" if accepted else "acceptance"
493
+ await self._expire_captcha_event(child_event_id)
494
+ raise CaptchaTimeoutError(phase) from None
495
+ finally:
496
+ self.chat._action_queues.pop(child_event_id, None)
497
+
498
+ async def _finish_captcha(
499
+ self,
500
+ child_event_id: int,
501
+ data: dict[str, Any],
502
+ auto_accept: bool,
503
+ *,
504
+ solved: bool,
505
+ ) -> CaptchaResult:
506
+ result = CaptchaResult(
507
+ solved=solved,
508
+ child_event_id=child_event_id,
509
+ proof_message_id=data.get("proof_message_id"),
510
+ correction_id=data.get("correction_id"),
511
+ browser=self,
512
+ )
513
+ if auto_accept and solved and result.correction_id:
514
+ await asyncio.sleep(2)
515
+ await result.accept_work()
516
+ return result
517
+
518
+ async def _create_captcha_event(
519
+ self, acceptance_timeout: float, completion_timeout: float,
520
+ ) -> int:
521
+ body = {
522
+ "acceptance_deadline_at": int(acceptance_timeout),
523
+ "completion_deadline_at": int(completion_timeout),
524
+ }
525
+ async with httpx.AsyncClient() as http:
526
+ resp = await http.post(
527
+ f"{self._client.api_url}/api/agent/sessions/{self._match.event_id}/captcha-request",
528
+ headers={**self._api_headers(), "Content-Type": "application/json"},
529
+ json=body,
530
+ )
531
+ resp.raise_for_status()
532
+ result = resp.json()
533
+ event_id = result.get("id")
534
+ if not event_id:
535
+ raise RuntimeError("captcha request did not return an id")
536
+ return int(event_id)
537
+
538
+ async def _expire_captcha_event(self, child_event_id: int) -> None:
539
+ try:
540
+ async with httpx.AsyncClient() as http:
541
+ await http.patch(
542
+ f"{self._client.api_url}/api/agent/kal/event/{child_event_id}",
543
+ headers={**self._api_headers(), "Content-Type": "application/json"},
544
+ json={"status_id": 777},
545
+ )
546
+ except Exception as exc:
547
+ log.warning("expire captcha event %d failed: %s", child_event_id, exc)
548
+
549
+ # ──────────────────────────────────────────────────────────────────────────
550
+ # Internal dispatch (called from Client._reader_loop)
551
+ # ──────────────────────────────────────────────────────────────────────────
552
+
553
+ async def _on_cdp_response(self, msg: dict[str, Any]) -> None:
554
+ cmd_id = msg.get("id")
555
+ if cmd_id is not None and cmd_id in self._pending_cdp:
556
+ fut = self._pending_cdp.pop(cmd_id)
557
+ if not fut.done():
558
+ if msg.get("ok", True):
559
+ fut.set_result(msg.get("result", {}))
560
+ else:
561
+ err = msg.get("error", {})
562
+ fut.set_exception(Exception(f"CDP error {err}"))
563
+
564
+ async def _on_cdp_event(self, msg: dict[str, Any]) -> None:
565
+ method = msg.get("method", "")
566
+ params = msg.get("params", {})
567
+ for cb in self._event_callbacks:
568
+ asyncio.create_task(cast(Coroutine, cb(method, params)))
569
+
570
+ async def _on_tab_opened(self, msg: dict[str, Any]) -> None:
571
+ url = msg.get("url", "")
572
+ for cb in self._tab_opened_callbacks:
573
+ asyncio.create_task(cast(Coroutine, cb(url)))
574
+
575
+ async def _on_session_ended(self, msg: dict[str, Any]) -> None:
576
+ reason = msg.get("reason", "completed")
577
+ self._ended_reason = reason
578
+ if reason == "provider_disconnected":
579
+ exc: Exception = ProviderDisconnected()
580
+ else:
581
+ exc = SessionEnded(reason)
582
+ for fut in self._pending_cdp.values():
583
+ if not fut.done():
584
+ fut.set_exception(exc)
585
+ self._pending_cdp.clear()
586
+ self._ended.set()
587
+ self._client._active_browsers.pop(self.session_id, None)
588
+
589
+ async def _on_provider_disconnected(self, msg: dict[str, Any]) -> None:
590
+ for cb in self._provider_disconnected_callbacks:
591
+ asyncio.create_task(cast(Coroutine, cb()))
592
+
593
+ async def _on_provider_reconnected(self, msg: dict[str, Any]) -> None:
594
+ for cb in self._provider_reconnected_callbacks:
595
+ asyncio.create_task(cast(Coroutine, cb()))
596
+
597
+ async def _on_user_events(self, msg: dict[str, Any]) -> None:
598
+ events: list[dict[str, Any]] = msg.get("events", [])
599
+ for cb in self._user_event_callbacks:
600
+ asyncio.create_task(cast(Coroutine, cb(events)))
601
+
602
+ async def _on_error(self, msg: dict[str, Any]) -> None:
603
+ code = msg.get("code", 0)
604
+ cmd_id = msg.get("id")
605
+
606
+ if code == -1013:
607
+ exc: Exception = RateLimitExceeded(retry_after=float(msg.get("retry_after", 1.0)))
608
+ if cmd_id is not None and cmd_id in self._pending_cdp:
609
+ fut = self._pending_cdp.pop(cmd_id)
610
+ if not fut.done():
611
+ fut.set_exception(exc)
612
+ return
613
+
614
+ if code == -1050:
615
+ last_err = msg.get("last_error", msg.get("message", "cdp_error"))
616
+ exc = CdpUnrecoverable(last_error=str(last_err))
617
+ if cmd_id is not None and cmd_id in self._pending_cdp:
618
+ fut = self._pending_cdp.pop(cmd_id)
619
+ if not fut.done():
620
+ fut.set_exception(exc)
621
+ return
622
+
623
+ if code == -1011:
624
+ reason = "heartbeat_timeout"
625
+ elif code == -1012:
626
+ reason = "insufficient_funds"
627
+ elif code == -1015:
628
+ reason = "provider_declined"
629
+ elif code == -1018:
630
+ reason = "killed"
631
+ else:
632
+ reason = msg.get("reason") or msg.get("message") or f"error_{code}"
633
+
634
+ self._ended_reason = reason
635
+ terminal_exc: Exception
636
+ if code == -1012:
637
+ terminal_exc = InsufficientFunds()
638
+ else:
639
+ terminal_exc = SessionEnded(reason)
640
+
641
+ for fut in self._pending_cdp.values():
642
+ if not fut.done():
643
+ fut.set_exception(terminal_exc)
644
+ self._pending_cdp.clear()
645
+ self._ended.set()
646
+ self._client._active_browsers.pop(self.session_id, None)