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/__init__.py +234 -0
- scalebrowser/_http.py +180 -0
- scalebrowser/_sync.py +604 -0
- scalebrowser/_version.py +3 -0
- scalebrowser/cdp.py +429 -0
- scalebrowser/client.py +825 -0
- scalebrowser/errors.py +97 -0
- scalebrowser/events.py +52 -0
- scalebrowser/models.py +861 -0
- scalebrowser/models_control.py +128 -0
- scalebrowser/models_identity.py +130 -0
- scalebrowser/models_runs.py +56 -0
- scalebrowser-0.2.0.dist-info/METADATA +170 -0
- scalebrowser-0.2.0.dist-info/RECORD +16 -0
- scalebrowser-0.2.0.dist-info/WHEEL +4 -0
- scalebrowser-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|