scalebrowser 0.2.0__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.
scalebrowser/cdp.py ADDED
@@ -0,0 +1,429 @@
1
+ """Direct-CDP driver (nodriver-style) over websockets — **not** a Playwright /
2
+ Puppeteer control plane (DEC-006 / AC-FP-005).
3
+
4
+ ``start`` returns a ``cdp_ws`` endpoint; we connect with our own minimal CDP
5
+ client: send/await commands by ``id`` over a single multiplexed socket, demux
6
+ events, attach to page targets (``flatten``), and create isolated worlds. We
7
+ deliberately never call ``Runtime.enable`` (a documented detection leak — the
8
+ svebaa ``console.*`` + proxy-chain bypass); ``Runtime.evaluate`` works without it.
9
+
10
+ The ``humanize_*`` helpers route to the daemon trusted-input endpoint
11
+ (``POST /v1/profiles/:id/input``) so real OS/CDP events carry humanization
12
+ primitives (G8).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import json
19
+ from typing import Any, AsyncIterator, Awaitable, Callable, Optional, Union
20
+ from urllib.parse import quote
21
+
22
+ from websockets.asyncio.client import connect as ws_connect
23
+ from websockets.exceptions import ConnectionClosed
24
+
25
+ from ._http import AsyncTransport
26
+ from .errors import CdpError, NetworkError, ScalebrowserError
27
+
28
+ CdpEvent = dict[str, Any]
29
+ """A demuxed CDP event: ``{ "method", "params", "sessionId" }``."""
30
+
31
+ EventCallback = Callable[[CdpEvent], Any]
32
+
33
+ _CLOSED = object()
34
+ """Queue sentinel: the connection died, the event stream is over."""
35
+
36
+
37
+ class _EventIterator:
38
+ """Async iterator over CDP events. Registers its queue **eagerly** (on
39
+ construction) so events emitted before the first ``__anext__`` are not lost.
40
+ """
41
+
42
+ def __init__(self, connection: "_Connection") -> None:
43
+ self._connection = connection
44
+ self._queue: asyncio.Queue[Any] = asyncio.Queue()
45
+ if connection._closed:
46
+ # Connection already gone → the stream is immediately exhausted.
47
+ self._queue.put_nowait(_CLOSED)
48
+ else:
49
+ connection._queues.append(self._queue)
50
+
51
+ def __aiter__(self) -> "_EventIterator":
52
+ return self
53
+
54
+ async def __anext__(self) -> CdpEvent:
55
+ event = await self._queue.get()
56
+ if event is _CLOSED:
57
+ raise StopAsyncIteration
58
+ return event
59
+
60
+ async def aclose(self) -> None:
61
+ if self._queue in self._connection._queues:
62
+ self._connection._queues.remove(self._queue)
63
+
64
+
65
+ class _Connection:
66
+ """Owns the websocket, the request/response id map and the event fan-out.
67
+
68
+ A single connection backs the root session and every attached child session
69
+ (flatten mode multiplexes all sessions over one socket and one id space).
70
+ """
71
+
72
+ def __init__(self, ws: Any) -> None:
73
+ self._ws = ws
74
+ self._next_id = 0
75
+ self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
76
+ self._listeners: dict[str, list[EventCallback]] = {}
77
+ self._queues: list[asyncio.Queue[CdpEvent]] = []
78
+ self._closed = False
79
+ self._reader: asyncio.Task[None] = asyncio.create_task(self._read_loop())
80
+
81
+ def _new_id(self) -> int:
82
+ self._next_id += 1
83
+ return self._next_id
84
+
85
+ async def send(
86
+ self,
87
+ method: str,
88
+ params: Optional[dict[str, Any]] = None,
89
+ session_id: Optional[str] = None,
90
+ ) -> dict[str, Any]:
91
+ if self._closed:
92
+ raise ScalebrowserError("CDP connection is closed.")
93
+ msg_id = self._new_id()
94
+ message: dict[str, Any] = {"id": msg_id, "method": method}
95
+ if params is not None:
96
+ message["params"] = params
97
+ if session_id is not None:
98
+ message["sessionId"] = session_id
99
+ future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
100
+ self._pending[msg_id] = future
101
+ try:
102
+ await self._ws.send(json.dumps(message))
103
+ except ConnectionClosed as exc:
104
+ self._pending.pop(msg_id, None)
105
+ raise NetworkError("CDP connection closed while sending.", exc) from exc
106
+ return await future
107
+
108
+ async def _read_loop(self) -> None:
109
+ try:
110
+ async for raw in self._ws:
111
+ try:
112
+ message = json.loads(raw)
113
+ except (ValueError, TypeError):
114
+ continue
115
+ msg_id = message.get("id")
116
+ if msg_id is not None and msg_id in self._pending:
117
+ future = self._pending.pop(msg_id)
118
+ if future.done():
119
+ continue
120
+ error = message.get("error")
121
+ if error:
122
+ future.set_exception(
123
+ CdpError(error.get("code"), error.get("message", "unknown"), error.get("data"))
124
+ )
125
+ else:
126
+ future.set_result(message.get("result", {}))
127
+ elif "method" in message:
128
+ self._dispatch(message)
129
+ except ConnectionClosed:
130
+ pass
131
+ finally:
132
+ self._fail_pending()
133
+
134
+ def _dispatch(self, message: dict[str, Any]) -> None:
135
+ event: CdpEvent = {
136
+ "method": message["method"],
137
+ "params": message.get("params", {}),
138
+ "sessionId": message.get("sessionId"),
139
+ }
140
+ for callback in (*self._listeners.get(event["method"], ()), *self._listeners.get("*", ())):
141
+ try:
142
+ callback(event)
143
+ except Exception: # noqa: BLE001 — a bad listener must not kill the reader
144
+ pass
145
+ for queue in self._queues:
146
+ queue.put_nowait(event)
147
+
148
+ def _fail_pending(self) -> None:
149
+ self._closed = True
150
+ for future in self._pending.values():
151
+ if not future.done():
152
+ future.set_exception(NetworkError("CDP connection closed."))
153
+ self._pending.clear()
154
+ # End every event stream: a pending ``__anext__`` must resolve with
155
+ # ``StopAsyncIteration`` instead of hanging forever after a disconnect.
156
+ for queue in self._queues:
157
+ queue.put_nowait(_CLOSED)
158
+ self._queues.clear()
159
+
160
+ def on(self, method: str, callback: EventCallback) -> Callable[[], None]:
161
+ self._listeners.setdefault(method, []).append(callback)
162
+
163
+ def unsubscribe() -> None:
164
+ handlers = self._listeners.get(method)
165
+ if handlers and callback in handlers:
166
+ handlers.remove(callback)
167
+
168
+ return unsubscribe
169
+
170
+ def events(self) -> _EventIterator:
171
+ return _EventIterator(self)
172
+
173
+ async def close(self) -> None:
174
+ if self._closed:
175
+ return
176
+ self._closed = True
177
+ self._reader.cancel()
178
+ try:
179
+ await self._ws.close()
180
+ except Exception: # noqa: BLE001
181
+ pass
182
+ self._fail_pending()
183
+
184
+
185
+ class CdpSession:
186
+ """A handle on the CDP connection, optionally bound to a target ``session_id``.
187
+
188
+ The root session (no ``session_id``) drives the browser endpoint; children
189
+ returned by :meth:`attach_to_page` drive a page target.
190
+ """
191
+
192
+ def __init__(
193
+ self,
194
+ connection: _Connection,
195
+ session_id: Optional[str] = None,
196
+ *,
197
+ transport: Optional[AsyncTransport] = None,
198
+ profile_id: Optional[str] = None,
199
+ owns_connection: bool = False,
200
+ ) -> None:
201
+ self._conn = connection
202
+ self.session_id = session_id
203
+ self._transport = transport
204
+ self._profile_id = profile_id
205
+ self._owns = owns_connection
206
+
207
+ # ── raw protocol ──────────────────────────────────────────────────────────
208
+
209
+ async def send(self, method: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]:
210
+ """Send a CDP command and await its result (raises :class:`CdpError`)."""
211
+ return await self._conn.send(method, params, self.session_id)
212
+
213
+ def on(self, method: str, callback: EventCallback) -> Callable[[], None]:
214
+ """Subscribe to a CDP event method (``"*"`` for all). Returns an unsubscribe."""
215
+ return self._conn.on(method, callback)
216
+
217
+ def events(self) -> AsyncIterator[CdpEvent]:
218
+ """Async-iterate every CDP event seen on the connection."""
219
+ return self._conn.events()
220
+
221
+ # ── targets ───────────────────────────────────────────────────────────────
222
+
223
+ async def attach_to_page(
224
+ self,
225
+ target_id: Optional[str] = None,
226
+ *,
227
+ create: bool = False,
228
+ url: Optional[str] = None,
229
+ ) -> "CdpSession":
230
+ """Attach to a page target (flatten) and return a child session bound to it."""
231
+ if create:
232
+ created = await self.send("Target.createTarget", {"url": url or "about:blank"})
233
+ target_id = created["targetId"]
234
+ elif target_id is None:
235
+ targets = await self.send("Target.getTargets", {})
236
+ pages = [t for t in targets.get("targetInfos", []) if t.get("type") == "page"]
237
+ if pages:
238
+ target_id = pages[0]["targetId"]
239
+ else:
240
+ created = await self.send("Target.createTarget", {"url": url or "about:blank"})
241
+ target_id = created["targetId"]
242
+ attached = await self.send("Target.attachToTarget", {"targetId": target_id, "flatten": True})
243
+ return CdpSession(
244
+ self._conn,
245
+ attached["sessionId"],
246
+ transport=self._transport,
247
+ profile_id=self._profile_id,
248
+ )
249
+
250
+ # ── navigation & evaluation ──────────────────────────────────────────────
251
+
252
+ async def navigate(self, url: str, *, wait: bool = True, timeout: float = 30.0) -> dict[str, Any]:
253
+ """Navigate the page to ``url``; optionally wait for ``document.readyState``."""
254
+ result = await self.send("Page.navigate", {"url": url})
255
+ if wait:
256
+ await self._wait_ready(timeout)
257
+ return result
258
+
259
+ async def _wait_ready(self, timeout: float) -> None:
260
+ loop = asyncio.get_running_loop()
261
+ deadline = loop.time() + timeout
262
+ while loop.time() < deadline:
263
+ try:
264
+ state = await self.evaluate("document.readyState")
265
+ except CdpError:
266
+ state = None
267
+ if state in ("interactive", "complete"):
268
+ return
269
+ await asyncio.sleep(0.05)
270
+ raise TimeoutError(f"Timed out after {timeout}s waiting for the page to load.")
271
+
272
+ async def evaluate(
273
+ self,
274
+ expression: str,
275
+ *,
276
+ await_promise: bool = True,
277
+ return_by_value: bool = True,
278
+ isolated: bool = False,
279
+ ) -> Any:
280
+ """Evaluate JS via ``Runtime.evaluate`` (never ``Runtime.enable``)."""
281
+ params: dict[str, Any] = {
282
+ "expression": expression,
283
+ "returnByValue": return_by_value,
284
+ "awaitPromise": await_promise,
285
+ }
286
+ if isolated:
287
+ params["contextId"] = await self.create_isolated_world()
288
+ result = await self.send("Runtime.evaluate", params)
289
+ details = result.get("exceptionDetails")
290
+ if details:
291
+ raise CdpError(None, details.get("text", "evaluation failed"), details)
292
+ remote = result.get("result", {})
293
+ return remote.get("value") if return_by_value else remote
294
+
295
+ async def create_isolated_world(
296
+ self,
297
+ frame_id: Optional[str] = None,
298
+ world_name: str = "__sb",
299
+ ) -> int:
300
+ """Create an isolated world and return its ``executionContextId``."""
301
+ if frame_id is None:
302
+ tree = await self.send("Page.getFrameTree", {})
303
+ frame_id = tree["frameTree"]["frame"]["id"]
304
+ world = await self.send(
305
+ "Page.createIsolatedWorld",
306
+ {"frameId": frame_id, "worldName": world_name},
307
+ )
308
+ return world["executionContextId"]
309
+
310
+ # ── humanized trusted input (G8) ─────────────────────────────────────────
311
+
312
+ async def _input(self, body: dict[str, Any]) -> None:
313
+ if self._transport is None or self._profile_id is None:
314
+ raise ScalebrowserError(
315
+ "Humanized input needs a profile-bound CDP session. "
316
+ "Use client.connect_cdp(result, profile_id=...) or client.launch()."
317
+ )
318
+ await self._transport.post(f"/v1/profiles/{quote(self._profile_id)}/input", body)
319
+
320
+ async def humanize_move(self, x: float, y: float, *, width: float | None = None) -> None:
321
+ # B5: `width` = an element's effective target width; the daemon's Fitts-law
322
+ # approach lands within the element instead of a fixed corner.
323
+ body: dict[str, Any] = {"action": "move", "x": x, "y": y, "humanize": True}
324
+ if width is not None:
325
+ body["width"] = width
326
+ await self._input(body)
327
+
328
+ async def humanize_click(
329
+ self,
330
+ x: float,
331
+ y: float,
332
+ *,
333
+ button: str = "left",
334
+ click_count: int = 1,
335
+ width: float | None = None,
336
+ ) -> None:
337
+ body: dict[str, Any] = {
338
+ "action": "click",
339
+ "x": x,
340
+ "y": y,
341
+ "button": button,
342
+ "click_count": click_count,
343
+ "humanize": True,
344
+ }
345
+ if width is not None:
346
+ body["width"] = width
347
+ await self._input(body)
348
+
349
+ async def humanize_move_to_element(
350
+ self, box: dict[str, float]
351
+ ) -> None:
352
+ """B5: humanized move to the CENTRE of an element box ``{x, y, width, height}``
353
+ (e.g. from ``getBoundingClientRect()``), feeding the width to the daemon's
354
+ Fitts-law approach so the pointer lands within the element."""
355
+ await self.humanize_move(
356
+ box["x"] + box["width"] / 2, box["y"] + box["height"] / 2, width=box["width"]
357
+ )
358
+
359
+ async def humanize_click_element(
360
+ self, box: dict[str, float], *, button: str = "left", click_count: int = 1
361
+ ) -> None:
362
+ """B5: humanized click at the centre of an element box (see
363
+ :meth:`humanize_move_to_element`)."""
364
+ await self.humanize_click(
365
+ box["x"] + box["width"] / 2,
366
+ box["y"] + box["height"] / 2,
367
+ button=button,
368
+ click_count=click_count,
369
+ width=box["width"],
370
+ )
371
+
372
+ async def humanize_type(self, text: str) -> None:
373
+ await self._input({"action": "type", "text": text, "humanize": True})
374
+
375
+ async def humanize_scroll(
376
+ self, x: float, y: float, *, delta_x: float = 0, delta_y: float = 0
377
+ ) -> None:
378
+ await self._input(
379
+ {"action": "scroll", "x": x, "y": y, "delta_x": delta_x, "delta_y": delta_y, "humanize": True}
380
+ )
381
+
382
+ # ── lifecycle ─────────────────────────────────────────────────────────────
383
+
384
+ async def close(self) -> None:
385
+ if self._owns:
386
+ await self._conn.close()
387
+
388
+ async def __aenter__(self) -> "CdpSession":
389
+ return self
390
+
391
+ async def __aexit__(self, *exc: object) -> None:
392
+ await self.close()
393
+
394
+
395
+ async def connect_cdp(
396
+ cdp_ws: str,
397
+ *,
398
+ token: Optional[str] = None,
399
+ transport: Optional[AsyncTransport] = None,
400
+ profile_id: Optional[str] = None,
401
+ attach: bool = True,
402
+ ) -> CdpSession:
403
+ """Open a direct-CDP session against a ``cdp_ws`` endpoint.
404
+
405
+ With ``attach=True`` (default) the returned session is bound to a page target
406
+ so :meth:`CdpSession.navigate` / :meth:`CdpSession.evaluate` work immediately.
407
+ """
408
+ headers: dict[str, str] = {}
409
+ if token:
410
+ headers["Authorization"] = f"Bearer {token}"
411
+ try:
412
+ ws = await ws_connect(cdp_ws, additional_headers=headers, max_size=None)
413
+ except Exception as exc: # noqa: BLE001 — normalize handshake/transport failures
414
+ raise NetworkError(f"Could not connect to CDP endpoint {cdp_ws}.", exc) from exc
415
+
416
+ connection = _Connection(ws)
417
+ root = CdpSession(
418
+ connection, None, transport=transport, profile_id=profile_id, owns_connection=True
419
+ )
420
+ if not attach:
421
+ return root
422
+ try:
423
+ page = await root.attach_to_page()
424
+ except Exception:
425
+ await connection.close()
426
+ raise
427
+ page._owns = True
428
+ root._owns = False
429
+ return page