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.
@@ -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
+ [![CI](https://github.com/MathiasPaulenko/cdpwave/actions/workflows/ci.yml/badge.svg)](https://github.com/MathiasPaulenko/cdpwave/actions/workflows/ci.yml)
40
+ [![Tests](https://github.com/MathiasPaulenko/cdpwave/actions/workflows/test.yml/badge.svg)](https://github.com/MathiasPaulenko/cdpwave/actions/workflows/test.yml)
41
+ [![PyPI](https://img.shields.io/pypi/v/cdpwave.svg)](https://pypi.org/project/cdpwave/)
42
+ [![Python](https://img.shields.io/pypi/pyversions/cdpwave.svg)](https://pypi.org/project/cdpwave/)
43
+ [![License: MIT](https://img.shields.io/badge-License-MIT-yellow.svg)](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