Files
CloakHQ 89fb671af8 fix(async): run blocking launch lookups off the event loop
Python async launchers ran the binary/license check and the GeoIP and
WebRTC exit-IP lookups on the caller's event loop, freezing every other
coroutine for up to 20s per launch. Run them via asyncio.to_thread.

.NET: license validation and Pro version lookups inside async download
methods now run via Task.Run instead of holding the caller's thread.
2026-10-12 22:01:53 +02:00

1862 lines
76 KiB
Python

"""Core browser launch functions for cloakbrowser.
Provides launch() and launch_async() — thin wrappers around Playwright
that use our patched stealth Chromium binary instead of stock Chromium.
Usage:
from cloakbrowser import launch
browser = launch()
page = browser.new_page()
page.goto("https://protected-site.com")
browser.close()
"""
from __future__ import annotations
import asyncio
import inspect
import logging
import os
import sys
from typing import Any, Literal, TypedDict
from urllib.parse import quote, unquote, urlparse, urlunparse
from .config import (
DEFAULT_VIEWPORT,
IGNORE_DEFAULT_ARGS,
binary_supports_headless_no_viewport,
binary_supports_http_proxy_inline_auth,
binary_supports_maximized_window,
get_default_stealth_args,
persistent_seed_args,
)
from .download import ensure_binary
from .license import (
CloakBrowserLicenseError,
build_launch_env,
license_error_for_code,
license_error_message,
mint_denial_file,
read_denial_file,
resolve_license_key,
)
from .human.config import HumanConfigOverrides, HumanPreset
from .widevine import seed_widevine_hint
logger = logging.getLogger("cloakbrowser")
def _license_error(exc: BaseException) -> CloakBrowserLicenseError | None:
"""Return a CloakBrowserLicenseError if a launch failure was a license deny.
The Pro binary exits with a distinct code (76-79) on a license problem;
Playwright embeds that code in the launch-failure text. Returns None for any
other failure so a genuine crash propagates unchanged.
"""
msg = license_error_message(str(exc))
return CloakBrowserLicenseError(msg) if msg is not None else None
# Factory methods whose return value is itself a handle the user drives (a page
# or context handed back after launch). Guarding these *deeply* — guarding the
# object they return — is what lets a denial that lands AFTER the handshake
# surface on the returned page's first call, not only on a second new_page().
_GUARD_FACTORY_METHODS = ("new_page", "new_context")
# Never wrap these. ``close`` already carries teardown logic and must not be
# turned into a license error mid-cleanup; the event-listener surface is called
# internally by Playwright (event dispatch), so throwing a license error from
# inside it would crash the driver rather than surface cleanly to the user.
_GUARD_SKIP_METHODS = frozenset({
"close", "on", "once", "remove_listener",
})
class _Guarded:
"""A non-descriptor callable that wraps the guard closure.
The guard is stored back on the target as a plain attribute, and humanize
copies it into a ``type("Originals", ...)()`` holder and reads it off that
*instance*. Anything with ``__get__`` (a bare function, or ``functools.partial``
on Python 3.14+, where partial became a method descriptor) would re-bind to the
holder and inject a spurious first positional arg. This class has no ``__get__``,
so it stays inert wherever it is stored — on any Python version. Works for both
the sync guard and the async guard: for the async case ``__call__`` returns the
coroutine the closure produces, which the caller awaits. (issue #488)
"""
__slots__ = ("_fn",)
def __init__(self, fn: Any) -> None:
self._fn = fn
def __call__(self, *args: Any, **kwargs: Any) -> Any:
return self._fn(*args, **kwargs)
def _denial_license_error(denial_path: str) -> CloakBrowserLicenseError | None:
"""Read the denial file and map it to a license error, or None."""
code = read_denial_file(denial_path)
return license_error_for_code(code) if code is not None else None
def _guardable_method_names(target: Any) -> list[str]:
"""Public, non-property callables on *target* — the methods that can raise a
TargetClosedError once the browser dies. Properties are skipped by name so
their getters are never triggered here; this covers every real method
without enumerating them by hand.
"""
names = []
for name in dir(target):
if name.startswith("_") or name in _GUARD_SKIP_METHODS:
continue
if isinstance(getattr(type(target), name, None), property):
continue
try:
attr = getattr(target, name)
except Exception:
continue
if callable(attr) and not isinstance(attr, type):
names.append(name)
return names
def _install_license_guard(target: Any, denial_path: str) -> None:
"""Guard every public method of *target* (a browser, context, or page) so a
post-handshake license denial surfaces as CloakBrowserLicenseError on the
user's first failing call — whichever method that is.
A denial that lands after Playwright connected kills the browser without a
launch failure; the user's next call would otherwise raise a bare
TargetClosedError. Each wrapped method, on any exception, checks the denial
file the binary wrote and re-raises as CloakBrowserLicenseError when a
license code is present — otherwise the original error propagates unchanged,
so a genuine crash is never mislabelled. Factory methods (new_page/
new_context) additionally guard the object they return, so a page or context
obtained after launch is covered too.
"""
for name in _guardable_method_names(target):
original = getattr(target, name)
deep = name in _GUARD_FACTORY_METHODS
def make(original: Any, deep: bool) -> Any:
def guarded(*args: Any, **kwargs: Any) -> Any:
try:
result = original(*args, **kwargs)
except Exception as exc:
lic = _denial_license_error(denial_path)
if lic is not None:
raise lic from exc
raise
# Even on success the browser may already be denied: the binary
# writes the denial file the instant it's over cap but keeps
# serving (blank) responses for ~1s before it exits. Check after
# every call so the denial surfaces on the first call that runs
# once the file exists, not only after the process has died.
lic = _denial_license_error(denial_path)
if lic is not None:
raise lic
if deep and result is not None:
_install_license_guard(result, denial_path)
return result
return guarded
# Wrap so the guard is NOT a descriptor. Playwright's real methods are
# bound methods; humanize copies them into a class attribute
# (type("Originals", ...)) and reads them back off an instance. A bare
# function — or a functools.partial on Python 3.14+, where partial became
# a method descriptor — would re-bind there and inject a spurious first
# positional arg. _Guarded has no __get__, so it stays inert. (issue #488)
setattr(target, name, _Guarded(make(original, deep)))
def _install_license_guard_async(target: Any, denial_path: str) -> None:
"""Async variant of :func:`_install_license_guard`. Only coroutine methods
are wrapped — sync helpers (``is_closed``, ``on`` …) never do a CDP round
trip and can't raise the disconnect error, and awaiting them would break.
"""
for name in _guardable_method_names(target):
original = getattr(target, name)
if not inspect.iscoroutinefunction(original):
continue
deep = name in _GUARD_FACTORY_METHODS
def make(original: Any, deep: bool) -> Any:
async def guarded(*args: Any, **kwargs: Any) -> Any:
try:
result = await original(*args, **kwargs)
except Exception as exc:
lic = _denial_license_error(denial_path)
if lic is not None:
raise lic from exc
raise
# See the sync variant: a denial can land while calls still
# succeed, so check the file after every call, not just on error.
lic = _denial_license_error(denial_path)
if lic is not None:
raise lic
if deep and result is not None:
_install_license_guard_async(result, denial_path)
return result
return guarded
# Wrap so the guard is not a descriptor (see sync variant, issue #488).
setattr(target, name, _Guarded(make(original, deep)))
# Sentinel to distinguish "viewport not provided" from "viewport=None" (disable emulation)
_VIEWPORT_UNSET = object()
def _default_no_viewport(browser: Any) -> None:
"""Default ``new_page()``/``new_context()`` to ``no_viewport=True``.
``launch()`` returns a raw Playwright ``Browser``; a bare ``browser.new_page()``
would otherwise inherit Playwright's emulated 1280x720 viewport, producing
``outerWidth < innerWidth`` — a physically impossible window (bot tell). We wrap
the two factory methods so pages track the real OS window instead. ``setdefault``
only: an explicit ``viewport`` or ``no_viewport`` from the caller is never
overridden (Playwright rejects passing both). Applied for headed launches only.
Composes under humanize's ``patch_browser`` (apply this first).
"""
orig_new_context = browser.new_context
orig_new_page = browser.new_page
def _patched_new_context(**kwargs: Any) -> Any:
if "viewport" not in kwargs:
kwargs.setdefault("no_viewport", True)
return orig_new_context(**kwargs)
def _patched_new_page(**kwargs: Any) -> Any:
if "viewport" not in kwargs:
kwargs.setdefault("no_viewport", True)
return orig_new_page(**kwargs)
browser.new_context = _patched_new_context
browser.new_page = _patched_new_page
def _default_no_viewport_async(browser: Any) -> None:
"""Async variant of :func:`_default_no_viewport`."""
orig_new_context = browser.new_context
orig_new_page = browser.new_page
async def _patched_new_context(**kwargs: Any) -> Any:
if "viewport" not in kwargs:
kwargs.setdefault("no_viewport", True)
return await orig_new_context(**kwargs)
async def _patched_new_page(**kwargs: Any) -> Any:
if "viewport" not in kwargs:
kwargs.setdefault("no_viewport", True)
return await orig_new_page(**kwargs)
browser.new_context = _patched_new_context
browser.new_page = _patched_new_page
def _resolve_context_viewport(
viewport: Any, headless: bool, headless_no_viewport: bool = False
) -> dict[str, Any]:
"""Return the viewport kwarg for a context.
Headed: no emulated viewport so the page tracks the real window. Headless on a
newer binary (``headless_no_viewport``): also ``no_viewport``, since it reports
coherent dimensions without emulation. Headless on an older binary: a fixed
``DEFAULT_VIEWPORT`` keeps dimensions coherent and deterministic. Explicit
``viewport`` / ``None`` honored.
"""
if viewport is _VIEWPORT_UNSET:
if headless and not headless_no_viewport:
return {"viewport": DEFAULT_VIEWPORT}
return {"no_viewport": True}
if viewport is None:
return {"no_viewport": True}
return {"viewport": viewport}
def _drop_conflicting_viewport(context_kwargs: dict[str, Any], kwargs: dict[str, Any]) -> None:
"""Playwright rejects passing both ``viewport`` and ``no_viewport``. ``viewport`` is a
named parameter (never in ``**kwargs``), so the only conflict is a caller passing
``no_viewport`` via ``**kwargs`` alongside an explicit ``viewport`` — the explicit
``no_viewport`` wins; drop the viewport so Playwright doesn't error.
"""
if "no_viewport" in kwargs and "viewport" in context_kwargs:
logger.debug("Both viewport and no_viewport requested; no_viewport (kwargs) wins")
context_kwargs.pop("viewport", None)
def _resolve_timezone(timezone: str | None, kwargs: dict[str, Any]) -> str | None:
"""Accept both timezone and timezone_id — either works, no warning."""
if "timezone_id" in kwargs:
if timezone is None:
timezone = kwargs.pop("timezone_id")
else:
kwargs.pop("timezone_id")
return timezone
def _check_removed_kwargs(kwargs: dict[str, Any]) -> None:
"""Raise a clear error for removed parameters that now fall into **kwargs."""
if "backend" in kwargs:
raise TypeError(
"The 'backend' parameter has been removed — patchright is no longer "
"supported and stock Playwright is the only backend. Remove the argument."
)
class _ProxySettingsRequired(TypedDict):
server: str
class ProxySettings(_ProxySettingsRequired, total=False):
"""Playwright-compatible proxy configuration."""
bypass: str
username: str
password: str
def launch(
headless: bool = True,
proxy: str | ProxySettings | None = None,
args: list[str] | None = None,
stealth_args: bool = True,
timezone: str | None = None,
locale: str | None = None,
geoip: bool = False,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
extension_paths: list[str] | None = None,
license_key: str | None = None,
browser_version: str | None = None,
release_channel: str | None = None,
_suppress_maximize: bool = False,
**kwargs: Any,
) -> Any:
"""Launch stealth Chromium browser. Returns a Playwright Browser object.
Args:
headless: Run in headless mode (default True).
proxy: Proxy URL string or Playwright proxy dict.
String: 'http://user:pass@proxy:8080' (credentials auto-extracted).
Dict: {"server": "http://proxy:8080", "bypass": ".google.com", ...}
— passed directly to Playwright.
args: Additional Chromium CLI arguments to pass.
extension_paths: List of Chrome extension paths to load.
stealth_args: Include default stealth fingerprint args (default True).
Set to False if you want to pass your own --fingerprint flags.
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.
geoip: Auto-detect timezone/locale from proxy IP (default False).
Requires ``pip install cloakbrowser[geoip]``. Downloads ~70 MB
GeoLite2-City database on first use. Explicit timezone/locale
always override geoip results.
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 directly to playwright.chromium.launch().
Returns:
Playwright Browser object — use same API as playwright.chromium.launch().
Example:
>>> from cloakbrowser import launch
>>> browser = launch()
>>> page = browser.new_page()
>>> page.goto("https://bot.incolumitas.com")
>>> print(page.title())
>>> browser.close()
"""
_check_removed_kwargs(kwargs)
from playwright.sync_api import sync_playwright
binary_path = ensure_binary(license_key=license_key, browser_version=browser_version, release_channel=release_channel)
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale, args)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key, release_channel)
args = _resolve_webrtc_args(args, proxy)
args = _append_webrtc_exit_ip(args, exit_ip)
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version, release_channel) and not _suppress_maximize)
_maybe_warn_windows_fonts(chrome_args)
logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args))
denial_path = mint_denial_file() if resolve_license_key(license_key) else None
launch_env = build_launch_env(license_key, user_env=kwargs.pop("env", None), status_file=denial_path)
env_kwargs = {} if launch_env is None else {"env": launch_env}
pw = sync_playwright().start()
try:
browser = pw.chromium.launch(
executable_path=binary_path,
headless=headless,
args=chrome_args,
ignore_default_args=IGNORE_DEFAULT_ARGS,
**env_kwargs,
**proxy_kwargs,
**kwargs,
)
except Exception as exc:
lic = _license_error(exc)
try:
pw.stop()
except Exception as stop_exc:
logger.warning("Playwright cleanup after launch failure failed: %s", stop_exc)
if lic is None:
raise
raise lic from exc
# Patch close() to also stop the Playwright instance
_original_close = browser.close
def _close_with_cleanup(*, reason: str | None = None) -> None:
try:
if reason is None:
_original_close()
else:
_original_close(reason=reason)
finally:
pw.stop()
browser.close = _close_with_cleanup
# Convert a post-handshake license denial into a clear error on the user's
# first call. Installed before the wraps below so it sits closest to the
# real call. Stash the path so launch_context() can guard its context too.
if denial_path:
browser._cloak_denial_path = denial_path
_install_license_guard(browser, denial_path)
# Proxy went to Chrome as --proxy-server, so Playwright's request client has no
# proxy (#579). The impl "context" event fires for every new context,
# including the one new_page() and launch_context() create internally.
if proxy_extra_args:
browser._impl_obj.on("context", _warn_on_request_client_use)
# Default new_page()/new_context() to no_viewport for headed (page tracks the
# real window) and for headless on binaries that report coherent dimensions
# natively; older headless binaries keep Playwright's default viewport. Apply
# before humanize so the wraps compose.
if not headless or binary_supports_headless_no_viewport(license_key, browser_version, release_channel):
_default_no_viewport(browser)
# Human-like behavioral patching
if humanize:
from .human import patch_browser
from .human.config import resolve_config
cfg = resolve_config(human_preset, human_config)
patch_browser(browser, cfg)
return browser
async def launch_async( # noqa: C901
headless: bool = True,
proxy: str | ProxySettings | None = None,
args: list[str] | None = None,
stealth_args: bool = True,
timezone: str | None = None,
locale: str | None = None,
geoip: bool = False,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
extension_paths: list[str] | None = None,
license_key: str | None = None,
browser_version: str | None = None,
release_channel: str | None = None,
_suppress_maximize: bool = False,
**kwargs: Any,
) -> Any:
"""Async version of launch(). Returns a Playwright Browser object.
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 to pass.
extension_paths: List of Chrome extension paths to load.
stealth_args: Include default stealth fingerprint args (default True).
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.
geoip: Auto-detect timezone/locale from proxy IP (default False).
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 directly to playwright.chromium.launch().
Returns:
Playwright Browser object (async API).
Example:
>>> import asyncio
>>> from cloakbrowser import launch_async
>>>
>>> async def main():
... browser = await launch_async()
... page = await browser.new_page()
... await page.goto("https://bot.incolumitas.com")
... print(await page.title())
... await browser.close()
>>>
>>> asyncio.run(main())
"""
_check_removed_kwargs(kwargs)
from playwright.async_api import async_playwright
# Blocking network calls (license/download, exit-IP lookups) run in a thread so
# they don't freeze the caller's event loop.
binary_path = await asyncio.to_thread(ensure_binary, license_key=license_key, browser_version=browser_version, release_channel=release_channel)
timezone, locale, exit_ip = await asyncio.to_thread(maybe_resolve_geoip, geoip, proxy, timezone, locale, args)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key, release_channel)
args = await asyncio.to_thread(_resolve_webrtc_args, args, proxy)
args = _append_webrtc_exit_ip(args, exit_ip)
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version, release_channel) and not _suppress_maximize)
_maybe_warn_windows_fonts(chrome_args)
logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args))
denial_path = mint_denial_file() if resolve_license_key(license_key) else None
launch_env = build_launch_env(license_key, user_env=kwargs.pop("env", None), status_file=denial_path)
env_kwargs = {} if launch_env is None else {"env": launch_env}
pw = await async_playwright().start()
try:
browser = await pw.chromium.launch(
executable_path=binary_path,
headless=headless,
args=chrome_args,
ignore_default_args=IGNORE_DEFAULT_ARGS,
**env_kwargs,
**proxy_kwargs,
**kwargs,
)
except Exception as exc:
lic = _license_error(exc)
try:
await pw.stop()
except Exception as stop_exc:
logger.warning("Playwright cleanup after launch failure failed: %s", stop_exc)
if lic is None:
raise
raise lic from exc
# Patch close() to also stop the Playwright instance
_original_close = browser.close
async def _close_with_cleanup(*, reason: str | None = None) -> None:
try:
if reason is None:
await _original_close()
else:
await _original_close(reason=reason)
finally:
await pw.stop()
browser.close = _close_with_cleanup
# Convert a post-handshake license denial into a clear error on first use
# (see launch()). Stash the path for launch_context_async().
if denial_path:
browser._cloak_denial_path = denial_path
_install_license_guard_async(browser, denial_path)
# Warn on first use of Playwright's request client (see launch(), #579).
if proxy_extra_args:
browser._impl_obj.on("context", _warn_on_request_client_use)
# Default new_page()/new_context() to no_viewport for headed and qualifying
# headless binaries (see launch()).
if not headless or binary_supports_headless_no_viewport(license_key, browser_version, release_channel):
_default_no_viewport_async(browser)
# Human-like behavioral patching (async variant)
if humanize:
from .human import patch_browser_async
from .human.config import resolve_config
cfg = resolve_config(human_preset, human_config)
patch_browser_async(browser, cfg)
return browser
def launch_persistent_context(
user_data_dir: str | os.PathLike,
headless: bool = True,
proxy: str | ProxySettings | None = None,
args: list[str] | None = None,
stealth_args: bool = True,
user_agent: str | None = None,
viewport: Any = _VIEWPORT_UNSET,
locale: str | None = None,
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
extension_paths: list[str] | None = None,
license_key: str | None = None,
browser_version: str | None = None,
release_channel: str | None = None,
**kwargs: Any,
) -> Any:
"""Launch stealth browser with a persistent profile and return a BrowserContext.
This persists cookies, localStorage, cache, and other browser state across
sessions by storing them in ``user_data_dir``. Also avoids incognito detection
by services like BrowserScan (-10% penalty).
Args:
user_data_dir: Path to the directory where browser profile data is stored.
Created automatically if it doesn't exist. Reuse the same path across
sessions to restore cookies, localStorage, cached credentials, etc.
headless: Run in headless mode (default True).
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
args: Additional Chromium CLI arguments.
extension_paths: List of Chrome extension paths to load.
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'.
Default: None (uses Chromium default, which is 'light').
geoip: Auto-detect timezone/locale from proxy IP (default False).
Requires ``pip install cloakbrowser[geoip]``.
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 directly to playwright.chromium.launch_persistent_context().
Returns:
Playwright BrowserContext object backed by a persistent profile.
Call ``.close()`` when done — this also stops the Playwright instance.
Example:
>>> from cloakbrowser import launch_persistent_context
>>> ctx = launch_persistent_context("./my-profile", headless=False)
>>> page = ctx.new_page()
>>> page.goto("https://protected-site.com")
>>> ctx.close() # Profile is saved; re-use path next run to restore state.
"""
_check_removed_kwargs(kwargs)
from playwright.sync_api import sync_playwright
timezone = _resolve_timezone(timezone, kwargs)
binary_path = ensure_binary(license_key=license_key, browser_version=browser_version, release_channel=release_channel)
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale, args)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key, release_channel)
args = _resolve_webrtc_args(args, proxy)
args = _append_webrtc_exit_ip(args, exit_ip)
args = persistent_seed_args(user_data_dir, stealth_args, args)
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version, release_channel) and viewport is _VIEWPORT_UNSET and "viewport" not in kwargs and "no_viewport" not in kwargs)
_maybe_warn_windows_fonts(chrome_args)
logger.debug(
"Launching persistent stealth Chromium (headless=%s, user_data_dir=%s)",
headless,
user_data_dir,
)
# locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
# — NOT via Playwright context kwargs which use detectable CDP emulation.
context_kwargs: dict[str, Any] = {}
if user_agent:
context_kwargs["user_agent"] = user_agent
context_kwargs.update(
_resolve_context_viewport(
viewport, headless, binary_supports_headless_no_viewport(license_key, browser_version, release_channel)
)
)
if color_scheme:
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs)
_drop_conflicting_viewport(context_kwargs, kwargs)
# Resolve env for the browser process (license key injection, if needed)
user_env = context_kwargs.pop("env", None)
denial_path = mint_denial_file() if resolve_license_key(license_key) else None
launch_env = build_launch_env(license_key, user_env=user_env, status_file=denial_path)
if launch_env is not None:
context_kwargs["env"] = launch_env
seed_widevine_hint(user_data_dir, binary_path)
pw = sync_playwright().start()
try:
context = pw.chromium.launch_persistent_context(
user_data_dir=os.fspath(user_data_dir),
executable_path=binary_path,
headless=headless,
args=chrome_args,
ignore_default_args=IGNORE_DEFAULT_ARGS,
**proxy_kwargs,
**context_kwargs,
)
except Exception as exc:
lic = _license_error(exc)
try:
pw.stop()
except Exception as stop_exc:
logger.warning("Playwright cleanup after launch failure failed: %s", stop_exc)
if lic is None:
raise
raise lic from exc
# Patch close() to also stop the Playwright instance
_original_close = context.close
def _close_with_cleanup(*, reason: str | None = None) -> None:
try:
if reason is None:
_original_close()
else:
_original_close(reason=reason)
finally:
pw.stop()
context.close = _close_with_cleanup
# Warn on first use of Playwright's request client (see launch(), #579).
if proxy_extra_args:
_warn_on_request_client_use(context._impl_obj)
# Human-like behavioral patching. Applied BEFORE the license guard: the guard
# stores its wrapper on the page/context object itself (it needs this launch's
# denial path), and an object attribute shadows the class method humanize
# patches. Guarding first would therefore capture Playwright's own methods and
# silently disable humanize for every call on that page.
if humanize:
from .human import patch_context
from .human.config import resolve_config
cfg = resolve_config(human_preset, human_config)
patch_context(context, cfg)
# The persistent path hands back a context, so guard it (new_page deep — see
# launch()). A persistent context also arrives with a page already open, so
# the user navigates pages[0] directly and never calls new_page — guard those
# existing pages too, or a post-handshake denial surfaces as a bare
# TargetClosedError on that first navigation.
if denial_path:
_install_license_guard(context, denial_path)
for _pg in context.pages:
_install_license_guard(_pg, denial_path)
return context
async def launch_persistent_context_async(
user_data_dir: str | os.PathLike,
headless: bool = True,
proxy: str | ProxySettings | None = None,
args: list[str] | None = None,
stealth_args: bool = True,
user_agent: str | None = None,
viewport: Any = _VIEWPORT_UNSET,
locale: str | None = None,
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
extension_paths: list[str] | None = None,
license_key: str | None = None,
browser_version: str | None = None,
release_channel: str | None = None,
**kwargs: Any,
) -> Any:
"""Async version of launch_persistent_context().
Launch stealth browser with a persistent profile and return a BrowserContext.
This persists cookies, localStorage, cache, and other browser state across
sessions by storing them in ``user_data_dir``.
Args:
user_data_dir: Path to the directory where browser profile data is stored.
Created automatically if it doesn't exist.
headless: Run in headless mode (default True).
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
args: Additional Chromium CLI arguments.
extension_paths: List of Chrome extension paths to load.
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).
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 directly to playwright.chromium.launch_persistent_context().
Returns:
Playwright BrowserContext object backed by a persistent profile (async API).
Call ``await .close()`` when done.
Example:
>>> import asyncio
>>> from cloakbrowser import launch_persistent_context_async
>>>
>>> async def main():
... ctx = await launch_persistent_context_async("./my-profile", headless=False)
... page = await ctx.new_page()
... await page.goto("https://protected-site.com")
... await ctx.close()
>>>
>>> asyncio.run(main())
"""
_check_removed_kwargs(kwargs)
from playwright.async_api import async_playwright
timezone = _resolve_timezone(timezone, kwargs)
# Blocking network calls run in a thread (see launch_async).
binary_path = await asyncio.to_thread(ensure_binary, license_key=license_key, browser_version=browser_version, release_channel=release_channel)
timezone, locale, exit_ip = await asyncio.to_thread(maybe_resolve_geoip, geoip, proxy, timezone, locale, args)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key, release_channel)
args = await asyncio.to_thread(_resolve_webrtc_args, args, proxy)
args = _append_webrtc_exit_ip(args, exit_ip)
args = persistent_seed_args(user_data_dir, stealth_args, args)
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version, release_channel) and viewport is _VIEWPORT_UNSET and "viewport" not in kwargs and "no_viewport" not in kwargs)
_maybe_warn_windows_fonts(chrome_args)
logger.debug(
"Launching persistent stealth Chromium async (headless=%s, user_data_dir=%s)",
headless,
user_data_dir,
)
# locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
# — NOT via Playwright context kwargs which use detectable CDP emulation.
context_kwargs: dict[str, Any] = {}
if user_agent:
context_kwargs["user_agent"] = user_agent
context_kwargs.update(
_resolve_context_viewport(
viewport, headless, binary_supports_headless_no_viewport(license_key, browser_version, release_channel)
)
)
if color_scheme:
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs)
_drop_conflicting_viewport(context_kwargs, kwargs)
# Resolve env for the browser process (license key injection, if needed)
user_env = context_kwargs.pop("env", None)
denial_path = mint_denial_file() if resolve_license_key(license_key) else None
launch_env = build_launch_env(license_key, user_env=user_env, status_file=denial_path)
if launch_env is not None:
context_kwargs["env"] = launch_env
seed_widevine_hint(user_data_dir, binary_path)
pw = await async_playwright().start()
try:
context = await pw.chromium.launch_persistent_context(
user_data_dir=os.fspath(user_data_dir),
executable_path=binary_path,
headless=headless,
args=chrome_args,
ignore_default_args=IGNORE_DEFAULT_ARGS,
**proxy_kwargs,
**context_kwargs,
)
except Exception as exc:
lic = _license_error(exc)
try:
await pw.stop()
except Exception as stop_exc:
logger.warning("Playwright cleanup after launch failure failed: %s", stop_exc)
if lic is None:
raise
raise lic from exc
# Patch close() to also stop the Playwright instance
_original_close = context.close
async def _close_with_cleanup(*, reason: str | None = None) -> None:
try:
if reason is None:
await _original_close()
else:
await _original_close(reason=reason)
finally:
await pw.stop()
context.close = _close_with_cleanup
# Warn on first use of Playwright's request client (see launch(), #579).
if proxy_extra_args:
_warn_on_request_client_use(context._impl_obj)
# Human-like behavioral patching (async variant). Applied BEFORE the license
# guard, for the same reason as the sync variant: the guard stores its wrapper
# on the page/context object, which shadows the class method humanize patches.
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)
# The persistent path hands back a context, so guard it (new_page deep — see
# launch()). A persistent context also arrives with a page already open (see
# the sync variant): guard those existing pages so a post-handshake denial on
# pages[0] surfaces cleanly instead of as a bare TargetClosedError.
if denial_path:
context._cloak_denial_path = denial_path
_install_license_guard_async(context, denial_path)
for _pg in context.pages:
_install_license_guard_async(_pg, denial_path)
return context
def launch_context(
headless: bool = True,
proxy: str | ProxySettings | None = None,
args: list[str] | None = None,
stealth_args: bool = True,
user_agent: str | None = None,
viewport: Any = _VIEWPORT_UNSET,
locale: str | None = None,
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
extension_paths: list[str] | None = None,
license_key: str | None = None,
browser_version: str | None = None,
release_channel: str | None = None,
**kwargs: Any,
) -> Any:
"""Launch stealth browser and return a BrowserContext with common options pre-set.
Convenience function that creates a browser + context in one call.
Useful for setting user agent, viewport, locale, etc.
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.
extension_paths: List of Chrome extension paths to load.
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'.
Default: None (uses Chromium default, which is 'light').
geoip: Auto-detect timezone/locale from proxy IP (default False).
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().
Returns:
Playwright BrowserContext object.
"""
_check_removed_kwargs(kwargs)
timezone = _resolve_timezone(timezone, kwargs)
# Resolve geoip BEFORE launch() to avoid double-resolution and ensure
# resolved values flow to binary flags
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale, args)
# Inject geoip exit IP for WebRTC spoofing (free — no extra HTTP call)
args = _append_webrtc_exit_ip(args, 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 = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
timezone=timezone, locale=locale, extension_paths=extension_paths,
license_key=license_key, browser_version=browser_version,
release_channel=release_channel,
# Caller chose a viewport geometry → don't also auto-maximize
# the window (mirrors the persistent-context path + JS).
_suppress_maximize=(viewport is not _VIEWPORT_UNSET or "no_viewport" in kwargs))
context_kwargs: dict[str, Any] = {}
if user_agent:
context_kwargs["user_agent"] = user_agent
context_kwargs.update(
_resolve_context_viewport(
viewport, headless, binary_supports_headless_no_viewport(license_key, browser_version, release_channel)
)
)
if color_scheme:
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs)
_drop_conflicting_viewport(context_kwargs, kwargs)
try:
context = browser.new_context(**context_kwargs)
except Exception:
browser.close()
raise
# Patch close() to also close the browser (and its Playwright instance)
_original_ctx_close = context.close
def _close_context_with_cleanup() -> None:
try:
_original_ctx_close()
finally:
browser.close()
context.close = _close_context_with_cleanup
# browser.new_context above is already guarded by launch(); also guard the
# context's new_page for a denial that lands after the context is created.
denial_path = getattr(browser, "_cloak_denial_path", None)
if denial_path:
_install_license_guard(context, denial_path)
# Human-like behavioral patching
if humanize:
from .human import patch_context
from .human.config import resolve_config
cfg = resolve_config(human_preset, human_config)
patch_context(context, cfg)
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: Any = _VIEWPORT_UNSET,
locale: str | None = None,
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
extension_paths: list[str] | None = None,
license_key: str | None = None,
browser_version: str | None = None,
release_channel: str | 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.
extension_paths: List of Chrome extension paths to load.
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).
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())
"""
_check_removed_kwargs(kwargs)
timezone = _resolve_timezone(timezone, kwargs)
# Resolve geoip BEFORE launch_async() to avoid double-resolution and ensure
# resolved values flow to binary flags. Threaded: the lookup is blocking network I/O.
timezone, locale, exit_ip = await asyncio.to_thread(maybe_resolve_geoip, geoip, proxy, timezone, locale, args)
args = _append_webrtc_exit_ip(args, 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, extension_paths=extension_paths,
license_key=license_key, browser_version=browser_version,
release_channel=release_channel,
# Caller chose a viewport geometry → don't also auto-maximize
# the window (mirrors the persistent-context path + JS).
_suppress_maximize=(viewport is not _VIEWPORT_UNSET or "no_viewport" in kwargs))
context_kwargs: dict[str, Any] = {}
if user_agent:
context_kwargs["user_agent"] = user_agent
context_kwargs.update(
_resolve_context_viewport(
viewport, headless, binary_supports_headless_no_viewport(license_key, browser_version, release_channel)
)
)
if color_scheme:
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs)
_drop_conflicting_viewport(context_kwargs, 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
# browser.new_context above is already guarded by launch_async(); also guard
# the context's new_page for a denial that lands after context creation.
denial_path = getattr(browser, "_cloak_denial_path", None)
if denial_path:
_install_license_guard_async(context, denial_path)
# 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
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _ensure_proxy_scheme(proxy_url: str) -> str:
"""Prepend http:// to schemeless proxy URLs so parsers can extract hostname."""
return proxy_url if "://" in proxy_url else f"http://{proxy_url}"
def _assemble_proxy_url(
scheme: str,
host: str,
port: int | None,
enc_user: str,
enc_pass: str | None,
path: str = "",
params: str = "",
query: str = "",
fragment: str = "",
) -> str:
"""Build a proxy URL from already-percent-encoded credentials and host parts.
``enc_pass is None`` means no password (no colon in userinfo). Empty string
means present-but-empty (colon preserved). This mirrors the distinction
urlparse makes between ``user@host`` and ``user:@host``.
"""
if ":" in host: # IPv6 literal — re-add brackets
host = f"[{host}]"
if enc_pass is not None:
userinfo = f"{enc_user}:{enc_pass}@"
elif enc_user:
userinfo = f"{enc_user}@"
else:
userinfo = ""
netloc = f"{userinfo}{host}"
if port is not None:
netloc += f":{port}"
return urlunparse((scheme, netloc, path, params, query, fragment))
def _reconstruct_socks_url(proxy: ProxySettings) -> str:
"""Reconstruct a SOCKS5 URL with inline credentials from a Playwright proxy dict."""
server = proxy.get("server", "")
username = proxy.get("username", "")
password = proxy.get("password", "")
if not username:
return server
parsed = urlparse(server)
enc_user = quote(username, safe="")
# Dict convention: empty/missing password → no colon.
enc_pass = quote(password, safe="") if password else None
return _assemble_proxy_url(
parsed.scheme, parsed.hostname or "", parsed.port,
enc_user, enc_pass, parsed.path,
)
def _normalize_socks_string_url(url: str) -> str:
"""Re-encode credentials in a SOCKS5 URL string so Chromium's parser doesn't
truncate them at special chars like '='. Idempotent: pre-encoded input stays
the same (decoded then re-encoded).
Emits an INFO log when re-encoding actually changes the URL, so users who
previously hit silent SOCKS5 fallback (#157) can see what the wrapper did.
Silent on already-encoded inputs (no false-positive noise).
On unparseable input (invalid port, broken IPv6 literal, etc.) logs a
warning and returns the original string — preserves pre-fix pass-through
behavior so Chromium's own error handling kicks in.
"""
try:
parsed = urlparse(url)
# Accessing .port raises ValueError on invalid port strings.
_ = parsed.port
except ValueError as e:
logger.warning("Malformed SOCKS5 proxy URL, passing through unchanged: %s", e)
return url
# Skip only if no credentials at all (username AND password both absent).
# urlparse returns None for absent components, "" for present-but-empty.
if parsed.username is None and parsed.password is None:
return url
raw_user = parsed.username or ""
enc_user = quote(unquote(raw_user), safe="") if raw_user else ""
# Preserve the colon separator when password component is present, even if
# empty, so `user:@host` stays `user:@host`.
if parsed.password is not None:
raw_pass = parsed.password
enc_pass = quote(unquote(raw_pass), safe="") if raw_pass else ""
else:
raw_pass = None
enc_pass = None
normalized = _assemble_proxy_url(
parsed.scheme, parsed.hostname or "", parsed.port,
enc_user, enc_pass,
parsed.path, parsed.params, parsed.query, parsed.fragment,
)
# Compare credentials, not the full URL: urlparse cosmetically lowercases
# scheme and hostname, so a full-string compare would falsely fire on
# `socks5://USER:pass@HOST.com:1080` even when no encoding work happened.
if enc_user != raw_user or enc_pass != raw_pass:
logger.info(
"Auto URL-encoded SOCKS5 proxy credentials (special characters "
"detected). Pre-encode the URL to suppress this notice."
)
return normalized
def _extract_proxy_url(proxy: str | ProxySettings | None) -> str | None:
"""Extract and normalize proxy URL string from proxy param.
For proxy dicts with separate username/password fields, reconstructs
the full URL with inline credentials so proxy auth works.
"""
if proxy is None:
return None
if isinstance(proxy, dict):
server = proxy.get("server", "")
if not server:
return None
if proxy.get("username"):
return (
_reconstruct_socks_url(proxy) if _is_socks_proxy(proxy)
else _reconstruct_http_url(proxy)
)
return _ensure_proxy_scheme(server)
return _ensure_proxy_scheme(proxy)
def _get_flag_value(args: list[str] | None, *keys: str) -> str | None:
"""Return the value of the first ``--key=value`` flag found in *args*, else None."""
for a in args or []:
for k in keys:
if a.startswith(k + "="):
return a.split("=", 1)[1]
return None
def maybe_resolve_geoip(
geoip: bool,
proxy: str | ProxySettings | None,
timezone: str | None,
locale: str | None,
args: list[str] | None = None,
) -> tuple[str | None, str | None, str | None]:
"""Auto-fill timezone/locale from the egress IP when geoip is enabled.
Returns ``(timezone, locale, exit_ip)``. *exit_ip* is a free bonus
from the geoip lookup (no extra HTTP call) — used for WebRTC spoofing.
With a proxy the egress IP is the proxy's exit IP; with no proxy it is
the machine's own public IP, so geoip works proxy-free too.
A timezone/locale set as a raw flag in *args* (``--fingerprint-timezone``,
``--lang``, ``--fingerprint-locale``) counts as explicit: it is promoted to
the param so geoip leaves it alone, mirroring the ``timezone=``/``locale=``
override path.
"""
if not geoip:
return timezone, locale, None
# Promote raw flags to explicit params so geoip doesn't clobber them.
if timezone is None:
timezone = _get_flag_value(args, "--fingerprint-timezone")
if locale is None:
locale = _get_flag_value(args, "--lang", "--fingerprint-locale")
from .geoip import resolve_proxy_exit_ip, resolve_proxy_geo_with_ip
# None when no proxy → echo services resolve the machine's own public IP
proxy_url = _extract_proxy_url(proxy) if proxy else None
# When both tz/locale are explicit, resolve the exit IP for WebRTC — but only
# with a proxy. With no proxy the WebRTC IP would just be the real connection
# IP the site already sees (a no-op), so skip the third-party echo call.
if timezone is not None and locale is not None:
exit_ip = resolve_proxy_exit_ip(proxy_url) if proxy_url else None
return timezone, locale, exit_ip
geo_tz, geo_locale, exit_ip = resolve_proxy_geo_with_ip(proxy_url)
if timezone is None:
timezone = geo_tz
if locale is None:
locale = geo_locale
missing = [name for name, value in (("timezone", timezone), ("locale", locale)) if value is None]
if missing:
raise RuntimeError(
"GeoIP resolution failed: could not determine " + " and ".join(missing)
)
return timezone, locale, exit_ip
def _resolve_webrtc_args(
args: list[str] | None,
proxy: str | ProxySettings | None,
) -> list[str] | None:
"""Replace --fingerprint-webrtc-ip=auto with the resolved proxy exit IP.
Returns args unchanged if no ``auto`` value is present.
"""
if not args:
return args
idx = None
for i, a in enumerate(args):
if a == "--fingerprint-webrtc-ip=auto":
idx = i
break
if idx is None:
return args
proxy_url = _extract_proxy_url(proxy)
if not proxy_url:
logger.warning("--fingerprint-webrtc-ip=auto requires a proxy; removing flag")
args = list(args)
del args[idx]
return args
try:
from .geoip import resolve_proxy_exit_ip
exit_ip = resolve_proxy_exit_ip(proxy_url)
except Exception:
logger.warning("Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto")
args = list(args)
del args[idx]
return args
if exit_ip:
args = list(args)
args[idx] = f"--fingerprint-webrtc-ip={exit_ip}"
else:
logger.warning("Could not resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto")
args = list(args)
del args[idx]
return args
def _append_webrtc_exit_ip(
args: list[str] | None, exit_ip: str | None
) -> list[str] | None:
"""Append ``--fingerprint-webrtc-ip=<exit_ip>`` unless the user already set it.
*exit_ip* comes free from the geoip lookup; it spoofs the WebRTC IP to the
egress IP. No-op when there is no exit IP or the flag is already present. This
rule must stay identical across every launch path, so it lives in one place.
"""
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}")
return args
def build_args(
stealth_args: bool,
extra_args: list[str] | None,
timezone: str | None = None,
locale: str | None = None,
headless: bool = True,
extension_paths: list[str] | None = None,
start_maximized: bool = False,
) -> list[str]:
"""Combine stealth args with user-provided args and locale flags.
Deduplicates by flag key (everything before '=').
Priority: stealth defaults < user args < dedicated params (timezone/locale).
"""
seen: dict[str, str] = {}
if stealth_args:
for arg in get_default_stealth_args():
seen[arg.split("=", 1)[0]] = arg
# GPU blocklist bypass:
# - Headed mode (all platforms): Chromium blocks WebGL on software GPUs
# in Docker/Xvfb. Flag lets SwiftShader serve WebGL. See issue #56.
# - Windows (all modes): Chromium's GPU blocklist blocks WebGPU for the
# Microsoft Basic Render Driver. Dawn's adapter_blocklist bypass alone
# isn't enough — need this flag too. Linux doesn't need it.
import platform as _platform
if not headless or _platform.system() == "Windows":
seen["--ignore-gpu-blocklist"] = "--ignore-gpu-blocklist"
if 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
# Playwright's default launch args switch off a browser feature that stock Chrome
# ships enabled. Re-enable it alongside the Windows font-metrics profile so the
# feature set matches a stock browser rather than a test harness. Merged into any
# existing --enable-features value rather than added as a second flag.
if "--fingerprint-windows-font-metrics" in seen:
key = "--enable-features"
current = seen[key].split("=", 1)[-1] if key in seen else ""
features = [f for f in current.split(",") if f]
if "MediaRouter" not in features:
features.append("MediaRouter")
seen[key] = f"{key}={','.join(features)}"
# Timezone/locale flags are independent of stealth_args — always inject when set
if 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:
for key in ("--lang", "--fingerprint-locale"):
flag = f"{key}={locale}"
if key in seen:
logger.debug("Arg override: %s -> %s", seen[key], flag)
seen[key] = flag
if extension_paths:
abs_paths = [os.path.abspath(p) for p in extension_paths]
ext_val = ",".join(abs_paths)
seen["--load-extension"] = f"--load-extension={ext_val}"
seen["--disable-extensions-except"] = (
f"--disable-extensions-except={ext_val}"
)
# Open maximized (real Windows Chrome overwhelmingly runs maximized) so the
# window fills the spoofed screen. Skipped if the caller already chose a
# window geometry. Gated to binaries where this stays coherent (see
# binary_supports_maximized_window) — below the gate it would create
# outerWidth < innerWidth.
if start_maximized and not any(
k in seen for k in ("--start-maximized", "--window-size", "--window-position")
):
seen["--start-maximized"] = "--start-maximized"
return list(seen.values())
# ---------------------------------------------------------------------------
# Windows-font mismatch warning (Linux only)
#
# On Linux the binary spoofs the Windows platform by default, but fonts come
# from the host OS. A font-less Linux box contradicts the Windows claim and
# font-fingerprinting anti-bot systems flag the mismatch. Warn once per
# environment. See docs/chrome40-fpjs-font-minimum-set-investigation.md.
# ---------------------------------------------------------------------------
# Microsoft-proprietary fonts that signal a real Windows install (absent from
# ttf-mscorefonts-installer). Keep in sync with issue #395 and
# docs/chrome40-fpjs-font-minimum-set-investigation.md.
# Windows OS fonts — ship with Windows itself, so their absence on a
# Windows-spoofing Linux host degrades results. The two monospace fonts
# (Consolas + Courier New) are part of the recommended set so the generic
# `monospace` family resolves to a Windows font. See issue #395.
_WINDOWS_FONT_TELLS = (
"Segoe UI",
"Segoe UI Light",
"Calibri",
"Marlett",
"MS UI Gothic",
"Franklin Gothic",
"Consolas",
"Courier New",
)
# MS Office supplemental fonts, installed as one atomic block by every Office
# install. Roughly half of real Windows machines have this pack and half do
# not, so its absence is a perfectly normal Windows setup, NOT a problem —
# reported as an informational signal only, never a warning.
_OFFICE_FONT_TELLS = (
"MT Extra",
"Century",
"Century Gothic",
"MS Reference Specialty",
"Wingdings 2",
"Wingdings 3",
"Book Antiqua",
"Bookshelf Symbol 7",
"Monotype Corsiva",
"Bookman Old Style",
)
# macOS OS fonts — the core set present on essentially every real Mac. Their
# absence on a macOS-spoofing Linux host degrades results, just as a Windows-
# spoofing host needs the Windows set above. Several entries (Arial, Georgia,
# Times New Roman, Courier New, Tahoma) also ship on a Windows-provisioned box,
# so a partial count on a non-Mac host is normal, not a fault.
_MAC_FONT_TELLS = (
"Apple Color Emoji",
"Arial",
"Arial Narrow",
"Arial Unicode MS",
"Comic Sans MS",
"Courier",
"Courier New",
"Georgia",
"Gill Sans",
"Helvetica",
"Helvetica Neue",
"Impact",
"Menlo",
"Microsoft Sans Serif",
"Monaco",
"Tahoma",
"Times New Roman",
"Trebuchet MS",
"Webdings",
"Wingdings",
)
_font_warning_checked = False
def _count_fonts_present(tells: tuple[str, ...]) -> int | None:
"""Count how many tell-tale fonts are installed, via fc-list.
Returns the number present (0..len(tells)), or None if it can't be
determined (fc-list missing or errored). Callers must NOT treat None as
zero — None means "unknown", 0 means "genuinely none installed".
"""
import subprocess
try:
result = subprocess.run(
["fc-list"], capture_output=True, text=True, timeout=5
)
except (OSError, subprocess.SubprocessError):
return None
if result.returncode != 0:
return None
listing = result.stdout.lower()
return sum(1 for font in tells if font.lower() in listing)
def _windows_fonts_present() -> bool | None:
"""True if ALL Windows OS fonts are installed, False if any are missing,
None if unknown. Strict: a partial set is treated as incomplete, since the
font install is atomic and a missing font degrades the Windows persona.
"""
n = _count_fonts_present(_WINDOWS_FONT_TELLS)
return None if n is None else n == len(_WINDOWS_FONT_TELLS)
def _maybe_warn_windows_fonts(chrome_args: list[str]) -> None:
"""Warn once when spoofing Windows on a Linux host without the full Windows
font set.
Best-effort and silent on error — never raises. Gated by an in-process flag
plus a cache-dir marker so it fires at most once per environment. Suppress
entirely with CLOAKBROWSER_SUPPRESS_FONT_WARNING.
"""
global _font_warning_checked
if _font_warning_checked:
return
_font_warning_checked = True
try:
import platform
if os.environ.get("CLOAKBROWSER_SUPPRESS_FONT_WARNING"):
return
if platform.system() != "Linux":
return
# Effective platform = the last --fingerprint-platform in the final argv
# (build_args dedups, so there is at most one). None => no Windows spoof.
effective_platform = None
for arg in chrome_args:
if arg.startswith("--fingerprint-platform="):
effective_platform = arg.split("=", 1)[1].strip().lower()
if effective_platform != "windows":
return
from .config import get_cache_dir
marker = get_cache_dir() / ".font_warning_shown"
if marker.exists():
return
present = _windows_fonts_present()
if present is None or present:
return # full set present, or can't determine — don't warn
# Write straight to stderr (like the welcome banner and the JS/.NET
# wrappers) so an app's logging config can't silence it. ASCII-only and
# swallow-everything: on a legacy Windows console stderr is cp1252/strict
# and this write is on the launch path.
try:
sys.stderr.write(
"[cloakbrowser] Incomplete Windows font set - installing the full "
"set is strongly advised for best results when spoofing Windows on "
"Linux. https://github.com/CloakHQ/cloakbrowser#font-setup-on-linux "
"(silence: CLOAKBROWSER_SUPPRESS_FONT_WARNING=1)\n"
)
except Exception:
pass
try:
marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text("")
except OSError:
pass
except Exception:
pass
def _parse_proxy_url(proxy: str) -> dict[str, Any]:
"""Parse HTTP(S) proxy URL, extracting credentials into separate Playwright fields.
Handles: http://user:pass@host:port -> {server: "http://host:port", username: "user", password: "pass"}
Also handles: no credentials, URL-encoded special chars, missing port,
and bare proxy strings without a scheme (e.g. 'user:pass@host:port' -> treated as http).
SOCKS5 URLs are NOT handled here — they take a dedicated path via
``_normalize_socks_string_url`` in ``_resolve_proxy_config``.
"""
# Bare format: "user:pass@host:port" — urlparse needs a scheme to extract credentials.
normalized = proxy
if "@" in proxy and "://" not in proxy:
normalized = f"http://{proxy}"
parsed = urlparse(normalized)
if not parsed.username:
return {"server": proxy} # no creds — return original unchanged
# Rebuild server URL without credentials
netloc = parsed.hostname or ""
if parsed.port:
netloc += f":{parsed.port}"
server = urlunparse((parsed.scheme, netloc, parsed.path, "", "", ""))
result: dict[str, Any] = {"server": server}
result["username"] = unquote(parsed.username)
if parsed.password:
result["password"] = unquote(parsed.password)
return result
def _has_credentials(proxy: str | ProxySettings) -> bool:
"""Check if the proxy has inline or dict-level credentials."""
if isinstance(proxy, dict):
return bool(proxy.get("username"))
return "@" in proxy
def _reconstruct_http_url(proxy: ProxySettings) -> str:
"""Reconstruct an HTTP(S) proxy URL with inline credentials from a Playwright proxy dict."""
server = proxy.get("server", "")
username = proxy.get("username", "")
password = proxy.get("password", "")
if not username:
return server
parsed = urlparse(_ensure_proxy_scheme(server))
enc_user = quote(username, safe="")
enc_pass = quote(password, safe="") if password else None
return _assemble_proxy_url(
parsed.scheme, parsed.hostname or "", parsed.port,
enc_user, enc_pass, parsed.path,
)
def _normalize_http_string_url(url: str) -> str:
"""Re-encode credentials in an HTTP(S) proxy URL string for --proxy-server.
Same pattern as ``_normalize_socks_string_url`` — decode then re-encode to
ensure Chromium's proxy URL parser handles special chars correctly.
"""
normalized = url if "://" in url else f"http://{url}"
try:
parsed = urlparse(normalized)
_ = parsed.port
except ValueError as e:
logger.warning("Malformed HTTP proxy URL, passing through unchanged: %s", e)
return normalized
if parsed.username is None and parsed.password is None:
return normalized
raw_user = parsed.username or ""
enc_user = quote(unquote(raw_user), safe="") if raw_user else ""
if parsed.password is not None:
raw_pass = parsed.password
enc_pass = quote(unquote(raw_pass), safe="") if raw_pass else ""
else:
raw_pass = None
enc_pass = None
result = _assemble_proxy_url(
parsed.scheme, parsed.hostname or "", parsed.port,
enc_user, enc_pass,
parsed.path, parsed.params, parsed.query, parsed.fragment,
)
if enc_user != raw_user or enc_pass != raw_pass:
logger.info(
"Auto URL-encoded HTTP proxy credentials (special characters "
"detected). Pre-encode the URL to suppress this notice."
)
return result
def _is_socks_proxy(proxy: str | ProxySettings | None) -> bool:
"""Check if the proxy uses SOCKS5 protocol."""
if proxy is None:
return False
url = proxy.get("server", "") if isinstance(proxy, dict) else proxy
return url.lower().startswith(("socks5://", "socks5h://"))
_request_proxy_warned = False
def _warn_on_request_client_use(impl_context: Any) -> None:
"""Warn once, on first use, that Playwright's request client does not use a
proxy we passed as --proxy-server (#579).
Playwright only proxies ``context.request`` when it owns the proxy. Its
get/post/fetch, ``page.request`` and ``route.fetch()`` all funnel through the
impl ``APIRequestContext._inner_fetch`` (shared by the sync and async APIs),
so one instance wrap catches every path.
"""
# A per-context proxy does reach the request client, so nothing leaks there.
if (getattr(impl_context, "_options", None) or {}).get("proxy"):
return
request = getattr(impl_context, "_request", None)
original = getattr(request, "_inner_fetch", None)
# Private Playwright API: if it is ever renamed, skip rather than break.
if original is None:
return
async def _inner_fetch(*args: Any, **kwargs: Any) -> Any:
global _request_proxy_warned
# Self-remove so only the first call runs through us. pop() because two
# concurrent first calls can both land here.
vars(request).pop("_inner_fetch", None)
if not _request_proxy_warned:
_request_proxy_warned = True
# Straight to stderr so an app's logging config can't silence it.
sys.stderr.write(
"[cloakbrowser] context.request / page.request / route.fetch() do not use "
"this proxy (it is set on the browser, not Playwright) and will send from "
"your real IP. Use page.evaluate('fetch(...)') for proxied requests.\n"
)
return await original(*args, **kwargs)
request._inner_fetch = _inner_fetch
def _resolve_proxy_config(
proxy: str | ProxySettings | None,
browser_version: str | None = None,
license_key: str | None = None,
release_channel: str | None = None,
) -> tuple[dict[str, Any], list[str]]:
"""Resolve proxy into Playwright kwargs and Chrome args.
Proxies with credentials (SOCKS5 always; HTTP/HTTPS only on binaries that
support inline proxy auth) are passed via Chrome's --proxy-server flag with
inline credentials, bypassing Playwright's CDP auth interceptor which breaks
on some proxies and Google domains (#182). HTTP/HTTPS creds on older binaries
fall back to Playwright's proxy dict.
Returns:
(proxy_kwargs, extra_chrome_args) — one or both will be empty.
"""
if proxy is None:
return {}, []
if _is_socks_proxy(proxy):
# SOCKS5: bypass Playwright, pass directly to Chrome via --proxy-server.
# Chrome handles SOCKS5 auth natively from the URL.
if isinstance(proxy, dict):
url = _reconstruct_socks_url(proxy)
extra_args = [f"--proxy-server={url}"]
bypass = proxy.get("bypass")
if bypass:
extra_args.append(f"--proxy-bypass-list={bypass}")
return {}, extra_args
# String URL — re-encode creds to work around Chromium parser truncating
# passwords at '=' and other special chars (#157).
return {}, [f"--proxy-server={_normalize_socks_string_url(proxy)}"]
# HTTP/HTTPS with credentials, only on binaries that ship inline proxy auth:
# use Chrome's native proxy authentication path instead of Playwright's CDP
# auth interceptor (#182). Older binaries (free macOS/linux-arm64) can't parse
# inline credentials, so they fall through to the Playwright proxy dict below.
if _has_credentials(proxy) and binary_supports_http_proxy_inline_auth(license_key, browser_version, release_channel):
if isinstance(proxy, dict):
url = _reconstruct_http_url(proxy)
extra_args = [f"--proxy-server={url}"]
bypass = proxy.get("bypass")
if bypass:
extra_args.append(f"--proxy-bypass-list={bypass}")
return {}, extra_args
return {}, [f"--proxy-server={_normalize_http_string_url(proxy)}"]
# HTTP/HTTPS without credentials: use Playwright's proxy dict
if isinstance(proxy, dict):
return {"proxy": proxy}, []
return {"proxy": _parse_proxy_url(proxy)}, []