Compare commits

...
Author SHA1 Message Date
CloakHQ 4459f66593 release: v0.3.25 — Chromium 146.0.7680.177.3, launch_context_async, contextOptions 2026-04-16 22:24:36 +02:00
CloakHQ ce8b92ba4f feat: add launch_context_async() + JS contextOptions escape hatch (#141)
Python: add async counterpart to launch_context(). Forwards all kwargs to
browser.new_context() — enables storage_state, permissions, extra_http_headers,
etc. without needing a persistent profile folder.

JS: launchContext() and launchPersistentContext() silently dropped unknown
options. New contextOptions field in LaunchContextOptions is spread into
newContext() to forward arbitrary Playwright context options (e.g.
storageState, permissions, geolocation).
2026-04-16 21:30:40 +02:00
CloakHQ 4e1027847e fix: bump CHROMIUM_VERSION display constant to .2 (#157) 2026-04-15 18:20:01 +02:00
EternalandGitHub f164c1c874 fix(types): type humanConfig properly (#151) 2026-04-12 22:58:48 +02:00
lilosandGitHub f5e242a160 Update CHANGELOG for version 0.3.24 (#139)
Wrong username :(
2026-04-11 01:03:29 +02:00
CloakHQ 935beef980 docs: add recommended anti-bot config and SOCKS5 tips to troubleshooting
Based on recurring GitHub issue patterns (#117, #78, #130, #131).
2026-04-10 23:47:42 +02:00
14 changed files with 558 additions and 46 deletions
+11 -1
View File
@@ -6,10 +6,20 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
--- ---
## [Unreleased]
## [0.3.25] — 2026-04-16
- **[wrapper]** Python: add `launch_context_async()` — async counterpart to `launch_context()`. Returns a BrowserContext with all kwargs forwarded to `browser.new_context()`, enabling `storage_state`, `permissions`, `extra_http_headers`, etc. without a persistent profile folder. Closes #141.
- **[wrapper]** JS: `launchContext()` and `launchPersistentContext()` silently dropped unknown options (including `storageState`). New `contextOptions` escape hatch forwards arbitrary options to Playwright's `newContext()`.
- **[wrapper]** Fix `humanConfig` TypeScript typing (#151).
- **[binary]** New build 146.0.7680.177.3 for Linux x64 + arm64 — 57 source-level fingerprint patches (up from 49): WebAuthn capabilities, AAC audio encoder, and window position spoofing; WebGL and canvas format consistency fixes; SOCKS5 warm connection pool auth fix for credentialed proxies.
- **[docs]** Add recommended anti-bot config and SOCKS5 tips to troubleshooting.
## [0.3.24] — 2026-04-10 ## [0.3.24] — 2026-04-10
- **[wrapper]** Native SOCKS5 proxy support — pass `proxy="socks5://user:pass@host:port"` directly. Credentials handled natively by Chrome. Works across all launch functions, Python + JS. - **[wrapper]** Native SOCKS5 proxy support — pass `proxy="socks5://user:pass@host:port"` directly. Credentials handled natively by Chrome. Works across all launch functions, Python + JS.
- **[wrapper]** Add Playwright ElementHandle humanize support — `element_handle.click()`, `.fill()`, `.type()` now use human-like behavior when `humanize=True` (thanks [@lilos](https://github.com/lilos), #133) - **[wrapper]** Add Playwright ElementHandle humanize support — `element_handle.click()`, `.fill()`, `.type()` now use human-like behavior when `humanize=True` (thanks [@evelaa123](https://github.com/evelaa123), #133)
- **[binary]** Upgrade Linux arm64 to Chromium 146.0.7680.177.2 (49 patches) — now matches Linux x64 - **[binary]** Upgrade Linux arm64 to Chromium 146.0.7680.177.2 (49 patches) — now matches Linux x64
- **[binary]** New build 146.0.7680.177.2 for both Linux platforms: native SOCKS5 proxy with UDP ASSOCIATE (QUIC/HTTP3 over SOCKS5) - **[binary]** New build 146.0.7680.177.2 for both Linux platforms: native SOCKS5 proxy with UDP ASSOCIATE (QUIC/HTTP3 over SOCKS5)
- **[docs]** Clarify humanize requires wrapper import over CDP (#126) - **[docs]** Clarify humanize requires wrapper import over CDP (#126)
+72 -6
View File
@@ -128,11 +128,13 @@ Open [http://localhost:8080](http://localhost:8080). Create a profile. Click **L
--- ---
## Latest: v0.3.24 (Chromium 146.0.7680.177.2) ## Latest: v0.3.25 (Chromium 146.0.7680.177.3)
- **`launch_context_async()`** — async counterpart to `launch_context()`. Forwards kwargs to `browser.new_context()` for `storage_state`, `permissions`, `extra_http_headers` without a persistent profile folder.
- **JS `contextOptions` escape hatch** — forward arbitrary options (including `storageState`) to Playwright's `newContext()` from `launchContext()` / `launchPersistentContext()`.
- **Native SOCKS5 proxy** — `proxy="socks5://user:pass@host:port"` works directly in all launch functions, Python + JS. QUIC/HTTP3 tunnels through SOCKS5 via UDP ASSOCIATE. - **Native SOCKS5 proxy** — `proxy="socks5://user:pass@host:port"` works directly in all launch functions, Python + JS. QUIC/HTTP3 tunnels through SOCKS5 via UDP ASSOCIATE.
- **Chromium 146 upgrade** — rebased all patches from 145.0.7632.x to 146.0.7680.177 - **Chromium 146 upgrade** — rebased all patches from 145.0.7632.x to 146.0.7680.177
- **49 fingerprint patches** — Linux arm64 now matches Linux x64 on Chromium 146 - **57 fingerprint patches** — additional detection-vector coverage (WebAuthn, AAC audio, window position) and WebGL/canvas consistency fixes
- **WebRTC IP spoofing** — `--fingerprint-webrtc-ip=auto` resolves your proxy's exit IP and spoofs WebRTC ICE candidates. Auto-injected when using `geoip=True` (no extra network call) - **WebRTC IP spoofing** — `--fingerprint-webrtc-ip=auto` resolves your proxy's exit IP and spoofs WebRTC ICE candidates. Auto-injected when using `geoip=True` (no extra network call)
- **Proxy signal removal** — DNS/connect/SSL timing zeroed, proxy cache headers stripped, Proxy-Connection header leak removed - **Proxy signal removal** — DNS/connect/SSL timing zeroed, proxy cache headers stripped, Proxy-Connection header leak removed
- **`cloakserve` CDP multiplexer** — rewritten as a multi-connection CDP proxy with per-connection fingerprint seeds - **`cloakserve` CDP multiplexer** — rewritten as a multi-connection CDP proxy with per-connection fingerprint seeds
@@ -316,6 +318,38 @@ page.goto("https://protected-site.com")
context.close() context.close()
``` ```
Extra kwargs are forwarded to Playwright's `browser.new_context()` — use this for `storage_state`, `permissions`, `extra_http_headers`, etc. without needing a persistent profile folder:
```python
from cloakbrowser import launch_context
# Restore a saved session (cookies, localStorage) from a JSON file
context = launch_context(storage_state="state.json")
page = context.new_page()
page.goto("https://example.com")
# Save state back for next run
context.storage_state(path="state.json")
context.close()
```
### `launch_context_async()`
Async counterpart to `launch_context()`. Same signature and kwargs forwarding:
```python
import asyncio
from cloakbrowser import launch_context_async
async def main():
ctx = await launch_context_async(storage_state="state.json")
page = await ctx.new_page()
await page.goto("https://example.com")
await ctx.storage_state(path="state.json")
await ctx.close()
asyncio.run(main())
```
### `launch_persistent_context()` ### `launch_persistent_context()`
Same as `launch_context()`, but with a persistent user profile. Cookies, localStorage, and cache persist across sessions. Same as `launch_context()`, but with a persistent user profile. Cookies, localStorage, and cache persist across sessions.
@@ -372,7 +406,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': '146.0.7680.177.2', 'platform': 'linux-x64', 'installed': True, ...} # {'version': '146.0.7680.177.3', 'platform': 'linux-x64', 'installed': True, ...}
# Force re-download # Force re-download
clear_cache() clear_cache()
@@ -874,7 +908,39 @@ page.goto("https://heavily-protected-site.com") # passes DataDome, etc.
browser.close() browser.close()
``` ```
This runs a real headed browser rendered on a virtual display — no physical monitor needed. Combined with a residential proxy, this passes even the most aggressive detection services. Datacenter IPs are often flagged by IP reputation regardless of browser fingerprint — a residential proxy makes the difference. This runs a real headed browser rendered on a virtual display — no physical monitor needed. Combine with the recommended config below for maximum stealth.
---
### Recommended config for anti-bot sites
Most blocks come from missing one of these three things, not from browser fingerprint detection:
```python
browser = launch(
proxy="http://your-residential-proxy:port", # residential IP — datacenter IPs get blocked by reputation alone
geoip=True, # matches timezone + locale to proxy exit IP (without this: UTC + en-US = bot signal)
headless=False, # headed mode — some sites detect headless even with C++ patches
humanize=True, # human-like mouse, keyboard, scroll behavior
)
```
```javascript
const browser = await launch({
proxy: 'http://your-residential-proxy:port',
geoip: true,
headless: false,
humanize: true,
});
```
If your proxy supports SOCKS5, use it for better compatibility — SOCKS5 tunnels raw TCP, avoiding HTTP CONNECT issues that some proxies have with HTTP/2:
```python
browser = launch(proxy="socks5://user:pass@proxy:1080", geoip=True, headless=False, humanize=True)
```
If you're still blocked after this, the issue is almost always IP reputation — try a different proxy region or test from a home ISP to confirm the browser itself is clean.
--- ---
@@ -910,7 +976,7 @@ await ctx.close();
ctx = await launchPersistentContext({ userDataDir: './profile' }); ctx = await launchPersistentContext({ userDataDir: './profile' });
``` ```
For stateless/ephemeral use cases, `launch(args=["--disable-http2"])` forces HTTP/1.1 which bypasses the check. Only use this flag for sites that require it — most work fine with HTTP/2. For stateless/ephemeral use cases, `launch(args=["--disable-http2"])` forces HTTP/1.1 which bypasses the check. Only use this flag for sites that require it — most work fine with HTTP/2. If your proxy supports SOCKS5, use `proxy="socks5://user:pass@host:port"` instead — SOCKS5 bypasses HTTP CONNECT entirely.
--- ---
@@ -1060,7 +1126,7 @@ All releases are signed for supply chain verification.
```bash ```bash
# Verify GPG signature (binary release tag) # Verify GPG signature (binary release tag)
gpg --keyserver keyserver.ubuntu.com --recv-keys C60C0DDC9D0DE2DD gpg --keyserver keyserver.ubuntu.com --recv-keys C60C0DDC9D0DE2DD
git verify-tag chromium-v146.0.7680.177.2 git verify-tag chromium-v146.0.7680.177.3
# Verify GitHub binary attestation (Sigstore) # Verify GitHub binary attestation (Sigstore)
gh attestation verify cloakbrowser-linux-x64.tar.gz --repo CloakHQ/cloakbrowser gh attestation verify cloakbrowser-linux-x64.tar.gz --repo CloakHQ/cloakbrowser
+2 -1
View File
@@ -11,7 +11,7 @@ Usage:
browser.close() browser.close()
""" """
from .browser import launch, launch_async, launch_context, launch_persistent_context, launch_persistent_context_async, ProxySettings, build_args, maybe_resolve_geoip from .browser import launch, launch_async, launch_context, launch_context_async, launch_persistent_context, launch_persistent_context_async, ProxySettings, build_args, maybe_resolve_geoip
from .config import CHROMIUM_VERSION, get_default_stealth_args from .config import CHROMIUM_VERSION, get_default_stealth_args
from .download import binary_info, check_for_update, clear_cache, ensure_binary from .download import binary_info, check_for_update, clear_cache, ensure_binary
from ._version import __version__ from ._version import __version__
@@ -32,6 +32,7 @@ __all__ = [
"launch", "launch",
"launch_async", "launch_async",
"launch_context", "launch_context",
"launch_context_async",
"launch_persistent_context", "launch_persistent_context",
"launch_persistent_context_async", "launch_persistent_context_async",
"ensure_binary", "ensure_binary",
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.3.24" __version__ = "0.3.25"
+140 -15
View File
@@ -21,6 +21,7 @@ from urllib.parse import quote, unquote, urlparse, urlunparse
from .config import DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS, get_default_stealth_args from .config import DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS, get_default_stealth_args
from .download import ensure_binary from .download import ensure_binary
from .human.config import HumanConfigOverrides, HumanPreset
logger = logging.getLogger("cloakbrowser") logger = logging.getLogger("cloakbrowser")
@@ -60,8 +61,8 @@ def launch(
geoip: bool = False, geoip: bool = False,
backend: str | None = None, backend: str | None = None,
humanize: bool = False, humanize: bool = False,
human_preset: str = "default", human_preset: HumanPreset = "default",
human_config: dict | None = None, human_config: HumanConfigOverrides | 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.
@@ -87,7 +88,7 @@ def launch(
Override globally with CLOAKBROWSER_BACKEND env var. Override globally with CLOAKBROWSER_BACKEND env var.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False). humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset 'default' or 'careful' (default 'default'). human_preset: Humanize preset 'default' or 'careful' (default 'default').
human_config: Custom humanize config dict to override preset values. human_config: Custom humanize config mapping to override preset values.
**kwargs: Passed directly to playwright.chromium.launch(). **kwargs: Passed directly to playwright.chromium.launch().
Returns: Returns:
@@ -155,8 +156,8 @@ async def launch_async( # noqa: C901
geoip: bool = False, geoip: bool = False,
backend: str | None = None, backend: str | None = None,
humanize: bool = False, humanize: bool = False,
human_preset: str = "default", human_preset: HumanPreset = "default",
human_config: dict | None = None, human_config: HumanConfigOverrides | 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.
@@ -172,7 +173,7 @@ async def launch_async( # noqa: C901
backend: Playwright backend 'playwright' (default) or 'patchright'. backend: Playwright backend 'playwright' (default) or 'patchright'.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False). humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset 'default' or 'careful' (default 'default'). human_preset: Humanize preset 'default' or 'careful' (default 'default').
human_config: Custom humanize config dict to override preset values. human_config: Custom humanize config mapping to override preset values.
**kwargs: Passed directly to playwright.chromium.launch(). **kwargs: Passed directly to playwright.chromium.launch().
Returns: Returns:
@@ -249,8 +250,8 @@ def launch_persistent_context(
geoip: bool = False, geoip: bool = False,
backend: str | None = None, backend: str | None = None,
humanize: bool = False, humanize: bool = False,
human_preset: str = "default", human_preset: HumanPreset = "default",
human_config: dict | None = None, human_config: HumanConfigOverrides | 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.
@@ -279,7 +280,7 @@ def launch_persistent_context(
backend: Playwright backend 'playwright' (default) or 'patchright'. backend: Playwright backend 'playwright' (default) or 'patchright'.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False). humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset 'default' or 'careful' (default 'default'). human_preset: Humanize preset 'default' or 'careful' (default 'default').
human_config: Custom humanize config dict to override preset values. human_config: Custom humanize config mapping to override preset values.
**kwargs: Passed directly to playwright.chromium.launch_persistent_context(). **kwargs: Passed directly to playwright.chromium.launch_persistent_context().
Returns: Returns:
@@ -373,8 +374,8 @@ async def launch_persistent_context_async(
geoip: bool = False, geoip: bool = False,
backend: str | None = None, backend: str | None = None,
humanize: bool = False, humanize: bool = False,
human_preset: str = "default", human_preset: HumanPreset = "default",
human_config: dict | None = None, human_config: HumanConfigOverrides | None = None,
**kwargs: Any, **kwargs: Any,
) -> Any: ) -> Any:
"""Async version of launch_persistent_context(). """Async version of launch_persistent_context().
@@ -400,7 +401,7 @@ async def launch_persistent_context_async(
backend: Playwright backend 'playwright' (default) or 'patchright'. backend: Playwright backend 'playwright' (default) or 'patchright'.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False). humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset 'default' or 'careful' (default 'default'). human_preset: Humanize preset 'default' or 'careful' (default 'default').
human_config: Custom humanize config dict to override preset values. human_config: Custom humanize config mapping to override preset values.
**kwargs: Passed directly to playwright.chromium.launch_persistent_context(). **kwargs: Passed directly to playwright.chromium.launch_persistent_context().
Returns: Returns:
@@ -498,8 +499,8 @@ def launch_context(
geoip: bool = False, geoip: bool = False,
backend: str | None = None, backend: str | None = None,
humanize: bool = False, humanize: bool = False,
human_preset: str = "default", human_preset: HumanPreset = "default",
human_config: dict | None = None, human_config: HumanConfigOverrides | 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.
@@ -523,7 +524,7 @@ def launch_context(
backend: Playwright backend 'playwright' (default) or 'patchright'. backend: Playwright backend 'playwright' (default) or 'patchright'.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False). humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset 'default' or 'careful' (default 'default'). human_preset: Humanize preset 'default' or 'careful' (default 'default').
human_config: Custom humanize config dict to override preset values. human_config: Custom humanize config mapping to override preset values.
**kwargs: Passed to browser.new_context(). **kwargs: Passed to browser.new_context().
Returns: Returns:
@@ -584,6 +585,130 @@ def launch_context(
return context return context
async def launch_context_async(
headless: bool = True,
proxy: str | ProxySettings | None = None,
args: list[str] | None = None,
stealth_args: bool = True,
user_agent: str | None = None,
viewport: dict | None = _VIEWPORT_UNSET,
locale: str | None = None,
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
**kwargs: Any,
) -> Any:
"""Async version of launch_context().
Launch stealth browser and return a BrowserContext with common options pre-set.
All extra kwargs are forwarded to ``browser.new_context()`` use this for
``storage_state``, ``permissions``, ``extra_http_headers``, etc. without needing
a persistent profile folder.
Args:
headless: Run in headless mode (default True).
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
args: Additional Chromium CLI arguments.
stealth_args: Include default stealth fingerprint args (default True).
user_agent: Custom user agent string.
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
Pass None to disable viewport emulation (use OS window size).
locale: Browser locale, e.g. "en-US".
timezone: IANA timezone (e.g. 'America/New_York').
color_scheme: Color scheme preference 'light', 'dark', or 'no-preference'.
geoip: Auto-detect timezone/locale from proxy IP (default False).
backend: Playwright backend 'playwright' (default) or 'patchright'.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset 'default' or 'careful' (default 'default').
human_config: Custom humanize config mapping to override preset values.
**kwargs: Passed to browser.new_context() e.g. storage_state, permissions.
Returns:
Playwright BrowserContext object (async API).
Call ``await .close()`` when done this also closes the underlying browser.
Example:
>>> import asyncio
>>> from cloakbrowser import launch_context_async
>>>
>>> async def main():
... # Load saved session (cookies, localStorage)
... ctx = await launch_context_async(
... headless=True,
... storage_state="state.json",
... )
... page = await ctx.new_page()
... await page.goto("https://example.com")
... # Save state back
... await ctx.storage_state(path="state.json")
... await ctx.close()
>>>
>>> asyncio.run(main())
"""
timezone = _resolve_timezone(timezone, kwargs)
# Resolve geoip BEFORE launch_async() to avoid double-resolution and ensure
# resolved values flow to binary flags
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
# --fingerprint-timezone is process-wide (reads CommandLine in renderer),
# so it applies to ALL contexts, not just the default one.
# locale and timezone are set via binary flags only — no CDP emulation.
browser = await launch_async(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
timezone=timezone, locale=locale, backend=backend)
context_kwargs: dict[str, Any] = {}
if user_agent:
context_kwargs["user_agent"] = user_agent
if viewport is _VIEWPORT_UNSET:
context_kwargs["viewport"] = DEFAULT_VIEWPORT
elif viewport is None:
context_kwargs["no_viewport"] = True
else:
context_kwargs["viewport"] = viewport
if color_scheme:
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs)
# Catch BaseException (not just Exception) so that asyncio.CancelledError
# triggers browser cleanup — otherwise the underlying Chromium process
# leaks when the awaiting task is cancelled.
try:
context = await browser.new_context(**context_kwargs)
except BaseException:
try:
await browser.close()
except BaseException:
pass
raise
# Patch close() to also close the browser (and its Playwright instance)
_original_ctx_close = context.close
async def _close_context_with_cleanup() -> None:
try:
await _original_ctx_close()
finally:
await browser.close()
context.close = _close_context_with_cleanup
# Human-like behavioral patching (async variant)
if humanize:
from .human import patch_context_async
from .human.config import resolve_config
cfg = resolve_config(human_preset, human_config)
patch_context_async(context, cfg)
return context
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Backend resolution # Backend resolution
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+3 -3
View File
@@ -15,11 +15,11 @@ 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 = "146.0.7680.177.1" CHROMIUM_VERSION = "146.0.7680.177.3"
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = { PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
"linux-x64": "146.0.7680.177.2", "linux-x64": "146.0.7680.177.3",
"linux-arm64": "146.0.7680.177.2", "linux-arm64": "146.0.7680.177.3",
"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.159.7", "windows-x64": "145.0.7632.159.7",
+47 -3
View File
@@ -10,7 +10,7 @@ import math
import random import random
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Literal, Tuple from typing import Literal, Tuple, TypedDict
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Type alias # Type alias
@@ -20,6 +20,50 @@ Range = Tuple[float, float]
HumanPreset = Literal["default", "careful"] HumanPreset = Literal["default", "careful"]
class HumanConfigOverrides(TypedDict, total=False):
typing_delay: float
typing_delay_spread: float
typing_pause_chance: float
typing_pause_range: Range
shift_down_delay: Range
shift_up_delay: Range
key_hold: Range
field_switch_delay: Range
mistype_chance: float
mistype_delay_notice: Range
mistype_delay_correct: Range
mouse_steps_divisor: float
mouse_min_steps: int
mouse_max_steps: int
mouse_wobble_max: float
mouse_overshoot_chance: float
mouse_overshoot_px: Range
mouse_burst_size: Range
mouse_burst_pause: Range
click_aim_delay_input: Range
click_aim_delay_button: Range
click_hold_input: Range
click_hold_button: Range
click_input_x_range: Range
idle_drift_px: float
idle_pause_range: Range
scroll_delta_base: Range
scroll_delta_variance: float
scroll_pause_fast: Range
scroll_pause_slow: Range
scroll_accel_steps: Range
scroll_decel_steps: Range
scroll_overshoot_chance: float
scroll_overshoot_px: Range
scroll_settle_delay: Range
scroll_target_zone: Range
scroll_pre_move_delay: Range
initial_cursor_x: Range
initial_cursor_y: Range
idle_between_actions: bool
idle_between_duration: Range
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Configuration dataclass # Configuration dataclass
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -130,13 +174,13 @@ _PRESETS: dict[str, HumanConfig] = {
def resolve_config( def resolve_config(
preset: HumanPreset = "default", preset: HumanPreset = "default",
overrides: dict | None = None, overrides: HumanConfigOverrides | None = None,
) -> HumanConfig: ) -> HumanConfig:
"""Resolve a preset name + optional overrides into a full HumanConfig. """Resolve a preset name + optional overrides into a full HumanConfig.
Args: Args:
preset: 'default' or 'careful'. preset: 'default' or 'careful'.
overrides: Dict of field names to override values. overrides: Typed mapping of HumanConfig field names to override values.
Returns: Returns:
A new HumanConfig instance. A new HumanConfig instance.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "cloakbrowser", "name": "cloakbrowser",
"version": "0.3.24", "version": "0.3.25",
"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",
+3 -3
View File
@@ -27,11 +27,11 @@ 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 = "146.0.7680.177.1"; export const CHROMIUM_VERSION = "146.0.7680.177.3";
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = { export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
"linux-x64": "146.0.7680.177.2", "linux-x64": "146.0.7680.177.3",
"linux-arm64": "146.0.7680.177.2", "linux-arm64": "146.0.7680.177.3",
"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.159.7", "windows-x64": "145.0.7632.159.7",
+36 -7
View File
@@ -3,7 +3,7 @@
* Mirrors Python cloakbrowser/browser.py. * Mirrors Python cloakbrowser/browser.py.
*/ */
import type { Browser, BrowserContext } from "playwright-core"; import type { Browser, BrowserContext, BrowserContextOptions } from "playwright-core";
import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js"; import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js";
import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js"; import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
import { buildArgs } from "./args.js"; import { buildArgs } from "./args.js";
@@ -21,6 +21,29 @@ export function resolveTimezone<T extends { timezone?: string; timezoneId?: stri
return options; return options;
} }
/**
* Strip `locale` and `timezoneId` from user-provided contextOptions both route
* through detectable CDP emulation. The wrapper's top-level `locale`/`timezone`
* fields use binary flags instead (undetectable). Warn so users notice.
*/
function filterStealthCtxOptions(ctx?: BrowserContextOptions): Partial<BrowserContextOptions> {
if (!ctx) return {};
const { locale, timezoneId, ...rest } = ctx;
if (locale !== undefined) {
console.warn(
"[cloakbrowser] contextOptions.locale ignored — use top-level `locale` " +
"instead (routes through binary flag, avoids detectable CDP emulation)."
);
}
if (timezoneId !== undefined) {
console.warn(
"[cloakbrowser] contextOptions.timezoneId ignored — use top-level `timezone` " +
"instead (routes through binary flag, avoids detectable CDP emulation)."
);
}
return rest;
}
/** /**
* Launch stealth Chromium browser via Playwright. * Launch stealth Chromium browser via Playwright.
* *
@@ -60,8 +83,8 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
const { patchBrowser } = await import('./human/index.js'); const { patchBrowser } = await import('./human/index.js');
const { resolveConfig } = await import('./human/config.js'); const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig( const cfg = resolveConfig(
(options.humanPreset as any) ?? 'default', options.humanPreset ?? 'default',
options.humanConfig as any, options.humanConfig,
); );
patchBrowser(browser, cfg); patchBrowser(browser, cfg);
} }
@@ -104,6 +127,9 @@ export async function launchContext(
let context: BrowserContext; let context: BrowserContext;
try { try {
context = await browser.newContext({ context = await browser.newContext({
// contextOptions first — explicit wrapper fields below override it.
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
...filterStealthCtxOptions(options.contextOptions),
...(options.userAgent ? { userAgent: options.userAgent } : {}), ...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport, viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}), ...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
@@ -125,8 +151,8 @@ export async function launchContext(
const { patchContext } = await import('./human/index.js'); const { patchContext } = await import('./human/index.js');
const { resolveConfig } = await import('./human/config.js'); const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig( const cfg = resolveConfig(
(options.humanPreset as any) ?? 'default', options.humanPreset ?? 'default',
options.humanConfig as any, options.humanConfig,
); );
patchContext(context, cfg); patchContext(context, cfg);
} }
@@ -178,6 +204,9 @@ export async function launchPersistentContext(
args, args,
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS, ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
...(proxyOption ? { proxy: proxyOption } : {}), ...(proxyOption ? { proxy: proxyOption } : {}),
// contextOptions before explicit wrapper fields so explicit wins.
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
...filterStealthCtxOptions(options.contextOptions),
...(options.userAgent ? { userAgent: options.userAgent } : {}), ...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport, viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}), ...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
@@ -189,8 +218,8 @@ export async function launchPersistentContext(
const { patchContext } = await import('./human/index.js'); const { patchContext } = await import('./human/index.js');
const { resolveConfig } = await import('./human/config.js'); const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig( const cfg = resolveConfig(
(options.humanPreset as any) ?? 'default', options.humanPreset ?? 'default',
options.humanConfig as any, options.humanConfig,
); );
patchContext(context, cfg); patchContext(context, cfg);
} }
+2 -2
View File
@@ -94,8 +94,8 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
const { patchBrowser } = await import('./human-puppeteer/index.js'); const { patchBrowser } = await import('./human-puppeteer/index.js');
const { resolveConfig } = await import('./human/config.js'); const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig( const cfg = resolveConfig(
(options.humanPreset as any) ?? 'default', options.humanPreset ?? 'default',
options.humanConfig as any, options.humanConfig,
); );
patchBrowser(browser, cfg); patchBrowser(browser, cfg);
} }
+14 -2
View File
@@ -2,6 +2,9 @@
* Shared types for cloakbrowser launch wrappers. * Shared types for cloakbrowser launch wrappers.
*/ */
import type { BrowserContextOptions } from "playwright-core";
import type { HumanConfig, HumanPreset } from "./human/config.js";
export interface LaunchOptions { export interface LaunchOptions {
/** Run in headless mode (default: true). */ /** Run in headless mode (default: true). */
headless?: boolean; headless?: boolean;
@@ -27,9 +30,9 @@ export interface LaunchOptions {
/** Enable human-like mouse, keyboard, and scroll behavior. */ /** Enable human-like mouse, keyboard, and scroll behavior. */
humanize?: boolean; humanize?: boolean;
/** Human behavior preset: 'default' or 'careful'. */ /** Human behavior preset: 'default' or 'careful'. */
humanPreset?: 'default' | 'careful'; humanPreset?: HumanPreset;
/** Override individual human behavior parameters. */ /** Override individual human behavior parameters. */
humanConfig?: Record<string, unknown>; humanConfig?: Partial<HumanConfig>;
} }
export interface LaunchContextOptions extends LaunchOptions { export interface LaunchContextOptions extends LaunchOptions {
@@ -43,6 +46,15 @@ export interface LaunchContextOptions extends LaunchOptions {
timezoneId?: string; timezoneId?: string;
/** Color scheme preference — 'light', 'dark', or 'no-preference'. */ /** Color scheme preference — 'light', 'dark', or 'no-preference'. */
colorScheme?: "light" | "dark" | "no-preference"; colorScheme?: "light" | "dark" | "no-preference";
/**
* Extra options forwarded directly to Playwright's `browser.newContext()`
* e.g. `storageState`, `permissions`, `geolocation`, `extraHTTPHeaders`,
* `httpCredentials`. Use this for context-level options not surfaced as
* top-level fields. `locale` and `timezoneId` are stripped here to avoid
* detectable CDP emulation use the top-level `locale` and `timezone`
* wrapper fields instead (they route through undetectable binary flags).
*/
contextOptions?: BrowserContextOptions;
} }
export interface LaunchPersistentContextOptions extends LaunchContextOptions { export interface LaunchPersistentContextOptions extends LaunchContextOptions {
+103
View File
@@ -130,6 +130,60 @@ describe("launchContext (unit)", () => {
// Browser also closed // Browser also closed
expect(mockBrowser.close).toHaveBeenCalledOnce(); expect(mockBrowser.close).toHaveBeenCalledOnce();
}); });
it("forwards contextOptions to newContext (storageState, etc.)", async () => {
const { launchContext } = await import("../src/playwright.js");
await launchContext({
contextOptions: {
storageState: "state.json",
permissions: ["geolocation"],
},
});
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
expect(ctxArgs.storageState).toBe("state.json");
expect(ctxArgs.permissions).toEqual(["geolocation"]);
});
it("explicit top-level fields win over contextOptions on collision", async () => {
const { launchContext } = await import("../src/playwright.js");
await launchContext({
userAgent: "Explicit/1.0",
viewport: { width: 1280, height: 720 },
colorScheme: "dark",
contextOptions: {
userAgent: "ShouldBeOverridden/9.9",
viewport: { width: 9999, height: 9999 },
colorScheme: "light",
},
});
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
expect(ctxArgs.userAgent).toBe("Explicit/1.0");
expect(ctxArgs.viewport).toEqual({ width: 1280, height: 720 });
expect(ctxArgs.colorScheme).toBe("dark");
});
it("strips locale and timezoneId from contextOptions (stealth-sensitive)", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { launchContext } = await import("../src/playwright.js");
await launchContext({
contextOptions: {
storageState: "state.json",
locale: "de-DE",
timezoneId: "Europe/Berlin",
},
});
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
// Stealth-sensitive keys stripped — they would reintroduce detectable CDP emulation.
expect(ctxArgs.locale).toBeUndefined();
expect(ctxArgs.timezoneId).toBeUndefined();
// Benign keys preserved
expect(ctxArgs.storageState).toBe("state.json");
// Warning was logged for both stripped keys
expect(warnSpy).toHaveBeenCalledTimes(2);
});
}); });
describe("launchPersistentContext (unit)", () => { describe("launchPersistentContext (unit)", () => {
@@ -207,4 +261,53 @@ describe("launchPersistentContext (unit)", () => {
expect(args.userAgent).toBe("Custom/1.0"); expect(args.userAgent).toBe("Custom/1.0");
expect(args.colorScheme).toBe("dark"); expect(args.colorScheme).toBe("dark");
}); });
it("forwards contextOptions to launchPersistentContext", async () => {
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({
userDataDir: "/tmp/profile",
contextOptions: {
permissions: ["geolocation"],
extraHTTPHeaders: { "X-Custom": "1" },
},
});
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.permissions).toEqual(["geolocation"]);
expect(args.extraHTTPHeaders).toEqual({ "X-Custom": "1" });
});
it("explicit top-level fields win over contextOptions in persistent context", async () => {
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({
userDataDir: "/tmp/profile",
userAgent: "Explicit/1.0",
viewport: { width: 1280, height: 720 },
contextOptions: {
userAgent: "ShouldBeOverridden/9.9",
viewport: { width: 9999, height: 9999 },
},
});
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.userAgent).toBe("Explicit/1.0");
expect(args.viewport).toEqual({ width: 1280, height: 720 });
});
it("strips locale and timezoneId from contextOptions (persistent context)", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({
userDataDir: "/tmp/profile",
contextOptions: {
locale: "de-DE",
timezoneId: "Europe/Berlin",
},
});
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.locale).toBeUndefined();
expect(args.timezoneId).toBeUndefined();
expect(warnSpy).toHaveBeenCalledTimes(2);
});
}); });
+123 -1
View File
@@ -1,6 +1,6 @@
"""Unit tests for launch_context() — context kwargs, viewport defaults, close cleanup.""" """Unit tests for launch_context() — context kwargs, viewport defaults, close cleanup."""
from unittest.mock import MagicMock, call, patch from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest import pytest
@@ -207,3 +207,125 @@ def test_kwargs_passthrough(mock_launch, _mock_bin):
# Verify kwarg did NOT leak to launch() # Verify kwarg did NOT leak to launch()
launch_kwargs = mock_launch.call_args[1] launch_kwargs = mock_launch.call_args[1]
assert "record_video_dir" not in launch_kwargs assert "record_video_dir" not in launch_kwargs
# ---------------------------------------------------------------------------
# Async: launch_context_async()
# ---------------------------------------------------------------------------
def _make_mock_async_browser():
"""Create a mock async browser whose new_context() returns a mock context."""
browser = AsyncMock()
context = AsyncMock()
browser.new_context.return_value = context
return browser, context
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch_async")
async def test_async_storage_state_forwarded(mock_launch_async, _mock_bin):
"""storage_state kwarg forwarded to browser.new_context() in async path.
This is the motivating use case from issue #141.
"""
browser, context = _make_mock_async_browser()
mock_launch_async.return_value = browser
from cloakbrowser.browser import launch_context_async
await launch_context_async(storage_state="state.json")
ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["storage_state"] == "state.json"
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch_async")
async def test_async_default_viewport(mock_launch_async, _mock_bin):
"""DEFAULT_VIEWPORT applied when no viewport given (async)."""
browser, context = _make_mock_async_browser()
mock_launch_async.return_value = browser
from cloakbrowser.browser import launch_context_async
await launch_context_async()
ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["viewport"] == DEFAULT_VIEWPORT
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch_async")
async def test_async_locale_flows_to_binary_not_cdp(mock_launch_async, _mock_bin):
"""locale flows to launch_async() for --lang flag, NOT to new_context() CDP."""
browser, context = _make_mock_async_browser()
mock_launch_async.return_value = browser
from cloakbrowser.browser import launch_context_async
await launch_context_async(locale="de-DE", timezone="Europe/Berlin")
# Binary flags
assert mock_launch_async.call_args[1]["locale"] == "de-DE"
assert mock_launch_async.call_args[1]["timezone"] == "Europe/Berlin"
# Not in context — would trigger detectable CDP emulation
ctx_kwargs = browser.new_context.call_args
assert "locale" not in ctx_kwargs[1]
assert "timezone_id" not in ctx_kwargs[1]
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch_async")
async def test_async_close_closes_browser(mock_launch_async, _mock_bin):
"""await ctx.close() also closes the underlying browser."""
browser, context = _make_mock_async_browser()
original_ctx_close = context.close
mock_launch_async.return_value = browser
from cloakbrowser.browser import launch_context_async
ctx = await launch_context_async()
await ctx.close()
original_ctx_close.assert_called_once()
browser.close.assert_called_once()
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch_async")
async def test_async_error_closes_browser(mock_launch_async, _mock_bin):
"""If new_context() raises in async path, browser is still closed."""
browser = AsyncMock()
browser.new_context.side_effect = RuntimeError("context creation failed")
mock_launch_async.return_value = browser
from cloakbrowser.browser import launch_context_async
with pytest.raises(RuntimeError, match="context creation failed"):
await launch_context_async()
browser.close.assert_called_once()
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch_async")
async def test_async_cancellation_closes_browser(mock_launch_async, _mock_bin):
"""asyncio.CancelledError during new_context() still closes browser.
CancelledError derives from BaseException (not Exception) in Python 3.8+,
so the cleanup must catch BaseException to prevent browser process leaks
when the awaiting task is cancelled.
"""
import asyncio
browser = AsyncMock()
browser.new_context.side_effect = asyncio.CancelledError()
mock_launch_async.return_value = browser
from cloakbrowser.browser import launch_context_async
with pytest.raises(asyncio.CancelledError):
await launch_context_async()
browser.close.assert_called_once()