cdpwave 0.1.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.
- cdpwave/__init__.py +34 -0
- cdpwave/browser/__init__.py +0 -0
- cdpwave/browser/discovery.py +86 -0
- cdpwave/browser/finder.py +130 -0
- cdpwave/browser/launcher.py +170 -0
- cdpwave/client.py +332 -0
- cdpwave/domains/__init__.py +0 -0
- cdpwave/domains/base.py +15 -0
- cdpwave/domains/console.py +14 -0
- cdpwave/domains/dom.py +92 -0
- cdpwave/domains/log.py +26 -0
- cdpwave/domains/network.py +141 -0
- cdpwave/domains/page.py +86 -0
- cdpwave/domains/runtime.py +71 -0
- cdpwave/domains/target.py +40 -0
- cdpwave/events/__init__.py +0 -0
- cdpwave/events/dispatcher.py +44 -0
- cdpwave/events/handlers.py +37 -0
- cdpwave/exceptions.py +40 -0
- cdpwave/session/__init__.py +0 -0
- cdpwave/session/manager.py +32 -0
- cdpwave/transport/__init__.py +0 -0
- cdpwave/transport/connection.py +142 -0
- cdpwave/transport/correlation.py +37 -0
- cdpwave/transport/serializer.py +33 -0
- cdpwave/types.py +8 -0
- cdpwave-0.1.0.dist-info/METADATA +94 -0
- cdpwave-0.1.0.dist-info/RECORD +30 -0
- cdpwave-0.1.0.dist-info/WHEEL +4 -0
- cdpwave-0.1.0.dist-info/licenses/LICENSE +21 -0
cdpwave/domains/page.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from cdpwave.domains.base import BaseDomain
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PageDomain(BaseDomain):
|
|
7
|
+
async def enable(self) -> dict[str, Any]:
|
|
8
|
+
return await self._call("Page.enable")
|
|
9
|
+
|
|
10
|
+
async def disable(self) -> dict[str, Any]:
|
|
11
|
+
return await self._call("Page.disable")
|
|
12
|
+
|
|
13
|
+
async def navigate(
|
|
14
|
+
self,
|
|
15
|
+
url: str,
|
|
16
|
+
referrer: str | None = None,
|
|
17
|
+
transition_type: str | None = None,
|
|
18
|
+
) -> dict[str, Any]:
|
|
19
|
+
params: dict[str, Any] = {"url": url}
|
|
20
|
+
if referrer is not None:
|
|
21
|
+
params["referrer"] = referrer
|
|
22
|
+
if transition_type is not None:
|
|
23
|
+
params["transitionType"] = transition_type
|
|
24
|
+
return await self._call("Page.navigate", params)
|
|
25
|
+
|
|
26
|
+
async def reload(self, ignore_cache: bool = False) -> dict[str, Any]:
|
|
27
|
+
return await self._call(
|
|
28
|
+
"Page.reload",
|
|
29
|
+
{"ignoreCache": ignore_cache},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
async def stop(self) -> dict[str, Any]:
|
|
33
|
+
return await self._call("Page.stop")
|
|
34
|
+
|
|
35
|
+
async def capture_screenshot(
|
|
36
|
+
self,
|
|
37
|
+
format: str = "png",
|
|
38
|
+
quality: int = 80,
|
|
39
|
+
clip: dict[str, Any] | None = None,
|
|
40
|
+
from_surface: bool = True,
|
|
41
|
+
capture_beyond_viewport: bool = False,
|
|
42
|
+
) -> dict[str, Any]:
|
|
43
|
+
params: dict[str, Any] = {
|
|
44
|
+
"format": format,
|
|
45
|
+
"quality": quality,
|
|
46
|
+
"fromSurface": from_surface,
|
|
47
|
+
"captureBeyondViewport": capture_beyond_viewport,
|
|
48
|
+
}
|
|
49
|
+
if clip is not None:
|
|
50
|
+
params["clip"] = clip
|
|
51
|
+
return await self._call("Page.captureScreenshot", params)
|
|
52
|
+
|
|
53
|
+
async def print_to_pdf(
|
|
54
|
+
self,
|
|
55
|
+
landscape: bool = False,
|
|
56
|
+
display_header_footer: bool = False,
|
|
57
|
+
print_background: bool = False,
|
|
58
|
+
scale: float = 1.0,
|
|
59
|
+
paper_width: float = 8.5,
|
|
60
|
+
paper_height: float = 11.0,
|
|
61
|
+
margin_top: float = 0.4,
|
|
62
|
+
margin_bottom: float = 0.4,
|
|
63
|
+
margin_left: float = 0.4,
|
|
64
|
+
margin_right: float = 0.4,
|
|
65
|
+
) -> dict[str, Any]:
|
|
66
|
+
return await self._call(
|
|
67
|
+
"Page.printToPDF",
|
|
68
|
+
{
|
|
69
|
+
"landscape": landscape,
|
|
70
|
+
"displayHeaderFooter": display_header_footer,
|
|
71
|
+
"printBackground": print_background,
|
|
72
|
+
"scale": scale,
|
|
73
|
+
"paperWidth": paper_width,
|
|
74
|
+
"paperHeight": paper_height,
|
|
75
|
+
"marginTop": margin_top,
|
|
76
|
+
"marginBottom": margin_bottom,
|
|
77
|
+
"marginLeft": margin_left,
|
|
78
|
+
"marginRight": margin_right,
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
async def get_layout_metrics(self) -> dict[str, Any]:
|
|
83
|
+
return await self._call("Page.getLayoutMetrics")
|
|
84
|
+
|
|
85
|
+
async def get_navigation_history(self) -> dict[str, Any]:
|
|
86
|
+
return await self._call("Page.getNavigationHistory")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from cdpwave.domains.base import BaseDomain
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RuntimeDomain(BaseDomain):
|
|
7
|
+
async def enable(self) -> dict[str, Any]:
|
|
8
|
+
return await self._call("Runtime.enable")
|
|
9
|
+
|
|
10
|
+
async def disable(self) -> dict[str, Any]:
|
|
11
|
+
return await self._call("Runtime.disable")
|
|
12
|
+
|
|
13
|
+
async def evaluate(
|
|
14
|
+
self,
|
|
15
|
+
expression: str,
|
|
16
|
+
return_by_value: bool = True,
|
|
17
|
+
await_promise: bool = False,
|
|
18
|
+
user_gesture: bool = False,
|
|
19
|
+
) -> dict[str, Any]:
|
|
20
|
+
return await self._call(
|
|
21
|
+
"Runtime.evaluate",
|
|
22
|
+
{
|
|
23
|
+
"expression": expression,
|
|
24
|
+
"returnByValue": return_by_value,
|
|
25
|
+
"awaitPromise": await_promise,
|
|
26
|
+
"userGesture": user_gesture,
|
|
27
|
+
},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
async def call_function_on(
|
|
31
|
+
self,
|
|
32
|
+
object_id: str,
|
|
33
|
+
function_declaration: str,
|
|
34
|
+
args: list[dict[str, Any]] | None = None,
|
|
35
|
+
return_by_value: bool = True,
|
|
36
|
+
await_promise: bool = False,
|
|
37
|
+
) -> dict[str, Any]:
|
|
38
|
+
params: dict[str, Any] = {
|
|
39
|
+
"objectId": object_id,
|
|
40
|
+
"functionDeclaration": function_declaration,
|
|
41
|
+
"returnByValue": return_by_value,
|
|
42
|
+
"awaitPromise": await_promise,
|
|
43
|
+
}
|
|
44
|
+
if args is not None:
|
|
45
|
+
params["arguments"] = args
|
|
46
|
+
return await self._call("Runtime.callFunctionOn", params)
|
|
47
|
+
|
|
48
|
+
async def release_object(self, object_id: str) -> dict[str, Any]:
|
|
49
|
+
return await self._call(
|
|
50
|
+
"Runtime.releaseObject",
|
|
51
|
+
{"objectId": object_id},
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
async def release_object_group(self, object_group: str) -> dict[str, Any]:
|
|
55
|
+
return await self._call(
|
|
56
|
+
"Runtime.releaseObjectGroup",
|
|
57
|
+
{"objectGroup": object_group},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
async def get_properties(
|
|
61
|
+
self,
|
|
62
|
+
object_id: str,
|
|
63
|
+
own_properties: bool = True,
|
|
64
|
+
) -> dict[str, Any]:
|
|
65
|
+
return await self._call(
|
|
66
|
+
"Runtime.getProperties",
|
|
67
|
+
{
|
|
68
|
+
"objectId": object_id,
|
|
69
|
+
"ownProperties": own_properties,
|
|
70
|
+
},
|
|
71
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from cdpwave.domains.base import BaseDomain
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TargetDomain(BaseDomain):
|
|
7
|
+
async def create_target(self, url: str) -> dict[str, Any]:
|
|
8
|
+
return await self._call("Target.createTarget", {"url": url})
|
|
9
|
+
|
|
10
|
+
async def attach_to_target(
|
|
11
|
+
self,
|
|
12
|
+
target_id: str,
|
|
13
|
+
flatten: bool = True,
|
|
14
|
+
) -> dict[str, Any]:
|
|
15
|
+
return await self._call(
|
|
16
|
+
"Target.attachToTarget",
|
|
17
|
+
{"targetId": target_id, "flatten": flatten},
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
async def detach_from_target(self, session_id: str) -> dict[str, Any]:
|
|
21
|
+
return await self._call(
|
|
22
|
+
"Target.detachFromTarget",
|
|
23
|
+
{"sessionId": session_id},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
async def close_target(self, target_id: str) -> dict[str, Any]:
|
|
27
|
+
return await self._call("Target.closeTarget", {"targetId": target_id})
|
|
28
|
+
|
|
29
|
+
async def get_targets(self) -> dict[str, Any]:
|
|
30
|
+
return await self._call("Target.getTargets")
|
|
31
|
+
|
|
32
|
+
async def set_auto_attach(
|
|
33
|
+
self,
|
|
34
|
+
auto_attach: bool,
|
|
35
|
+
flatten: bool = True,
|
|
36
|
+
) -> dict[str, Any]:
|
|
37
|
+
return await self._call(
|
|
38
|
+
"Target.setAutoAttach",
|
|
39
|
+
{"autoAttach": auto_attach, "flatten": flatten},
|
|
40
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from cdpwave.events.handlers import EventHandler, Subscription
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger("cdpwave.events")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EventDispatcher:
|
|
10
|
+
def __init__(self) -> None:
|
|
11
|
+
self._handlers: dict[str, list[EventHandler]] = {}
|
|
12
|
+
|
|
13
|
+
def on(self, event_name: str, handler: EventHandler) -> Subscription:
|
|
14
|
+
if event_name not in self._handlers:
|
|
15
|
+
self._handlers[event_name] = []
|
|
16
|
+
self._handlers[event_name].append(handler)
|
|
17
|
+
return Subscription(self, event_name, handler)
|
|
18
|
+
|
|
19
|
+
def off(self, event_name: str, handler: EventHandler) -> None:
|
|
20
|
+
handlers = self._handlers.get(event_name)
|
|
21
|
+
if handlers is None:
|
|
22
|
+
return
|
|
23
|
+
if handler in handlers:
|
|
24
|
+
handlers.remove(handler)
|
|
25
|
+
if not handlers:
|
|
26
|
+
del self._handlers[event_name]
|
|
27
|
+
|
|
28
|
+
async def dispatch(self, event_name: str, params: dict[str, Any]) -> None:
|
|
29
|
+
handlers = list(self._handlers.get(event_name, []))
|
|
30
|
+
for handler in handlers:
|
|
31
|
+
try:
|
|
32
|
+
await handler(params)
|
|
33
|
+
except Exception:
|
|
34
|
+
logger.exception(
|
|
35
|
+
"Handler error for event %s",
|
|
36
|
+
event_name,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def handler_count(self) -> int:
|
|
41
|
+
return sum(len(h) for h in self._handlers.values())
|
|
42
|
+
|
|
43
|
+
def clear(self) -> None:
|
|
44
|
+
self._handlers.clear()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import weakref
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from cdpwave.events.dispatcher import EventDispatcher
|
|
9
|
+
|
|
10
|
+
EventHandler = Callable[[dict[str, Any]], Awaitable[None]]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Subscription:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
dispatcher: EventDispatcher,
|
|
17
|
+
event_name: str,
|
|
18
|
+
handler: EventHandler,
|
|
19
|
+
) -> None:
|
|
20
|
+
self._dispatcher_ref: weakref.ReferenceType[EventDispatcher] = (
|
|
21
|
+
weakref.ref(dispatcher)
|
|
22
|
+
)
|
|
23
|
+
self._event_name = event_name
|
|
24
|
+
self._handler = handler
|
|
25
|
+
|
|
26
|
+
def unsubscribe(self) -> None:
|
|
27
|
+
dispatcher = self._dispatcher_ref()
|
|
28
|
+
if dispatcher is not None:
|
|
29
|
+
dispatcher.off(self._event_name, self._handler)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def event_name(self) -> str:
|
|
33
|
+
return self._event_name
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def handler(self) -> EventHandler:
|
|
37
|
+
return self._handler
|
cdpwave/exceptions.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
class CDPError(Exception):
|
|
2
|
+
"""Base exception for all cdpwave errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConnectionClosedError(CDPError):
|
|
6
|
+
"""WebSocket connection was closed."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CommandError(CDPError):
|
|
10
|
+
"""CDP command returned an error response."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, code: int, message: str, data: dict[str, object] | None = None) -> None:
|
|
13
|
+
self.code = code
|
|
14
|
+
self.message = message
|
|
15
|
+
self.data = data
|
|
16
|
+
super().__init__(f"[{code}] {message}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CommandTimeoutError(CDPError):
|
|
20
|
+
"""CDP command did not respond within timeout."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BrowserNotFoundError(CDPError):
|
|
24
|
+
"""No Chromium-based browser found on the system."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SessionClosedError(CDPError):
|
|
28
|
+
"""CDP session was closed by the browser."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DiscoveryError(CDPError):
|
|
32
|
+
"""HTTP discovery endpoint request failed."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LaunchTimeoutError(CDPError):
|
|
36
|
+
"""Browser did not start within the specified timeout."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class LaunchError(CDPError):
|
|
40
|
+
"""Browser crashed or failed during startup."""
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from cdpwave.transport.connection import Connection
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SessionManager:
|
|
5
|
+
def __init__(self, connection: Connection) -> None:
|
|
6
|
+
self._connection = connection
|
|
7
|
+
|
|
8
|
+
async def create_target(self, url: str = "about:blank") -> str:
|
|
9
|
+
result = await self._connection.send_command(
|
|
10
|
+
"Target.createTarget",
|
|
11
|
+
{"url": url},
|
|
12
|
+
)
|
|
13
|
+
return str(result["targetId"])
|
|
14
|
+
|
|
15
|
+
async def attach_to_target(self, target_id: str) -> str:
|
|
16
|
+
result = await self._connection.send_command(
|
|
17
|
+
"Target.attachToTarget",
|
|
18
|
+
{"targetId": target_id, "flatten": True},
|
|
19
|
+
)
|
|
20
|
+
return str(result["sessionId"])
|
|
21
|
+
|
|
22
|
+
async def detach_session(self, session_id: str) -> None:
|
|
23
|
+
await self._connection.send_command(
|
|
24
|
+
"Target.detachFromTarget",
|
|
25
|
+
{"sessionId": session_id},
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
async def close_target(self, target_id: str) -> None:
|
|
29
|
+
await self._connection.send_command(
|
|
30
|
+
"Target.closeTarget",
|
|
31
|
+
{"targetId": target_id},
|
|
32
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import websockets
|
|
8
|
+
from websockets.asyncio.client import ClientConnection
|
|
9
|
+
|
|
10
|
+
from cdpwave.exceptions import CommandError, CommandTimeoutError, ConnectionClosedError
|
|
11
|
+
from cdpwave.transport.correlation import Correlator
|
|
12
|
+
from cdpwave.transport.serializer import (
|
|
13
|
+
deserialize_message,
|
|
14
|
+
is_error,
|
|
15
|
+
is_event,
|
|
16
|
+
is_response,
|
|
17
|
+
serialize_command,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("cdpwave.transport")
|
|
21
|
+
|
|
22
|
+
EventCallback = Callable[
|
|
23
|
+
[str, dict[str, Any], str | None],
|
|
24
|
+
Awaitable[None],
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Connection:
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
url: str,
|
|
32
|
+
event_callback: EventCallback | None = None,
|
|
33
|
+
default_timeout: float = 30.0,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._url = url
|
|
36
|
+
self._correlator = Correlator()
|
|
37
|
+
self._ws: ClientConnection | None = None
|
|
38
|
+
self._receive_task: asyncio.Task[None] | None = None
|
|
39
|
+
self._closed = False
|
|
40
|
+
self._event_callback = event_callback
|
|
41
|
+
self._default_timeout = default_timeout
|
|
42
|
+
|
|
43
|
+
async def connect(self) -> None:
|
|
44
|
+
self._ws = await websockets.connect(self._url)
|
|
45
|
+
self._receive_task = asyncio.create_task(self._receive_loop())
|
|
46
|
+
logger.info("Connected to %s", self._url)
|
|
47
|
+
|
|
48
|
+
async def send_command(
|
|
49
|
+
self,
|
|
50
|
+
method: str,
|
|
51
|
+
params: dict[str, Any] | None = None,
|
|
52
|
+
session_id: str | None = None,
|
|
53
|
+
timeout: float | None = None,
|
|
54
|
+
) -> dict[str, Any]:
|
|
55
|
+
if self._ws is None or self._closed:
|
|
56
|
+
raise ConnectionClosedError("Connection is closed")
|
|
57
|
+
|
|
58
|
+
effective_timeout = self._default_timeout if timeout is None else timeout
|
|
59
|
+
|
|
60
|
+
cmd_id = self._correlator.next_id()
|
|
61
|
+
future = self._correlator.register(cmd_id)
|
|
62
|
+
|
|
63
|
+
message = serialize_command(cmd_id, method, params, session_id)
|
|
64
|
+
await self._ws.send(message)
|
|
65
|
+
logger.debug("→ [%d] %s", cmd_id, method)
|
|
66
|
+
|
|
67
|
+
if effective_timeout <= 0:
|
|
68
|
+
return await future
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
return await asyncio.wait_for(future, timeout=effective_timeout)
|
|
72
|
+
except TimeoutError:
|
|
73
|
+
self._correlator.reject(
|
|
74
|
+
cmd_id,
|
|
75
|
+
CommandError(-1, f"Command timeout: {method}"),
|
|
76
|
+
)
|
|
77
|
+
raise CommandTimeoutError(f"Command timeout: {method}") from None
|
|
78
|
+
|
|
79
|
+
async def close(self) -> None:
|
|
80
|
+
if self._closed:
|
|
81
|
+
return
|
|
82
|
+
self._closed = True
|
|
83
|
+
|
|
84
|
+
if self._receive_task is not None:
|
|
85
|
+
self._receive_task.cancel()
|
|
86
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
87
|
+
await self._receive_task
|
|
88
|
+
|
|
89
|
+
if self._ws is not None:
|
|
90
|
+
with contextlib.suppress(Exception):
|
|
91
|
+
await self._ws.close()
|
|
92
|
+
|
|
93
|
+
self._correlator.reject_all(ConnectionClosedError("Connection closed"))
|
|
94
|
+
logger.info("Connection closed")
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def url(self) -> str:
|
|
98
|
+
return self._url
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def is_closed(self) -> bool:
|
|
102
|
+
return self._closed
|
|
103
|
+
|
|
104
|
+
async def _receive_loop(self) -> None:
|
|
105
|
+
assert self._ws is not None
|
|
106
|
+
try:
|
|
107
|
+
async for raw in self._ws:
|
|
108
|
+
data = deserialize_message(str(raw))
|
|
109
|
+
|
|
110
|
+
if is_response(data):
|
|
111
|
+
cmd_id = data["id"]
|
|
112
|
+
if is_error(data):
|
|
113
|
+
error = data.get("error", {})
|
|
114
|
+
self._correlator.reject(
|
|
115
|
+
cmd_id,
|
|
116
|
+
CommandError(
|
|
117
|
+
code=int(error.get("code", -1)),
|
|
118
|
+
message=str(error.get("message", "Unknown error")),
|
|
119
|
+
data=error.get("data"),
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
logger.debug("← [%d] error: %s", cmd_id, error.get("message"))
|
|
123
|
+
else:
|
|
124
|
+
result = data.get("result", {})
|
|
125
|
+
self._correlator.resolve(cmd_id, result)
|
|
126
|
+
logger.debug("← [%d] success", cmd_id)
|
|
127
|
+
elif is_event(data):
|
|
128
|
+
method = data.get("method", "unknown")
|
|
129
|
+
params = data.get("params", {})
|
|
130
|
+
session = data.get("sessionId")
|
|
131
|
+
if self._event_callback is not None:
|
|
132
|
+
await self._event_callback(method, params, session)
|
|
133
|
+
else:
|
|
134
|
+
logger.debug("← event: %s (session=%s)", method, session)
|
|
135
|
+
except websockets.ConnectionClosedOK:
|
|
136
|
+
logger.info("WebSocket closed normally")
|
|
137
|
+
except websockets.ConnectionClosed:
|
|
138
|
+
logger.info("WebSocket closed by remote")
|
|
139
|
+
finally:
|
|
140
|
+
if not self._closed:
|
|
141
|
+
self._closed = True
|
|
142
|
+
self._correlator.reject_all(ConnectionClosedError("WebSocket closed"))
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Correlator:
|
|
6
|
+
def __init__(self) -> None:
|
|
7
|
+
self._next_id: int = 0
|
|
8
|
+
self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
9
|
+
|
|
10
|
+
def next_id(self) -> int:
|
|
11
|
+
self._next_id += 1
|
|
12
|
+
return self._next_id
|
|
13
|
+
|
|
14
|
+
def register(self, cmd_id: int) -> asyncio.Future[dict[str, Any]]:
|
|
15
|
+
fut: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
|
|
16
|
+
self._pending[cmd_id] = fut
|
|
17
|
+
return fut
|
|
18
|
+
|
|
19
|
+
def resolve(self, cmd_id: int, result: dict[str, Any]) -> None:
|
|
20
|
+
fut = self._pending.pop(cmd_id, None)
|
|
21
|
+
if fut is not None and not fut.done():
|
|
22
|
+
fut.set_result(result)
|
|
23
|
+
|
|
24
|
+
def reject(self, cmd_id: int, error: Exception) -> None:
|
|
25
|
+
fut = self._pending.pop(cmd_id, None)
|
|
26
|
+
if fut is not None and not fut.done():
|
|
27
|
+
fut.set_exception(error)
|
|
28
|
+
|
|
29
|
+
def reject_all(self, error: Exception) -> None:
|
|
30
|
+
for fut in self._pending.values():
|
|
31
|
+
if not fut.done():
|
|
32
|
+
fut.set_exception(error)
|
|
33
|
+
self._pending.clear()
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def pending_count(self) -> int:
|
|
37
|
+
return len(self._pending)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def serialize_command(
|
|
6
|
+
cmd_id: int,
|
|
7
|
+
method: str,
|
|
8
|
+
params: dict[str, Any] | None = None,
|
|
9
|
+
session_id: str | None = None,
|
|
10
|
+
) -> str:
|
|
11
|
+
message: dict[str, Any] = {"id": cmd_id, "method": method}
|
|
12
|
+
if params is not None:
|
|
13
|
+
message["params"] = params
|
|
14
|
+
if session_id is not None:
|
|
15
|
+
message["sessionId"] = session_id
|
|
16
|
+
return json.dumps(message)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def deserialize_message(raw: str) -> dict[str, Any]:
|
|
20
|
+
result: dict[str, Any] = json.loads(raw)
|
|
21
|
+
return result
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_response(msg: dict[str, Any]) -> bool:
|
|
25
|
+
return "id" in msg
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_event(msg: dict[str, Any]) -> bool:
|
|
29
|
+
return "method" in msg and "id" not in msg
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_error(msg: dict[str, Any]) -> bool:
|
|
33
|
+
return "error" in msg
|
cdpwave/types.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
|
+
from typing import Any, Literal
|
|
3
|
+
|
|
4
|
+
CommandSender = Callable[[str, dict[str, Any] | None], Awaitable[dict[str, Any]]]
|
|
5
|
+
|
|
6
|
+
EventHandler = Callable[[dict[str, Any]], Awaitable[None]]
|
|
7
|
+
|
|
8
|
+
BrowserType = Literal["chrome", "edge", "brave", "chromium"]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cdpwave
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Chrome DevTools Protocol for Python — direct CDP over WebSocket, no Selenium, no Playwright
|
|
5
|
+
Project-URL: Homepage, https://github.com/MathiasPaulenko/cdpwave
|
|
6
|
+
Project-URL: Repository, https://github.com/MathiasPaulenko/cdpwave
|
|
7
|
+
Project-URL: Issues, https://github.com/MathiasPaulenko/cdpwave/issues
|
|
8
|
+
Author-email: Mathias Paulenko <mathias.paulenko@outlook.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: async,automation,browser,cdp,chrome,chromium,devtools
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
|
|
20
|
+
Classifier: Topic :: Software Development :: Testing
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: pydantic>=2.5
|
|
23
|
+
Requires-Dist: websockets>=12.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
26
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
31
|
+
Provides-Extra: docs
|
|
32
|
+
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
|
|
33
|
+
Requires-Dist: mkdocs>=1.6; extra == 'docs'
|
|
34
|
+
Requires-Dist: mkdocstrings[python]>=0.24; extra == 'docs'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# cdpwave
|
|
38
|
+
|
|
39
|
+
[](https://github.com/MathiasPaulenko/cdpwave/actions/workflows/ci.yml)
|
|
40
|
+
[](https://github.com/MathiasPaulenko/cdpwave/actions/workflows/test.yml)
|
|
41
|
+
[](https://pypi.org/project/cdpwave/)
|
|
42
|
+
[](https://pypi.org/project/cdpwave/)
|
|
43
|
+
[](https://opensource.org/licenses/MIT)
|
|
44
|
+
|
|
45
|
+
Chrome DevTools Protocol for Python — direct, typed, async.
|
|
46
|
+
|
|
47
|
+
cdpwave talks to Chrome over a raw WebSocket. No Node.js, no ChromeDriver, no browser downloads. Just pure Python with full type hints and async-first design.
|
|
48
|
+
|
|
49
|
+
## Why cdpwave?
|
|
50
|
+
|
|
51
|
+
- **Direct WebSocket** — single connection to Chrome's DevTools Protocol, no intermediate layers
|
|
52
|
+
- **Fully typed** — `mypy --strict` across the entire codebase, IDE autocomplete everywhere
|
|
53
|
+
- **Async-first** — built on `asyncio`, no threading, no blocking calls
|
|
54
|
+
- **Browser detection** — finds Chrome, Edge, Brave, or Chromium already on your system
|
|
55
|
+
- **Flatten sessions** — one WebSocket for all tabs via `Target.attachToTarget` + `sessionId`
|
|
56
|
+
- **Escape hatch** — `session.send("Any.CDPMethod", params)` for uncovered domains
|
|
57
|
+
- **HTTP discovery** — typed access to `/json/version` and `/json/list` endpoints
|
|
58
|
+
- **MIT licensed** — permissive, compatible with any use
|
|
59
|
+
|
|
60
|
+
## Install
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install cdpwave
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Quick start
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
import asyncio
|
|
70
|
+
from cdpwave import CDPClient
|
|
71
|
+
|
|
72
|
+
async def main() -> None:
|
|
73
|
+
async with await CDPClient.launch(headless=True) as client:
|
|
74
|
+
page = await client.new_page("https://example.com")
|
|
75
|
+
result = await page.runtime.evaluate("document.title", return_by_value=True)
|
|
76
|
+
print(result["result"]["value"]) # "Example Domain"
|
|
77
|
+
await page.close()
|
|
78
|
+
|
|
79
|
+
asyncio.run(main())
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Documentation
|
|
83
|
+
|
|
84
|
+
Full documentation at [mathiaspaulenko.github.io/cdpwave](https://mathiaspaulenko.github.io/cdpwave/).
|
|
85
|
+
|
|
86
|
+
- [Quickstart](https://mathiaspaulenko.github.io/cdpwave/quickstart/) — 10-minute tutorial
|
|
87
|
+
- [Guide](https://mathiaspaulenko.github.io/cdpwave/guide/installation/) — in-depth feature coverage
|
|
88
|
+
- [Cookbook](https://mathiaspaulenko.github.io/cdpwave/cookbook/connect-existing/) — common recipes
|
|
89
|
+
- [API Reference](https://mathiaspaulenko.github.io/cdpwave/api/client/) — auto-generated docs
|
|
90
|
+
- [Migration](https://mathiaspaulenko.github.io/cdpwave/migration/pyppeteer/) — from pyppeteer or pychrome
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT
|