cdp-connect-kit 0.3.1__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,15 @@
1
+ """CDP connection toolkit with Playwright integration."""
2
+
3
+ from cdp_connect_kit.cdp.client import CdpClient, CdpError
4
+ from cdp_connect_kit.cdp.url import CdpEndpoint, parse_endpoint
5
+ from cdp_connect_kit.connect import ConnectMetrics, connect_playwright
6
+
7
+ __all__ = [
8
+ "CdpClient",
9
+ "CdpEndpoint",
10
+ "CdpError",
11
+ "ConnectMetrics",
12
+ "connect_playwright",
13
+ "parse_endpoint",
14
+ ]
15
+ __version__ = "0.3.1"
@@ -0,0 +1,14 @@
1
+ """CDP HTTP discovery."""
2
+
3
+ from cdp_connect_kit.cdp.client import CdpClient, CdpError
4
+ from cdp_connect_kit.cdp.models import BrowserInfo, HealthSnapshot
5
+ from cdp_connect_kit.cdp.url import CdpEndpoint, parse_endpoint
6
+
7
+ __all__ = [
8
+ "BrowserInfo",
9
+ "CdpClient",
10
+ "CdpEndpoint",
11
+ "CdpError",
12
+ "HealthSnapshot",
13
+ "parse_endpoint",
14
+ ]
@@ -0,0 +1,105 @@
1
+ """CDP HTTP client (stdlib)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ import urllib.error
8
+ import urllib.request
9
+ from typing import Any
10
+
11
+ from cdp_connect_kit.cdp.models import BrowserInfo, HealthSnapshot
12
+ from cdp_connect_kit.cdp.url import CdpEndpoint, parse_endpoint
13
+ from cdp_connect_kit.retry import backoff_delay
14
+
15
+
16
+ class CdpError(Exception):
17
+ """Raised when CDP HTTP discovery fails."""
18
+
19
+
20
+ class CdpClient:
21
+ def __init__(self, endpoint: str | CdpEndpoint, *, timeout: float = 10.0) -> None:
22
+ self.endpoint = endpoint if isinstance(endpoint, CdpEndpoint) else parse_endpoint(endpoint)
23
+ self.timeout = timeout
24
+
25
+ def _fetch_json(self, url: str) -> Any:
26
+ request = urllib.request.Request(url, headers={"Accept": "application/json"})
27
+ try:
28
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
29
+ return json.loads(response.read().decode("utf-8"))
30
+ except (urllib.error.URLError, OSError) as exc:
31
+ raise CdpError(f"Failed to reach {url}: {exc}") from exc
32
+ except json.JSONDecodeError as exc:
33
+ raise CdpError(f"Invalid JSON from {url}") from exc
34
+
35
+ def get_version(self) -> BrowserInfo:
36
+ payload = self._fetch_json(self.endpoint.version_url)
37
+ if not isinstance(payload, dict):
38
+ raise CdpError("Unexpected /json/version payload")
39
+ return BrowserInfo.from_json(payload)
40
+
41
+ def list_targets(self) -> list[dict[str, Any]]:
42
+ payload = self._fetch_json(self.endpoint.list_url)
43
+ if not isinstance(payload, list):
44
+ raise CdpError("Unexpected /json/list payload")
45
+ return [item for item in payload if isinstance(item, dict)]
46
+
47
+ def ping(self) -> bool:
48
+ try:
49
+ self.get_version()
50
+ except CdpError:
51
+ return False
52
+ return True
53
+
54
+ def wait_until_ready(
55
+ self,
56
+ *,
57
+ timeout: float = 30.0,
58
+ max_retries: int | None = None,
59
+ retry_base: float = 0.5,
60
+ ) -> BrowserInfo:
61
+ """Poll /json/version until ready or timeout (exponential backoff)."""
62
+ deadline = time.monotonic() + timeout
63
+ last_error: CdpError | None = None
64
+ attempt = 0
65
+ while time.monotonic() < deadline:
66
+ attempt += 1
67
+ try:
68
+ return self.get_version()
69
+ except CdpError as exc:
70
+ last_error = exc
71
+ if max_retries is not None and attempt >= max_retries:
72
+ break
73
+ delay = backoff_delay(attempt, base=retry_base)
74
+ if time.monotonic() + delay > deadline:
75
+ break
76
+ time.sleep(delay)
77
+ detail = f"after {attempt} attempts" if max_retries is not None else f"after {timeout}s"
78
+ raise CdpError(
79
+ f"CDP endpoint not ready {detail}: {self.endpoint.http_base}"
80
+ ) from last_error
81
+
82
+ def resolve_websocket_url(self) -> str:
83
+ if self.endpoint.websocket_hint:
84
+ return self.endpoint.websocket_hint
85
+ info = self.get_version()
86
+ if not info.web_socket_debugger_url:
87
+ raise CdpError("Browser did not return webSocketDebuggerUrl")
88
+ return info.web_socket_debugger_url
89
+
90
+ def health_snapshot(self) -> HealthSnapshot:
91
+ start = time.perf_counter()
92
+ try:
93
+ info = self.get_version()
94
+ targets = self.list_targets()
95
+ latency = (time.perf_counter() - start) * 1000.0
96
+ return HealthSnapshot(
97
+ reachable=True,
98
+ latency_ms=latency,
99
+ browser=info.browser,
100
+ web_socket_debugger_url=info.web_socket_debugger_url,
101
+ target_count=len(targets),
102
+ )
103
+ except CdpError as exc:
104
+ latency = (time.perf_counter() - start) * 1000.0
105
+ return HealthSnapshot(reachable=False, latency_ms=latency, error=str(exc))
@@ -0,0 +1,43 @@
1
+ """CDP discovery models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class BrowserInfo:
11
+ browser: str
12
+ protocol_version: str
13
+ user_agent: str
14
+ web_socket_debugger_url: str
15
+
16
+ @classmethod
17
+ def from_json(cls, payload: dict[str, Any]) -> BrowserInfo:
18
+ return cls(
19
+ browser=str(payload.get("Browser", "")),
20
+ protocol_version=str(payload.get("Protocol-Version", "")),
21
+ user_agent=str(payload.get("User-Agent", "")),
22
+ web_socket_debugger_url=str(payload.get("webSocketDebuggerUrl", "")),
23
+ )
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class HealthSnapshot:
28
+ reachable: bool
29
+ latency_ms: float | None
30
+ browser: str | None = None
31
+ web_socket_debugger_url: str | None = None
32
+ target_count: int | None = None
33
+ error: str | None = None
34
+
35
+ def to_dict(self) -> dict[str, Any]:
36
+ return {
37
+ "reachable": self.reachable,
38
+ "latency_ms": self.latency_ms,
39
+ "browser": self.browser,
40
+ "web_socket_debugger_url": self.web_socket_debugger_url,
41
+ "target_count": self.target_count,
42
+ "error": self.error,
43
+ }
@@ -0,0 +1,77 @@
1
+ """Parse CDP HTTP and WebSocket endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from urllib.parse import urlparse, urlunparse
8
+
9
+ _WS_RE = re.compile(r"^wss?$", re.IGNORECASE)
10
+ _HTTP_RE = re.compile(r"^https?$", re.IGNORECASE)
11
+
12
+ CDP_DEFAULT_PORT = 9222
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class CdpEndpoint:
17
+ http_base: str
18
+ host: str
19
+ port: int
20
+ websocket_hint: str | None = None
21
+
22
+ @property
23
+ def version_url(self) -> str:
24
+ return f"{self.http_base}/json/version"
25
+
26
+ @property
27
+ def list_url(self) -> str:
28
+ return f"{self.http_base}/json/list"
29
+
30
+
31
+ def _host_port_netloc(host: str, port: int) -> str:
32
+ if ":" in host and not host.startswith("["):
33
+ return f"[{host}]:{port}"
34
+ return f"{host}:{port}"
35
+
36
+
37
+ def _default_port(scheme: str, explicit: int | None) -> int:
38
+ if explicit is not None:
39
+ return explicit
40
+ if scheme in {"https", "wss"}:
41
+ return 443
42
+ return CDP_DEFAULT_PORT
43
+
44
+
45
+ def parse_endpoint(raw: str) -> CdpEndpoint:
46
+ """Normalize CDP endpoint from HTTP URL, WebSocket URL, or host:port shorthand."""
47
+ value = raw.strip()
48
+ if not value:
49
+ raise ValueError("Endpoint cannot be empty")
50
+
51
+ ws_hint: str | None = None
52
+ if "://" not in value:
53
+ value = f"http://{value}"
54
+
55
+ parsed = urlparse(value)
56
+ scheme = parsed.scheme.lower()
57
+
58
+ if _WS_RE.match(scheme):
59
+ ws_hint = value
60
+ http_scheme = "https" if scheme == "wss" else "http"
61
+ path = ""
62
+ scheme = http_scheme
63
+ elif _HTTP_RE.match(scheme):
64
+ http_scheme = scheme
65
+ path = ""
66
+ scheme = http_scheme
67
+ else:
68
+ raise ValueError(f"Unsupported URL scheme: {parsed.scheme!r}")
69
+
70
+ host = parsed.hostname
71
+ if not host:
72
+ raise ValueError(f"Could not determine host from {raw!r}")
73
+
74
+ port = _default_port(scheme, parsed.port)
75
+ netloc = _host_port_netloc(host, port)
76
+ http_base = urlunparse((scheme, netloc, path or "", "", "", ""))
77
+ return CdpEndpoint(http_base=http_base, host=host, port=port, websocket_hint=ws_hint)
cdp_connect_kit/cli.py ADDED
@@ -0,0 +1,211 @@
1
+ """Click CLI for cdp-connect."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+ from cdp_connect_kit.cdp.client import CdpClient, CdpError
13
+ from cdp_connect_kit.connect import connect_playwright
14
+ from cdp_connect_kit.watch import format_sample_line, watch_endpoint
15
+
16
+ EXIT_OK = 0
17
+ EXIT_FAIL = 1
18
+ EXIT_ERROR = 2
19
+
20
+
21
+ @click.group(invoke_without_command=True)
22
+ @click.version_option(package_name="cdp-connect-kit", prog_name="cdp-connect")
23
+ @click.option("--show-deal", is_flag=True, help="Print Multilogin coupon info and exit.")
24
+ @click.pass_context
25
+ def main(ctx: click.Context, show_deal: bool) -> None:
26
+ """CDP connect toolkit — ping, Playwright connect, watch, MLX launcher."""
27
+ if show_deal:
28
+ from cdp_connect_kit.deal import print_show_deal
29
+
30
+ print_show_deal()
31
+ ctx.exit(0)
32
+
33
+
34
+ @main.command("ping")
35
+ @click.argument("endpoint")
36
+ @click.option("--timeout", default=10.0, show_default=True)
37
+ def ping_cmd(endpoint: str, timeout: float) -> None:
38
+ """Validate CDP HTTP endpoint and print WebSocket URL."""
39
+ client = CdpClient(endpoint, timeout=timeout)
40
+ try:
41
+ info = client.get_version()
42
+ ws = client.resolve_websocket_url()
43
+ except CdpError as exc:
44
+ click.echo(f"unreachable: {client.endpoint.http_base} ({exc})", err=True)
45
+ sys.exit(EXIT_FAIL)
46
+ click.echo(f"ok: {client.endpoint.http_base}")
47
+ click.echo(f"browser: {info.browser}")
48
+ click.echo(f"websocket: {ws}")
49
+ sys.exit(EXIT_OK)
50
+
51
+
52
+ @main.command("playwright")
53
+ @click.option("--endpoint", required=True, help="CDP HTTP, WebSocket URL, or host:port.")
54
+ @click.option("--screenshot", "screenshot_path", type=click.Path(path_type=Path), default=None)
55
+ @click.option("--timeout", default=30.0, show_default=True)
56
+ @click.option("--max-retries", default=3, show_default=True, help="Max connect attempts.")
57
+ def playwright_cmd(
58
+ endpoint: str,
59
+ screenshot_path: Path | None,
60
+ timeout: float,
61
+ max_retries: int,
62
+ ) -> None:
63
+ """Connect Playwright over CDP and optionally capture a screenshot."""
64
+
65
+ async def _run() -> None:
66
+ browser, playwright, metrics = await connect_playwright(
67
+ endpoint,
68
+ timeout=timeout,
69
+ max_retries=max_retries,
70
+ )
71
+ try:
72
+ context = browser.contexts[0] if browser.contexts else await browser.new_context()
73
+ page = context.pages[0] if context.pages else await context.new_page()
74
+ if screenshot_path:
75
+ await page.screenshot(path=str(screenshot_path))
76
+ click.echo(json.dumps(metrics.to_dict(), indent=2))
77
+ if screenshot_path:
78
+ click.echo(f"screenshot: {screenshot_path}", err=True)
79
+ finally:
80
+ await browser.close()
81
+ await playwright.stop()
82
+
83
+ try:
84
+ asyncio.run(_run())
85
+ except Exception as exc:
86
+ raise click.ClickException(str(exc)) from exc
87
+
88
+
89
+ @main.command("watch")
90
+ @click.option("--endpoint", required=True)
91
+ @click.option("--interval", default=5.0, show_default=True)
92
+ @click.option("--count", default=0, show_default=True, help="Stop after N samples (0 = infinite).")
93
+ @click.option(
94
+ "--format",
95
+ "fmt",
96
+ type=click.Choice(["jsonl", "plain"]),
97
+ default="jsonl",
98
+ show_default=True,
99
+ help="jsonl = one JSON object per line (farm-runner); plain = human text.",
100
+ )
101
+ def watch_cmd(endpoint: str, interval: float, count: int, fmt: str) -> None:
102
+ """Poll CDP health metrics at an interval (JSONL for piping)."""
103
+
104
+ async def _run() -> None:
105
+ iterations = None if count == 0 else count
106
+
107
+ def _print(sample: dict) -> None:
108
+ click.echo(format_sample_line(sample, fmt=fmt), nl=fmt == "plain")
109
+
110
+ await watch_endpoint(
111
+ endpoint,
112
+ interval=interval,
113
+ iterations=iterations,
114
+ on_sample=_print,
115
+ )
116
+
117
+ try:
118
+ asyncio.run(_run())
119
+ except KeyboardInterrupt:
120
+ click.echo("stopped", err=True)
121
+
122
+
123
+ @main.command("mlx-start")
124
+ @click.option("--profile-id", required=True, help="MLX profile UUID.")
125
+ @click.option("--folder-id", envvar="MLX_FOLDER_ID", help="MLX folder UUID.")
126
+ @click.option("--token", envvar="MLX_TOKEN", help="Bearer token (or MLX_TOKEN).")
127
+ @click.option(
128
+ "--launcher-url",
129
+ "launcher_url",
130
+ default=None,
131
+ help="Launcher base URL (MLX_LAUNCHER_URL / MLX_LAUNCHER_HOST).",
132
+ )
133
+ @click.option("--wait-timeout", default=120.0, show_default=True, help="Seconds to wait for CDP.")
134
+ @click.option(
135
+ "--print-cdp-url",
136
+ is_flag=True,
137
+ help="Print CDP HTTP URL to stdout only (for shell scripts).",
138
+ )
139
+ @click.option(
140
+ "--connect",
141
+ is_flag=True,
142
+ help="Connect Playwright and emit ConnectMetrics JSON to stdout.",
143
+ )
144
+ @click.option("--headed", is_flag=True, help="Launcher headless_mode=false.")
145
+ def mlx_start_cmd(
146
+ profile_id: str,
147
+ folder_id: str | None,
148
+ token: str | None,
149
+ launcher_url: str | None,
150
+ wait_timeout: float,
151
+ print_cdp_url: bool,
152
+ connect: bool,
153
+ headed: bool,
154
+ ) -> None:
155
+ """Start MLX profile via Launcher, wait for CDP, optionally connect Playwright."""
156
+ from cdp_connect_kit.mlx_cli import mlx_start
157
+
158
+ if print_cdp_url and connect:
159
+ raise click.ClickException("Use either --print-cdp-url or --connect, not both.")
160
+
161
+ mlx_start(
162
+ profile_id=profile_id,
163
+ folder_id=folder_id,
164
+ token=token,
165
+ launcher_url=launcher_url,
166
+ wait_timeout=wait_timeout,
167
+ headless=not headed,
168
+ print_cdp_url=print_cdp_url,
169
+ connect=connect,
170
+ )
171
+
172
+
173
+ @main.command("mlx-stop")
174
+ @click.option("--profile-id", required=True, help="MLX profile UUID.")
175
+ @click.option("--token", envvar="MLX_TOKEN")
176
+ @click.option("--launcher-url", "launcher_url", default=None)
177
+ @click.option(
178
+ "--idempotent/--strict",
179
+ default=True,
180
+ show_default=True,
181
+ help="Idempotent: treat not-running as success.",
182
+ )
183
+ def mlx_stop_cmd(
184
+ profile_id: str,
185
+ token: str | None,
186
+ launcher_url: str | None,
187
+ idempotent: bool,
188
+ ) -> None:
189
+ """Stop an MLX profile via Launcher API."""
190
+ from cdp_connect_kit.mlx_cli import mlx_stop
191
+
192
+ mlx_stop(
193
+ profile_id=profile_id,
194
+ token=token,
195
+ launcher_url=launcher_url,
196
+ idempotent=idempotent,
197
+ )
198
+
199
+
200
+ @main.command("mlx-list")
201
+ @click.option("--token", envvar="MLX_TOKEN")
202
+ @click.option("--launcher-url", "launcher_url", default=None)
203
+ def mlx_list_cmd(token: str | None, launcher_url: str | None) -> None:
204
+ """List running MLX profiles from the local Launcher."""
205
+ from cdp_connect_kit.mlx_cli import mlx_list
206
+
207
+ mlx_list(token=token, launcher_url=launcher_url)
208
+
209
+
210
+ if __name__ == "__main__":
211
+ main()
@@ -0,0 +1,97 @@
1
+ """Playwright connect_over_cdp with retry and metrics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ from playwright.async_api import Browser, async_playwright
11
+
12
+ from cdp_connect_kit.cdp.client import CdpClient, CdpError
13
+ from cdp_connect_kit.retry import backoff_delay
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class ConnectMetrics:
18
+ endpoint: str
19
+ http_base: str
20
+ web_socket_url: str | None = None
21
+ attempts: int = 0
22
+ connect_ms: float | None = None
23
+ ready_ms: float | None = None
24
+ target_count: int | None = None
25
+ errors: list[str] = field(default_factory=list)
26
+
27
+ def to_dict(self) -> dict[str, Any]:
28
+ return {
29
+ "endpoint": self.endpoint,
30
+ "http_base": self.http_base,
31
+ "web_socket_url": self.web_socket_url,
32
+ "attempts": self.attempts,
33
+ "connect_ms": self.connect_ms,
34
+ "ready_ms": self.ready_ms,
35
+ "target_count": self.target_count,
36
+ "errors": self.errors,
37
+ }
38
+
39
+
40
+ async def connect_playwright(
41
+ endpoint: str,
42
+ *,
43
+ timeout: float = 30.0,
44
+ max_retries: int = 3,
45
+ retry_base: float = 0.5,
46
+ wait_ready: bool = True,
47
+ retries: int | None = None,
48
+ ) -> tuple[Browser, Any, ConnectMetrics]:
49
+ """Connect Playwright to a CDP endpoint with exponential-backoff retry."""
50
+ attempts_limit = max_retries if retries is None else retries
51
+ client = CdpClient(endpoint, timeout=min(timeout, 10.0))
52
+ metrics = ConnectMetrics(endpoint=endpoint, http_base=client.endpoint.http_base)
53
+ playwright = await async_playwright().start()
54
+ browser: Browser | None = None
55
+
56
+ try:
57
+ ready_start = time.perf_counter()
58
+ if wait_ready:
59
+ loop = asyncio.get_event_loop()
60
+ await loop.run_in_executor(
61
+ None,
62
+ lambda: client.wait_until_ready(
63
+ timeout=timeout,
64
+ max_retries=attempts_limit,
65
+ retry_base=retry_base,
66
+ ),
67
+ )
68
+ metrics.ready_ms = (time.perf_counter() - ready_start) * 1000.0
69
+
70
+ ws_url = client.resolve_websocket_url()
71
+ metrics.web_socket_url = ws_url
72
+ http_endpoint = client.endpoint.http_base
73
+
74
+ last_error: Exception | None = None
75
+ for attempt in range(1, attempts_limit + 1):
76
+ metrics.attempts = attempt
77
+ connect_start = time.perf_counter()
78
+ try:
79
+ browser = await playwright.chromium.connect_over_cdp(
80
+ http_endpoint,
81
+ timeout=timeout * 1000,
82
+ )
83
+ metrics.connect_ms = (time.perf_counter() - connect_start) * 1000.0
84
+ metrics.target_count = len(client.list_targets())
85
+ return browser, playwright, metrics
86
+ except Exception as exc:
87
+ last_error = exc
88
+ metrics.errors.append(str(exc))
89
+ if attempt < attempts_limit:
90
+ await asyncio.sleep(backoff_delay(attempt, base=retry_base))
91
+
92
+ raise CdpError(f"connect_over_cdp failed after {attempts_limit} attempts") from last_error
93
+ except Exception:
94
+ if browser:
95
+ await browser.close()
96
+ await playwright.stop()
97
+ raise
@@ -0,0 +1,25 @@
1
+ """Multilogin partner coupon output for --show-deal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ SHOW_DEAL_TEXT = """Partner info (affiliate links — optional, not required for cdp-connect)
8
+
9
+ Need a stable CDP port from Multilogin Launcher for connect_over_cdp?
10
+ This kit starts profiles and normalizes endpoints — MLX desktop agent required.
11
+
12
+ Multilogin X — antidetect browser & cloud phone (verify eligibility before checkout)
13
+
14
+ SAAS50 — browser plans (eligible new purchases)
15
+ MIN50 — cloud phone (eligible new purchases)
16
+
17
+ https://multilogin.com/?ref=SAAS50
18
+ Coupons: https://anti-detect.github.io/
19
+ Scripts: https://t.me/Multilogin_Scripts_Bot
20
+
21
+ Disclosure: we may earn a commission. Offers change; confirm on vendor site."""
22
+
23
+
24
+ def print_show_deal() -> None:
25
+ click.echo(SHOW_DEAL_TEXT)