ai-dev-browser 0.5.3__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.
- ai_dev_browser/__init__.py +110 -0
- ai_dev_browser/_cli.py +428 -0
- ai_dev_browser/_version.py +13 -0
- ai_dev_browser/cdp/__init__.py +6 -0
- ai_dev_browser/cdp/accessibility.py +668 -0
- ai_dev_browser/cdp/animation.py +494 -0
- ai_dev_browser/cdp/audits.py +1990 -0
- ai_dev_browser/cdp/autofill.py +292 -0
- ai_dev_browser/cdp/background_service.py +215 -0
- ai_dev_browser/cdp/bluetooth_emulation.py +626 -0
- ai_dev_browser/cdp/browser.py +821 -0
- ai_dev_browser/cdp/cache_storage.py +311 -0
- ai_dev_browser/cdp/cast.py +172 -0
- ai_dev_browser/cdp/console.py +107 -0
- ai_dev_browser/cdp/crash_report_context.py +55 -0
- ai_dev_browser/cdp/css.py +2710 -0
- ai_dev_browser/cdp/debugger.py +1405 -0
- ai_dev_browser/cdp/device_access.py +141 -0
- ai_dev_browser/cdp/device_orientation.py +45 -0
- ai_dev_browser/cdp/dom.py +2257 -0
- ai_dev_browser/cdp/dom_debugger.py +321 -0
- ai_dev_browser/cdp/dom_snapshot.py +876 -0
- ai_dev_browser/cdp/dom_storage.py +222 -0
- ai_dev_browser/cdp/emulation.py +1779 -0
- ai_dev_browser/cdp/event_breakpoints.py +56 -0
- ai_dev_browser/cdp/extensions.py +246 -0
- ai_dev_browser/cdp/fed_cm.py +283 -0
- ai_dev_browser/cdp/fetch.py +507 -0
- ai_dev_browser/cdp/file_system.py +115 -0
- ai_dev_browser/cdp/headless_experimental.py +115 -0
- ai_dev_browser/cdp/heap_profiler.py +401 -0
- ai_dev_browser/cdp/indexed_db.py +528 -0
- ai_dev_browser/cdp/input_.py +701 -0
- ai_dev_browser/cdp/inspector.py +95 -0
- ai_dev_browser/cdp/io.py +101 -0
- ai_dev_browser/cdp/layer_tree.py +464 -0
- ai_dev_browser/cdp/log.py +190 -0
- ai_dev_browser/cdp/media.py +313 -0
- ai_dev_browser/cdp/memory.py +305 -0
- ai_dev_browser/cdp/network.py +5317 -0
- ai_dev_browser/cdp/overlay.py +1468 -0
- ai_dev_browser/cdp/page.py +3970 -0
- ai_dev_browser/cdp/performance.py +124 -0
- ai_dev_browser/cdp/performance_timeline.py +200 -0
- ai_dev_browser/cdp/preload.py +575 -0
- ai_dev_browser/cdp/profiler.py +420 -0
- ai_dev_browser/cdp/pwa.py +278 -0
- ai_dev_browser/cdp/py.typed +0 -0
- ai_dev_browser/cdp/runtime.py +1589 -0
- ai_dev_browser/cdp/schema.py +50 -0
- ai_dev_browser/cdp/security.py +518 -0
- ai_dev_browser/cdp/service_worker.py +401 -0
- ai_dev_browser/cdp/smart_card_emulation.py +891 -0
- ai_dev_browser/cdp/storage.py +1573 -0
- ai_dev_browser/cdp/system_info.py +327 -0
- ai_dev_browser/cdp/target.py +822 -0
- ai_dev_browser/cdp/tethering.py +65 -0
- ai_dev_browser/cdp/tracing.py +377 -0
- ai_dev_browser/cdp/util.py +17 -0
- ai_dev_browser/cdp/web_audio.py +606 -0
- ai_dev_browser/cdp/web_authn.py +598 -0
- ai_dev_browser/cdp/web_mcp.py +219 -0
- ai_dev_browser/core/__init__.py +218 -0
- ai_dev_browser/core/_case.py +38 -0
- ai_dev_browser/core/_cf_template.py +84 -0
- ai_dev_browser/core/_element.py +431 -0
- ai_dev_browser/core/_tab.py +817 -0
- ai_dev_browser/core/_transport.py +362 -0
- ai_dev_browser/core/ax.py +605 -0
- ai_dev_browser/core/browser.py +323 -0
- ai_dev_browser/core/cdp.py +66 -0
- ai_dev_browser/core/chrome.py +253 -0
- ai_dev_browser/core/cloudflare.py +77 -0
- ai_dev_browser/core/config.py +95 -0
- ai_dev_browser/core/connection.py +403 -0
- ai_dev_browser/core/cookies.py +103 -0
- ai_dev_browser/core/dialog.py +165 -0
- ai_dev_browser/core/download.py +38 -0
- ai_dev_browser/core/elements.py +913 -0
- ai_dev_browser/core/human.py +612 -0
- ai_dev_browser/core/login.py +122 -0
- ai_dev_browser/core/mouse.py +140 -0
- ai_dev_browser/core/navigation.py +225 -0
- ai_dev_browser/core/overlays.py +148 -0
- ai_dev_browser/core/page.py +302 -0
- ai_dev_browser/core/port.py +465 -0
- ai_dev_browser/core/process.py +114 -0
- ai_dev_browser/core/snapshot.py +425 -0
- ai_dev_browser/core/storage.py +50 -0
- ai_dev_browser/core/tabs.py +125 -0
- ai_dev_browser/core/text_match.py +217 -0
- ai_dev_browser/core/window.py +84 -0
- ai_dev_browser/pool/__init__.py +34 -0
- ai_dev_browser/pool/job.py +140 -0
- ai_dev_browser/pool/persistence.py +154 -0
- ai_dev_browser/pool/pool.py +873 -0
- ai_dev_browser/pool/worker.py +171 -0
- ai_dev_browser/profile.py +107 -0
- ai_dev_browser/py.typed +0 -0
- ai_dev_browser/tools/__init__.py +9 -0
- ai_dev_browser/tools/_generate.py +209 -0
- ai_dev_browser/tools/browser_list.py +14 -0
- ai_dev_browser/tools/browser_start.py +14 -0
- ai_dev_browser/tools/browser_stop.py +14 -0
- ai_dev_browser/tools/cdp_send.py +14 -0
- ai_dev_browser/tools/click_by_html_id.py +14 -0
- ai_dev_browser/tools/click_by_ref.py +14 -0
- ai_dev_browser/tools/click_by_text.py +14 -0
- ai_dev_browser/tools/click_by_xpath.py +14 -0
- ai_dev_browser/tools/cloudflare_verify.py +14 -0
- ai_dev_browser/tools/cookies_list.py +14 -0
- ai_dev_browser/tools/cookies_load.py +14 -0
- ai_dev_browser/tools/cookies_save.py +14 -0
- ai_dev_browser/tools/dialog_respond.py +14 -0
- ai_dev_browser/tools/download.py +14 -0
- ai_dev_browser/tools/drag_by_ref.py +14 -0
- ai_dev_browser/tools/find_by_html_id.py +14 -0
- ai_dev_browser/tools/find_by_xpath.py +14 -0
- ai_dev_browser/tools/focus_by_ref.py +14 -0
- ai_dev_browser/tools/highlight_by_ref.py +14 -0
- ai_dev_browser/tools/hover_by_ref.py +14 -0
- ai_dev_browser/tools/html_by_ref.py +14 -0
- ai_dev_browser/tools/js_evaluate.py +14 -0
- ai_dev_browser/tools/login_interactive.py +14 -0
- ai_dev_browser/tools/mouse_click.py +14 -0
- ai_dev_browser/tools/mouse_drag.py +14 -0
- ai_dev_browser/tools/mouse_move.py +14 -0
- ai_dev_browser/tools/page_discover.py +14 -0
- ai_dev_browser/tools/page_emulate_focus.py +14 -0
- ai_dev_browser/tools/page_goto.py +14 -0
- ai_dev_browser/tools/page_html.py +14 -0
- ai_dev_browser/tools/page_info.py +14 -0
- ai_dev_browser/tools/page_reload.py +14 -0
- ai_dev_browser/tools/page_screenshot.py +14 -0
- ai_dev_browser/tools/page_scroll.py +14 -0
- ai_dev_browser/tools/page_wait_element.py +14 -0
- ai_dev_browser/tools/page_wait_ready.py +14 -0
- ai_dev_browser/tools/page_wait_url.py +14 -0
- ai_dev_browser/tools/screenshot_by_ref.py +14 -0
- ai_dev_browser/tools/select_by_ref.py +14 -0
- ai_dev_browser/tools/storage_get.py +14 -0
- ai_dev_browser/tools/storage_set.py +14 -0
- ai_dev_browser/tools/tab_close.py +14 -0
- ai_dev_browser/tools/tab_list.py +14 -0
- ai_dev_browser/tools/tab_new.py +14 -0
- ai_dev_browser/tools/tab_switch.py +14 -0
- ai_dev_browser/tools/type_by_ref.py +14 -0
- ai_dev_browser/tools/type_by_text.py +14 -0
- ai_dev_browser/tools/upload_by_ref.py +14 -0
- ai_dev_browser/tools/window_set.py +14 -0
- ai_dev_browser-0.5.3.dist-info/METADATA +177 -0
- ai_dev_browser-0.5.3.dist-info/RECORD +155 -0
- ai_dev_browser-0.5.3.dist-info/WHEEL +5 -0
- ai_dev_browser-0.5.3.dist-info/licenses/LICENSE +25 -0
- ai_dev_browser-0.5.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Async WebSocket transport for Chrome DevTools Protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import collections
|
|
7
|
+
import importlib
|
|
8
|
+
import itertools
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import types
|
|
12
|
+
from asyncio import iscoroutinefunction
|
|
13
|
+
from typing import Any, Callable, Generator
|
|
14
|
+
|
|
15
|
+
import websockets
|
|
16
|
+
import websockets.asyncio.client
|
|
17
|
+
import websockets.exceptions
|
|
18
|
+
|
|
19
|
+
from ai_dev_browser import cdp
|
|
20
|
+
|
|
21
|
+
from ._case import camel_to_snake
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
MAX_SIZE: int = 2**28
|
|
26
|
+
PING_TIMEOUT: int = 900 # 15 minutes
|
|
27
|
+
COMMAND_TIMEOUT: int = 30 # seconds per CDP command
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ProtocolException(Exception):
|
|
31
|
+
"""CDP protocol error."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, *args, method: str = None, params: dict = None):
|
|
34
|
+
self.message = None
|
|
35
|
+
self.code = None
|
|
36
|
+
self.method = method # For retry: original CDP method
|
|
37
|
+
self.params = params # For retry: original CDP params
|
|
38
|
+
self.args = args
|
|
39
|
+
if isinstance(args[0], dict):
|
|
40
|
+
self.message = args[0].get("message")
|
|
41
|
+
self.code = args[0].get("code")
|
|
42
|
+
else:
|
|
43
|
+
self.message = "| ".join(str(x) for x in args)
|
|
44
|
+
|
|
45
|
+
def __str__(self):
|
|
46
|
+
return f"{self.message} [code: {self.code}]" if self.code else f"{self.message}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Transaction(asyncio.Future):
|
|
50
|
+
"""Wraps a CDP generator into a Future that resolves when response arrives.
|
|
51
|
+
|
|
52
|
+
CDP methods return generators that:
|
|
53
|
+
1. yield {"method": ..., "params": ...} (the request)
|
|
54
|
+
2. receive the response dict via .send()
|
|
55
|
+
3. raise StopIteration with parsed result as .value
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, cdp_obj: Generator):
|
|
59
|
+
super().__init__()
|
|
60
|
+
self.__cdp_obj__ = cdp_obj
|
|
61
|
+
self.id: int | None = None
|
|
62
|
+
self.method, *params = next(self.__cdp_obj__).values()
|
|
63
|
+
self.params = params.pop() if params else {}
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def message(self) -> str:
|
|
67
|
+
return json.dumps({"method": self.method, "params": self.params, "id": self.id})
|
|
68
|
+
|
|
69
|
+
def __call__(self, **response): # type: ignore[override]
|
|
70
|
+
"""Process CDP response: feed to generator or set exception."""
|
|
71
|
+
if "error" in response:
|
|
72
|
+
return self.set_exception(ProtocolException(response["error"]))
|
|
73
|
+
try:
|
|
74
|
+
self.__cdp_obj__.send(response.get("result", {})) # type: ignore[arg-type]
|
|
75
|
+
except StopIteration as e:
|
|
76
|
+
self.set_result(e.value)
|
|
77
|
+
except KeyError as e:
|
|
78
|
+
raise KeyError(f"key '{e.args}' not found in response: {response}") from e
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _cdp_get_module(domain: str | types.ModuleType):
|
|
82
|
+
"""Get CDP domain module by name or module reference."""
|
|
83
|
+
if isinstance(domain, types.ModuleType):
|
|
84
|
+
return domain
|
|
85
|
+
if domain == "input":
|
|
86
|
+
domain = "input_"
|
|
87
|
+
mod = getattr(cdp, domain, None)
|
|
88
|
+
if mod:
|
|
89
|
+
return mod
|
|
90
|
+
return importlib.import_module(domain)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class CDPConnection:
|
|
94
|
+
"""Async WebSocket connection to a CDP endpoint.
|
|
95
|
+
|
|
96
|
+
Handles sending CDP commands (generator protocol), receiving responses,
|
|
97
|
+
and dispatching CDP events to registered handlers.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, websocket_url: str):
|
|
101
|
+
self.websocket_url = websocket_url
|
|
102
|
+
self._websocket: websockets.asyncio.client.ClientConnection | None = None
|
|
103
|
+
self._listener_task: asyncio.Task | None = None
|
|
104
|
+
self._counter = itertools.count(0)
|
|
105
|
+
self._pending: dict[int, Transaction] = {}
|
|
106
|
+
self.handlers: dict[type, list[Callable]] = collections.defaultdict(list)
|
|
107
|
+
self.enabled_domains: list = []
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def closed(self) -> bool:
|
|
111
|
+
if not self._websocket:
|
|
112
|
+
return True
|
|
113
|
+
return bool(self._websocket.close_code)
|
|
114
|
+
|
|
115
|
+
async def connect(self):
|
|
116
|
+
"""Open WebSocket and start listener."""
|
|
117
|
+
if self._websocket and not self._websocket.close_code:
|
|
118
|
+
return # already connected
|
|
119
|
+
self._websocket = await websockets.connect(
|
|
120
|
+
self.websocket_url,
|
|
121
|
+
ping_timeout=PING_TIMEOUT,
|
|
122
|
+
max_size=MAX_SIZE,
|
|
123
|
+
)
|
|
124
|
+
self._listener_task = asyncio.ensure_future(self._listener())
|
|
125
|
+
await self._register_handlers()
|
|
126
|
+
|
|
127
|
+
async def disconnect(self):
|
|
128
|
+
"""Close WebSocket and stop listener."""
|
|
129
|
+
if self._listener_task:
|
|
130
|
+
self._listener_task.cancel()
|
|
131
|
+
self._listener_task = None
|
|
132
|
+
if self._websocket:
|
|
133
|
+
self.enabled_domains.clear()
|
|
134
|
+
await self._websocket.close()
|
|
135
|
+
|
|
136
|
+
async def send(
|
|
137
|
+
self,
|
|
138
|
+
cdp_obj: Generator[dict[str, Any], dict[str, Any], Any],
|
|
139
|
+
_is_update: bool = False,
|
|
140
|
+
*,
|
|
141
|
+
timeout: float | None = None,
|
|
142
|
+
) -> Any:
|
|
143
|
+
"""Send a CDP command and await the response.
|
|
144
|
+
|
|
145
|
+
Auto-reconnects if WebSocket is dead. On send failure, reconnects
|
|
146
|
+
and raises (caller can retry with a fresh generator).
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
timeout: Per-call timeout in seconds. None uses COMMAND_TIMEOUT.
|
|
150
|
+
Pass a larger value for commands known to take time
|
|
151
|
+
(e.g. Runtime.evaluate with await_promise on a long fetch).
|
|
152
|
+
"""
|
|
153
|
+
if self.closed:
|
|
154
|
+
await self.connect()
|
|
155
|
+
if not _is_update:
|
|
156
|
+
await self._register_handlers()
|
|
157
|
+
tx = Transaction(cdp_obj)
|
|
158
|
+
tx.id = next(self._counter)
|
|
159
|
+
self._pending[tx.id] = tx
|
|
160
|
+
try:
|
|
161
|
+
await self._websocket.send(tx.message)
|
|
162
|
+
except Exception as e:
|
|
163
|
+
self._pending.pop(tx.id, None)
|
|
164
|
+
# Connection is broken — force reconnect so next call works
|
|
165
|
+
logger.debug("WebSocket send failed, forcing reconnect: %s", e)
|
|
166
|
+
await self._force_reconnect()
|
|
167
|
+
raise ProtocolException(f"WebSocket send failed: {e}")
|
|
168
|
+
effective_timeout = timeout if timeout is not None else COMMAND_TIMEOUT
|
|
169
|
+
try:
|
|
170
|
+
return await asyncio.wait_for(tx, timeout=effective_timeout)
|
|
171
|
+
except asyncio.TimeoutError:
|
|
172
|
+
self._pending.pop(tx.id, None)
|
|
173
|
+
logger.debug("CDP command timed out (%s), forcing reconnect", tx.method)
|
|
174
|
+
await self._force_reconnect()
|
|
175
|
+
raise ProtocolException(
|
|
176
|
+
f"CDP command timed out after {effective_timeout}s: {tx.method}",
|
|
177
|
+
method=tx.method,
|
|
178
|
+
params=tx.params,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
async def send_raw(self, method: str, params: dict) -> Any:
|
|
182
|
+
"""Send a raw CDP command and return the parsed result.
|
|
183
|
+
|
|
184
|
+
Used for retries where the original CDP generator is consumed.
|
|
185
|
+
Reconstructs a fresh generator from the CDP module to parse the
|
|
186
|
+
response with the same typed protocol as normal send().
|
|
187
|
+
"""
|
|
188
|
+
# Reconstruct the CDP generator from method name + params
|
|
189
|
+
# e.g. "Page.captureScreenshot" → cdp.page.capture_screenshot(**params)
|
|
190
|
+
domain_name, cmd_name = method.split(".")
|
|
191
|
+
domain_mod = _cdp_get_module(domain_name.lower())
|
|
192
|
+
cmd_func = getattr(domain_mod, camel_to_snake(cmd_name))
|
|
193
|
+
|
|
194
|
+
snake_params = (
|
|
195
|
+
{camel_to_snake(k): v for k, v in params.items()} if params else {}
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Fail fast with full context if cdp-python rejects a kwarg —
|
|
199
|
+
# masking the TypeError and retrying with cmd_func() (the previous
|
|
200
|
+
# behaviour) hid case-conversion bugs behind misleading "missing
|
|
201
|
+
# required positional argument" errors from the empty retry.
|
|
202
|
+
try:
|
|
203
|
+
cdp_obj = cmd_func(**snake_params)
|
|
204
|
+
except TypeError as e:
|
|
205
|
+
raise TypeError(
|
|
206
|
+
f"CDP {method}: cdp-python rejected kwargs {sorted(snake_params)} — {e}"
|
|
207
|
+
) from e
|
|
208
|
+
|
|
209
|
+
return await self.send(cdp_obj, _is_update=True)
|
|
210
|
+
|
|
211
|
+
async def _force_reconnect(self):
|
|
212
|
+
"""Disconnect and reconnect the WebSocket."""
|
|
213
|
+
try:
|
|
214
|
+
await self.disconnect()
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
await self.connect()
|
|
218
|
+
|
|
219
|
+
def add_handler(
|
|
220
|
+
self,
|
|
221
|
+
event_type: type | types.ModuleType | list[type],
|
|
222
|
+
handler: Callable,
|
|
223
|
+
):
|
|
224
|
+
"""Register an event handler.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
event_type: CDP event class, module (registers all events in module),
|
|
228
|
+
or list of event classes.
|
|
229
|
+
handler: Sync or async callback. Called with (event) or (event, connection).
|
|
230
|
+
"""
|
|
231
|
+
if not isinstance(event_type, list):
|
|
232
|
+
event_type = [event_type] # type: ignore[assignment,list-item]
|
|
233
|
+
for evt in event_type:
|
|
234
|
+
if isinstance(evt, types.ModuleType):
|
|
235
|
+
import inspect
|
|
236
|
+
|
|
237
|
+
for name, obj in inspect.getmembers_static(evt):
|
|
238
|
+
if name.isupper() or not name[0].isupper():
|
|
239
|
+
continue
|
|
240
|
+
if type(obj) is type:
|
|
241
|
+
self.handlers[obj].append(handler)
|
|
242
|
+
else:
|
|
243
|
+
self.handlers[evt].append(handler)
|
|
244
|
+
|
|
245
|
+
# Domains that should never be removed by _register_handlers cleanup.
|
|
246
|
+
# These are either always-on (target, storage, input_) or essential
|
|
247
|
+
# for core operations (page, dom) — enabled by Tab._ensure_connected().
|
|
248
|
+
_PROTECTED_DOMAINS = None # Populated lazily to avoid import-time issues
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def _get_protected_domains(cls):
|
|
252
|
+
if cls._PROTECTED_DOMAINS is None:
|
|
253
|
+
cls._PROTECTED_DOMAINS = {
|
|
254
|
+
cdp.target,
|
|
255
|
+
cdp.storage,
|
|
256
|
+
cdp.input_,
|
|
257
|
+
cdp.page,
|
|
258
|
+
cdp.dom,
|
|
259
|
+
}
|
|
260
|
+
return cls._PROTECTED_DOMAINS
|
|
261
|
+
|
|
262
|
+
async def _register_handlers(self):
|
|
263
|
+
"""Auto-enable CDP domains for registered event handlers.
|
|
264
|
+
|
|
265
|
+
Does NOT remove domains that were explicitly enabled (page, dom, etc.)
|
|
266
|
+
even if they have no event handlers — those are needed for commands.
|
|
267
|
+
"""
|
|
268
|
+
protected = self._get_protected_domains()
|
|
269
|
+
enabled_copy = self.enabled_domains.copy()
|
|
270
|
+
for event_type in list(self.handlers):
|
|
271
|
+
if not self.handlers[event_type]:
|
|
272
|
+
self.handlers.pop(event_type, None)
|
|
273
|
+
continue
|
|
274
|
+
if not isinstance(event_type, type):
|
|
275
|
+
continue
|
|
276
|
+
domain_mod = _cdp_get_module(event_type.__module__.rsplit(".", 1)[-1])
|
|
277
|
+
if domain_mod in self.enabled_domains:
|
|
278
|
+
if domain_mod in enabled_copy:
|
|
279
|
+
enabled_copy.remove(domain_mod)
|
|
280
|
+
continue
|
|
281
|
+
if domain_mod in protected:
|
|
282
|
+
continue
|
|
283
|
+
try:
|
|
284
|
+
self.enabled_domains.append(domain_mod)
|
|
285
|
+
await self.send(domain_mod.enable(), _is_update=True)
|
|
286
|
+
except Exception:
|
|
287
|
+
logger.debug("Failed to enable domain %s", domain_mod, exc_info=True)
|
|
288
|
+
try:
|
|
289
|
+
self.enabled_domains.remove(domain_mod)
|
|
290
|
+
except ValueError:
|
|
291
|
+
pass
|
|
292
|
+
# Remove domains that no longer have handlers, EXCEPT protected ones
|
|
293
|
+
for ed in enabled_copy:
|
|
294
|
+
if ed in protected:
|
|
295
|
+
continue
|
|
296
|
+
try:
|
|
297
|
+
self.enabled_domains.remove(ed)
|
|
298
|
+
except ValueError:
|
|
299
|
+
pass
|
|
300
|
+
|
|
301
|
+
def _cancel_pending(self, reason: str = "connection closed"):
|
|
302
|
+
"""Cancel all pending transactions (e.g., when WebSocket closes)."""
|
|
303
|
+
for tx_id, tx in list(self._pending.items()):
|
|
304
|
+
if not tx.done():
|
|
305
|
+
tx.set_exception(ProtocolException(reason))
|
|
306
|
+
self._pending.clear()
|
|
307
|
+
|
|
308
|
+
async def _listener(self):
|
|
309
|
+
"""Background task: receive messages, dispatch responses and events."""
|
|
310
|
+
try:
|
|
311
|
+
await self._listener_loop()
|
|
312
|
+
finally:
|
|
313
|
+
self._cancel_pending("WebSocket listener stopped")
|
|
314
|
+
|
|
315
|
+
async def _listener_loop(self):
|
|
316
|
+
while True:
|
|
317
|
+
try:
|
|
318
|
+
raw = await asyncio.wait_for(self._websocket.recv(), 0.05)
|
|
319
|
+
except asyncio.TimeoutError:
|
|
320
|
+
await asyncio.sleep(0.05)
|
|
321
|
+
continue
|
|
322
|
+
except websockets.exceptions.ConnectionClosedOK:
|
|
323
|
+
break
|
|
324
|
+
except websockets.exceptions.ConnectionClosed:
|
|
325
|
+
break
|
|
326
|
+
except Exception:
|
|
327
|
+
logger.info("WebSocket recv error", exc_info=True)
|
|
328
|
+
break
|
|
329
|
+
|
|
330
|
+
message = json.loads(raw)
|
|
331
|
+
if "id" in message:
|
|
332
|
+
# Response to a command
|
|
333
|
+
tx = self._pending.pop(message["id"], None)
|
|
334
|
+
if tx:
|
|
335
|
+
tx(**message)
|
|
336
|
+
else:
|
|
337
|
+
# CDP event
|
|
338
|
+
try:
|
|
339
|
+
event = cdp.util.parse_json_event(message)
|
|
340
|
+
except Exception:
|
|
341
|
+
continue
|
|
342
|
+
callbacks = self.handlers.get(type(event))
|
|
343
|
+
if not callbacks:
|
|
344
|
+
continue
|
|
345
|
+
for callback in callbacks:
|
|
346
|
+
try:
|
|
347
|
+
if iscoroutinefunction(callback):
|
|
348
|
+
try:
|
|
349
|
+
asyncio.create_task(callback(event, self))
|
|
350
|
+
except TypeError:
|
|
351
|
+
asyncio.create_task(callback(event))
|
|
352
|
+
else:
|
|
353
|
+
try:
|
|
354
|
+
callback(event, self)
|
|
355
|
+
except TypeError:
|
|
356
|
+
callback(event)
|
|
357
|
+
except Exception:
|
|
358
|
+
logger.warning(
|
|
359
|
+
"Exception in handler for %s",
|
|
360
|
+
type(event).__name__,
|
|
361
|
+
exc_info=True,
|
|
362
|
+
)
|