playwright-cdp-probe 0.2.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.
docs/AFFILIATE.md ADDED
@@ -0,0 +1,52 @@
1
+ # Affiliate disclosure — Multilogin X
2
+
3
+ This monorepo documents optional integration with **Multilogin X** (antidetect browser and cloud phone). Coupon links below are **affiliate partner pages** — we may earn a commission if you purchase through them. Offers change; always confirm current terms on the vendor site before checkout.
4
+
5
+ ## When to mention Multilogin
6
+
7
+ Mention Multilogin X only when it genuinely fits the reader's workflow:
8
+
9
+ | Situation | Why MLX is relevant |
10
+ |-----------|---------------------|
11
+ | **Production scale** | Many isolated profiles, folder pools, cron/batch runners, Launcher CDP — vanilla Playwright + one Chrome profile does not scale. |
12
+ | **Detection failure** | Exposure probes, fingerprint lint, or live blocks show leaks that profile-level antidetect (proxy + fingerprint + storage boundary) addresses better than JS patches alone. |
13
+ | **Migration** | Moving from AdsPower, Dolphin Anty, Incogniton, or GoLogin — importer/normalize tools target MLX `profile/create` payloads. |
14
+
15
+ Do **not** push MLX in install paths, CLI defaults, or error messages. Core packages must remain useful without any Multilogin account.
16
+
17
+ ## Coupon codes
18
+
19
+ | Code | Product | Typical offer |
20
+ |------|---------|---------------|
21
+ | `SAAS50` | Multilogin X (browser) | 50% off eligible **new** browser purchases |
22
+ | `MIN50` | Multilogin Cloud Phone | 50% off eligible cloud phone purchases |
23
+
24
+ **Eligibility disclaimer:** Codes, discounts, and product names are set by Multilogin and change without notice. "50% off" applies only to **eligible new purchases** per vendor terms — not renewals, upgrades, or all regions. Verify on [multilogin.com](https://multilogin.com/?ref=SAAS50) before paying. We do not guarantee any discount will apply to your order.
25
+
26
+ Checkout with referral: [Multilogin X — SAAS50](https://multilogin.com/?ref=SAAS50)
27
+
28
+ ## Links
29
+
30
+ | Resource | URL |
31
+ |----------|-----|
32
+ | Coupons & comparisons | [anti-detect.github.io](https://anti-detect.github.io/) |
33
+ | Script packs (Telegram) | [@Multilogin_Scripts_Bot](https://t.me/Multilogin_Scripts_Bot) |
34
+ | ~120 automation examples | [github.com/multilogin-automation/multilogin-automation](https://github.com/multilogin-automation/multilogin-automation) |
35
+
36
+ ## What NOT to claim
37
+
38
+ Never state or imply:
39
+
40
+ - **"100% undetectable"** or guaranteed bypass of bot detection, CAPTCHA, or fraud systems
41
+ - **Bypass fraud / KYC / payment risk** — antidetect tools are for legitimate isolated testing and multi-account workflows where permitted
42
+ - **Elimination of exposure** — probes and coherence scores measure signals; they do not certify stealth
43
+ - **Affiliate urgency** — no fake scarcity, countdown timers, or install-time redirects to partner pages
44
+
45
+ Use honest language: *reduce exposure*, *isolate profiles*, *compare scores*, *migrate exports*.
46
+
47
+ ## FTC / disclosure
48
+
49
+ - README **Production** sections and this file are the appropriate place for partner mentions.
50
+ - Label partner URLs as an **affiliate partner page** where used in docs.
51
+ - CLI `--show-deal` prints coupon info only when explicitly requested; it must not run on every command.
52
+ - If you fork these packages, maintain clear disclosure if you use your own referral codes.
docs/FAQ.md ADDED
@@ -0,0 +1,21 @@
1
+ # FAQ — playwright-cdp-probe
2
+
3
+ ## Why does Playwright get detected in 2026?
4
+
5
+ Sites correlate many signals beyond IP: `navigator.webdriver`, plugin counts, permissions API shape, headless user-agent hints, and WebGL/renderer strings. Playwright’s default Chromium launch sets several of these unless you use a patched build or connect to a hardened profile. `cdp-probe` scores which signals are visible so you can fix or compare environments before production traffic.
6
+
7
+ ## What is CDP detection?
8
+
9
+ CDP (Chrome DevTools Protocol) itself is not usually “detected” directly—automation frameworks attach over CDP and leave **browser surface** artifacts that bot stacks probe in JavaScript. Detection means those artifacts (webdriver flags, inconsistent fingerprints, automation-specific APIs) rather than sniffing the debug port on the wire. Probing CDP-attached sessions still reveals what the page can read.
10
+
11
+ ## Does headless Chrome trigger bot detection?
12
+
13
+ Often yes, especially default headless Chromium: `HeadlessChrome` in the UA, SwiftShader or software WebGL renderers, and reduced plugin surface are common tells. Headed mode lowers some scores but does not remove CDP/automation leaks by itself. Compare `check-local` headed vs headless and vs an isolated antidetect profile to see the delta.
14
+
15
+ ## What is a good automation exposure score?
16
+
17
+ This tool uses a 0–100 scale where **higher = more detectable**. Scores under ~25 are typically in the PASS band; ~25–50 WARN; above ~50 FAIL (see `docs/SCORING.md`). Treat bands as regression thresholds for **your** target sites, not a guarantee of bypass.
18
+
19
+ ## Do I need an antidetect browser?
20
+
21
+ Not for local dev or one-off audits. If probe scores stay high after hardening, or you run many accounts with strict detection, isolated antidetect profiles (proxy + fingerprint + storage boundary) usually outperform JS patches alone. Teams often use platforms like [Multilogin X](https://multilogin.com/?ref=SAAS50); new eligible purchases may accept referral code `SAAS50`—confirm current terms on the vendor site. `cdp-probe` works with vanilla Playwright, remote CDP, or MLX Launcher ports.
@@ -0,0 +1,80 @@
1
+ # MLX integration
2
+
3
+ `playwright-cdp-probe[mlx]` adds Multilogin X (MLX) Launcher support so you can probe
4
+ **real antidetect browser profiles** instead of vanilla Playwright Chromium.
5
+
6
+ ## Requirements
7
+
8
+ ```bash
9
+ pip install playwright-cdp-probe[mlx]
10
+ playwright install chromium
11
+ ```
12
+
13
+ - **MLX desktop agent** running locally (Launcher listens on `https://launcher.mlx.yt:45001`)
14
+ - **Bearer token** — `MLX_TOKEN` or `--token`
15
+ - **Folder UUID** — `MLX_FOLDER_ID` or `--folder-id` (Launcher start path needs folder + profile)
16
+
17
+ No cloud API calls are required for the probe itself; only the local Launcher is contacted.
18
+
19
+ ## Flow
20
+
21
+ ```mermaid
22
+ sequenceDiagram
23
+ participant CLI as cdp-probe mlx
24
+ participant L as MLX Launcher
25
+ participant PW as Playwright
26
+ participant Page as Profile browser
27
+
28
+ CLI->>L: GET /api/v2/profile/f/{folder}/p/{profile}/start
29
+ L-->>CLI: automation port (CDP HTTP)
30
+ CLI->>PW: connect_over_cdp(http://127.0.0.1:port)
31
+ PW->>Page: collect_signals + score
32
+ CLI->>L: GET /api/v1/profile/stop?profile_id=...
33
+ ```
34
+
35
+ 1. **Start** — Launcher opens the profile with `automation_type=playwright`.
36
+ 2. **CDP** — Playwright connects to `http://127.0.0.1:{port}` (WebSocket negotiated by Playwright).
37
+ 3. **Probe** — Same signal collection and exposure scoring as `cdp-probe run`.
38
+ 4. **Stop** — Profile is stopped via Launcher API (even when probe fails).
39
+
40
+ ## CLI
41
+
42
+ ```bash
43
+ export MLX_TOKEN=your_bearer_token
44
+ export MLX_FOLDER_ID=your-folder-uuid
45
+
46
+ cdp-probe mlx --profile-id PROFILE_UUID --url https://example.com
47
+ cdp-probe mlx --profile-id PROFILE_UUID --format json --output report.json
48
+ ```
49
+
50
+ `mlx-launch` remains as a hidden alias for backward compatibility.
51
+
52
+ ## Python API
53
+
54
+ ```python
55
+ from playwright_cdp_probe.integrations.mlx import mlx_launch_and_probe
56
+
57
+ report = mlx_launch_and_probe(
58
+ "PROFILE_UUID",
59
+ folder_id="FOLDER_UUID",
60
+ token="MLX_TOKEN",
61
+ url="https://example.com",
62
+ )
63
+ print(report.exposure.score, report.mode) # mode == "mlx"
64
+ ```
65
+
66
+ Pass a pre-built `MlxLauncherClient(transport=httpx.MockTransport(...))` in tests — see
67
+ `tests/test_integrations_mlx.py`.
68
+
69
+ ## Compare with local Playwright
70
+
71
+ | Command | Browser source | Typical use |
72
+ |---------|----------------|-------------|
73
+ | `cdp-probe check-local` | Stock Playwright Chromium | Baseline automation exposure |
74
+ | `cdp-probe mlx` | MLX isolated profile | Production-like antidetect fingerprint |
75
+
76
+ Run both and diff scores to quantify exposure reduction (not elimination).
77
+
78
+ ## Affiliate
79
+
80
+ New to Multilogin? See the README affiliate links ([SAAS50](https://multilogin.com/?ref=SAAS50) · [MIN50](https://multilogin.com/?ref=MIN50)).
docs/SCORING.md ADDED
@@ -0,0 +1,59 @@
1
+ # Exposure scoring rubric
2
+
3
+ `cdp-probe` scores **automation exposure** from 0 (few obvious automation artifacts) to 100 (many classic bot signals firing at once). The score is the **sum of triggered signal weights**, capped at 100. It is a checklist-style rubric, not a statistical model of detection probability.
4
+
5
+ ## How to read the score
6
+
7
+ | Score | CLI exit | Band | Meaning |
8
+ |-------|----------|------|---------|
9
+ | 0–30 | 0 | pass | Few or no weighted findings |
10
+ | 31–60 | 1 | warn | Some automation hints; review before production |
11
+ | 61–100 | 2 | fail | Strong automation fingerprint; likely flagged |
12
+
13
+ Internal grade labels (`low`, `moderate`, `high`, `critical`) are derived from the same score for display only.
14
+
15
+ ## Signal weights
16
+
17
+ Each row is independent: if the condition is true, its weight is added once.
18
+
19
+ | Signal key | Weight | Trigger condition |
20
+ |------------|--------|-------------------|
21
+ | `webdriver` | 35 | `navigator.webdriver === true` |
22
+ | `headless_ua` | 25 | User-Agent matches `/HeadlessChrome/i` |
23
+ | `no_plugins` | 12 | `navigator.plugins.length === 0` |
24
+ | `no_mime_types` | 8 | `navigator.mimeTypes.length === 0` |
25
+ | `missing_chrome_runtime` | 15 | Chromium probe and `window.chrome.runtime` is missing |
26
+ | `swiftshader_renderer` | 12 | WebGL renderer string contains `swiftshader` (software GL) |
27
+ | `window_size_mismatch` | 10 | Outer window is 0×0 or outer equals inner (common headless layout) |
28
+ | `zero_languages` | 5 | `navigator.languages` is empty |
29
+ | `zero_outer_dimensions` | 8 | `window.outerWidth` or `outerHeight` is 0 |
30
+
31
+ **Maximum theoretical sum:** 130 → **capped at 100** in code.
32
+
33
+ ## What we do *not* score
34
+
35
+ These are collected in the report JSON but **not** added to the score today:
36
+
37
+ - `permissions_api`, `notification_permission`
38
+ - `hardware_concurrency`, `device_memory`, `connection_rtt`
39
+ - `headless_launch` (launch flag recorded for headed vs headless comparison)
40
+ - CDP vs local launch mode
41
+
42
+ Keeping the rubric small makes CI thresholds predictable. See `playwright_cdp_probe/scoring.py` for the source of truth.
43
+
44
+ ## Example stacks
45
+
46
+ | Scenario | Typical findings | Approx. score |
47
+ |----------|------------------|---------------|
48
+ | Headed Chromium, normal profile | none | 0–15 |
49
+ | Headless Chromium default | `headless_ua`, `no_plugins`, `window_size_mismatch` | 40–55 |
50
+ | Headless + `webdriver` leak | above + `webdriver` | 75+ (fail) |
51
+
52
+ ## Tuning for CI
53
+
54
+ ```bash
55
+ cdp-probe run --url https://example.com --format github-actions
56
+ echo $? # 0 pass, 1 warn, 2 fail, 3 runtime error
57
+ ```
58
+
59
+ Adjust browser launch (headed, antidetect profile, MLX CDP) until score ≤ 30 for production gates.
@@ -0,0 +1,7 @@
1
+ """Playwright CDP automation exposure probe."""
2
+
3
+ from playwright_cdp_probe.probe import ProbeReport, ProbeRunner
4
+ from playwright_cdp_probe.scoring import compute_exposure_score
5
+
6
+ __all__ = ["ProbeReport", "ProbeRunner", "compute_exposure_score"]
7
+ __version__ = "0.2.0"
@@ -0,0 +1,247 @@
1
+ """Click CLI for cdp-probe."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from playwright_cdp_probe.exit_codes import EXIT_ERROR, exit_code_for_score
11
+ from playwright_cdp_probe.presets import PRESET_NAMES
12
+ from playwright_cdp_probe.probe import ProbeOptions, ProbeRunner
13
+ from playwright_cdp_probe.report import (
14
+ DEFAULT_REPORT_PATH,
15
+ ReportFormat,
16
+ format_report,
17
+ load_report,
18
+ save_report,
19
+ )
20
+
21
+
22
+ def _finish_probe(report, *, output_path: Path | None, fmt: ReportFormat) -> None:
23
+ path = save_report(report, output_path)
24
+ click.echo(format_report(report, fmt))
25
+ click.echo(f"\nReport saved: {path}", err=True)
26
+ sys.exit(exit_code_for_score(report.exposure.score))
27
+
28
+
29
+ @click.group(invoke_without_command=True)
30
+ @click.version_option(package_name="playwright-cdp-probe", prog_name="cdp-probe")
31
+ @click.option("--show-deal", is_flag=True, help="Print Multilogin coupon info and exit.")
32
+ @click.pass_context
33
+ def main(ctx: click.Context, show_deal: bool) -> None:
34
+ """Audit Playwright/CDP sessions for automation exposure signals."""
35
+ if show_deal:
36
+ from playwright_cdp_probe.deal import print_show_deal
37
+
38
+ print_show_deal()
39
+ ctx.exit(0)
40
+
41
+
42
+ @main.command("run")
43
+ @click.option("--url", default="https://example.com", show_default=True, help="Page to load.")
44
+ @click.option("--browser", type=click.Choice(["chromium", "firefox"]), default="chromium")
45
+ @click.option("--headless/--no-headless", default=True, show_default=True)
46
+ @click.option("--output", "output_path", type=click.Path(path_type=Path), default=None)
47
+ @click.option(
48
+ "--format",
49
+ "fmt",
50
+ type=click.Choice(["json", "table", "github-actions"]),
51
+ default="table",
52
+ show_default=True,
53
+ )
54
+ @click.option("--timeout", "timeout_ms", default=30_000, show_default=True, help="Navigation ms.")
55
+ @click.option(
56
+ "--preset",
57
+ type=click.Choice(PRESET_NAMES),
58
+ default=None,
59
+ help="Site-class signal weights from presets/ (see README).",
60
+ )
61
+ def run_cmd(
62
+ url: str,
63
+ browser: str,
64
+ headless: bool,
65
+ output_path: Path | None,
66
+ fmt: ReportFormat,
67
+ timeout_ms: int,
68
+ preset: str | None,
69
+ ) -> None:
70
+ """Launch Playwright, navigate, and score automation exposure."""
71
+ try:
72
+ runner = ProbeRunner(
73
+ ProbeOptions(
74
+ url=url,
75
+ browser=browser, # type: ignore[arg-type]
76
+ headless=headless,
77
+ mode="run",
78
+ timeout_ms=timeout_ms,
79
+ preset=preset,
80
+ )
81
+ )
82
+ _finish_probe(runner.run(), output_path=output_path, fmt=fmt)
83
+ except Exception as exc:
84
+ click.echo(f"::error title=cdp-probe::{exc}", err=True)
85
+ sys.exit(EXIT_ERROR)
86
+
87
+
88
+ @main.command("check-local")
89
+ @click.option("--browser", type=click.Choice(["chromium", "firefox"]), default="chromium")
90
+ @click.option("--headless/--no-headless", default=True, show_default=True)
91
+ @click.option("--output", "output_path", type=click.Path(path_type=Path), default=None)
92
+ @click.option(
93
+ "--format",
94
+ "fmt",
95
+ type=click.Choice(["json", "table", "github-actions"]),
96
+ default="table",
97
+ show_default=True,
98
+ )
99
+ def check_local_cmd(
100
+ browser: str,
101
+ headless: bool,
102
+ output_path: Path | None,
103
+ fmt: ReportFormat,
104
+ ) -> None:
105
+ """Probe a locally launched browser at about:blank."""
106
+ try:
107
+ runner = ProbeRunner(
108
+ ProbeOptions(
109
+ url="about:blank",
110
+ browser=browser, # type: ignore[arg-type]
111
+ headless=headless,
112
+ mode="check-local",
113
+ )
114
+ )
115
+ _finish_probe(runner.run(), output_path=output_path, fmt=fmt)
116
+ except Exception as exc:
117
+ click.echo(f"::error title=cdp-probe::{exc}", err=True)
118
+ sys.exit(EXIT_ERROR)
119
+
120
+
121
+ @main.command("report")
122
+ @click.option(
123
+ "--format",
124
+ "fmt",
125
+ type=click.Choice(["json", "table", "github-actions"]),
126
+ default="table",
127
+ )
128
+ @click.option("--input", "input_path", type=click.Path(path_type=Path), default=None)
129
+ def report_cmd(fmt: ReportFormat, input_path: Path | None) -> None:
130
+ """Print the last saved probe report."""
131
+ try:
132
+ report = load_report(input_path)
133
+ except (FileNotFoundError, ValueError) as exc:
134
+ click.echo(f"::error title=cdp-probe::{exc}", err=True)
135
+ sys.exit(EXIT_ERROR)
136
+ click.echo(format_report(report, fmt))
137
+ sys.exit(exit_code_for_score(report.exposure.score))
138
+
139
+
140
+ def _mlx_probe_cmd(
141
+ profile_id: str,
142
+ folder_id: str | None,
143
+ url: str,
144
+ token: str | None,
145
+ headless: bool,
146
+ output_path: Path | None,
147
+ fmt: ReportFormat,
148
+ *,
149
+ legacy_mode: str,
150
+ ) -> None:
151
+ from playwright_cdp_probe.integrations.mlx import mlx_launch_and_probe, require_mlx_extra
152
+
153
+ require_mlx_extra()
154
+ try:
155
+ report = mlx_launch_and_probe(
156
+ profile_id,
157
+ folder_id=folder_id,
158
+ token=token,
159
+ url=url,
160
+ headless=headless,
161
+ )
162
+ if legacy_mode:
163
+ report.mode = legacy_mode
164
+ path = save_report(report, output_path or DEFAULT_REPORT_PATH)
165
+ click.echo(format_report(report, fmt))
166
+ click.echo(f"\nReport saved: {path}", err=True)
167
+ sys.exit(exit_code_for_score(report.exposure.score))
168
+ except Exception as exc:
169
+ click.echo(f"::error title=cdp-probe::{exc}", err=True)
170
+ sys.exit(EXIT_ERROR)
171
+
172
+
173
+ def _mlx_click_options(func): # noqa: ANN001
174
+ options = [
175
+ click.option("--profile-id", required=True, help="MLX profile UUID."),
176
+ click.option(
177
+ "--folder-id",
178
+ envvar="MLX_FOLDER_ID",
179
+ help="MLX folder UUID (or MLX_FOLDER_ID).",
180
+ ),
181
+ click.option("--url", default="https://example.com", show_default=True),
182
+ click.option("--token", envvar="MLX_TOKEN", help="Bearer token (or MLX_TOKEN)."),
183
+ click.option("--headless/--no-headless", default=False, show_default=True),
184
+ click.option("--output", "output_path", type=click.Path(path_type=Path), default=None),
185
+ click.option(
186
+ "--format",
187
+ "fmt",
188
+ type=click.Choice(["json", "table", "github-actions"]),
189
+ default="table",
190
+ show_default=True,
191
+ ),
192
+ ]
193
+ for decorator in reversed(options):
194
+ func = decorator(func)
195
+ return func
196
+
197
+
198
+ @_mlx_click_options
199
+ @main.command("mlx")
200
+ def mlx_cmd(
201
+ profile_id: str,
202
+ folder_id: str | None,
203
+ url: str,
204
+ token: str | None,
205
+ headless: bool,
206
+ output_path: Path | None,
207
+ fmt: ReportFormat,
208
+ ) -> None:
209
+ """Start MLX profile via Launcher API, probe over CDP, then stop (requires [mlx])."""
210
+ _mlx_probe_cmd(
211
+ profile_id,
212
+ folder_id,
213
+ url,
214
+ token,
215
+ headless,
216
+ output_path,
217
+ fmt,
218
+ legacy_mode="",
219
+ )
220
+
221
+
222
+ @_mlx_click_options
223
+ @main.command("mlx-launch", hidden=True)
224
+ def mlx_launch_cmd(
225
+ profile_id: str,
226
+ folder_id: str | None,
227
+ url: str,
228
+ token: str | None,
229
+ headless: bool,
230
+ output_path: Path | None,
231
+ fmt: ReportFormat,
232
+ ) -> None:
233
+ """Deprecated alias for `cdp-probe mlx`."""
234
+ _mlx_probe_cmd(
235
+ profile_id,
236
+ folder_id,
237
+ url,
238
+ token,
239
+ headless,
240
+ output_path,
241
+ fmt,
242
+ legacy_mode="mlx-launch",
243
+ )
244
+
245
+
246
+ if __name__ == "__main__":
247
+ main()
@@ -0,0 +1,15 @@
1
+ """Multilogin partner coupon output for --show-deal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ SHOW_DEAL_TEXT = """Multilogin X — Antidetect Browser & Cloud Phone
8
+ SAAS50: 50% off Browser (new eligible purchases)
9
+ MIN50: 50% off Cloud Phone
10
+ https://anti-detect.github.io/
11
+ Scripts: https://t.me/Multilogin_Scripts_Bot"""
12
+
13
+
14
+ def print_show_deal() -> None:
15
+ click.echo(SHOW_DEAL_TEXT)
@@ -0,0 +1,34 @@
1
+ """CLI exit codes keyed to exposure score bands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ EXIT_PASS = 0
6
+ EXIT_WARN = 1
7
+ EXIT_FAIL = 2
8
+ EXIT_ERROR = 3
9
+
10
+ PASS_MAX = 30
11
+ WARN_MAX = 60
12
+
13
+
14
+ def exit_code_for_score(score: int) -> int:
15
+ """Map exposure score to process exit code.
16
+
17
+ - 0 pass: score ≤ 30
18
+ - 1 warn: score 31–60
19
+ - 2 fail: score > 60
20
+ """
21
+ if score <= PASS_MAX:
22
+ return EXIT_PASS
23
+ if score <= WARN_MAX:
24
+ return EXIT_WARN
25
+ return EXIT_FAIL
26
+
27
+
28
+ def band_label(score: int) -> str:
29
+ code = exit_code_for_score(score)
30
+ if code == EXIT_PASS:
31
+ return "pass"
32
+ if code == EXIT_WARN:
33
+ return "warn"
34
+ return "fail"
@@ -0,0 +1 @@
1
+ """Optional third-party integrations."""
@@ -0,0 +1,164 @@
1
+ """MLX Launcher: start profile → CDP probe → stop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import importlib.util
7
+ import os
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Literal
10
+
11
+ from playwright_cdp_probe.probe import ProbeOptions, ProbeRunner
12
+ from playwright_cdp_probe.report import ProbeReport
13
+
14
+ _MLX_HINT = "Install MLX support with: pip install playwright-cdp-probe[mlx]"
15
+
16
+
17
+ def require_mlx_extra() -> None:
18
+ if importlib.util.find_spec("httpx") is None:
19
+ raise ImportError(_MLX_HINT)
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class MlxSession:
24
+ """Active MLX Launcher automation session."""
25
+
26
+ profile_id: str
27
+ folder_id: str
28
+ http_endpoint: str
29
+ port: str
30
+ raw: dict[str, Any] = field(default_factory=dict)
31
+
32
+
33
+ class MlxLauncherClient:
34
+ """httpx client for MLX Launcher start/stop → local CDP HTTP endpoint."""
35
+
36
+ def __init__(
37
+ self,
38
+ *,
39
+ token: str | None = None,
40
+ base_url: str = "https://launcher.mlx.yt:45001",
41
+ timeout: float = 120.0,
42
+ transport: Any | None = None,
43
+ ) -> None:
44
+ require_mlx_extra()
45
+ import httpx # noqa: PLC0415
46
+
47
+ kwargs: dict[str, Any] = {
48
+ "base_url": base_url.rstrip("/"),
49
+ "timeout": timeout,
50
+ "verify": True,
51
+ }
52
+ if transport is not None:
53
+ kwargs["transport"] = transport
54
+ self._client = httpx.Client(**kwargs)
55
+ self._token = token
56
+
57
+ def close(self) -> None:
58
+ self._client.close()
59
+
60
+ def _headers(self) -> dict[str, str]:
61
+ headers = {"Accept": "application/json"}
62
+ if self._token:
63
+ headers["Authorization"] = f"Bearer {self._token}"
64
+ return headers
65
+
66
+ def start_profile(
67
+ self,
68
+ folder_id: str,
69
+ profile_id: str,
70
+ *,
71
+ automation_type: Literal["playwright", "puppeteer", "selenium"] = "playwright",
72
+ headless: bool = False,
73
+ ) -> MlxSession:
74
+ path = f"/api/v2/profile/f/{folder_id}/p/{profile_id}/start"
75
+ response = self._client.get(
76
+ path,
77
+ headers=self._headers(),
78
+ params={
79
+ "automation_type": automation_type,
80
+ "headless_mode": str(headless).lower(),
81
+ },
82
+ )
83
+ body = response.json()
84
+ if response.status_code >= 400:
85
+ status = body.get("status", {}) if isinstance(body, dict) else {}
86
+ message = status.get("message") if isinstance(status, dict) else response.text
87
+ raise RuntimeError(message or f"Launcher HTTP {response.status_code}")
88
+
89
+ data = body.get("data", {}) if isinstance(body, dict) else {}
90
+ port = data.get("port")
91
+ if port is None:
92
+ raise RuntimeError("Launcher did not return automation port")
93
+
94
+ return MlxSession(
95
+ profile_id=profile_id,
96
+ folder_id=folder_id,
97
+ http_endpoint=f"http://127.0.0.1:{port}",
98
+ port=str(port),
99
+ raw=data if isinstance(data, dict) else {},
100
+ )
101
+
102
+ def stop_profile(self, profile_id: str) -> dict[str, Any]:
103
+ response = self._client.get(
104
+ "/api/v1/profile/stop",
105
+ headers=self._headers(),
106
+ params={"profile_id": profile_id},
107
+ )
108
+ body = response.json()
109
+ if response.status_code >= 400:
110
+ status = body.get("status", {}) if isinstance(body, dict) else {}
111
+ message = status.get("message") if isinstance(status, dict) else response.text
112
+ raise RuntimeError(message or f"Launcher HTTP {response.status_code}")
113
+ if isinstance(body, dict):
114
+ data = body.get("data")
115
+ return data if isinstance(data, dict) else {"raw": data}
116
+ return {"raw": body}
117
+
118
+
119
+ def mlx_launch_and_probe(
120
+ profile_id: str,
121
+ *,
122
+ folder_id: str | None = None,
123
+ token: str | None = None,
124
+ url: str = "https://example.com",
125
+ headless: bool = False,
126
+ timeout_ms: int = 30_000,
127
+ launcher: MlxLauncherClient | None = None,
128
+ runner: ProbeRunner | None = None,
129
+ ) -> ProbeReport:
130
+ """Start MLX profile, connect Playwright over CDP, probe, then stop the profile."""
131
+ resolved_folder = folder_id or os.environ.get("MLX_FOLDER_ID")
132
+ resolved_token = token or os.environ.get("MLX_TOKEN")
133
+ if not resolved_folder:
134
+ raise ValueError("folder_id or MLX_FOLDER_ID is required")
135
+ if not resolved_token:
136
+ raise ValueError("token or MLX_TOKEN is required")
137
+
138
+ owns_launcher = launcher is None
139
+ client = launcher or MlxLauncherClient(token=resolved_token)
140
+ try:
141
+ session = client.start_profile(
142
+ resolved_folder,
143
+ profile_id,
144
+ automation_type="playwright",
145
+ headless=headless,
146
+ )
147
+ try:
148
+ probe = runner or ProbeRunner(
149
+ ProbeOptions(
150
+ url=url,
151
+ browser="chromium",
152
+ cdp_endpoint=session.http_endpoint,
153
+ mode="mlx",
154
+ headless=headless,
155
+ timeout_ms=timeout_ms,
156
+ )
157
+ )
158
+ return probe.run()
159
+ finally:
160
+ with contextlib.suppress(RuntimeError):
161
+ client.stop_profile(profile_id)
162
+ finally:
163
+ if owns_launcher:
164
+ client.close()