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/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
2
|
+
|
|
3
|
+
from cdpwave.client import CDPClient, CDPSession
|
|
4
|
+
from cdpwave.events.dispatcher import EventDispatcher
|
|
5
|
+
from cdpwave.events.handlers import EventHandler, Subscription
|
|
6
|
+
from cdpwave.exceptions import (
|
|
7
|
+
BrowserNotFoundError,
|
|
8
|
+
CDPError,
|
|
9
|
+
CommandError,
|
|
10
|
+
CommandTimeoutError,
|
|
11
|
+
ConnectionClosedError,
|
|
12
|
+
DiscoveryError,
|
|
13
|
+
LaunchError,
|
|
14
|
+
LaunchTimeoutError,
|
|
15
|
+
SessionClosedError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"CDPClient",
|
|
20
|
+
"CDPSession",
|
|
21
|
+
"CDPError",
|
|
22
|
+
"ConnectionClosedError",
|
|
23
|
+
"CommandError",
|
|
24
|
+
"CommandTimeoutError",
|
|
25
|
+
"BrowserNotFoundError",
|
|
26
|
+
"SessionClosedError",
|
|
27
|
+
"DiscoveryError",
|
|
28
|
+
"EventDispatcher",
|
|
29
|
+
"Subscription",
|
|
30
|
+
"EventHandler",
|
|
31
|
+
"LaunchError",
|
|
32
|
+
"LaunchTimeoutError",
|
|
33
|
+
"__version__",
|
|
34
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import urllib.request
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class TargetInfo:
|
|
10
|
+
target_id: str
|
|
11
|
+
type: str
|
|
12
|
+
title: str
|
|
13
|
+
url: str
|
|
14
|
+
web_socket_debugger_url: str | None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class VersionInfo:
|
|
19
|
+
browser: str
|
|
20
|
+
protocol_version: str
|
|
21
|
+
user_agent: str
|
|
22
|
+
web_socket_debugger_url: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _http_get(url: str) -> Any:
|
|
26
|
+
with urllib.request.urlopen(url, timeout=10) as resp:
|
|
27
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
28
|
+
return data
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _http_put(url: str) -> Any:
|
|
32
|
+
req = urllib.request.Request(url, method="PUT")
|
|
33
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
34
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
35
|
+
return data
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TargetDiscovery:
|
|
39
|
+
def __init__(self, host: str = "localhost", port: int = 9222) -> None:
|
|
40
|
+
self._base_url = f"http://{host}:{port}"
|
|
41
|
+
|
|
42
|
+
async def get_version(self) -> VersionInfo:
|
|
43
|
+
data: dict[str, Any] = await asyncio.to_thread(
|
|
44
|
+
_http_get, f"{self._base_url}/json/version"
|
|
45
|
+
)
|
|
46
|
+
return VersionInfo(
|
|
47
|
+
browser=str(data.get("Browser", "")),
|
|
48
|
+
protocol_version=str(data.get("Protocol-Version", "")),
|
|
49
|
+
user_agent=str(data.get("User-Agent", "")),
|
|
50
|
+
web_socket_debugger_url=str(data.get("webSocketDebuggerUrl", "")),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
async def list_targets(self) -> list[TargetInfo]:
|
|
54
|
+
data: list[dict[str, Any]] = await asyncio.to_thread(
|
|
55
|
+
_http_get, f"{self._base_url}/json/list"
|
|
56
|
+
)
|
|
57
|
+
targets: list[TargetInfo] = []
|
|
58
|
+
for item in data:
|
|
59
|
+
targets.append(
|
|
60
|
+
TargetInfo(
|
|
61
|
+
target_id=str(item.get("id", "")),
|
|
62
|
+
type=str(item.get("type", "")),
|
|
63
|
+
title=str(item.get("title", "")),
|
|
64
|
+
url=str(item.get("url", "")),
|
|
65
|
+
web_socket_debugger_url=item.get("webSocketDebuggerUrl"),
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
return targets
|
|
69
|
+
|
|
70
|
+
async def new_tab(self, url: str = "about:blank") -> TargetInfo:
|
|
71
|
+
data: dict[str, Any] = await asyncio.to_thread(
|
|
72
|
+
_http_put, f"{self._base_url}/json/new?{url}"
|
|
73
|
+
)
|
|
74
|
+
return TargetInfo(
|
|
75
|
+
target_id=str(data.get("id", "")),
|
|
76
|
+
type=str(data.get("type", "")),
|
|
77
|
+
title=str(data.get("title", "")),
|
|
78
|
+
url=str(data.get("url", "")),
|
|
79
|
+
web_socket_debugger_url=data.get("webSocketDebuggerUrl"),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
async def activate_tab(self, target_id: str) -> None:
|
|
83
|
+
await asyncio.to_thread(_http_get, f"{self._base_url}/json/activate/{target_id}")
|
|
84
|
+
|
|
85
|
+
async def close_tab(self, target_id: str) -> None:
|
|
86
|
+
await asyncio.to_thread(_http_get, f"{self._base_url}/json/close/{target_id}")
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from cdpwave.exceptions import BrowserNotFoundError
|
|
6
|
+
from cdpwave.types import BrowserType
|
|
7
|
+
|
|
8
|
+
_WIN_CHROME_PATHS = [
|
|
9
|
+
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
|
10
|
+
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
_WIN_EDGE_PATHS = [
|
|
14
|
+
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
|
|
15
|
+
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
_WIN_BRAVE_PATHS = [
|
|
19
|
+
os.path.expandvars(r"%LOCALAPPDATA%\BraveSoftware\Brave-Browser\Application\brave.exe"),
|
|
20
|
+
r"C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe",
|
|
21
|
+
r"C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
_WIN_CHROMIUM_PATHS = [
|
|
25
|
+
r"C:\Program Files\Chromium\Application\chromium.exe",
|
|
26
|
+
r"C:\Program Files (x86)\Chromium\Application\chromium.exe",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
_MAC_CHROME_PATH = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
|
30
|
+
_MAC_EDGE_PATH = "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
|
|
31
|
+
_MAC_BRAVE_PATH = "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
|
|
32
|
+
_MAC_CHROMIUM_PATH = "/Applications/Chromium.app/Contents/MacOS/Chromium"
|
|
33
|
+
|
|
34
|
+
_LINUX_NAMES: dict[str, list[str]] = {
|
|
35
|
+
"chrome": ["google-chrome", "google-chrome-stable", "chrome"],
|
|
36
|
+
"edge": ["microsoft-edge", "microsoft-edge-stable"],
|
|
37
|
+
"brave": ["brave-browser", "brave"],
|
|
38
|
+
"chromium": ["chromium", "chromium-browser"],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _check_paths(paths: list[str]) -> str | None:
|
|
43
|
+
for path in paths:
|
|
44
|
+
if os.path.isfile(path):
|
|
45
|
+
return path
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _find_on_linux(names: list[str]) -> str | None:
|
|
50
|
+
for name in names:
|
|
51
|
+
path = shutil.which(name)
|
|
52
|
+
if path:
|
|
53
|
+
return path
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def find_chrome() -> str | None:
|
|
58
|
+
env_path = os.environ.get("CDPWAVE_CHROME_PATH")
|
|
59
|
+
if env_path and os.path.isfile(env_path):
|
|
60
|
+
return env_path
|
|
61
|
+
if sys.platform == "win32":
|
|
62
|
+
return _check_paths(_WIN_CHROME_PATHS)
|
|
63
|
+
if sys.platform == "darwin":
|
|
64
|
+
return _check_paths([_MAC_CHROME_PATH])
|
|
65
|
+
return _find_on_linux(_LINUX_NAMES["chrome"])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def find_edge() -> str | None:
|
|
69
|
+
env_path = os.environ.get("CDPWAVE_EDGE_PATH")
|
|
70
|
+
if env_path and os.path.isfile(env_path):
|
|
71
|
+
return env_path
|
|
72
|
+
if sys.platform == "win32":
|
|
73
|
+
return _check_paths(_WIN_EDGE_PATHS)
|
|
74
|
+
if sys.platform == "darwin":
|
|
75
|
+
return _check_paths([_MAC_EDGE_PATH])
|
|
76
|
+
return _find_on_linux(_LINUX_NAMES["edge"])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def find_brave() -> str | None:
|
|
80
|
+
env_path = os.environ.get("CDPWAVE_BRAVE_PATH")
|
|
81
|
+
if env_path and os.path.isfile(env_path):
|
|
82
|
+
return env_path
|
|
83
|
+
if sys.platform == "win32":
|
|
84
|
+
return _check_paths(_WIN_BRAVE_PATHS)
|
|
85
|
+
if sys.platform == "darwin":
|
|
86
|
+
return _check_paths([_MAC_BRAVE_PATH])
|
|
87
|
+
return _find_on_linux(_LINUX_NAMES["brave"])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def find_chromium() -> str | None:
|
|
91
|
+
env_path = os.environ.get("CDPWAVE_CHROMIUM_PATH")
|
|
92
|
+
if env_path and os.path.isfile(env_path):
|
|
93
|
+
return env_path
|
|
94
|
+
if sys.platform == "win32":
|
|
95
|
+
return _check_paths(_WIN_CHROMIUM_PATHS)
|
|
96
|
+
if sys.platform == "darwin":
|
|
97
|
+
return _check_paths([_MAC_CHROMIUM_PATH])
|
|
98
|
+
return _find_on_linux(_LINUX_NAMES["chromium"])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_FINDER_NAMES: dict[BrowserType, str] = {
|
|
102
|
+
"chrome": "find_chrome",
|
|
103
|
+
"edge": "find_edge",
|
|
104
|
+
"brave": "find_brave",
|
|
105
|
+
"chromium": "find_chromium",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
_SEARCH_ORDER: list[BrowserType] = ["chrome", "edge", "brave", "chromium"]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def find_browser(preferred: BrowserType | None = None) -> str:
|
|
112
|
+
env_override = os.environ.get("CDPWAVE_BROWSER_PATH")
|
|
113
|
+
if env_override and os.path.isfile(env_override):
|
|
114
|
+
return env_override
|
|
115
|
+
|
|
116
|
+
search_order: list[BrowserType] = []
|
|
117
|
+
if preferred is not None:
|
|
118
|
+
search_order.append(preferred)
|
|
119
|
+
search_order.extend([b for b in _SEARCH_ORDER if b != preferred])
|
|
120
|
+
|
|
121
|
+
for browser_type in search_order:
|
|
122
|
+
finder = globals()[_FINDER_NAMES[browser_type]]
|
|
123
|
+
path: str | None = finder()
|
|
124
|
+
if path:
|
|
125
|
+
return path
|
|
126
|
+
|
|
127
|
+
raise BrowserNotFoundError(
|
|
128
|
+
"No Chromium-based browser found."
|
|
129
|
+
" Set CDPWAVE_BROWSER_PATH or install Chrome/Edge/Brave/Chromium."
|
|
130
|
+
)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import socket
|
|
7
|
+
import tempfile
|
|
8
|
+
import urllib.request
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from cdpwave.browser.finder import find_browser
|
|
12
|
+
from cdpwave.exceptions import LaunchError, LaunchTimeoutError
|
|
13
|
+
|
|
14
|
+
_DEFAULT_FLAGS = [
|
|
15
|
+
"--no-first-run",
|
|
16
|
+
"--no-default-browser-check",
|
|
17
|
+
"--disable-features=Translate",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
_CI_ENV_VARS = ["CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS_URL"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_ci() -> bool:
|
|
24
|
+
return any(os.environ.get(var) for var in _CI_ENV_VARS)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _find_free_port() -> int:
|
|
28
|
+
with socket.socket() as s:
|
|
29
|
+
s.bind(("127.0.0.1", 0))
|
|
30
|
+
port: int = s.getsockname()[1]
|
|
31
|
+
return port
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class BrowserInfo:
|
|
36
|
+
web_socket_debugger_url: str
|
|
37
|
+
browser_version: str
|
|
38
|
+
protocol_version: str
|
|
39
|
+
user_agent: str
|
|
40
|
+
port: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BrowserLauncher:
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
browser_path: str | None = None,
|
|
47
|
+
port: int = 0,
|
|
48
|
+
headless: bool = True,
|
|
49
|
+
user_data_dir: str | None = None,
|
|
50
|
+
extra_args: list[str] | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self._browser_path = browser_path
|
|
53
|
+
self._port = port
|
|
54
|
+
self._headless = headless
|
|
55
|
+
self._user_data_dir = user_data_dir
|
|
56
|
+
self._extra_args = extra_args
|
|
57
|
+
self._process: asyncio.subprocess.Process | None = None
|
|
58
|
+
self._temp_dir: str | None = None
|
|
59
|
+
self._info: BrowserInfo | None = None
|
|
60
|
+
|
|
61
|
+
def _build_args(self) -> list[str]:
|
|
62
|
+
if self._browser_path is None:
|
|
63
|
+
self._browser_path = find_browser()
|
|
64
|
+
|
|
65
|
+
port = self._port if self._port != 0 else _find_free_port()
|
|
66
|
+
self._port = port
|
|
67
|
+
|
|
68
|
+
user_data_dir = self._user_data_dir
|
|
69
|
+
if user_data_dir is None:
|
|
70
|
+
user_data_dir = self._create_temp_user_dir()
|
|
71
|
+
self._user_data_dir = user_data_dir
|
|
72
|
+
|
|
73
|
+
args = [
|
|
74
|
+
self._browser_path,
|
|
75
|
+
f"--remote-debugging-port={port}",
|
|
76
|
+
f"--user-data-dir={user_data_dir}",
|
|
77
|
+
*_DEFAULT_FLAGS,
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
if self._headless:
|
|
81
|
+
args.append("--headless=new")
|
|
82
|
+
|
|
83
|
+
if _is_ci():
|
|
84
|
+
args.append("--no-sandbox")
|
|
85
|
+
|
|
86
|
+
if self._extra_args:
|
|
87
|
+
args.extend(self._extra_args)
|
|
88
|
+
|
|
89
|
+
args.append("about:blank")
|
|
90
|
+
return args
|
|
91
|
+
|
|
92
|
+
def _create_temp_user_dir(self) -> str:
|
|
93
|
+
self._temp_dir = tempfile.mkdtemp(prefix="cdpwave-")
|
|
94
|
+
return self._temp_dir
|
|
95
|
+
|
|
96
|
+
async def launch(self, timeout: float = 10.0) -> BrowserInfo:
|
|
97
|
+
if self._process is not None:
|
|
98
|
+
raise RuntimeError("Browser is already running")
|
|
99
|
+
|
|
100
|
+
args = self._build_args()
|
|
101
|
+
|
|
102
|
+
self._process = await asyncio.create_subprocess_exec(
|
|
103
|
+
*args,
|
|
104
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
105
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
self._info = await self._wait_for_endpoint(timeout=timeout)
|
|
109
|
+
return self._info
|
|
110
|
+
|
|
111
|
+
async def _wait_for_endpoint(self, timeout: float = 10.0) -> BrowserInfo:
|
|
112
|
+
url = f"http://localhost:{self._port}/json/version"
|
|
113
|
+
delay = 0.1
|
|
114
|
+
elapsed = 0.0
|
|
115
|
+
|
|
116
|
+
while elapsed < timeout:
|
|
117
|
+
if self._process is not None and self._process.returncode is not None:
|
|
118
|
+
raise LaunchError(
|
|
119
|
+
f"Browser process exited with code {self._process.returncode}"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
data = await asyncio.to_thread(_fetch_version, url)
|
|
124
|
+
return BrowserInfo(
|
|
125
|
+
web_socket_debugger_url=str(data.get("webSocketDebuggerUrl", "")),
|
|
126
|
+
browser_version=str(data.get("Browser", "")),
|
|
127
|
+
protocol_version=str(data.get("Protocol-Version", "")),
|
|
128
|
+
user_agent=str(data.get("User-Agent", "")),
|
|
129
|
+
port=self._port,
|
|
130
|
+
)
|
|
131
|
+
except Exception:
|
|
132
|
+
await asyncio.sleep(delay)
|
|
133
|
+
elapsed += delay
|
|
134
|
+
delay = min(delay * 1.5, 1.0)
|
|
135
|
+
|
|
136
|
+
raise LaunchTimeoutError(
|
|
137
|
+
f"Browser did not become ready within {timeout}s on port {self._port}"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
async def close(self) -> None:
|
|
141
|
+
if self._process is not None:
|
|
142
|
+
with contextlib.suppress(Exception):
|
|
143
|
+
if self._process.returncode is None:
|
|
144
|
+
self._process.terminate()
|
|
145
|
+
try:
|
|
146
|
+
await asyncio.wait_for(self._process.wait(), timeout=2.0)
|
|
147
|
+
except TimeoutError:
|
|
148
|
+
self._process.kill()
|
|
149
|
+
await self._process.wait()
|
|
150
|
+
self._process = None
|
|
151
|
+
|
|
152
|
+
if self._temp_dir is not None:
|
|
153
|
+
shutil.rmtree(self._temp_dir, ignore_errors=True)
|
|
154
|
+
self._temp_dir = None
|
|
155
|
+
|
|
156
|
+
self._info = None
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def is_running(self) -> bool:
|
|
160
|
+
return self._process is not None and self._process.returncode is None
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def info(self) -> BrowserInfo | None:
|
|
164
|
+
return self._info
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _fetch_version(url: str) -> dict[str, object]:
|
|
168
|
+
with urllib.request.urlopen(url, timeout=5) as resp:
|
|
169
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
170
|
+
return data # type: ignore[no-any-return]
|