mirror of
https://github.com/KingOfTheNOPs/CDP-Toolkit
synced 2026-06-29 08:59:48 +00:00
181 lines
7.1 KiB
Python
181 lines
7.1 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import json
|
|
from collections import defaultdict
|
|
from typing import Any, Callable
|
|
|
|
import websockets
|
|
|
|
from .config import DEFAULT_TIMEOUT
|
|
from .log import info
|
|
|
|
|
|
class CDPError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class CDPConnection:
|
|
def __init__(self, ws_url: str, proxy: str | None = None, timeout: float = DEFAULT_TIMEOUT):
|
|
self.ws_url = ws_url
|
|
self.proxy = proxy
|
|
self.timeout = timeout
|
|
self._ws = None
|
|
self._next_id = 0
|
|
self._pending: dict[int, asyncio.Future] = {}
|
|
self._events: dict[str, list[dict]] = defaultdict(list)
|
|
self._event_waiters: dict[str, list[tuple[asyncio.Future, Callable[[dict], bool] | None]]] = defaultdict(list)
|
|
self._subscribers: list[tuple[asyncio.Queue, str | None, str | None]] = []
|
|
self._reader_task: asyncio.Task | None = None
|
|
|
|
async def __aenter__(self) -> "CDPConnection":
|
|
await self.connect()
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
await self.close()
|
|
|
|
async def connect(self) -> None:
|
|
kwargs: dict[str, Any] = {"max_size": None}
|
|
if self.proxy:
|
|
kwargs["proxy"] = self.proxy
|
|
info("opening CDP websocket {}{}", self.ws_url, f" via {self.proxy}" if self.proxy else "")
|
|
try:
|
|
self._ws = await websockets.connect(self.ws_url, **kwargs)
|
|
except ImportError as exc:
|
|
if self.proxy:
|
|
raise CDPError("SOCKS WebSocket support requires python-socks[asyncio]. Reinstall the package with its current dependencies.") from exc
|
|
raise
|
|
except TypeError as exc:
|
|
if self.proxy:
|
|
raise CDPError("Installed websockets package does not support proxy=. Upgrade websockets or omit --socks.") from exc
|
|
raise
|
|
info("CDP websocket connected")
|
|
self._reader_task = asyncio.create_task(self._reader())
|
|
|
|
async def close(self) -> None:
|
|
if self._reader_task:
|
|
self._reader_task.cancel()
|
|
try:
|
|
await self._reader_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
self._reader_task = None
|
|
if self._ws:
|
|
info("closing CDP websocket")
|
|
await self._ws.close()
|
|
self._ws = None
|
|
for future in self._pending.values():
|
|
if not future.done():
|
|
future.cancel()
|
|
self._pending.clear()
|
|
|
|
async def _reader(self) -> None:
|
|
assert self._ws is not None
|
|
async for raw in self._ws:
|
|
msg = json.loads(raw)
|
|
msg_id = msg.get("id")
|
|
if msg_id is not None and msg_id in self._pending:
|
|
future = self._pending.pop(msg_id)
|
|
if msg.get("error"):
|
|
err = msg["error"]
|
|
future.set_exception(CDPError(f"{err.get('message', 'CDP error')} ({err.get('code', 'unknown')})"))
|
|
else:
|
|
future.set_result(msg.get("result", {}))
|
|
continue
|
|
method = msg.get("method")
|
|
if method:
|
|
if method != "Page.screencastFrame":
|
|
self._events[method].append(msg)
|
|
if len(self._events[method]) > 100:
|
|
del self._events[method][:-100]
|
|
waiters = self._event_waiters.get(method, [])
|
|
remaining = []
|
|
for future, predicate in waiters:
|
|
if future.done():
|
|
continue
|
|
params = msg.get("params", {})
|
|
if predicate is None or predicate(params):
|
|
future.set_result(msg)
|
|
else:
|
|
remaining.append((future, predicate))
|
|
self._event_waiters[method] = remaining
|
|
for queue, session_id, subscriber_method in list(self._subscribers):
|
|
if session_id is not None and msg.get("sessionId") != session_id:
|
|
continue
|
|
if subscriber_method is not None and method != subscriber_method:
|
|
continue
|
|
try:
|
|
queue.put_nowait(msg)
|
|
except asyncio.QueueFull:
|
|
try:
|
|
queue.get_nowait()
|
|
except asyncio.QueueEmpty:
|
|
pass
|
|
with contextlib.suppress(asyncio.QueueFull):
|
|
queue.put_nowait(msg)
|
|
|
|
async def send(self, method: str, params: dict | None = None, session_id: str | None = None, timeout: float | None = None) -> dict:
|
|
if not self._ws:
|
|
raise CDPError("CDP connection is not open")
|
|
self._next_id += 1
|
|
msg_id = self._next_id
|
|
payload: dict[str, Any] = {"id": msg_id, "method": method, "params": params or {}}
|
|
if session_id:
|
|
payload["sessionId"] = session_id
|
|
info("CDP -> {}{} id={}", method, f" session={session_id[:8]}" if session_id else "", msg_id)
|
|
loop = asyncio.get_running_loop()
|
|
future = loop.create_future()
|
|
self._pending[msg_id] = future
|
|
await self._ws.send(json.dumps(payload, separators=(",", ":")))
|
|
try:
|
|
result = await asyncio.wait_for(future, timeout or self.timeout)
|
|
info("CDP <- {} id={}", method, msg_id)
|
|
return result
|
|
finally:
|
|
self._pending.pop(msg_id, None)
|
|
|
|
async def send_no_wait(self, method: str, params: dict | None = None, session_id: str | None = None) -> int:
|
|
if not self._ws:
|
|
raise CDPError("CDP connection is not open")
|
|
self._next_id += 1
|
|
msg_id = self._next_id
|
|
payload: dict[str, Any] = {"id": msg_id, "method": method, "params": params or {}}
|
|
if session_id:
|
|
payload["sessionId"] = session_id
|
|
info("CDP -> {}{} id={} no-wait", method, f" session={session_id[:8]}" if session_id else "", msg_id)
|
|
await self._ws.send(json.dumps(payload, separators=(",", ":")))
|
|
return msg_id
|
|
|
|
async def wait_for_event(
|
|
self,
|
|
method: str,
|
|
predicate: Callable[[dict], bool] | None = None,
|
|
timeout: float | None = None,
|
|
) -> dict:
|
|
loop = asyncio.get_running_loop()
|
|
future = loop.create_future()
|
|
waiter = (future, predicate)
|
|
self._event_waiters[method].append(waiter)
|
|
try:
|
|
return await asyncio.wait_for(future, timeout or self.timeout)
|
|
finally:
|
|
self._event_waiters[method] = [
|
|
existing for existing in self._event_waiters[method] if existing[0] is not future
|
|
]
|
|
|
|
def subscribe_events(
|
|
self,
|
|
*,
|
|
session_id: str | None = None,
|
|
method: str | None = None,
|
|
maxsize: int = 1000,
|
|
) -> asyncio.Queue:
|
|
queue: asyncio.Queue = asyncio.Queue(maxsize=maxsize)
|
|
self._subscribers.append((queue, session_id, method))
|
|
return queue
|
|
|
|
def unsubscribe_events(self, queue: asyncio.Queue) -> None:
|
|
self._subscribers = [subscriber for subscriber in self._subscribers if subscriber[0] is not queue]
|