Skip to content

Commit 2a376b5

Browse files
committed
test: add live stealth report
1 parent 321ef70 commit 2a376b5

7 files changed

Lines changed: 828 additions & 39 deletions

README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,54 @@ and deviceandbrowserinfo behavioral detection. See
267267
`stealth` extra; without a `user_data_dir` it uses a throwaway persistent profile,
268268
which patchright requires for full stealth.
269269

270+
Generate the live headed/headless graph report with:
271+
272+
```bash
273+
$env:WEBSKRAP_LIVE=1
274+
python scripts\live_stealth_report.py --no-open --report-only
275+
```
276+
277+
Open `.webskrap/reports/live-stealth-results.html`.
278+
279+
For proxy DNS checks, set `WEBSKRAP_LIVE_EXPECTED_PUBLIC_IP` or
280+
`WEBSKRAP_LIVE_EXPECTED_COUNTRY`.
281+
282+
## Comparison
283+
284+
CloakBrowser values below are copied from its
285+
[upstream README](https://github.com/CloakHQ/CloakBrowser/blob/main/README.md).
286+
WebSkrap values are from the local live report generated on 2026-06-26 with
287+
`python scripts\live_stealth_report.py --no-open --report-only`.
288+
289+
| Feature | Playwright | playwright-stealth | undetected-chromedriver | Camoufox | CloakBrowser | WebSkrap patchright |
290+
|---|---|---|---|---|---|---|
291+
| reCAPTCHA v3 score | 0.1 | 0.3-0.5 | 0.3-0.7 | 0.7-0.9 | **0.9** | Pass in headed mode (`>=0.7` gate) |
292+
| Cloudflare Turnstile | Fail | Sometimes | Sometimes | Pass | **Pass** | Pass headed; renders headless |
293+
| Patch level | None | JS injection | Config patches | C++ (Firefox) | **C++ (Chromium)** | Patchright browser driver + Chrome flags |
294+
| Survives Chrome updates | N/A | Breaks often | Breaks often | Yes | **Yes** | Depends on Chrome + Patchright compatibility |
295+
| Maintained | Yes | Stale | Stale | Unstable | **Active** | Active project tests |
296+
| Browser engine | Chromium | Chromium | Chrome | Firefox | **Chromium** | Chrome/Chromium |
297+
| Playwright API | Native | Native | No (Selenium) | No | **Native** | Native-compatible |
298+
299+
| Detection Service | Stock Playwright | CloakBrowser | WebSkrap patchright headed | Notes |
300+
|---|---|---|---|---|
301+
| **reCAPTCHA v3** | 0.1 (bot) | **0.9** (human) | **PASS** | WebSkrap asserts score `>=0.7` when Google's demo returns one |
302+
| **Cloudflare Turnstile** (non-interactive) | FAIL | **PASS** | **PASS** | Public demo returns a token and success JSON |
303+
| **FingerprintJS** bot detection | DETECTED | **PASS** | **PASS** | `demo.fingerprint.com/web-scraping` returns demo data |
304+
| **BrowserScan** bot detection | DETECTED | **NORMAL** (4/4) | **PASS** | 0 abnormal checks in headed run |
305+
| **bot.incolumitas.com** | 13 fails | **1 fail** | **PASS** | Only tolerated network/spec false positives |
306+
| **deviceandbrowserinfo.com** | 6 true flags | **0 true flags** | **PASS** | `isBot: false` |
307+
| **bot.sannysoft.com** | DETECTED | Not listed | **TIMEOUT** | Latest run timed out waiting for `networkidle` |
308+
| **BrowserLeaks WebRTC** | Not listed | Not listed | **PASS** | No private ICE candidate IPs exposed |
309+
| **BrowserLeaks Client Hints** | Not listed | Not listed | **PASS** | No `HeadlessChrome` token |
310+
| **TLS / JA3 visibility** | Mismatch | **Identical to Chrome** | **PASS** | TLS/JA3/JA4 surface is visible; no proxy mismatch without proxy |
311+
| **DNS leak standard test** | Not listed | Not listed | **PASS** | Resolver rows are public; optional proxy country/IP expectations supported |
312+
313+
Latest WebSkrap live summary: 25 passed, 2 failed, 1 skipped. Headed: 17
314+
passed, 1 failed. Headless: 7 passed, 1 failed, 1 skipped. The two failures
315+
were Sannysoft headed/headless `networkidle` timeouts; the headless skip was
316+
reCAPTCHA v3 not returning a score from Google's public demo.
317+
270318
### Headless patchright
271319

272320
Headed patchright is the strongest stealth mode. For best-effort headless runs,

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "webskrap"
7-
version = "0.4.8"
7+
version = "0.4.9"
88
description = "A Playwright-based Python scraping framework with coherent browser profiles and session controls."
99
readme = "README.md"
1010
requires-python = ">=3.11"

scripts/live_stealth_report.py

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import json
5+
import os
6+
import sys
7+
import time
8+
import webbrowser
9+
from collections import Counter, defaultdict
10+
from dataclasses import dataclass
11+
from datetime import UTC, datetime
12+
from html import escape
13+
from pathlib import Path
14+
from typing import Any
15+
16+
import pytest
17+
18+
ROOT = Path(__file__).resolve().parents[1]
19+
DEFAULT_REPORT_DIR = ROOT / ".webskrap" / "reports"
20+
DEFAULT_JSON = DEFAULT_REPORT_DIR / "live-stealth-results.json"
21+
DEFAULT_HTML = DEFAULT_REPORT_DIR / "live-stealth-results.html"
22+
MATRIX = ROOT / "bot_detection_test_sites.md"
23+
SUITES = (
24+
ROOT / "tests" / "test_bot_detection.py",
25+
ROOT / "tests" / "test_bot_detection_headless.py",
26+
ROOT / "tests" / "test_bot_detection_sites_matrix.py",
27+
)
28+
29+
30+
@dataclass
31+
class TestResult:
32+
nodeid: str
33+
suite: str
34+
name: str
35+
outcome: str
36+
duration: float
37+
failure: str = ""
38+
39+
def to_json(self) -> dict[str, Any]:
40+
return {
41+
"nodeid": self.nodeid,
42+
"suite": self.suite,
43+
"name": self.name,
44+
"outcome": self.outcome,
45+
"duration": round(self.duration, 3),
46+
"failure": self.failure,
47+
}
48+
49+
50+
class ResultCollector:
51+
def __init__(self) -> None:
52+
self._reports: dict[str, list[Any]] = defaultdict(list)
53+
54+
def pytest_runtest_logreport(self, report: Any) -> None:
55+
self._reports[report.nodeid].append(report)
56+
57+
def results(self) -> list[TestResult]:
58+
results: list[TestResult] = []
59+
for nodeid, reports in sorted(self._reports.items()):
60+
duration = sum(float(report.duration) for report in reports)
61+
report = next((r for r in reports if r.failed), None)
62+
report = report or next((r for r in reports if r.skipped), None)
63+
report = report or next((r for r in reports if r.when == "call"), reports[-1])
64+
results.append(
65+
TestResult(
66+
nodeid=nodeid,
67+
suite=suite_for_nodeid(nodeid),
68+
name=nodeid.rsplit("::", 1)[-1],
69+
outcome=str(report.outcome),
70+
duration=duration,
71+
failure=longrepr_text(report),
72+
)
73+
)
74+
return results
75+
76+
77+
def longrepr_text(report: Any) -> str:
78+
if not getattr(report, "failed", False):
79+
return ""
80+
return str(getattr(report, "longrepr", ""))[-2000:]
81+
82+
83+
def suite_for_nodeid(nodeid: str) -> str:
84+
path = nodeid.split("::", 1)[0].replace("\\", "/")
85+
if path.endswith("test_bot_detection_headless.py"):
86+
return "headless"
87+
if path.endswith("test_bot_detection_sites_matrix.py"):
88+
return "matrix"
89+
if path.endswith("test_bot_detection.py"):
90+
return "headed"
91+
return "other"
92+
93+
94+
def summarize(results: list[TestResult]) -> dict[str, Any]:
95+
overall = Counter(result.outcome for result in results)
96+
by_suite: dict[str, dict[str, int]] = {}
97+
for suite in sorted({result.suite for result in results}):
98+
counts = Counter(result.outcome for result in results if result.suite == suite)
99+
by_suite[suite] = dict(counts)
100+
return {"overall": dict(overall), "by_suite": by_suite}
101+
102+
103+
def category_counts(matrix: Path = MATRIX) -> dict[str, int]:
104+
if not matrix.exists():
105+
return {}
106+
counts: Counter[str] = Counter()
107+
for line in matrix.read_text(encoding="utf-8").splitlines():
108+
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
109+
if len(cells) >= 5 and cells[0].lower() == "active":
110+
counts[cells[1]] += 1
111+
return dict(sorted(counts.items()))
112+
113+
114+
def comparison_rows(results: list[TestResult]) -> list[dict[str, str]]:
115+
pairs: dict[str, dict[str, str]] = defaultdict(dict)
116+
for result in results:
117+
if result.suite not in {"headed", "headless"}:
118+
continue
119+
name = result.name.removesuffix("_headless")
120+
pairs[name][result.suite] = result.outcome
121+
return [
122+
{"test": name, "headed": pair.get("headed", "-"), "headless": pair.get("headless", "-")}
123+
for name, pair in sorted(pairs.items())
124+
]
125+
126+
127+
def bar_chart(title: str, values: dict[str, int], colors: dict[str, str] | None = None) -> str:
128+
colors = colors or {}
129+
width = 640
130+
row_height = 28
131+
label_width = 190
132+
max_value = max(values.values(), default=1)
133+
height = 44 + row_height * max(1, len(values))
134+
rows = [f'<h2>{escape(title)}</h2><svg viewBox="0 0 {width} {height}" role="img">']
135+
rows.append(f'<text x="0" y="18" class="chart-title">{escape(title)}</text>')
136+
for index, (label, value) in enumerate(values.items()):
137+
y = 34 + index * row_height
138+
bar_width = int((width - label_width - 60) * (value / max_value)) if value else 0
139+
color = colors.get(label, "#3b82f6")
140+
rows.append(f'<text x="0" y="{y + 15}">{escape(label)}</text>')
141+
rows.append(
142+
f'<rect x="{label_width}" y="{y}" width="{bar_width}" height="18" fill="{color}" />'
143+
)
144+
rows.append(f'<text x="{label_width + bar_width + 8}" y="{y + 15}">{value}</text>')
145+
rows.append("</svg>")
146+
return "\n".join(rows)
147+
148+
149+
def render_html(payload: dict[str, Any]) -> str:
150+
results = [TestResult(**item) for item in payload["tests"]]
151+
summary = payload["summary"]
152+
categories = payload.get("matrix_categories", {})
153+
colors = {"passed": "#15803d", "failed": "#b91c1c", "skipped": "#a16207"}
154+
suite_values = {
155+
suite: sum(counts.values()) for suite, counts in summary.get("by_suite", {}).items()
156+
}
157+
rows = "\n".join(
158+
"<tr>"
159+
f"<td>{escape(row['test'])}</td>"
160+
f"<td class='{escape(row['headed'])}'>{escape(row['headed'])}</td>"
161+
f"<td class='{escape(row['headless'])}'>{escape(row['headless'])}</td>"
162+
"</tr>"
163+
for row in comparison_rows(results)
164+
)
165+
failures = "\n".join(
166+
"<details><summary>"
167+
f"{escape(result.suite)} / {escape(result.name)}"
168+
"</summary><pre>"
169+
f"{escape(result.failure)}"
170+
"</pre></details>"
171+
for result in results
172+
if result.outcome == "failed"
173+
)
174+
failures = failures or "<p>No failures captured.</p>"
175+
return f"""<!doctype html>
176+
<html lang="en">
177+
<head>
178+
<meta charset="utf-8">
179+
<title>WebSkrap Live Stealth Results</title>
180+
<style>
181+
body {{ font-family: system-ui, sans-serif; margin: 32px; color: #172033; }}
182+
h1 {{ margin-bottom: 4px; }}
183+
h2 {{ margin-top: 28px; }}
184+
.meta {{ color: #526070; }}
185+
svg {{ width: 100%; max-width: 760px; height: auto; display: block; margin: 8px 0 20px; }}
186+
text {{ font-size: 13px; fill: #172033; }}
187+
.chart-title {{ font-weight: 700; }}
188+
table {{ border-collapse: collapse; width: 100%; max-width: 920px; }}
189+
th, td {{ border-bottom: 1px solid #d9e0ea; padding: 8px; text-align: left; }}
190+
.passed {{ color: #15803d; font-weight: 700; }}
191+
.failed {{ color: #b91c1c; font-weight: 700; }}
192+
.skipped {{ color: #a16207; font-weight: 700; }}
193+
pre {{ white-space: pre-wrap; background: #f6f8fb; padding: 12px; overflow: auto; }}
194+
</style>
195+
</head>
196+
<body>
197+
<h1>WebSkrap Live Stealth Results</h1>
198+
<p class="meta">
199+
Started {escape(payload["started_at"])}; duration {payload["duration_seconds"]:.1f}s.
200+
</p>
201+
{bar_chart("Overall Outcomes", summary.get("overall", {}), colors)}
202+
{bar_chart("Tests Per Suite", suite_values)}
203+
{bar_chart("Matrix Category Coverage", categories)}
204+
<h2>Headed vs Headless</h2>
205+
<table>
206+
<thead><tr><th>Test</th><th>Headed</th><th>Headless</th></tr></thead>
207+
<tbody>{rows}</tbody>
208+
</table>
209+
<h2>Failures</h2>
210+
{failures}
211+
</body>
212+
</html>
213+
"""
214+
215+
216+
def build_payload(results: list[TestResult], started: float, finished: float) -> dict[str, Any]:
217+
return {
218+
"started_at": datetime.fromtimestamp(started, UTC).isoformat(),
219+
"finished_at": datetime.fromtimestamp(finished, UTC).isoformat(),
220+
"duration_seconds": round(finished - started, 3),
221+
"environment": {
222+
"browser_channel": os.environ.get("WEBSKRAP_BROWSER_CHANNEL", "chrome"),
223+
"headed_profile_dir": os.environ.get(
224+
"WEBSKRAP_LIVE_PROFILE_DIR", ".webskrap/live-stealth-profile"
225+
),
226+
"headless_profile_dir": os.environ.get(
227+
"WEBSKRAP_LIVE_HEADLESS_PROFILE_DIR", ".webskrap/live-headless-profile"
228+
),
229+
"proxy": bool(os.environ.get("WEBSKRAP_LIVE_PROXY")),
230+
},
231+
"tests": [result.to_json() for result in results],
232+
"summary": summarize(results),
233+
"matrix_categories": category_counts(),
234+
}
235+
236+
237+
def run_pytest() -> tuple[int, list[TestResult], float, float]:
238+
os.environ["WEBSKRAP_LIVE"] = "1"
239+
os.environ["PYTHONPATH"] = str(ROOT / "src")
240+
sys.path.insert(0, str(ROOT / "src"))
241+
collector = ResultCollector()
242+
started = time.time()
243+
code = pytest.main(["-q", *(str(path) for path in SUITES)], plugins=[collector])
244+
finished = time.time()
245+
return int(code), collector.results(), started, finished
246+
247+
248+
def write_report(json_path: Path, html_path: Path, payload: dict[str, Any]) -> None:
249+
json_path.parent.mkdir(parents=True, exist_ok=True)
250+
html_path.parent.mkdir(parents=True, exist_ok=True)
251+
json_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
252+
html_path.write_text(render_html(payload), encoding="utf-8")
253+
254+
255+
def parse_args() -> argparse.Namespace:
256+
parser = argparse.ArgumentParser(description="Run live stealth tests and graph the results.")
257+
parser.add_argument("--json", type=Path, default=DEFAULT_JSON, help="JSON output path.")
258+
parser.add_argument("--html", type=Path, default=DEFAULT_HTML, help="HTML output path.")
259+
parser.add_argument("--no-open", action="store_true", help="Do not open the HTML report.")
260+
parser.add_argument(
261+
"--report-only",
262+
"--always-zero",
263+
action="store_true",
264+
help="Write reports and exit 0 even if live tests fail.",
265+
)
266+
return parser.parse_args()
267+
268+
269+
def main() -> int:
270+
args = parse_args()
271+
code, results, started, finished = run_pytest()
272+
payload = build_payload(results, started, finished)
273+
write_report(args.json, args.html, payload)
274+
print(f"Wrote {args.json}")
275+
print(f"Wrote {args.html}")
276+
if not args.no_open:
277+
webbrowser.open(args.html.resolve().as_uri())
278+
if args.report_only:
279+
return 0
280+
return code
281+
282+
283+
if __name__ == "__main__":
284+
raise SystemExit(main())

0 commit comments

Comments
 (0)