diff --git a/README.md b/README.md index 8ff429b..6d44db9 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ Drop-in Playwright/Puppeteer replacement for Python and JavaScript. Same API, same code — just swap the import. Your browser now scores **0.9 on reCAPTCHA v3**, passes **Cloudflare Turnstile**, and clears **30 out of 30** stealth detection tests. -- 🔒 **16 source-level C++ patches** — not JS injection, not config flags +- 🔒 **22 source-level C++ patches** — not JS injection, not config flags +- 🛡️ **CDP stealth built-in** — powered by [Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright), hides Playwright's automation signals - 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified - ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 30/30 tests - 🔄 **Drop-in replacement** — works with Playwright (Python & JS) and Puppeteer (JS) @@ -79,11 +80,12 @@ On first run, the stealth Chromium binary is automatically downloaded (~200MB, c - **Config-level patches break** — `playwright-stealth`, `undetected-chromedriver`, and `puppeteer-extra` inject JavaScript or tweak flags. Every Chrome update breaks them. Antibot systems detect the patches themselves. - **CloakBrowser patches Chromium source code** — fingerprints are modified at the C++ level, compiled into the binary. Detection sites see a real browser because it *is* a real browser. +- **Two layers of stealth** — C++ patches handle fingerprints (GPU, screen, UA, voices, media devices), while the Patchright driver eliminates CDP automation leaks (Runtime.enable, Chrome flags, console detection). Most stealth tools only do one or the other. - **One line to switch** — same Playwright API, no new abstractions, no CAPTCHA-solving services. ## Test Results -All tests verified against live detection services. Last tested: Feb 2026 (Chromium 142). +All tests verified against live detection services. Last tested: Feb 2026 (Chromium 145). | Detection Service | Stock Playwright | CloakBrowser | Notes | |---|---|---|---| @@ -98,7 +100,7 @@ All tests verified against live detection services. Last tested: Feb 2026 (Chrom | `navigator.webdriver` | `true` | **`false`** | Source-level patch | | `navigator.plugins.length` | 0 | **5** | Real plugin list | | `window.chrome` | `undefined` | **`object`** | Present like real Chrome | -| UA string | `HeadlessChrome` | **`Chrome/142.0.0.0`** | No headless leak | +| UA string | `HeadlessChrome` | **`Chrome/145.0.0.0`** | No headless leak | | CDP detection | Detected | **Not detected** | `isAutomatedWithCDP: false` | | TLS fingerprint | Mismatch | **Identical to Chrome** | ja3n/ja4/akamai match | @@ -140,17 +142,7 @@ CloakBrowser is a thin wrapper (Python + JavaScript) around a custom-built Chrom 3. **Every launch** → Playwright or Puppeteer starts with our binary + stealth args 4. **You write code** → standard Playwright/Puppeteer API, nothing new to learn -The binary includes 16 source-level patches that modify: -- Canvas fingerprint generation -- WebGL renderer output -- Audio processing fingerprint -- Font enumeration results -- Hardware concurrency reporting -- Client rect measurements -- GPU vendor/renderer strings -- WebDriver flag -- Headless detection signals -- And more... +The binary includes 26 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, hardware reporting, and automation signal removal. These are compiled into the Chromium binary — not injected via JavaScript, not set via flags. @@ -462,7 +454,7 @@ page.goto("https://example.com") | Linux x64 binary | ✅ Released | | macOS arm64 (Apple Silicon) | ✅ Released | | macOS x64 (Intel) | ✅ Released | -| Chromium 145 build | 🔜 In progress | +| Chromium 145 build | ✅ Released | | JavaScript/Puppeteer + Playwright support | ✅ Released | | Fingerprint rotation per session | ✅ Released | | Built-in proxy rotation | 📋 Planned | @@ -523,6 +515,20 @@ This runs a real headed browser rendered on a virtual display — no physical mo ## Troubleshooting +**Reddit or similar sites show CAPTCHA / "Prove your humanity"** + +Some sites (notably Reddit homepage) use HTTP/2 fingerprinting that detects Playwright's connection layer. Pass `--disable-http2` to fall back to HTTP/1.1: + +```python +browser = launch(args=["--disable-http2"]) +``` + +```javascript +const browser = await launch({ args: ['--disable-http2'] }); +``` + +Only use this flag for sites that require it — most sites work fine with HTTP/2. + **Binary download fails / timeout** Set a custom download URL or use a local binary: ```bash @@ -532,7 +538,7 @@ export CLOAKBROWSER_BINARY_PATH=/path/to/your/chrome **"playwright install" vs CloakBrowser binary** You do NOT need `playwright install chromium`. CloakBrowser downloads its own binary. You only need Playwright's system deps: ```bash -playwright install-deps chromium +patchright install-deps chromium ``` **reCAPTCHA v3 scores are low (0.1–0.3)** diff --git a/cloakbrowser/browser.py b/cloakbrowser/browser.py index 0d17f6e..15950e6 100644 --- a/cloakbrowser/browser.py +++ b/cloakbrowser/browser.py @@ -18,7 +18,7 @@ import logging from typing import Any from urllib.parse import unquote, urlparse, urlunparse -from .config import get_default_stealth_args +from .config import DEFAULT_VIEWPORT, get_default_stealth_args from .download import ensure_binary logger = logging.getLogger("cloakbrowser") @@ -61,7 +61,7 @@ def launch( >>> print(page.title()) >>> browser.close() """ - from playwright.sync_api import sync_playwright + from patchright.sync_api import sync_playwright binary_path = ensure_binary() timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale) @@ -129,7 +129,7 @@ async def launch_async( >>> >>> asyncio.run(main()) """ - from playwright.async_api import async_playwright + from patchright.async_api import async_playwright binary_path = ensure_binary() timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale) @@ -168,6 +168,7 @@ def launch_context( viewport: dict | None = None, locale: str | None = None, timezone_id: str | None = None, + color_scheme: str | None = None, geoip: bool = False, **kwargs: Any, ) -> Any: @@ -185,6 +186,9 @@ def launch_context( viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}. locale: Browser locale, e.g. "en-US". timezone_id: Timezone, e.g. "America/New_York". + color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'. + Default: None (uses Chromium default, which is 'light'). + Note: 'no-preference' doesn't work in Patchright (falls back to 'light'). geoip: Auto-detect timezone/locale from proxy IP (default False). **kwargs: Passed to browser.new_context(). @@ -200,12 +204,13 @@ def launch_context( context_kwargs: dict[str, Any] = {} if user_agent: context_kwargs["user_agent"] = user_agent - if viewport: - context_kwargs["viewport"] = viewport + context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT if locale: context_kwargs["locale"] = locale if timezone_id: context_kwargs["timezone_id"] = timezone_id + if color_scheme: + context_kwargs["color_scheme"] = color_scheme context_kwargs.update(kwargs) try: diff --git a/cloakbrowser/config.py b/cloakbrowser/config.py index 4db1cc1..d7d0ed3 100644 --- a/cloakbrowser/config.py +++ b/cloakbrowser/config.py @@ -45,8 +45,20 @@ def get_default_stealth_args() -> list[str]: "--fingerprint-hardware-concurrency=8", "--fingerprint-gpu-vendor=NVIDIA Corporation", "--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070", + "--fingerprint-taskbar-height=40", + "--fingerprint-screen-width=1920", + "--fingerprint-screen-height=1080", + "--window-size=1920,1080", ] + +# --------------------------------------------------------------------------- +# Default viewport — realistic maximized Chrome on 1080p Windows +# screen=1920x1080, availHeight=1040 (minus 40px taskbar), +# innerHeight=955 (minus ~85px Chrome UI: tabs + address bar + bookmarks) +# --------------------------------------------------------------------------- +DEFAULT_VIEWPORT = {"width": 1920, "height": 955} + # --------------------------------------------------------------------------- # Platform detection # --------------------------------------------------------------------------- diff --git a/examples/fingerprint_scan_test.py b/examples/fingerprint_scan_test.py new file mode 100644 index 0000000..e390fc5 --- /dev/null +++ b/examples/fingerprint_scan_test.py @@ -0,0 +1,227 @@ +"""Test against fingerprint-scan.com and CreepJS. + +Tests the specific headless detection signals flagged by the community: +- noTaskbar, noContentIndex, noContactsManager, noDownlinkMax +- Bot risk score (fingerprint-scan.com) +- Headless/stealth percentages (CreepJS) +- Full CreepJS signal breakdown (likeHeadless, headless, stealth) + +Usage: + python examples/fingerprint_scan_test.py + python examples/fingerprint_scan_test.py --proxy http://10.50.96.5:8888 + python examples/fingerprint_scan_test.py --headless +""" + +import sys + +from cloakbrowser import launch_context + +HEADLESS = "--headless" in sys.argv +PROXY = None +for i, arg in enumerate(sys.argv): + if arg == "--proxy" and i + 1 < len(sys.argv): + PROXY = sys.argv[i + 1] + + +def test_fingerprint_scan(page): + """fingerprint-scan.com — bot risk score + headless detection signals.""" + print("=== fingerprint-scan.com ===") + page.goto("https://fingerprint-scan.com/", wait_until="domcontentloaded", timeout=30000) + page.wait_for_timeout(20000) # Castle.js needs time to compute score + + # Check bot risk score + score = page.evaluate( + 'document.getElementById("fingerprintScore")?.textContent || "Score not rendered"' + ) + print(f"Bot Risk Score: {score}") + + # Check headless detection signals + apis = page.evaluate("""() => ({ + noTaskbar: screen.height === screen.availHeight, + taskbarSize: screen.height - screen.availHeight, + noContentIndex: typeof window.ContentIndex === "undefined", + noContactsManager: !("contacts" in navigator), + noDownlinkMax: !("downlinkMax" in (navigator.connection || {})), + downlinkMax: navigator.connection?.downlinkMax ?? null, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + webdriver: navigator.webdriver, + isPlaywright: "__pwInitScripts" in window || "__playwright__binding__" in window, + webgpu: typeof navigator.gpu !== "undefined" ? "available" : "NOT_AVAILABLE", + scrollbarWidth: (() => { const d = document.createElement("div"); d.style.cssText = "overflow:scroll;width:100px;height:100px;position:absolute;top:-999px"; document.body.appendChild(d); const w = d.offsetWidth - d.clientWidth; d.remove(); return w; })() + })""") + + print("\nHeadless detection signals:") + headless_fails = 0 + for k, v in apis.items(): + is_fail = k.startswith("no") and v is True + if is_fail: + headless_fails += 1 + flag = "FAIL" if is_fail else "" + print(f" {k}: {v} {flag}") + + # Extract bot test results from page + bot_tests = page.evaluate("""() => { + const text = document.body.innerText; + const tests = {}; + for (const key of ['WebDriver', 'Is Selenium Chrome', 'CDP Check', 'Is Playwright']) { + const match = text.match(new RegExp(key + '\\\\s+(true|false)')); + if (match) tests[key] = match[1]; + } + return tests; + }""") + print("\nBot Detection Tests:") + for k, v in bot_tests.items(): + status = "PASS" if v == "false" else "FAIL" + print(f" {k}: {v} [{status}]") + + page.screenshot(path="/results/fingerprint-scan.png", full_page=True) + print("\nScreenshot: /results/fingerprint-scan.png") + + return { + "score": score, + "headless_fails": headless_fails, + "apis": apis, + "bot_tests": bot_tests, + } + + +def test_creepjs(page): + """abrahamjuliot.github.io/creepjs — comprehensive fingerprint analysis.""" + print("\n=== CreepJS ===") + page.goto( + "https://abrahamjuliot.github.io/creepjs/", wait_until="domcontentloaded", timeout=30000 + ) + print("Waiting 30s for CreepJS analysis...") + page.wait_for_timeout(30000) + + # Extract % scores from page text (matches test-infra/matrix_tests/group3_bot_detection.py) + scores = page.evaluate("""() => { + const text = document.body.innerText; + const likeMatch = text.match(/(\\d+)%\\s*like headless/i); + const headlessMatch = text.match(/(\\d+)%\\s*headless:/i); + const stealthMatch = text.match(/(\\d+)%\\s*stealth:/i); + return { + likeHeadlessPct: likeMatch ? parseInt(likeMatch[1]) : null, + headlessPct: headlessMatch ? parseInt(headlessMatch[1]) : null, + stealthPct: stealthMatch ? parseInt(stealthMatch[1]) : null, + }; + }""") + + print(f"\nScores:") + print(f" like-headless: {scores['likeHeadlessPct']}% (target: <=30%)") + print(f" headless: {scores['headlessPct']}% (target: 0%)") + print(f" stealth: {scores['stealthPct']}% (target: 0%)") + + # Extract full signal breakdown from window.Fingerprint.headless (CreepJS internal object) + signals = page.evaluate("""() => { + try { + const fp = window.Fingerprint; + if (!fp || !fp.headless) return null; + return { + likeHeadless: fp.headless.likeHeadless || null, + headless: fp.headless.headless || null, + stealth: fp.headless.stealth || null, + }; + } catch { return null; } + }""") + + if signals: + if signals.get("likeHeadless"): + print("\nlikeHeadless signals:") + fails = 0 + for k, v in signals["likeHeadless"].items(): + is_fail = v is True + if is_fail: + fails += 1 + flag = " FAIL" if is_fail else "" + print(f" {k}: {v}{flag}") + print(f" ({fails} fails)") + + if signals.get("headless"): + print("\nheadless signals:") + for k, v in signals["headless"].items(): + flag = " FAIL" if v is True else "" + print(f" {k}: {v}{flag}") + + if signals.get("stealth"): + print("\nstealth signals:") + for k, v in signals["stealth"].items(): + flag = " FAIL" if v is True else "" + print(f" {k}: {v}{flag}") + else: + print("\n(window.Fingerprint.headless not available — signals not extracted)") + + # Extract platform estimate + platform = page.evaluate("""() => { + try { + const fp = window.Fingerprint; + if (!fp || !fp.platformEstimate) return null; + return fp.platformEstimate; + } catch { return null; } + }""") + if platform: + print(f"\nPlatform estimate: {platform}") + + passed = ( + scores["headlessPct"] is not None + and scores["headlessPct"] <= 30 + and scores["stealthPct"] is not None + and scores["stealthPct"] <= 30 + ) + print(f"\nVerdict: {'PASS' if passed else 'FAIL'} (<=30% headless, <=30% stealth)") + + page.screenshot(path="/results/creepjs.png", full_page=True) + print("Screenshot: /results/creepjs.png") + + return {**scores, "signals": signals, "platform": platform} + + +def main(): + print("=" * 60) + print("CloakBrowser — Fingerprint & Headless Detection Tests") + print("=" * 60) + print(f"Mode: {'headless' if HEADLESS else 'headed'}") + print(f"Proxy: {PROXY or 'none'}") + print() + + context = launch_context( + headless=HEADLESS, + proxy=PROXY, + args=[ + "--fingerprint-screen-width=1920", + "--fingerprint-screen-height=1080", + "--timezone=Asia/Jerusalem", + ], + ) + page = context.new_page() + + try: + fp_result = test_fingerprint_scan(page) + creep_result = test_creepjs(page) + finally: + context.close() + + # Summary + print("\n" + "=" * 60) + print("SUMMARY") + print("=" * 60) + print(f"fingerprint-scan.com: {fp_result['score']}") + print(f" Headless signal fails: {fp_result['headless_fails']}") + like = creep_result["likeHeadlessPct"] + headless = creep_result["headlessPct"] + stealth = creep_result["stealthPct"] + print(f"CreepJS: like-headless={like}%, headless={headless}%, stealth={stealth}%") + + # Count CreepJS signal fails + sigs = creep_result.get("signals") + if sigs and sigs.get("likeHeadless"): + fail_names = [k for k, v in sigs["likeHeadless"].items() if v is True] + if fail_names: + print(f" likeHeadless fails: {', '.join(fail_names)}") + print("=" * 60) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/js/README.md b/js/README.md index dfc1af7..4e6b926 100644 --- a/js/README.md +++ b/js/README.md @@ -11,7 +11,7 @@ Drop-in Playwright/Puppeteer replacement. Same API — just swap the import. Scores **0.9 on reCAPTCHA v3**, passes **Cloudflare Turnstile**, and clears **30/30** stealth detection tests. -- 🔒 **16 source-level C++ patches** — not JS injection, not config flags +- 🔒 **22 source-level C++ patches** — not JS injection, not config flags - 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified - ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 30/30 tests - 🔄 **Drop-in replacement** — works with both Playwright and Puppeteer diff --git a/js/src/config.ts b/js/src/config.ts index f450e9d..51a93b0 100644 --- a/js/src/config.ts +++ b/js/src/config.ts @@ -139,6 +139,11 @@ export function getLocalBinaryOverride(): string | undefined { // --------------------------------------------------------------------------- // Default stealth arguments // --------------------------------------------------------------------------- +// Default viewport — realistic maximized Chrome on 1080p Windows +// screen=1920x1080, availHeight=1040 (minus 40px taskbar), +// innerHeight=955 (minus ~85px Chrome UI: tabs + address bar + bookmarks) +export const DEFAULT_VIEWPORT = { width: 1920, height: 955 }; + export function getDefaultStealthArgs(): string[] { const seed = Math.floor(Math.random() * 90000) + 10000; // 10000-99999 const isMac = process.platform === "darwin"; @@ -161,5 +166,9 @@ export function getDefaultStealthArgs(): string[] { "--fingerprint-hardware-concurrency=8", "--fingerprint-gpu-vendor=NVIDIA Corporation", "--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070", + "--fingerprint-taskbar-height=40", + "--fingerprint-screen-width=1920", + "--fingerprint-screen-height=1080", + "--window-size=1920,1080", ]; } diff --git a/js/src/playwright.ts b/js/src/playwright.ts index 22e32ca..816a4e8 100644 --- a/js/src/playwright.ts +++ b/js/src/playwright.ts @@ -5,7 +5,7 @@ import type { Browser, BrowserContext } from "playwright-core"; import type { LaunchOptions, LaunchContextOptions } from "./types.js"; -import { getDefaultStealthArgs } from "./config.js"; +import { DEFAULT_VIEWPORT, getDefaultStealthArgs } from "./config.js"; import { ensureBinary } from "./download.js"; import { parseProxyUrl } from "./proxy.js"; @@ -68,7 +68,7 @@ export async function launchContext( try { context = await browser.newContext({ ...(options.userAgent ? { userAgent: options.userAgent } : {}), - ...(options.viewport ? { viewport: options.viewport } : {}), + viewport: options.viewport ?? DEFAULT_VIEWPORT, ...(resolved.locale ? { locale: resolved.locale } : {}), ...(resolved.timezone ? { timezoneId: resolved.timezone } : {}), }); diff --git a/pyproject.toml b/pyproject.toml index a63f4f7..707fc64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ classifiers = [ "Topic :: Software Development :: Testing", ] dependencies = [ - "playwright>=1.40", + "patchright>=1.40", "httpx>=0.24", ]