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 +52 -0
- docs/FAQ.md +21 -0
- docs/MLX_INTEGRATION.md +80 -0
- docs/SCORING.md +59 -0
- playwright_cdp_probe/__init__.py +7 -0
- playwright_cdp_probe/cli.py +247 -0
- playwright_cdp_probe/deal.py +15 -0
- playwright_cdp_probe/exit_codes.py +34 -0
- playwright_cdp_probe/integrations/__init__.py +1 -0
- playwright_cdp_probe/integrations/mlx.py +164 -0
- playwright_cdp_probe/mlx.py +23 -0
- playwright_cdp_probe/presets.py +71 -0
- playwright_cdp_probe/probe.py +92 -0
- playwright_cdp_probe/report.py +157 -0
- playwright_cdp_probe/scoring.py +148 -0
- playwright_cdp_probe/signals.py +160 -0
- playwright_cdp_probe-0.2.0.dist-info/METADATA +237 -0
- playwright_cdp_probe-0.2.0.dist-info/RECORD +24 -0
- playwright_cdp_probe-0.2.0.dist-info/WHEEL +4 -0
- playwright_cdp_probe-0.2.0.dist-info/entry_points.txt +2 -0
- playwright_cdp_probe-0.2.0.dist-info/licenses/LICENSE +21 -0
- presets/cloudflare.json +16 -0
- presets/social-login.json +16 -0
- presets/stripe-dashboard.json +16 -0
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.
|
docs/MLX_INTEGRATION.md
ADDED
|
@@ -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()
|