Compare commits

...
Author SHA1 Message Date
Cloak-HQ 3880d30d0f fix: deduplicate CLI flags when user args overlap with stealth defaults
Bump version to 0.3.9. Extract shared buildArgs into js/src/args.ts (DRY),
guard console.debug behind DEBUG=cloakbrowser env var, strengthen caplog assertion.
2026-03-05 18:44:18 +01:00
Cloak-HQ 9c533e4120 feat: upgrade Chromium base to 145.0.7632.159 (Linux x64)
- Bump linux-x64 binary to 145.0.7632.159 (macOS/Windows stay at 145.0.7632.109.2)
- Wrapper version 0.3.8
- Fix rollback path examples to use correct per-platform versions
2026-03-05 18:44:17 +01:00
Cloak-HQ 98c216f07e feat: make patchright optional, default to stock playwright 2026-03-05 18:44:17 +01:00
Cloak-HQandGitHub ee953709b0 Merge pull request #29 from evelaa123/fix/python-download-timeout
fix(python): reduce download connect timeout to 10s, read to 60s for …
2026-03-05 12:08:04 +01:00
lilos a45fdc4d7e fix(python): reduce download connect timeout to 10s, read to 60s for faster fallback 2026-03-05 13:48:05 +03:00
18 changed files with 364 additions and 85 deletions
+8
View File
@@ -6,6 +6,14 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
--- ---
## [0.3.9] — 2026-03-05
- **[binary]** Upgrade Chromium base to 145.0.7632.159 (Linux x64). macOS and Windows remain on 145.0.7632.109.2
- **[binary]** WebGPU adapter spoofing for headless/Docker, timezone multi-context fix, stealth audit phase 2 (6 detection vector fixes), font auto-hide for cross-platform fingerprints
- **[wrapper]** Default Playwright backend switched from `patchright` to stock `playwright`. Patchright broke proxy auth and `add_init_script` (#27) and is redundant since the binary handles stealth at C++ level. Opt in with `launch(backend="patchright")` or `CLOAKBROWSER_BACKEND=patchright` env var. Install: `pip install cloakbrowser[patchright]`
- **[wrapper]** Deduplicate CLI flags when user args overlap with stealth defaults — user values win cleanly instead of passing both to Chromium
- **[wrapper]** Extract shared `buildArgs` into `js/src/args.ts` (JS DRY fix), guard debug logging behind `DEBUG=cloakbrowser` env var
## [0.3.7] — 2026-03-05 ## [0.3.7] — 2026-03-05
- **[wrapper]** Unify timezone parameter: rename `timezone_id` to `timezone` in `launch_context()`, `launch_persistent_context()`, and `launch_persistent_context_async()` (Python). Old `timezone_id` still works with a deprecation warning. JS: deprecate `timezoneId` on `LaunchContextOptions` — use `timezone` (inherited from `LaunchOptions`) - **[wrapper]** Unify timezone parameter: rename `timezone_id` to `timezone` in `launch_context()`, `launch_persistent_context()`, and `launch_persistent_context_async()` (Python). Old `timezone_id` still works with a deprecation warning. JS: deprecate `timezoneId` on `LaunchContextOptions` — use `timezone` (inherited from `LaunchOptions`)
+8 -7
View File
@@ -108,7 +108,7 @@ page.goto("https://example.com")
> ⭐ **Star** to show support — **[Watch releases](https://github.com/CloakHQ/CloakBrowser/subscription)** to get notified when new builds drop. > ⭐ **Star** to show support — **[Watch releases](https://github.com/CloakHQ/CloakBrowser/subscription)** to get notified when new builds drop.
## Latest: v0.3.5 (Chromium 145.0.7632.109) ## Latest: v0.3.8 (Chromium 145.0.7632.159)
- **All 4 platforms** — Linux x64, macOS arm64, macOS x64, and Windows x64 all on Chromium 145 - **All 4 platforms** — Linux x64, macOS arm64, macOS x64, and Windows x64 all on Chromium 145
- **26 fingerprint patches** — 10 new patches since v142 (screen, device memory, audio, WebGL, auto-spoof, and more) - **26 fingerprint patches** — 10 new patches since v142 (screen, device memory, audio, WebGL, auto-spoof, and more)
@@ -125,7 +125,7 @@ See the full [CHANGELOG.md](CHANGELOG.md) for details.
- **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. - **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. - **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, hardware reporting), while the Patchright driver defers Playwright's binding registration and randomizes internal world names. Most stealth tools only do one or the other. - **Source-level stealth** — C++ patches handle fingerprints (GPU, screen, UA, hardware reporting) at the binary level. No JavaScript injection, no config-level hacks. Most stealth tools only patch at the surface.
- **Same behavior everywhere** — works identically local, in Docker, and on VPS. No environment-specific patches or config needed. - **Same behavior everywhere** — works identically local, in Docker, and on VPS. No environment-specific patches or config needed.
- **Works with any browser automation framework** — tested and passing stealth checks with Playwright, Puppeteer, Selenium, undetected-chromedriver, browser-use, Crawl4AI, and agent-browser. Just point any Chromium-based framework at the binary path. - **Works with any browser automation framework** — tested and passing stealth checks with Playwright, Puppeteer, Selenium, undetected-chromedriver, browser-use, Crawl4AI, and agent-browser. Just point any Chromium-based framework at the binary path.
@@ -307,7 +307,7 @@ from cloakbrowser import binary_info, clear_cache, ensure_binary
# Check binary installation status # Check binary installation status
print(binary_info()) print(binary_info())
# {'version': '145.0.7632.109', 'platform': 'linux-x64', 'installed': True, ...} # {'version': '145.0.7632.159', 'platform': 'linux-x64', 'installed': True, ...}
# Force re-download # Force re-download
clear_cache() clear_cache()
@@ -630,13 +630,13 @@ export CLOAKBROWSER_BINARY_PATH=/path/to/your/chrome
When auto-update downloads a newer binary, the previous version stays in `~/.cloakbrowser/`. Point `CLOAKBROWSER_BINARY_PATH` to the older cached binary: When auto-update downloads a newer binary, the previous version stays in `~/.cloakbrowser/`. Point `CLOAKBROWSER_BINARY_PATH` to the older cached binary:
```bash ```bash
# Linux # Linux
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.109/chrome export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.159/chrome
# macOS # macOS
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.109/Chromium.app/Contents/MacOS/Chromium export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.109.2/Chromium.app/Contents/MacOS/Chromium
# Windows # Windows
set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-145.0.7632.109\chrome.exe set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-145.0.7632.109.2\chrome.exe
``` ```
**macOS: "App is damaged" or Gatekeeper blocks launch** **macOS: "App is damaged" or Gatekeeper blocks launch**
@@ -648,7 +648,7 @@ xattr -cr ~/.cloakbrowser/chromium-*/Chromium.app
**"playwright install" vs CloakBrowser binary** **"playwright install" vs CloakBrowser binary**
You do NOT need `playwright install chromium`. CloakBrowser downloads its own binary. You only need Playwright's system deps: You do NOT need `playwright install chromium`. CloakBrowser downloads its own binary. You only need Playwright's system deps:
```bash ```bash
patchright install-deps chromium playwright install-deps chromium
``` ```
**macOS: Blocked on some sites that pass on Linux** **macOS: Blocked on some sites that pass on Linux**
@@ -699,6 +699,7 @@ await new Promise(r => setTimeout(r, 3000));
``` ```
Other tips for maximizing reCAPTCHA scores: Other tips for maximizing reCAPTCHA scores:
- **Try the Patchright backend** — suppresses CDP automation signals that reCAPTCHA Enterprise detects. Install with `pip install cloakbrowser[patchright]`, then use `launch(backend="patchright")` or set `CLOAKBROWSER_BACKEND=patchright` globally. Note: Patchright breaks proxy auth and `add_init_script` — only use it when you need the extra CDP stealth
- **Use Playwright, not Puppeteer** — Puppeteer sends more CDP protocol traffic that reCAPTCHA detects ([details](#puppeteer)) - **Use Playwright, not Puppeteer** — Puppeteer sends more CDP protocol traffic that reCAPTCHA detects ([details](#puppeteer))
- **Use residential proxies** — datacenter IPs are flagged by IP reputation, not browser fingerprint - **Use residential proxies** — datacenter IPs are flagged by IP reputation, not browser fingerprint
- **Spend 15+ seconds on the page** before triggering reCAPTCHA — short visits score lower - **Spend 15+ seconds on the page** before triggering reCAPTCHA — short visits score lower
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.3.8" __version__ = "0.3.9"
+87 -13
View File
@@ -57,6 +57,7 @@ def launch(
timezone: str | None = None, timezone: str | None = None,
locale: str | None = None, locale: str | None = None,
geoip: bool = False, geoip: bool = False,
backend: str | None = None,
**kwargs: Any, **kwargs: Any,
) -> Any: ) -> Any:
"""Launch stealth Chromium browser. Returns a Playwright Browser object. """Launch stealth Chromium browser. Returns a Playwright Browser object.
@@ -76,6 +77,10 @@ def launch(
Requires ``pip install cloakbrowser[geoip]``. Downloads ~70 MB Requires ``pip install cloakbrowser[geoip]``. Downloads ~70 MB
GeoLite2-City database on first use. Explicit timezone/locale GeoLite2-City database on first use. Explicit timezone/locale
always override geoip results. always override geoip results.
backend: Playwright backend 'playwright' (default) or 'patchright'.
Patchright suppresses CDP signals (helps reCAPTCHA v3 Enterprise)
but breaks proxy auth and add_init_script.
Override globally with CLOAKBROWSER_BACKEND env var.
**kwargs: Passed directly to playwright.chromium.launch(). **kwargs: Passed directly to playwright.chromium.launch().
Returns: Returns:
@@ -89,7 +94,7 @@ def launch(
>>> print(page.title()) >>> print(page.title())
>>> browser.close() >>> browser.close()
""" """
from patchright.sync_api import sync_playwright sync_playwright = _import_sync_playwright(_resolve_backend(backend))
binary_path = ensure_binary() binary_path = ensure_binary()
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale) timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
@@ -127,6 +132,7 @@ async def launch_async(
timezone: str | None = None, timezone: str | None = None,
locale: str | None = None, locale: str | None = None,
geoip: bool = False, geoip: bool = False,
backend: str | None = None,
**kwargs: Any, **kwargs: Any,
) -> Any: ) -> Any:
"""Async version of launch(). Returns a Playwright Browser object. """Async version of launch(). Returns a Playwright Browser object.
@@ -139,6 +145,7 @@ async def launch_async(
timezone: IANA timezone (e.g. 'America/New_York'). Sets --fingerprint-timezone binary flag. timezone: IANA timezone (e.g. 'America/New_York'). Sets --fingerprint-timezone binary flag.
locale: BCP 47 locale (e.g. 'en-US'). Sets --lang binary flag. locale: BCP 47 locale (e.g. 'en-US'). Sets --lang binary flag.
geoip: Auto-detect timezone/locale from proxy IP (default False). geoip: Auto-detect timezone/locale from proxy IP (default False).
backend: Playwright backend 'playwright' (default) or 'patchright'.
**kwargs: Passed directly to playwright.chromium.launch(). **kwargs: Passed directly to playwright.chromium.launch().
Returns: Returns:
@@ -157,7 +164,7 @@ async def launch_async(
>>> >>>
>>> asyncio.run(main()) >>> asyncio.run(main())
""" """
from patchright.async_api import async_playwright async_playwright = _import_async_playwright(_resolve_backend(backend))
binary_path = ensure_binary() binary_path = ensure_binary()
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale) timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
@@ -199,6 +206,7 @@ def launch_persistent_context(
timezone: str | None = None, timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None, color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False, geoip: bool = False,
backend: str | None = None,
**kwargs: Any, **kwargs: Any,
) -> Any: ) -> Any:
"""Launch stealth browser with a persistent profile and return a BrowserContext. """Launch stealth browser with a persistent profile and return a BrowserContext.
@@ -223,6 +231,7 @@ def launch_persistent_context(
Default: None (uses Chromium default, which is 'light'). Default: None (uses Chromium default, which is 'light').
geoip: Auto-detect timezone/locale from proxy IP (default False). geoip: Auto-detect timezone/locale from proxy IP (default False).
Requires ``pip install cloakbrowser[geoip]``. Requires ``pip install cloakbrowser[geoip]``.
backend: Playwright backend 'playwright' (default) or 'patchright'.
**kwargs: Passed directly to playwright.chromium.launch_persistent_context(). **kwargs: Passed directly to playwright.chromium.launch_persistent_context().
Returns: Returns:
@@ -236,7 +245,7 @@ def launch_persistent_context(
>>> page.goto("https://protected-site.com") >>> page.goto("https://protected-site.com")
>>> ctx.close() # Profile is saved; re-use path next run to restore state. >>> ctx.close() # Profile is saved; re-use path next run to restore state.
""" """
from patchright.sync_api import sync_playwright sync_playwright = _import_sync_playwright(_resolve_backend(backend))
timezone = _migrate_timezone_id(timezone, kwargs) timezone = _migrate_timezone_id(timezone, kwargs)
@@ -297,6 +306,7 @@ async def launch_persistent_context_async(
timezone: str | None = None, timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None, color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False, geoip: bool = False,
backend: str | None = None,
**kwargs: Any, **kwargs: Any,
) -> Any: ) -> Any:
"""Async version of launch_persistent_context(). """Async version of launch_persistent_context().
@@ -318,6 +328,7 @@ async def launch_persistent_context_async(
timezone: IANA timezone (e.g. 'America/New_York'). timezone: IANA timezone (e.g. 'America/New_York').
color_scheme: Color scheme preference 'light', 'dark', or 'no-preference'. color_scheme: Color scheme preference 'light', 'dark', or 'no-preference'.
geoip: Auto-detect timezone/locale from proxy IP (default False). geoip: Auto-detect timezone/locale from proxy IP (default False).
backend: Playwright backend 'playwright' (default) or 'patchright'.
**kwargs: Passed directly to playwright.chromium.launch_persistent_context(). **kwargs: Passed directly to playwright.chromium.launch_persistent_context().
Returns: Returns:
@@ -336,7 +347,7 @@ async def launch_persistent_context_async(
>>> >>>
>>> asyncio.run(main()) >>> asyncio.run(main())
""" """
from patchright.async_api import async_playwright async_playwright = _import_async_playwright(_resolve_backend(backend))
timezone = _migrate_timezone_id(timezone, kwargs) timezone = _migrate_timezone_id(timezone, kwargs)
@@ -396,6 +407,7 @@ def launch_context(
timezone: str | None = None, timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None, color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False, geoip: bool = False,
backend: str | None = None,
**kwargs: Any, **kwargs: Any,
) -> Any: ) -> Any:
"""Launch stealth browser and return a BrowserContext with common options pre-set. """Launch stealth browser and return a BrowserContext with common options pre-set.
@@ -414,8 +426,8 @@ def launch_context(
timezone: IANA timezone (e.g. 'America/New_York'). timezone: IANA timezone (e.g. 'America/New_York').
color_scheme: Color scheme preference 'light', 'dark', or 'no-preference'. color_scheme: Color scheme preference 'light', 'dark', or 'no-preference'.
Default: None (uses Chromium default, which is 'light'). 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). geoip: Auto-detect timezone/locale from proxy IP (default False).
backend: Playwright backend 'playwright' (default) or 'patchright'.
**kwargs: Passed to browser.new_context(). **kwargs: Passed to browser.new_context().
Returns: Returns:
@@ -430,7 +442,7 @@ def launch_context(
# context and interferes with Playwright's timezone_id on new contexts. # context and interferes with Playwright's timezone_id on new contexts.
# Timezone is set via browser.new_context(timezone_id=...) below instead. # Timezone is set via browser.new_context(timezone_id=...) below instead.
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args, browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
timezone=None, locale=locale) timezone=None, locale=locale, backend=backend)
context_kwargs: dict[str, Any] = {} context_kwargs: dict[str, Any] = {}
if user_agent: if user_agent:
@@ -462,6 +474,47 @@ def launch_context(
return context return context
# ---------------------------------------------------------------------------
# Backend resolution
# ---------------------------------------------------------------------------
def _resolve_backend(backend: str | None) -> str:
"""Resolve backend: param > env var > default ('playwright')."""
b = backend or os.environ.get("CLOAKBROWSER_BACKEND", "playwright")
if b not in ("playwright", "patchright"):
raise ValueError(f"Unknown backend '{b}'. Use 'playwright' or 'patchright'.")
return b
def _import_sync_playwright(backend: str):
"""Import sync_playwright from the resolved backend."""
if backend == "patchright":
try:
from patchright.sync_api import sync_playwright
except ModuleNotFoundError:
raise ModuleNotFoundError(
"patchright is not installed. Install it with: pip install cloakbrowser[patchright]"
) from None
return sync_playwright
from playwright.sync_api import sync_playwright
return sync_playwright
def _import_async_playwright(backend: str):
"""Import async_playwright from the resolved backend."""
if backend == "patchright":
try:
from patchright.async_api import async_playwright
except ModuleNotFoundError:
raise ModuleNotFoundError(
"patchright is not installed. Install it with: pip install cloakbrowser[patchright]"
) from None
return async_playwright
from playwright.async_api import async_playwright
return async_playwright
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Internal helpers # Internal helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -496,18 +549,39 @@ def _build_args(
timezone: str | None = None, timezone: str | None = None,
locale: str | None = None, locale: str | None = None,
) -> list[str]: ) -> list[str]:
"""Combine stealth args with user-provided args and locale flags.""" """Combine stealth args with user-provided args and locale flags.
result = []
Deduplicates by flag key (everything before '=').
Priority: stealth defaults < user args < dedicated params (timezone/locale).
"""
seen: dict[str, str] = {}
if stealth_args: if stealth_args:
result.extend(get_default_stealth_args()) for arg in get_default_stealth_args():
seen[arg.split("=", 1)[0]] = arg
if extra_args: if extra_args:
result.extend(extra_args) for arg in extra_args:
key = arg.split("=", 1)[0]
if key in seen:
logger.debug("Arg override: %s -> %s", seen[key], arg)
seen[key] = arg
# Timezone/locale flags are independent of stealth_args — always inject when set # Timezone/locale flags are independent of stealth_args — always inject when set
if timezone: if timezone:
result.append(f"--fingerprint-timezone={timezone}") key = "--fingerprint-timezone"
flag = f"{key}={timezone}"
if key in seen:
logger.debug("Arg override: %s -> %s", seen[key], flag)
seen[key] = flag
if locale: if locale:
result.append(f"--lang={locale}") key = "--lang"
return result flag = f"{key}={locale}"
if key in seen:
logger.debug("Arg override: %s -> %s", seen[key], flag)
seen[key] = flag
return list(seen.values())
def _parse_proxy_url(proxy: str) -> dict[str, Any]: def _parse_proxy_url(proxy: str) -> dict[str, Any]:
+2 -2
View File
@@ -15,10 +15,10 @@ from ._version import __version__
# CHROMIUM_VERSION is the latest across all platforms (for display/reference). # CHROMIUM_VERSION is the latest across all platforms (for display/reference).
# Use get_chromium_version() for the current platform's actual version. # Use get_chromium_version() for the current platform's actual version.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
CHROMIUM_VERSION = "145.0.7632.109.2" CHROMIUM_VERSION = "145.0.7632.159"
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = { PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
"linux-x64": "145.0.7632.109.2", "linux-x64": "145.0.7632.159",
"darwin-arm64": "145.0.7632.109.2", "darwin-arm64": "145.0.7632.109.2",
"darwin-x64": "145.0.7632.109.2", "darwin-x64": "145.0.7632.109.2",
"windows-x64": "145.0.7632.109.2", "windows-x64": "145.0.7632.109.2",
+1 -1
View File
@@ -44,7 +44,7 @@ from .config import (
logger = logging.getLogger("cloakbrowser") logger = logging.getLogger("cloakbrowser")
# Timeout for download (large binary, allow 10 min) # Timeout for download (large binary, allow 10 min)
DOWNLOAD_TIMEOUT = 600.0 DOWNLOAD_TIMEOUT = httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=10.0)
# Auto-update check interval (1 hour) # Auto-update check interval (1 hour)
UPDATE_CHECK_INTERVAL = 3600 UPDATE_CHECK_INTERVAL = 3600
+3 -3
View File
@@ -247,13 +247,13 @@ Other tips for maximizing reCAPTCHA scores:
When auto-update downloads a newer binary, the previous version stays in `~/.cloakbrowser/`. Point `CLOAKBROWSER_BINARY_PATH` to the older cached binary: When auto-update downloads a newer binary, the previous version stays in `~/.cloakbrowser/`. Point `CLOAKBROWSER_BINARY_PATH` to the older cached binary:
```bash ```bash
# Linux # Linux
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.109/chrome export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.159/chrome
# macOS # macOS
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.109/Chromium.app/Contents/MacOS/Chromium export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.109.2/Chromium.app/Contents/MacOS/Chromium
# Windows # Windows
set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-145.0.7632.109\chrome.exe set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-145.0.7632.109.2\chrome.exe
``` ```
## Links ## Links
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "cloakbrowser", "name": "cloakbrowser",
"version": "0.3.8", "version": "0.3.9",
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.", "description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
+49
View File
@@ -0,0 +1,49 @@
/**
* Shared argument builder for Playwright and Puppeteer wrappers.
*/
import type { LaunchOptions } from "./types.js";
import { getDefaultStealthArgs } from "./config.js";
const DEBUG = /\bcloakbrowser\b/.test(process.env.DEBUG ?? "");
/**
* Build deduplicated Chromium CLI args from stealth defaults + user overrides.
*
* Priority: stealth defaults < user args < dedicated params (timezone/locale).
*/
export function buildArgs(options: LaunchOptions): string[] {
const seen = new Map<string, string>();
if (options.stealthArgs !== false) {
for (const arg of getDefaultStealthArgs()) {
seen.set(arg.split("=")[0], arg);
}
}
if (options.args) {
for (const arg of options.args) {
const key = arg.split("=")[0];
if (seen.has(key)) {
if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(key)} -> ${arg}`);
}
seen.set(key, arg);
}
}
if (options.timezone) {
const key = "--fingerprint-timezone";
const flag = `${key}=${options.timezone}`;
if (seen.has(key)) {
if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(key)} -> ${flag}`);
}
seen.set(key, flag);
}
if (options.locale) {
const key = "--lang";
const flag = `${key}=${options.locale}`;
if (seen.has(key)) {
if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(key)} -> ${flag}`);
}
seen.set(key, flag);
}
return [...seen.values()];
}
+2 -2
View File
@@ -27,10 +27,10 @@ export { WRAPPER_VERSION };
// CHROMIUM_VERSION is the latest across all platforms (for display/reference). // CHROMIUM_VERSION is the latest across all platforms (for display/reference).
// Use getChromiumVersion() for the current platform's actual version. // Use getChromiumVersion() for the current platform's actual version.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const CHROMIUM_VERSION = "145.0.7632.109.2"; export const CHROMIUM_VERSION = "145.0.7632.159";
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = { export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
"linux-x64": "145.0.7632.109.2", "linux-x64": "145.0.7632.159",
"darwin-arm64": "145.0.7632.109.2", "darwin-arm64": "145.0.7632.109.2",
"darwin-x64": "145.0.7632.109.2", "darwin-x64": "145.0.7632.109.2",
"windows-x64": "145.0.7632.109.2", "windows-x64": "145.0.7632.109.2",
+3 -22
View File
@@ -5,7 +5,8 @@
import type { Browser, BrowserContext } from "playwright-core"; import type { Browser, BrowserContext } from "playwright-core";
import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js"; import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js";
import { DEFAULT_VIEWPORT, getDefaultStealthArgs } from "./config.js"; import { DEFAULT_VIEWPORT } from "./config.js";
import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js"; import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js"; import { parseProxyUrl } from "./proxy.js";
@@ -176,24 +177,4 @@ async function maybeResolveGeoip(
} }
/** @internal Exposed for unit tests only. */ /** @internal Exposed for unit tests only. */
export function _buildArgsForTest(options: LaunchOptions): string[] { export { buildArgs as _buildArgsForTest } from "./args.js";
return buildArgs(options);
}
function buildArgs(options: LaunchOptions): string[] {
const args: string[] = [];
if (options.stealthArgs !== false) {
args.push(...getDefaultStealthArgs());
}
if (options.args) {
args.push(...options.args);
}
// Timezone/locale flags — always inject when set
if (options.timezone) {
args.push(`--fingerprint-timezone=${options.timezone}`);
}
if (options.locale) {
args.push(`--lang=${options.locale}`);
}
return args;
}
+1 -17
View File
@@ -5,7 +5,7 @@
import type { Browser } from "puppeteer-core"; import type { Browser } from "puppeteer-core";
import type { LaunchOptions } from "./types.js"; import type { LaunchOptions } from "./types.js";
import { getDefaultStealthArgs } from "./config.js"; import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js"; import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js"; import { parseProxyUrl } from "./proxy.js";
@@ -99,19 +99,3 @@ async function maybeResolveGeoip(
}; };
} }
function buildArgs(options: LaunchOptions): string[] {
const args: string[] = [];
if (options.stealthArgs !== false) {
args.push(...getDefaultStealthArgs());
}
if (options.args) {
args.push(...options.args);
}
if (options.timezone) {
args.push(`--fingerprint-timezone=${options.timezone}`);
}
if (options.locale) {
args.push(`--lang=${options.locale}`);
}
return args;
}
+53
View File
@@ -123,6 +123,59 @@ describe("buildArgs timezone/locale", () => {
}); });
}); });
describe("buildArgs deduplication", () => {
it("user --fingerprint overrides default seed", () => {
const args = _buildArgsForTest({ args: ["--fingerprint=99887"] });
const fpArgs = args.filter(a => a.startsWith("--fingerprint="));
expect(fpArgs).toHaveLength(1);
expect(fpArgs[0]).toBe("--fingerprint=99887");
});
it("user --fingerprint-platform overrides default", () => {
const args = _buildArgsForTest({ args: ["--fingerprint-platform=linux"] });
const platArgs = args.filter(a => a.startsWith("--fingerprint-platform="));
expect(platArgs).toHaveLength(1);
expect(platArgs[0]).toBe("--fingerprint-platform=linux");
});
it("timezone param overrides user --fingerprint-timezone arg", () => {
const args = _buildArgsForTest({
args: ["--fingerprint-timezone=Europe/London"],
timezone: "America/New_York",
});
const tzArgs = args.filter(a => a.startsWith("--fingerprint-timezone="));
expect(tzArgs).toHaveLength(1);
expect(tzArgs[0]).toBe("--fingerprint-timezone=America/New_York");
});
it("locale param overrides user --lang arg", () => {
const args = _buildArgsForTest({
args: ["--lang=de-DE"],
locale: "en-US",
});
const langArgs = args.filter(a => a.startsWith("--lang="));
expect(langArgs).toHaveLength(1);
expect(langArgs[0]).toBe("--lang=en-US");
});
it("no duplicate flag keys in output", () => {
const args = _buildArgsForTest({
args: ["--fingerprint=99887", "--fingerprint-timezone=UTC", "--lang=fr-FR"],
timezone: "Europe/Berlin",
locale: "de-DE",
});
const keys = args.map(a => a.split("=")[0]);
expect(new Set(keys).size).toBe(keys.length);
});
it("non-value flags preserved without dedup issues", () => {
const args = _buildArgsForTest({ args: ["--disable-gpu", "--no-zygote"] });
expect(args).toContain("--disable-gpu");
expect(args).toContain("--no-zygote");
expect(args).toContain("--no-sandbox");
});
});
describe("migrateTimezoneId deprecation", () => { describe("migrateTimezoneId deprecation", () => {
it("migrates timezoneId to timezone", () => { it("migrates timezoneId to timezone", () => {
const result = migrateTimezoneId({ timezoneId: "Europe/Paris" }); const result = migrateTimezoneId({ timezoneId: "Europe/Paris" });
+2 -1
View File
@@ -49,12 +49,13 @@ classifiers = [
"Topic :: Software Development :: Testing", "Topic :: Software Development :: Testing",
] ]
dependencies = [ dependencies = [
"patchright>=1.40", "playwright>=1.40",
"httpx>=0.24", "httpx>=0.24",
] ]
[project.optional-dependencies] [project.optional-dependencies]
geoip = ["geoip2>=4.0"] geoip = ["geoip2>=4.0"]
patchright = ["patchright>=1.40"]
[project.urls] [project.urls]
Homepage = "https://github.com/CloakHQ/CloakBrowser" Homepage = "https://github.com/CloakHQ/CloakBrowser"
+11
View File
@@ -0,0 +1,11 @@
"""Shared test fixtures."""
import os
import pytest
@pytest.fixture(autouse=True)
def _clean_backend_env(monkeypatch):
"""Ensure CLOAKBROWSER_BACKEND doesn't leak into tests from the host environment."""
monkeypatch.delenv("CLOAKBROWSER_BACKEND", raising=False)
+45
View File
@@ -0,0 +1,45 @@
"""Unit tests for backend resolution (_resolve_backend)."""
import os
from unittest.mock import patch
import pytest
from cloakbrowser.browser import _resolve_backend
def test_resolve_backend_default():
"""No param, no env var → 'playwright'."""
with patch.dict(os.environ, {}, clear=True):
assert _resolve_backend(None) == "playwright"
def test_resolve_backend_explicit_playwright():
assert _resolve_backend("playwright") == "playwright"
def test_resolve_backend_explicit_patchright():
assert _resolve_backend("patchright") == "patchright"
def test_resolve_backend_env_var():
"""CLOAKBROWSER_BACKEND env var used when no param."""
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "patchright"}):
assert _resolve_backend(None) == "patchright"
def test_resolve_backend_param_beats_env():
"""Explicit param overrides env var."""
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "patchright"}):
assert _resolve_backend("playwright") == "playwright"
def test_resolve_backend_invalid_raises():
with pytest.raises(ValueError, match="Unknown backend 'bogus'"):
_resolve_backend("bogus")
def test_resolve_backend_invalid_env_raises():
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "bogus"}):
with pytest.raises(ValueError, match="Unknown backend 'bogus'"):
_resolve_backend(None)
+72
View File
@@ -92,3 +92,75 @@ def test_migrate_both_none():
result = _migrate_timezone_id(None, kwargs) result = _migrate_timezone_id(None, kwargs)
assert result is None assert result is None
assert len(w) == 0 assert len(w) == 0
# --- Deduplication tests ---
def test_user_fingerprint_overrides_default():
"""User --fingerprint should override the random default seed."""
args = _build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
fingerprint_args = [a for a in args if a.startswith("--fingerprint=")]
assert len(fingerprint_args) == 1
assert fingerprint_args[0] == "--fingerprint=99887"
def test_user_platform_overrides_default():
"""User --fingerprint-platform should override the default."""
args = _build_args(stealth_args=True, extra_args=["--fingerprint-platform=linux"])
platform_args = [a for a in args if a.startswith("--fingerprint-platform=")]
assert len(platform_args) == 1
assert platform_args[0] == "--fingerprint-platform=linux"
def test_timezone_param_overrides_user_arg():
"""Dedicated timezone param should override user arg."""
args = _build_args(
stealth_args=True,
extra_args=["--fingerprint-timezone=Europe/London"],
timezone="America/New_York",
)
tz_args = [a for a in args if a.startswith("--fingerprint-timezone=")]
assert len(tz_args) == 1
assert tz_args[0] == "--fingerprint-timezone=America/New_York"
def test_locale_param_overrides_user_arg():
"""Dedicated locale param should override user --lang arg."""
args = _build_args(
stealth_args=True,
extra_args=["--lang=de-DE"],
locale="en-US",
)
lang_args = [a for a in args if a.startswith("--lang=")]
assert len(lang_args) == 1
assert lang_args[0] == "--lang=en-US"
def test_no_duplicate_flags():
"""No flag key should appear more than once in the output."""
args = _build_args(
stealth_args=True,
extra_args=["--fingerprint=99887", "--fingerprint-timezone=UTC", "--lang=fr-FR"],
timezone="Europe/Berlin",
locale="de-DE",
)
keys = [a.split("=", 1)[0] for a in args]
assert len(keys) == len(set(keys)), f"Duplicate keys found: {keys}"
def test_non_value_flags_preserved():
"""Flags without = should be preserved without dedup issues."""
args = _build_args(stealth_args=True, extra_args=["--disable-gpu", "--no-zygote"])
assert "--disable-gpu" in args
assert "--no-zygote" in args
assert "--no-sandbox" in args
def test_override_logs_debug(caplog):
"""Should log debug message when an override happens."""
import logging
with caplog.at_level(logging.DEBUG, logger="cloakbrowser"):
_build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
assert any("--fingerprint=" in r.message and "99887" in r.message for r in caplog.records)
+15 -15
View File
@@ -1,6 +1,6 @@
"""Unit tests for launch_persistent_context() and launch_persistent_context_async(). """Unit tests for launch_persistent_context() and launch_persistent_context_async().
All tests mock patchright to avoid needing a binary. All tests mock playwright to avoid needing a binary.
""" """
import warnings import warnings
@@ -32,7 +32,7 @@ def test_persistent_context_args_built(_mock_geoip, _mock_bin):
"""Stealth args + extra args combined correctly.""" """Stealth args + extra args combined correctly."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
with patch("patchright.sync_api.sync_playwright", return_value=pw_cm): with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", args=["--disable-gpu"]) launch_persistent_context("/tmp/profile", args=["--disable-gpu"])
@@ -48,7 +48,7 @@ def test_persistent_context_default_viewport(_mock_geoip, _mock_bin):
"""DEFAULT_VIEWPORT applied when no viewport given.""" """DEFAULT_VIEWPORT applied when no viewport given."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
with patch("patchright.sync_api.sync_playwright", return_value=pw_cm): with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile") launch_persistent_context("/tmp/profile")
@@ -63,7 +63,7 @@ def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin):
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
custom = {"width": 1280, "height": 720} custom = {"width": 1280, "height": 720}
with patch("patchright.sync_api.sync_playwright", return_value=pw_cm): with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", viewport=custom) launch_persistent_context("/tmp/profile", viewport=custom)
@@ -77,7 +77,7 @@ def test_persistent_context_user_agent(_mock_geoip, _mock_bin):
"""user_agent forwarded to launch_persistent_context().""" """user_agent forwarded to launch_persistent_context()."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
with patch("patchright.sync_api.sync_playwright", return_value=pw_cm): with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", user_agent="Custom/1.0") launch_persistent_context("/tmp/profile", user_agent="Custom/1.0")
@@ -90,7 +90,7 @@ def test_persistent_context_locale_and_timezone(_mock_bin):
"""Both timezone and locale flow to context kwargs and binary args.""" """Both timezone and locale flow to context kwargs and binary args."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
with patch("patchright.sync_api.sync_playwright", return_value=pw_cm): with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", timezone="Asia/Tokyo", locale="ja-JP") launch_persistent_context("/tmp/profile", timezone="Asia/Tokyo", locale="ja-JP")
@@ -109,7 +109,7 @@ def test_persistent_context_color_scheme(_mock_geoip, _mock_bin):
"""color_scheme forwarded correctly.""" """color_scheme forwarded correctly."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
with patch("patchright.sync_api.sync_playwright", return_value=pw_cm): with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", color_scheme="dark") launch_persistent_context("/tmp/profile", color_scheme="dark")
@@ -123,7 +123,7 @@ def test_persistent_context_geoip(_mock_bin, _mock_geoip):
"""geoip fills missing tz/locale.""" """geoip fills missing tz/locale."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
with patch("patchright.sync_api.sync_playwright", return_value=pw_cm): with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", proxy="http://proxy:8080", geoip=True) launch_persistent_context("/tmp/profile", proxy="http://proxy:8080", geoip=True)
@@ -137,7 +137,7 @@ def test_persistent_context_timezone_id_deprecation(_mock_bin):
"""Old timezone_id kwarg migrated with warning.""" """Old timezone_id kwarg migrated with warning."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
with patch("patchright.sync_api.sync_playwright", return_value=pw_cm): with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context from cloakbrowser.browser import launch_persistent_context
with warnings.catch_warnings(record=True) as w: with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always") warnings.simplefilter("always")
@@ -156,7 +156,7 @@ def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin):
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
original_close = context.close original_close = context.close
with patch("patchright.sync_api.sync_playwright", return_value=pw_cm): with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context from cloakbrowser.browser import launch_persistent_context
ctx = launch_persistent_context("/tmp/profile") ctx = launch_persistent_context("/tmp/profile")
@@ -171,7 +171,7 @@ def test_persistent_context_proxy_string(_mock_geoip, _mock_bin):
"""Proxy string parsed and passed.""" """Proxy string parsed and passed."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
with patch("patchright.sync_api.sync_playwright", return_value=pw_cm): with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", proxy="http://user:pass@proxy:8080") launch_persistent_context("/tmp/profile", proxy="http://user:pass@proxy:8080")
@@ -188,7 +188,7 @@ def test_persistent_context_proxy_dict(_mock_geoip, _mock_bin):
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"} proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"}
with patch("patchright.sync_api.sync_playwright", return_value=pw_cm): with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", proxy=proxy_dict) launch_persistent_context("/tmp/profile", proxy=proxy_dict)
@@ -218,7 +218,7 @@ async def test_persistent_context_async_args_built(_mock_geoip, _mock_bin):
"""Async launch builds args correctly.""" """Async launch builds args correctly."""
pw_cm, pw, context = _make_mock_async_pw_and_context() pw_cm, pw, context = _make_mock_async_pw_and_context()
with patch("patchright.async_api.async_playwright", return_value=pw_cm): with patch("playwright.async_api.async_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context_async from cloakbrowser.browser import launch_persistent_context_async
await launch_persistent_context_async("/tmp/profile", args=["--disable-gpu"]) await launch_persistent_context_async("/tmp/profile", args=["--disable-gpu"])
@@ -235,7 +235,7 @@ async def test_persistent_context_async_close_stops_pw(_mock_geoip, _mock_bin):
pw_cm, pw, context = _make_mock_async_pw_and_context() pw_cm, pw, context = _make_mock_async_pw_and_context()
original_close = context.close original_close = context.close
with patch("patchright.async_api.async_playwright", return_value=pw_cm): with patch("playwright.async_api.async_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context_async from cloakbrowser.browser import launch_persistent_context_async
ctx = await launch_persistent_context_async("/tmp/profile") ctx = await launch_persistent_context_async("/tmp/profile")
@@ -250,7 +250,7 @@ async def test_persistent_context_async_timezone_id_deprecation(_mock_bin):
"""Deprecated timezone_id kwarg migrated with warning in async path.""" """Deprecated timezone_id kwarg migrated with warning in async path."""
pw_cm, pw, context = _make_mock_async_pw_and_context() pw_cm, pw, context = _make_mock_async_pw_and_context()
with patch("patchright.async_api.async_playwright", return_value=pw_cm): with patch("playwright.async_api.async_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context_async from cloakbrowser.browser import launch_persistent_context_async
with warnings.catch_warnings(record=True) as w: with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always") warnings.simplefilter("always")