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.
- cdp_connect_kit/__init__.py +15 -0
- cdp_connect_kit/cdp/__init__.py +14 -0
- cdp_connect_kit/cdp/client.py +105 -0
- cdp_connect_kit/cdp/models.py +43 -0
- cdp_connect_kit/cdp/url.py +77 -0
- cdp_connect_kit/cli.py +211 -0
- cdp_connect_kit/connect.py +97 -0
- cdp_connect_kit/deal.py +25 -0
- cdp_connect_kit/mlx.py +367 -0
- cdp_connect_kit/mlx_cli.py +142 -0
- cdp_connect_kit/retry.py +45 -0
- cdp_connect_kit/watch.py +66 -0
- cdp_connect_kit-0.3.1.dist-info/METADATA +245 -0
- cdp_connect_kit-0.3.1.dist-info/RECORD +17 -0
- cdp_connect_kit-0.3.1.dist-info/WHEEL +4 -0
- cdp_connect_kit-0.3.1.dist-info/entry_points.txt +2 -0
- cdp_connect_kit-0.3.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
cdp_connect_kit/deal.py
ADDED
|
@@ -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)
|