mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
feat: add --fingerprint-webrtc-ip flag with auto-resolve support
Two ways to spoof WebRTC ICE candidate IPs: 1. --fingerprint-webrtc-ip=auto in args: resolves proxy exit IP via HTTP call through the proxy (ipify.org). No extra deps needed. 2. geoip=True: auto-injects the flag for free (exit IP already resolved during timezone/locale lookup, zero extra network cost). Explicit IP (--fingerprint-webrtc-ip=1.2.3.4) also supported. User-provided values always take precedence. Python + JS wrappers, README docs, tests.
This commit is contained in:
@@ -137,6 +137,7 @@ Open [http://localhost:8080](http://localhost:8080). Create a profile. Click **L
|
|||||||
- **42 fingerprint patches** (Linux x64) — all 4 platforms on Chromium 145
|
- **42 fingerprint patches** (Linux x64) — all 4 platforms on Chromium 145
|
||||||
- **Stealthy with zero flags** — binary auto-generates a random fingerprint seed at startup. No configuration required
|
- **Stealthy with zero flags** — binary auto-generates a random fingerprint seed at startup. No configuration required
|
||||||
- **Timezone & locale from proxy IP** — `launch(proxy="...", geoip=True)` auto-detects timezone and locale
|
- **Timezone & locale from proxy IP** — `launch(proxy="...", geoip=True)` auto-detects timezone and locale
|
||||||
|
- **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)
|
||||||
- **Persistent profiles** — `launch_persistent_context()` keeps cookies and localStorage across sessions, bypasses incognito detection
|
- **Persistent profiles** — `launch_persistent_context()` keeps cookies and localStorage across sessions, bypasses incognito detection
|
||||||
|
|
||||||
See the full [CHANGELOG.md](CHANGELOG.md) for details.
|
See the full [CHANGELOG.md](CHANGELOG.md) for details.
|
||||||
@@ -253,11 +254,19 @@ browser = launch(args=["--disable-gpu"])
|
|||||||
browser = launch(timezone="America/New_York", locale="en-US")
|
browser = launch(timezone="America/New_York", locale="en-US")
|
||||||
|
|
||||||
# Auto-detect timezone/locale from proxy IP (requires: pip install cloakbrowser[geoip])
|
# Auto-detect timezone/locale from proxy IP (requires: pip install cloakbrowser[geoip])
|
||||||
|
# Also auto-injects --fingerprint-webrtc-ip to prevent WebRTC IP leaks (no extra cost)
|
||||||
|
# Note: makes HTTP calls through your proxy to resolve exit IP (ipify.org, checkip.amazonaws.com)
|
||||||
browser = launch(proxy="http://proxy:8080", geoip=True)
|
browser = launch(proxy="http://proxy:8080", geoip=True)
|
||||||
|
|
||||||
# Explicit timezone/locale always win over auto-detection
|
# Explicit timezone/locale always win over auto-detection
|
||||||
browser = launch(proxy="http://proxy:8080", geoip=True, timezone="Europe/London")
|
browser = launch(proxy="http://proxy:8080", geoip=True, timezone="Europe/London")
|
||||||
|
|
||||||
|
# WebRTC IP spoofing only (no geoip dep needed — resolves exit IP via HTTP call through proxy)
|
||||||
|
browser = launch(proxy="http://proxy:8080", args=["--fingerprint-webrtc-ip=auto"])
|
||||||
|
|
||||||
|
# Explicit WebRTC IP (no network call)
|
||||||
|
browser = launch(proxy="http://proxy:8080", args=["--fingerprint-webrtc-ip=1.2.3.4"])
|
||||||
|
|
||||||
# Human-like mouse, keyboard, and scroll behavior
|
# Human-like mouse, keyboard, and scroll behavior
|
||||||
browser = launch(humanize=True)
|
browser = launch(humanize=True)
|
||||||
|
|
||||||
@@ -573,6 +582,7 @@ Supported by the binary but **not set by default** — pass via `args` to custom
|
|||||||
| `--fingerprint-storage-quota` | Override storage quota in MB — affects `storage.estimate()`, `storageBuckets`, and legacy webkit APIs. Auto-normalized when `--fingerprint` is set |
|
| `--fingerprint-storage-quota` | Override storage quota in MB — affects `storage.estimate()`, `storageBuckets`, and legacy webkit APIs. Auto-normalized when `--fingerprint` is set |
|
||||||
| `--fingerprint-taskbar-height` | Override taskbar height (binary defaults: Win=48, Mac=95, Linux=0) |
|
| `--fingerprint-taskbar-height` | Override taskbar height (binary defaults: Win=48, Mac=95, Linux=0) |
|
||||||
| `--fingerprint-fonts-dir` | Path to cross-platform font directory |
|
| `--fingerprint-fonts-dir` | Path to cross-platform font directory |
|
||||||
|
| `--fingerprint-webrtc-ip` | WebRTC ICE candidate IP replacement. Use `auto` to resolve from proxy exit IP (makes an HTTP call through the proxy), or pass an explicit IP. Auto-injected when `geoip=True` |
|
||||||
| `--fingerprint-noise=false` | Disable noise injection (canvas, WebGL, audio, client rects) while keeping the deterministic fingerprint seed active |
|
| `--fingerprint-noise=false` | Disable noise injection (canvas, WebGL, audio, client rects) while keeping the deterministic fingerprint seed active |
|
||||||
| `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements |
|
| `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements |
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -36,7 +36,7 @@ import aiohttp
|
|||||||
import websockets
|
import websockets
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from cloakbrowser.browser import build_args, maybe_resolve_geoip
|
from cloakbrowser.browser import build_args, maybe_resolve_geoip, _resolve_webrtc_args
|
||||||
from cloakbrowser.download import ensure_binary
|
from cloakbrowser.download import ensure_binary
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -166,8 +166,9 @@ class ChromePool:
|
|||||||
await self._cleanup_process(seed_key)
|
await self._cleanup_process(seed_key)
|
||||||
|
|
||||||
# Resolve geoip if requested
|
# Resolve geoip if requested
|
||||||
|
exit_ip = None
|
||||||
if geoip and proxy:
|
if geoip and proxy:
|
||||||
timezone, locale = maybe_resolve_geoip(True, proxy, timezone, locale)
|
timezone, locale, exit_ip = maybe_resolve_geoip(True, proxy, timezone, locale)
|
||||||
|
|
||||||
# Build Chrome args via shared logic
|
# Build Chrome args via shared logic
|
||||||
fp_extra = [f"--fingerprint={actual_seed}"]
|
fp_extra = [f"--fingerprint={actual_seed}"]
|
||||||
@@ -176,6 +177,12 @@ class ChromePool:
|
|||||||
if proxy:
|
if proxy:
|
||||||
fp_extra.append(f"--proxy-server={proxy}")
|
fp_extra.append(f"--proxy-server={proxy}")
|
||||||
|
|
||||||
|
# WebRTC IP spoofing: resolve auto, inject geoip exit IP
|
||||||
|
fp_extra = _resolve_webrtc_args(fp_extra, proxy)
|
||||||
|
if exit_ip and not any(a.startswith("--fingerprint-webrtc-ip") for a in (fp_extra or [])):
|
||||||
|
fp_extra = list(fp_extra or [])
|
||||||
|
fp_extra.append(f"--fingerprint-webrtc-ip={exit_ip}")
|
||||||
|
|
||||||
chrome_args = build_args(
|
chrome_args = build_args(
|
||||||
stealth_args=True,
|
stealth_args=True,
|
||||||
extra_args=fp_extra,
|
extra_args=fp_extra,
|
||||||
|
|||||||
+95
-15
@@ -101,7 +101,11 @@ def launch(
|
|||||||
sync_playwright = _import_sync_playwright(_resolve_backend(backend))
|
sync_playwright = _import_sync_playwright(_resolve_backend(backend))
|
||||||
|
|
||||||
binary_path = ensure_binary()
|
binary_path = ensure_binary()
|
||||||
timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||||
|
args = _resolve_webrtc_args(args, proxy)
|
||||||
|
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}")
|
||||||
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
|
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
|
||||||
|
|
||||||
logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args))
|
logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args))
|
||||||
@@ -186,7 +190,11 @@ async def launch_async( # noqa: C901
|
|||||||
async_playwright = _import_async_playwright(_resolve_backend(backend))
|
async_playwright = _import_async_playwright(_resolve_backend(backend))
|
||||||
|
|
||||||
binary_path = ensure_binary()
|
binary_path = ensure_binary()
|
||||||
timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||||
|
args = _resolve_webrtc_args(args, proxy)
|
||||||
|
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}")
|
||||||
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
|
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
|
||||||
|
|
||||||
logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args))
|
logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args))
|
||||||
@@ -284,7 +292,11 @@ def launch_persistent_context(
|
|||||||
timezone = _resolve_timezone(timezone, kwargs)
|
timezone = _resolve_timezone(timezone, kwargs)
|
||||||
|
|
||||||
binary_path = ensure_binary()
|
binary_path = ensure_binary()
|
||||||
timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||||
|
args = _resolve_webrtc_args(args, proxy)
|
||||||
|
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}")
|
||||||
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
|
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -399,7 +411,11 @@ async def launch_persistent_context_async(
|
|||||||
timezone = _resolve_timezone(timezone, kwargs)
|
timezone = _resolve_timezone(timezone, kwargs)
|
||||||
|
|
||||||
binary_path = ensure_binary()
|
binary_path = ensure_binary()
|
||||||
timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||||
|
args = _resolve_webrtc_args(args, proxy)
|
||||||
|
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}")
|
||||||
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
|
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -497,7 +513,11 @@ def launch_context(
|
|||||||
|
|
||||||
# Resolve geoip BEFORE launch() to avoid double-resolution and ensure
|
# Resolve geoip BEFORE launch() to avoid double-resolution and ensure
|
||||||
# resolved values flow to binary flags
|
# resolved values flow to binary flags
|
||||||
timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||||
|
# Inject geoip exit IP for WebRTC spoofing (free — no extra HTTP call)
|
||||||
|
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),
|
# --fingerprint-timezone is process-wide (reads CommandLine in renderer),
|
||||||
# so it applies to ALL contexts, not just the default one.
|
# so it applies to ALL contexts, not just the default one.
|
||||||
# locale and timezone are set via binary flags only — no CDP emulation.
|
# locale and timezone are set via binary flags only — no CDP emulation.
|
||||||
@@ -590,28 +610,88 @@ def _ensure_proxy_scheme(proxy_url: str) -> str:
|
|||||||
return proxy_url if "://" in proxy_url else f"http://{proxy_url}"
|
return proxy_url if "://" in proxy_url else f"http://{proxy_url}"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_proxy_url(proxy: str | ProxySettings | None) -> str | None:
|
||||||
|
"""Extract and normalize proxy URL string from proxy param."""
|
||||||
|
if proxy is None:
|
||||||
|
return None
|
||||||
|
raw = proxy.get("server") if isinstance(proxy, dict) else proxy
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
return _ensure_proxy_scheme(raw)
|
||||||
|
|
||||||
|
|
||||||
def maybe_resolve_geoip(
|
def maybe_resolve_geoip(
|
||||||
geoip: bool,
|
geoip: bool,
|
||||||
proxy: str | ProxySettings | None,
|
proxy: str | ProxySettings | None,
|
||||||
timezone: str | None,
|
timezone: str | None,
|
||||||
locale: str | None,
|
locale: str | None,
|
||||||
) -> tuple[str | None, str | None]:
|
) -> tuple[str | None, str | None, str | None]:
|
||||||
"""Auto-fill timezone/locale from proxy IP when geoip is enabled."""
|
"""Auto-fill timezone/locale from proxy IP when geoip is enabled.
|
||||||
if not geoip or not proxy or (timezone is not None and locale is not None):
|
|
||||||
return timezone, locale
|
|
||||||
|
|
||||||
from .geoip import resolve_proxy_geo
|
Returns ``(timezone, locale, exit_ip)``. *exit_ip* is a free bonus
|
||||||
|
from the geoip lookup (no extra HTTP call) — used for WebRTC spoofing.
|
||||||
|
"""
|
||||||
|
if not geoip or not proxy:
|
||||||
|
return timezone, locale, None
|
||||||
|
|
||||||
proxy_url = proxy.get("server") if isinstance(proxy, dict) else proxy
|
from .geoip import resolve_proxy_geo_with_ip
|
||||||
|
|
||||||
|
proxy_url = _extract_proxy_url(proxy)
|
||||||
if not proxy_url:
|
if not proxy_url:
|
||||||
return timezone, locale
|
return timezone, locale, None
|
||||||
proxy_url = _ensure_proxy_scheme(proxy_url)
|
|
||||||
geo_tz, geo_locale = resolve_proxy_geo(proxy_url)
|
# When both tz/locale are explicit, still resolve exit IP for WebRTC
|
||||||
|
if timezone is not None and locale is not None:
|
||||||
|
from .geoip import _resolve_exit_ip
|
||||||
|
exit_ip = _resolve_exit_ip(proxy_url)
|
||||||
|
return timezone, locale, exit_ip
|
||||||
|
|
||||||
|
geo_tz, geo_locale, exit_ip = resolve_proxy_geo_with_ip(proxy_url)
|
||||||
if timezone is None:
|
if timezone is None:
|
||||||
timezone = geo_tz
|
timezone = geo_tz
|
||||||
if locale is None:
|
if locale is None:
|
||||||
locale = geo_locale
|
locale = geo_locale
|
||||||
return timezone, locale
|
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.debug("--fingerprint-webrtc-ip=auto but no proxy set — removing flag")
|
||||||
|
args = list(args)
|
||||||
|
del args[idx]
|
||||||
|
return args
|
||||||
|
try:
|
||||||
|
from .geoip import _resolve_exit_ip
|
||||||
|
exit_ip = _resolve_exit_ip(proxy_url)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("WebRTC IP resolution failed — removing flag")
|
||||||
|
args = list(args)
|
||||||
|
del args[idx]
|
||||||
|
return args
|
||||||
|
if exit_ip:
|
||||||
|
args = list(args)
|
||||||
|
args[idx] = f"--fingerprint-webrtc-ip={exit_ip}"
|
||||||
|
else:
|
||||||
|
args = list(args)
|
||||||
|
del args[idx]
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
def build_args(
|
def build_args(
|
||||||
|
|||||||
+16
-4
@@ -53,6 +53,18 @@ def resolve_proxy_geo(proxy_url: str) -> tuple[str | None, str | None]:
|
|||||||
Returns ``(timezone, locale)`` — either or both may be ``None`` on
|
Returns ``(timezone, locale)`` — either or both may be ``None`` on
|
||||||
failure (missing dep, DB download error, lookup miss). Never raises.
|
failure (missing dep, DB download error, lookup miss). Never raises.
|
||||||
"""
|
"""
|
||||||
|
tz, locale, _ip = resolve_proxy_geo_with_ip(proxy_url)
|
||||||
|
return tz, locale
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_proxy_geo_with_ip(
|
||||||
|
proxy_url: str,
|
||||||
|
) -> tuple[str | None, str | None, str | None]:
|
||||||
|
"""Resolve timezone, locale, and exit IP from a proxy.
|
||||||
|
|
||||||
|
Returns ``(timezone, locale, exit_ip)``. The exit IP is a free bonus
|
||||||
|
from the lookup — reused for WebRTC spoofing without an extra HTTP call.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
import geoip2.database # noqa: F811
|
import geoip2.database # noqa: F811
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -63,14 +75,14 @@ def resolve_proxy_geo(proxy_url: str) -> tuple[str | None, str | None]:
|
|||||||
|
|
||||||
db_path = _ensure_geoip_db()
|
db_path = _ensure_geoip_db()
|
||||||
if db_path is None:
|
if db_path is None:
|
||||||
return None, None
|
return None, None, None
|
||||||
|
|
||||||
# Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
|
# Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
|
||||||
ip = _resolve_exit_ip(proxy_url)
|
ip = _resolve_exit_ip(proxy_url)
|
||||||
if ip is None:
|
if ip is None:
|
||||||
ip = _resolve_proxy_ip(proxy_url)
|
ip = _resolve_proxy_ip(proxy_url)
|
||||||
if ip is None:
|
if ip is None:
|
||||||
return None, None
|
return None, None, None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with geoip2.database.Reader(str(db_path)) as reader:
|
with geoip2.database.Reader(str(db_path)) as reader:
|
||||||
@@ -82,10 +94,10 @@ def resolve_proxy_geo(proxy_url: str) -> tuple[str | None, str | None]:
|
|||||||
"GeoIP: %s → tz=%s, country=%s, locale=%s",
|
"GeoIP: %s → tz=%s, country=%s, locale=%s",
|
||||||
ip, timezone, country, locale,
|
ip, timezone, country, locale,
|
||||||
)
|
)
|
||||||
return timezone, locale
|
return timezone, locale, ip
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("GeoIP lookup failed for %s: %s", ip, exc)
|
logger.debug("GeoIP lookup failed for %s: %s", ip, exc)
|
||||||
return None, None
|
return None, None, ip
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+53
-8
@@ -44,6 +44,7 @@ export const COUNTRY_LOCALE_MAP: Record<string, string> = {
|
|||||||
export interface GeoResult {
|
export interface GeoResult {
|
||||||
timezone: string | null;
|
timezone: string | null;
|
||||||
locale: string | null;
|
locale: string | null;
|
||||||
|
exitIp: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,12 +66,12 @@ export async function resolveProxyGeo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dbPath = await ensureGeoipDb();
|
const dbPath = await ensureGeoipDb();
|
||||||
if (!dbPath) return { timezone: null, locale: null };
|
if (!dbPath) return { timezone: null, locale: null, exitIp: null };
|
||||||
|
|
||||||
// Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
|
// Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
|
||||||
let ip = await resolveExitIp(proxyUrl);
|
let ip = await resolveExitIp(proxyUrl);
|
||||||
if (!ip) ip = await resolveProxyIp(proxyUrl);
|
if (!ip) ip = await resolveProxyIp(proxyUrl);
|
||||||
if (!ip) return { timezone: null, locale: null };
|
if (!ip) return { timezone: null, locale: null, exitIp: null };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buf = fs.readFileSync(dbPath);
|
const buf = fs.readFileSync(dbPath);
|
||||||
@@ -80,9 +81,9 @@ export async function resolveProxyGeo(
|
|||||||
const countryCode: string | null = result?.country?.iso_code ?? null;
|
const countryCode: string | null = result?.country?.iso_code ?? null;
|
||||||
const locale =
|
const locale =
|
||||||
countryCode ? (COUNTRY_LOCALE_MAP[countryCode] ?? null) : null;
|
countryCode ? (COUNTRY_LOCALE_MAP[countryCode] ?? null) : null;
|
||||||
return { timezone, locale };
|
return { timezone, locale, exitIp: ip };
|
||||||
} catch {
|
} catch {
|
||||||
return { timezone: null, locale: null };
|
return { timezone: null, locale: null, exitIp: ip };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,20 +266,64 @@ function maybeTriggerUpdate(dbPath: string): void {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-fill timezone/locale from proxy IP when geoip is enabled.
|
* Auto-fill timezone/locale from proxy IP when geoip is enabled.
|
||||||
* Shared by the Playwright and Puppeteer wrappers.
|
* Also returns exitIp as a free bonus (reused for WebRTC spoofing).
|
||||||
*/
|
*/
|
||||||
export async function maybeResolveGeoip(
|
export async function maybeResolveGeoip(
|
||||||
options: LaunchOptions
|
options: LaunchOptions
|
||||||
): Promise<{ timezone?: string; locale?: string }> {
|
): Promise<{ timezone?: string; locale?: string; exitIp?: string }> {
|
||||||
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
|
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
|
||||||
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
|
|
||||||
|
|
||||||
let proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
|
let proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
|
||||||
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
|
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
|
||||||
proxyUrl = ensureProxyScheme(proxyUrl);
|
proxyUrl = ensureProxyScheme(proxyUrl);
|
||||||
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
|
|
||||||
|
// When both tz/locale are explicit, still resolve exit IP for WebRTC
|
||||||
|
if (options.timezone && options.locale) {
|
||||||
|
const exitIp = await resolveExitIp(proxyUrl) ?? undefined;
|
||||||
|
return { timezone: options.timezone, locale: options.locale, exitIp };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { timezone: geoTz, locale: geoLocale, exitIp: geoExitIp } = await resolveProxyGeo(proxyUrl);
|
||||||
|
const exitIp = geoExitIp ?? undefined;
|
||||||
return {
|
return {
|
||||||
timezone: options.timezone ?? geoTz ?? undefined,
|
timezone: options.timezone ?? geoTz ?? undefined,
|
||||||
locale: options.locale ?? geoLocale ?? undefined,
|
locale: options.locale ?? geoLocale ?? undefined,
|
||||||
|
exitIp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace --fingerprint-webrtc-ip=auto with the resolved proxy exit IP.
|
||||||
|
* Returns args unchanged if no ``auto`` value is present.
|
||||||
|
*/
|
||||||
|
export async function resolveWebrtcArgs(
|
||||||
|
options: LaunchOptions
|
||||||
|
): Promise<string[] | undefined> {
|
||||||
|
const args = options.args;
|
||||||
|
if (!args) return args;
|
||||||
|
const idx = args.findIndex(a => a === "--fingerprint-webrtc-ip=auto");
|
||||||
|
if (idx === -1) return args;
|
||||||
|
|
||||||
|
let proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy?.server;
|
||||||
|
if (!proxyUrl) {
|
||||||
|
const result = [...args];
|
||||||
|
result.splice(idx, 1);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
proxyUrl = ensureProxyScheme(proxyUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ip = await resolveExitIp(proxyUrl);
|
||||||
|
const result = [...args];
|
||||||
|
if (ip) {
|
||||||
|
result[idx] = `--fingerprint-webrtc-ip=${ip}`;
|
||||||
|
} else {
|
||||||
|
result.splice(idx, 1);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
const result = [...args];
|
||||||
|
result.splice(idx, 1);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+20
-7
@@ -9,7 +9,7 @@ import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
|
|||||||
import { buildArgs } from "./args.js";
|
import { buildArgs } from "./args.js";
|
||||||
import { ensureBinary } from "./download.js";
|
import { ensureBinary } from "./download.js";
|
||||||
import { parseProxyUrl } from "./proxy.js";
|
import { parseProxyUrl } from "./proxy.js";
|
||||||
import { maybeResolveGeoip } from "./geoip.js";
|
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
|
||||||
|
|
||||||
/** @internal Accept both timezone and timezoneId — either works, no warning. Exported for testing. */
|
/** @internal Accept both timezone and timezoneId — either works, no warning. Exported for testing. */
|
||||||
export function resolveTimezone<T extends { timezone?: string; timezoneId?: string }>(options: T): T {
|
export function resolveTimezone<T extends { timezone?: string; timezoneId?: string }>(options: T): T {
|
||||||
@@ -38,8 +38,12 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
|||||||
const { chromium } = await import("playwright-core");
|
const { chromium } = await import("playwright-core");
|
||||||
|
|
||||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||||
const resolved = await maybeResolveGeoip(options);
|
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
|
||||||
const args = buildArgs({ ...options, ...resolved });
|
let resolvedArgs = await resolveWebrtcArgs(options);
|
||||||
|
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
|
||||||
|
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
|
||||||
|
}
|
||||||
|
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs });
|
||||||
|
|
||||||
const browser = await chromium.launch({
|
const browser = await chromium.launch({
|
||||||
executablePath: binaryPath,
|
executablePath: binaryPath,
|
||||||
@@ -87,11 +91,16 @@ export async function launchContext(
|
|||||||
): Promise<BrowserContext> {
|
): Promise<BrowserContext> {
|
||||||
options = resolveTimezone(options);
|
options = resolveTimezone(options);
|
||||||
// Resolve geoip BEFORE launch() to avoid double-resolution
|
// Resolve geoip BEFORE launch() to avoid double-resolution
|
||||||
const resolved = await maybeResolveGeoip(options);
|
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
|
||||||
|
let launchArgs = await resolveWebrtcArgs(options);
|
||||||
|
// Inject geoip exit IP for WebRTC spoofing (free — no extra HTTP call)
|
||||||
|
if (exitIp && !(launchArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
|
||||||
|
launchArgs = [...(launchArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
|
||||||
|
}
|
||||||
// --fingerprint-timezone is process-wide (reads CommandLine in renderer),
|
// --fingerprint-timezone is process-wide (reads CommandLine in renderer),
|
||||||
// so it applies to ALL contexts, not just the default one.
|
// so it applies to ALL contexts, not just the default one.
|
||||||
// locale and timezone are set via binary flags only — no CDP emulation.
|
// locale and timezone are set via binary flags only — no CDP emulation.
|
||||||
const browser = await launch({ ...options, ...resolved, geoip: false });
|
const browser = await launch({ ...options, ...resolved, args: launchArgs, geoip: false });
|
||||||
|
|
||||||
let context: BrowserContext;
|
let context: BrowserContext;
|
||||||
try {
|
try {
|
||||||
@@ -154,8 +163,12 @@ export async function launchPersistentContext(
|
|||||||
const { chromium } = await import("playwright-core");
|
const { chromium } = await import("playwright-core");
|
||||||
|
|
||||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||||
const resolved = await maybeResolveGeoip(options);
|
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
|
||||||
const args = buildArgs({ ...options, ...resolved });
|
let resolvedArgs = await resolveWebrtcArgs(options);
|
||||||
|
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
|
||||||
|
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
|
||||||
|
}
|
||||||
|
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs });
|
||||||
|
|
||||||
// locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
|
// locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
|
||||||
// — NOT via Playwright context kwargs which use detectable CDP emulation.
|
// — NOT via Playwright context kwargs which use detectable CDP emulation.
|
||||||
|
|||||||
+7
-3
@@ -9,7 +9,7 @@ import { IGNORE_DEFAULT_ARGS } from "./config.js";
|
|||||||
import { buildArgs } from "./args.js";
|
import { buildArgs } from "./args.js";
|
||||||
import { ensureBinary } from "./download.js";
|
import { ensureBinary } from "./download.js";
|
||||||
import { parseProxyUrl } from "./proxy.js";
|
import { parseProxyUrl } from "./proxy.js";
|
||||||
import { maybeResolveGeoip } from "./geoip.js";
|
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Launch stealth Chromium browser via Puppeteer.
|
* Launch stealth Chromium browser via Puppeteer.
|
||||||
@@ -28,8 +28,12 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
|||||||
const puppeteer = await import("puppeteer-core");
|
const puppeteer = await import("puppeteer-core");
|
||||||
|
|
||||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||||
const resolved = await maybeResolveGeoip(options);
|
const { exitIp, ...resolved } = (await maybeResolveGeoip(options)) ?? {};
|
||||||
const args = buildArgs({ ...options, ...resolved });
|
let resolvedArgs = (await resolveWebrtcArgs(options)) ?? options.args;
|
||||||
|
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
|
||||||
|
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
|
||||||
|
}
|
||||||
|
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs });
|
||||||
|
|
||||||
// Puppeteer handles proxy via CLI args, not a separate option.
|
// Puppeteer handles proxy via CLI args, not a separate option.
|
||||||
// Chromium's --proxy-server does NOT support inline credentials,
|
// Chromium's --proxy-server does NOT support inline credentials,
|
||||||
|
|||||||
@@ -182,6 +182,18 @@ describe("buildArgs deduplication", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("buildArgs webrtc IP", () => {
|
||||||
|
it("passes --fingerprint-webrtc-ip from args", () => {
|
||||||
|
const args = _buildArgsForTest({ args: ["--fingerprint-webrtc-ip=1.2.3.4"] });
|
||||||
|
expect(args).toContain("--fingerprint-webrtc-ip=1.2.3.4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not inject when not in args", () => {
|
||||||
|
const args = _buildArgsForTest({});
|
||||||
|
expect(args.some(a => a.startsWith("--fingerprint-webrtc-ip"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("resolveTimezone alias", () => {
|
describe("resolveTimezone alias", () => {
|
||||||
it("resolves timezoneId to timezone", () => {
|
it("resolves timezoneId to timezone", () => {
|
||||||
const result = resolveTimezone({ timezoneId: "Europe/Paris" });
|
const result = resolveTimezone({ timezoneId: "Europe/Paris" });
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ vi.mock("../src/download.js", () => ({
|
|||||||
vi.mock("../src/geoip.js", () => ({
|
vi.mock("../src/geoip.js", () => ({
|
||||||
resolveProxyGeo: vi.fn().mockResolvedValue({ timezone: null, locale: null }),
|
resolveProxyGeo: vi.fn().mockResolvedValue({ timezone: null, locale: null }),
|
||||||
maybeResolveGeoip: vi.fn().mockResolvedValue({}),
|
maybeResolveGeoip: vi.fn().mockResolvedValue({}),
|
||||||
|
resolveWebrtcArgs: vi.fn().mockImplementation((opts: any) => Promise.resolve(opts.args)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("puppeteer launch", () => {
|
describe("puppeteer launch", () => {
|
||||||
|
|||||||
@@ -158,3 +158,44 @@ def test_override_logs_debug(caplog):
|
|||||||
with caplog.at_level(logging.DEBUG, logger="cloakbrowser"):
|
with caplog.at_level(logging.DEBUG, logger="cloakbrowser"):
|
||||||
build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
|
build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
|
||||||
assert any("--fingerprint=" in r.message and "99887" in r.message for r in caplog.records)
|
assert any("--fingerprint=" in r.message and "99887" in r.message for r in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
# --- WebRTC IP spoofing ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_webrtc_ip_passed_through_args():
|
||||||
|
"""--fingerprint-webrtc-ip in args should pass through to output."""
|
||||||
|
args = build_args(stealth_args=True, extra_args=["--fingerprint-webrtc-ip=1.2.3.4"])
|
||||||
|
assert "--fingerprint-webrtc-ip=1.2.3.4" in args
|
||||||
|
|
||||||
|
|
||||||
|
def test_webrtc_ip_not_present_by_default():
|
||||||
|
"""No --fingerprint-webrtc-ip when not in args."""
|
||||||
|
args = build_args(stealth_args=True, extra_args=None)
|
||||||
|
assert not any(a.startswith("--fingerprint-webrtc-ip") for a in args)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_webrtc_args_auto():
|
||||||
|
"""--fingerprint-webrtc-ip=auto should be resolved to an IP."""
|
||||||
|
from cloakbrowser.browser import _resolve_webrtc_args
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
with patch("cloakbrowser.geoip._resolve_exit_ip", return_value="5.6.7.8"):
|
||||||
|
result = _resolve_webrtc_args(["--fingerprint-webrtc-ip=auto"], "http://proxy:8080")
|
||||||
|
assert result == ["--fingerprint-webrtc-ip=5.6.7.8"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_webrtc_args_explicit_ip_unchanged():
|
||||||
|
"""Explicit IP in args should not be touched."""
|
||||||
|
from cloakbrowser.browser import _resolve_webrtc_args
|
||||||
|
|
||||||
|
result = _resolve_webrtc_args(["--fingerprint-webrtc-ip=9.9.9.9"], "http://proxy:8080")
|
||||||
|
assert result == ["--fingerprint-webrtc-ip=9.9.9.9"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_webrtc_args_no_flag():
|
||||||
|
"""No webrtc flag in args should return args unchanged."""
|
||||||
|
from cloakbrowser.browser import _resolve_webrtc_args
|
||||||
|
|
||||||
|
result = _resolve_webrtc_args(["--no-sandbox"], "http://proxy:8080")
|
||||||
|
assert result == ["--no-sandbox"]
|
||||||
|
|||||||
+15
-10
@@ -97,46 +97,51 @@ def test_resolve_geo_returns_none_when_db_missing():
|
|||||||
|
|
||||||
|
|
||||||
def test_maybe_resolve_skips_when_geoip_false():
|
def test_maybe_resolve_skips_when_geoip_false():
|
||||||
tz, loc = maybe_resolve_geoip(False, "http://proxy:8080", None, None)
|
tz, loc, ip = maybe_resolve_geoip(False, "http://proxy:8080", None, None)
|
||||||
assert tz is None
|
assert tz is None
|
||||||
assert loc is None
|
assert loc is None
|
||||||
|
assert ip is None
|
||||||
|
|
||||||
|
|
||||||
def test_maybe_resolve_skips_when_no_proxy():
|
def test_maybe_resolve_skips_when_no_proxy():
|
||||||
tz, loc = maybe_resolve_geoip(True, None, None, None)
|
tz, loc, ip = maybe_resolve_geoip(True, None, None, None)
|
||||||
assert tz is None
|
assert tz is None
|
||||||
assert loc is None
|
assert loc is None
|
||||||
|
assert ip is None
|
||||||
|
|
||||||
|
|
||||||
def test_maybe_resolve_skips_when_both_explicit():
|
def test_maybe_resolve_skips_when_both_explicit():
|
||||||
"""Explicit values should not trigger geoip resolution."""
|
"""Explicit values should still resolve exit IP for WebRTC."""
|
||||||
tz, loc = maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", "de-DE")
|
with patch("cloakbrowser.geoip._resolve_exit_ip", return_value="1.2.3.4"):
|
||||||
|
tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", "de-DE")
|
||||||
assert tz == "Europe/Berlin"
|
assert tz == "Europe/Berlin"
|
||||||
assert loc == "de-DE"
|
assert loc == "de-DE"
|
||||||
|
assert ip == "1.2.3.4"
|
||||||
|
|
||||||
|
|
||||||
def test_maybe_resolve_fills_missing_timezone():
|
def test_maybe_resolve_fills_missing_timezone():
|
||||||
"""When only locale is explicit, geoip should fill timezone."""
|
"""When only locale is explicit, geoip should fill timezone."""
|
||||||
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")):
|
with patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4")):
|
||||||
tz, loc = maybe_resolve_geoip(True, "http://proxy:8080", None, "fr-FR")
|
tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", None, "fr-FR")
|
||||||
assert tz == "America/New_York"
|
assert tz == "America/New_York"
|
||||||
assert loc == "fr-FR" # Explicit wins
|
assert loc == "fr-FR" # Explicit wins
|
||||||
|
|
||||||
|
|
||||||
def test_maybe_resolve_fills_missing_locale():
|
def test_maybe_resolve_fills_missing_locale():
|
||||||
"""When only timezone is explicit, geoip should fill locale."""
|
"""When only timezone is explicit, geoip should fill locale."""
|
||||||
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")):
|
with patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4")):
|
||||||
tz, loc = maybe_resolve_geoip(True, "http://proxy:8080", "Asia/Tokyo", None)
|
tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", "Asia/Tokyo", None)
|
||||||
assert tz == "Asia/Tokyo" # Explicit wins
|
assert tz == "Asia/Tokyo" # Explicit wins
|
||||||
assert loc == "en-US"
|
assert loc == "en-US"
|
||||||
|
|
||||||
|
|
||||||
def test_maybe_resolve_fills_both():
|
def test_maybe_resolve_fills_both():
|
||||||
"""When neither is set, geoip should fill both."""
|
"""When neither is set, geoip should fill both."""
|
||||||
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Europe/Berlin", "de-DE")):
|
with patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8")):
|
||||||
tz, loc = maybe_resolve_geoip(True, "http://proxy:8080", None, None)
|
tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", None, None)
|
||||||
assert tz == "Europe/Berlin"
|
assert tz == "Europe/Berlin"
|
||||||
assert loc == "de-DE"
|
assert loc == "de-DE"
|
||||||
|
assert ip == "5.6.7.8"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ def test_color_scheme(mock_launch, _mock_bin):
|
|||||||
assert ctx_kwargs[1]["color_scheme"] == "dark"
|
assert ctx_kwargs[1]["color_scheme"] == "dark"
|
||||||
|
|
||||||
|
|
||||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE"))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8"))
|
||||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
@patch("cloakbrowser.browser.launch")
|
@patch("cloakbrowser.browser.launch")
|
||||||
def test_geoip_resolution(mock_launch, _mock_bin, _mock_geoip):
|
def test_geoip_resolution(mock_launch, _mock_bin, _mock_geoip):
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ def _make_mock_pw_and_context():
|
|||||||
|
|
||||||
|
|
||||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
||||||
def test_persistent_context_args_built(_mock_geoip, _mock_bin):
|
def test_persistent_context_args_built(_mock_geoip, _mock_bin):
|
||||||
"""Stealth args + extra args combined correctly."""
|
"""Stealth args + extra args combined correctly."""
|
||||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||||
@@ -42,7 +42,7 @@ def test_persistent_context_args_built(_mock_geoip, _mock_bin):
|
|||||||
|
|
||||||
|
|
||||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
||||||
def test_persistent_context_default_viewport(_mock_geoip, _mock_bin):
|
def test_persistent_context_default_viewport(_mock_geoip, _mock_bin):
|
||||||
"""DEFAULT_VIEWPORT applied when no viewport given."""
|
"""DEFAULT_VIEWPORT applied when no viewport given."""
|
||||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||||
@@ -56,7 +56,7 @@ def test_persistent_context_default_viewport(_mock_geoip, _mock_bin):
|
|||||||
|
|
||||||
|
|
||||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
||||||
def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin):
|
def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin):
|
||||||
"""Custom viewport overrides DEFAULT_VIEWPORT."""
|
"""Custom viewport overrides DEFAULT_VIEWPORT."""
|
||||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||||
@@ -71,7 +71,7 @@ def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin):
|
|||||||
|
|
||||||
|
|
||||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
||||||
def test_persistent_context_user_agent(_mock_geoip, _mock_bin):
|
def test_persistent_context_user_agent(_mock_geoip, _mock_bin):
|
||||||
"""user_agent forwarded to launch_persistent_context()."""
|
"""user_agent forwarded to launch_persistent_context()."""
|
||||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||||
@@ -103,7 +103,7 @@ def test_persistent_context_locale_and_timezone(_mock_bin):
|
|||||||
|
|
||||||
|
|
||||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
||||||
def test_persistent_context_color_scheme(_mock_geoip, _mock_bin):
|
def test_persistent_context_color_scheme(_mock_geoip, _mock_bin):
|
||||||
"""color_scheme forwarded correctly."""
|
"""color_scheme forwarded correctly."""
|
||||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||||
@@ -116,7 +116,7 @@ def test_persistent_context_color_scheme(_mock_geoip, _mock_bin):
|
|||||||
assert call_kwargs["color_scheme"] == "dark"
|
assert call_kwargs["color_scheme"] == "dark"
|
||||||
|
|
||||||
|
|
||||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE"))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8"))
|
||||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
def test_persistent_context_geoip(_mock_bin, _mock_geoip):
|
def test_persistent_context_geoip(_mock_bin, _mock_geoip):
|
||||||
"""geoip fills missing tz/locale — flows to binary args, not CDP context."""
|
"""geoip fills missing tz/locale — flows to binary args, not CDP context."""
|
||||||
@@ -150,7 +150,7 @@ def test_persistent_context_timezone_id_alias(_mock_bin):
|
|||||||
|
|
||||||
|
|
||||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
||||||
def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin):
|
def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin):
|
||||||
"""context.close() also calls pw.stop()."""
|
"""context.close() also calls pw.stop()."""
|
||||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||||
@@ -166,7 +166,7 @@ def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin):
|
|||||||
|
|
||||||
|
|
||||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
||||||
def test_persistent_context_proxy_string(_mock_geoip, _mock_bin):
|
def test_persistent_context_proxy_string(_mock_geoip, _mock_bin):
|
||||||
"""Proxy string parsed and passed."""
|
"""Proxy string parsed and passed."""
|
||||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||||
@@ -182,7 +182,7 @@ def test_persistent_context_proxy_string(_mock_geoip, _mock_bin):
|
|||||||
|
|
||||||
|
|
||||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
||||||
def test_persistent_context_proxy_dict(_mock_geoip, _mock_bin):
|
def test_persistent_context_proxy_dict(_mock_geoip, _mock_bin):
|
||||||
"""Proxy dict passed through."""
|
"""Proxy dict passed through."""
|
||||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||||
@@ -213,7 +213,7 @@ def _make_mock_async_pw_and_context():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
||||||
async def test_persistent_context_async_args_built(_mock_geoip, _mock_bin):
|
async def test_persistent_context_async_args_built(_mock_geoip, _mock_bin):
|
||||||
"""Async launch builds args correctly."""
|
"""Async launch builds args correctly."""
|
||||||
pw_cm, pw, context = _make_mock_async_pw_and_context()
|
pw_cm, pw, context = _make_mock_async_pw_and_context()
|
||||||
@@ -229,7 +229,7 @@ async def test_persistent_context_async_args_built(_mock_geoip, _mock_bin):
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
||||||
async def test_persistent_context_async_close_stops_pw(_mock_geoip, _mock_bin):
|
async def test_persistent_context_async_close_stops_pw(_mock_geoip, _mock_bin):
|
||||||
"""await context.close() calls await pw.stop()."""
|
"""await context.close() calls await pw.stop()."""
|
||||||
pw_cm, pw, context = _make_mock_async_pw_and_context()
|
pw_cm, pw, context = _make_mock_async_pw_and_context()
|
||||||
|
|||||||
+15
-12
@@ -68,49 +68,52 @@ class TestBuildProxyKwargs:
|
|||||||
|
|
||||||
|
|
||||||
class TestMaybeResolveGeoip:
|
class TestMaybeResolveGeoip:
|
||||||
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
|
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4"))
|
||||||
def test_geoip_with_string_proxy(self, mock_geo):
|
def test_geoip_with_string_proxy(self, mock_geo):
|
||||||
tz, locale = maybe_resolve_geoip(True, "http://proxy:8080", None, None)
|
tz, locale, ip = maybe_resolve_geoip(True, "http://proxy:8080", None, None)
|
||||||
mock_geo.assert_called_once_with("http://proxy:8080")
|
mock_geo.assert_called_once_with("http://proxy:8080")
|
||||||
assert tz == "America/New_York"
|
assert tz == "America/New_York"
|
||||||
assert locale == "en-US"
|
assert locale == "en-US"
|
||||||
|
assert ip == "1.2.3.4"
|
||||||
|
|
||||||
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Europe/London", "en-GB"))
|
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/London", "en-GB", "5.6.7.8"))
|
||||||
def test_geoip_with_dict_proxy_extracts_server(self, mock_geo):
|
def test_geoip_with_dict_proxy_extracts_server(self, mock_geo):
|
||||||
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"}
|
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"}
|
||||||
tz, locale = maybe_resolve_geoip(True, proxy_dict, None, None)
|
tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None)
|
||||||
mock_geo.assert_called_once_with("http://proxy:8080")
|
mock_geo.assert_called_once_with("http://proxy:8080")
|
||||||
assert tz == "Europe/London"
|
assert tz == "Europe/London"
|
||||||
assert locale == "en-GB"
|
assert locale == "en-GB"
|
||||||
|
|
||||||
def test_geoip_disabled_skips_resolution(self):
|
def test_geoip_disabled_skips_resolution(self):
|
||||||
tz, locale = maybe_resolve_geoip(False, "http://proxy:8080", None, None)
|
tz, locale, ip = maybe_resolve_geoip(False, "http://proxy:8080", None, None)
|
||||||
assert tz is None
|
assert tz is None
|
||||||
assert locale is None
|
assert locale is None
|
||||||
|
assert ip is None
|
||||||
|
|
||||||
def test_geoip_no_proxy_skips_resolution(self):
|
def test_geoip_no_proxy_skips_resolution(self):
|
||||||
tz, locale = maybe_resolve_geoip(True, None, None, None)
|
tz, locale, ip = maybe_resolve_geoip(True, None, None, None)
|
||||||
assert tz is None
|
assert tz is None
|
||||||
assert locale is None
|
assert locale is None
|
||||||
|
assert ip is None
|
||||||
|
|
||||||
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Asia/Tokyo", "ja-JP"))
|
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Asia/Tokyo", "ja-JP", "9.8.7.6"))
|
||||||
def test_geoip_preserves_explicit_timezone(self, mock_geo):
|
def test_geoip_preserves_explicit_timezone(self, mock_geo):
|
||||||
tz, locale = maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None)
|
tz, locale, _ip = maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None)
|
||||||
assert tz == "Europe/Berlin"
|
assert tz == "Europe/Berlin"
|
||||||
assert locale == "ja-JP"
|
assert locale == "ja-JP"
|
||||||
|
|
||||||
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
|
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4"))
|
||||||
def test_geoip_normalizes_bare_proxy_with_creds(self, mock_geo):
|
def test_geoip_normalizes_bare_proxy_with_creds(self, mock_geo):
|
||||||
# "user:pass@host:port" must be normalized to http:// before geoip lookup.
|
# "user:pass@host:port" must be normalized to http:// before geoip lookup.
|
||||||
tz, locale = maybe_resolve_geoip(True, "user:pass@proxy:8080", None, None)
|
tz, locale, _ip = maybe_resolve_geoip(True, "user:pass@proxy:8080", None, None)
|
||||||
mock_geo.assert_called_once_with("http://user:pass@proxy:8080")
|
mock_geo.assert_called_once_with("http://user:pass@proxy:8080")
|
||||||
assert tz == "America/New_York"
|
assert tz == "America/New_York"
|
||||||
assert locale == "en-US"
|
assert locale == "en-US"
|
||||||
|
|
||||||
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
|
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4"))
|
||||||
def test_geoip_normalizes_schemeless_proxy_no_creds(self, mock_geo):
|
def test_geoip_normalizes_schemeless_proxy_no_creds(self, mock_geo):
|
||||||
# "host:port" (no @ and no scheme) must also be normalized.
|
# "host:port" (no @ and no scheme) must also be normalized.
|
||||||
tz, locale = maybe_resolve_geoip(True, "proxy:8080", None, None)
|
tz, locale, _ip = maybe_resolve_geoip(True, "proxy:8080", None, None)
|
||||||
mock_geo.assert_called_once_with("http://proxy:8080")
|
mock_geo.assert_called_once_with("http://proxy:8080")
|
||||||
assert tz == "America/New_York"
|
assert tz == "America/New_York"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user