mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0c7704c4b | ||
|
|
ccda93669e | ||
|
|
eb4efef329 | ||
|
|
25d34dcea3 | ||
|
|
c9e4f58353 |
@@ -6,6 +6,18 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [0.3.20] — 2026-04-06
|
||||||
|
|
||||||
|
- **[binary]** Upgrade Linux x64 build to 145.0.7632.159.9 — 48 source-level C++ patches (up from 42)
|
||||||
|
- **[binary]** 6 new patches: WebRTC IP spoofing, proxy signal removal, network timing normalization, WebGL accuracy improvements
|
||||||
|
- **[binary]** New `--fingerprint-webrtc-ip` flag — spoof WebRTC ICE candidate IPs to match your proxy exit IP
|
||||||
|
- **[binary]** Proxy detection signals eliminated — timing, headers, and network metadata normalized when proxy is active
|
||||||
|
- **[binary]** WebGL rendering accuracy improvements for headed mode
|
||||||
|
- **[wrapper]** Auto-inject `--fingerprint-webrtc-ip` when `geoip=True` — uses resolved exit IP from GeoIP lookup
|
||||||
|
- **[wrapper]** Rewrite `cloakserve` as CDP multiplexer with per-connection fingerprint seeds and connection tracking
|
||||||
|
- **[wrapper]** Humanize keyboard improvements — better behavioral stealth for typing interactions (thanks [@evelaa123](https://github.com/evelaa123))
|
||||||
|
- **[meta]** Bump GitHub Actions dependencies
|
||||||
|
|
||||||
## [0.3.19] — 2026-03-30
|
## [0.3.19] — 2026-03-30
|
||||||
|
|
||||||
- **[binary]** Upgrade Linux x64 build to 145.0.7632.159.8 — 42 source-level C++ patches (up from 33)
|
- **[binary]** Upgrade Linux x64 build to 145.0.7632.159.8 — 42 source-level C++ patches (up from 33)
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ Drop-in Playwright/Puppeteer replacement for Python and JavaScript.<br>
|
|||||||
Same API, same code — just swap the import. <strong>3 lines of code, 30 seconds to unblock.</strong>
|
Same API, same code — just swap the import. <strong>3 lines of code, 30 seconds to unblock.</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
- **42 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals, CDP input behavior
|
- **48 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, WebRTC, network timing, automation signals, CDP input behavior
|
||||||
- **`humanize=True`** — human-like mouse curves, keyboard timing, and scroll patterns. One flag, behavioral detection passes
|
- **`humanize=True`** — human-like mouse curves, keyboard timing, and scroll patterns. One flag, behavioral detection passes
|
||||||
- **0.9 reCAPTCHA v3 score** — human-level, server-verified
|
- **0.9 reCAPTCHA v3 score** — human-level, server-verified
|
||||||
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
|
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
|
||||||
@@ -128,13 +128,14 @@ Open [http://localhost:8080](http://localhost:8080). Create a profile. Click **L
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Latest: v0.3.19 (Chromium 145.0.7632.159.8)
|
## Latest: v0.3.20 (Chromium 145.0.7632.159.9)
|
||||||
|
|
||||||
- **`humanize=True`** — one flag makes all mouse, keyboard, and scroll interactions behave like a real user. Bézier curves, per-character typing, realistic scroll patterns. Two presets: `default` and `careful`
|
- **48 fingerprint patches** (Linux x64) — 6 new patches covering WebRTC IP spoofing, proxy signal removal, and network timing normalization
|
||||||
- **CDP input behavior mimicking** — input events sent via CDP now produce the same signals as real user interactions. 4 source-level patches covering pointer, keyboard, and mouse behavior
|
- **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)
|
||||||
- **Native locale spoofing** — new C++ patch replaces detectable CDP-level locale emulation
|
- **Proxy signal removal** — DNS/connect/SSL timing zeroed, proxy cache headers stripped, Proxy-Connection header leak removed
|
||||||
- **WebGPU fingerprint hardening** — adapter features, limits, and device ID spoofed for cross-API consistency
|
- **`cloakserve` CDP multiplexer** — rewritten as a multi-connection CDP proxy with per-connection fingerprint seeds
|
||||||
- **42 fingerprint patches** (Linux x64) — all 4 platforms on Chromium 145
|
- **Humanize CDP isolation** — keyboard events now use isolated worlds and trusted dispatch for better behavioral stealth
|
||||||
|
- **`humanize=True`** — one flag makes all mouse, keyboard, and scroll interactions behave like a real user. Bézier curves, per-character typing, realistic scroll patterns
|
||||||
- **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
|
||||||
- **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
|
||||||
@@ -221,7 +222,7 @@ CloakBrowser is a thin wrapper (Python + JavaScript) around a custom-built Chrom
|
|||||||
3. **Every launch** → Playwright or Puppeteer starts with our binary + stealth args
|
3. **Every launch** → Playwright or Puppeteer starts with our binary + stealth args
|
||||||
4. **You write code** → standard Playwright/Puppeteer API, nothing new to learn
|
4. **You write code** → standard Playwright/Puppeteer API, nothing new to learn
|
||||||
|
|
||||||
The binary includes 42 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, hardware reporting, automation signal removal, and CDP input behavior mimicking.
|
The binary includes 48 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, WebRTC, network timing, hardware reporting, automation signal removal, and CDP input behavior mimicking.
|
||||||
|
|
||||||
These are compiled into the Chromium binary — not injected via JavaScript, not set via flags.
|
These are compiled into the Chromium binary — not injected via JavaScript, not set via flags.
|
||||||
|
|
||||||
@@ -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 |
|
||||||
|
|
||||||
@@ -1009,7 +1019,7 @@ A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
|
|||||||
|
|
||||||
| Feature | Status |
|
| Feature | Status |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| Linux x64 — Chromium 145 (42 patches) | ✅ Released |
|
| Linux x64 — Chromium 145 (48 patches) | ✅ Released |
|
||||||
| macOS arm64/x64 — Chromium 145 (26 patches) | ✅ Released |
|
| macOS arm64/x64 — Chromium 145 (26 patches) | ✅ Released |
|
||||||
| Windows x64 — Chromium 145 (33 patches) | ✅ Released |
|
| Windows x64 — Chromium 145 (33 patches) | ✅ Released |
|
||||||
| JavaScript/Puppeteer + Playwright support | ✅ Released |
|
| JavaScript/Puppeteer + Playwright support | ✅ Released |
|
||||||
@@ -1033,7 +1043,7 @@ All releases are signed for supply chain verification.
|
|||||||
```bash
|
```bash
|
||||||
# Verify GPG signature (binary release tag)
|
# Verify GPG signature (binary release tag)
|
||||||
gpg --keyserver keyserver.ubuntu.com --recv-keys C60C0DDC9D0DE2DD
|
gpg --keyserver keyserver.ubuntu.com --recv-keys C60C0DDC9D0DE2DD
|
||||||
git verify-tag chromium-v145.0.7632.159.8
|
git verify-tag chromium-v145.0.7632.159.9
|
||||||
|
|
||||||
# Verify GitHub binary attestation (Sigstore)
|
# Verify GitHub binary attestation (Sigstore)
|
||||||
gh attestation verify cloakbrowser-linux-x64.tar.gz --repo CloakHQ/cloakbrowser
|
gh attestation verify cloakbrowser-linux-x64.tar.gz --repo CloakHQ/cloakbrowser
|
||||||
|
|||||||
+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,
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.3.19"
|
__version__ = "0.3.20"
|
||||||
|
|||||||
+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(
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ from ._version import __version__
|
|||||||
# CHROMIUM_VERSION is the latest across all platforms (for display/reference).
|
# CHROMIUM_VERSION is the latest across all platforms (for display/reference).
|
||||||
# Use get_chromium_version() for the current platform's actual version.
|
# Use get_chromium_version() for the current platform's actual version.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
CHROMIUM_VERSION = "145.0.7632.159.8"
|
CHROMIUM_VERSION = "145.0.7632.159.9"
|
||||||
|
|
||||||
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
|
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
|
||||||
"linux-x64": "145.0.7632.159.8",
|
"linux-x64": "145.0.7632.159.9",
|
||||||
"linux-arm64": "145.0.7632.159.7",
|
"linux-arm64": "145.0.7632.159.7",
|
||||||
"darwin-arm64": "145.0.7632.109.2",
|
"darwin-arm64": "145.0.7632.109.2",
|
||||||
"darwin-x64": "145.0.7632.109.2",
|
"darwin-x64": "145.0.7632.109.2",
|
||||||
|
|||||||
+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
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+274
-11
@@ -3,14 +3,20 @@
|
|||||||
Activated via humanize=True in launch() / launch_async().
|
Activated via humanize=True in launch() / launch_async().
|
||||||
Patches page methods to use Bezier mouse curves, realistic typing, and smooth scrolling.
|
Patches page methods to use Bezier mouse curves, realistic typing, and smooth scrolling.
|
||||||
|
|
||||||
|
Stealth-aware (fixes #110):
|
||||||
|
- isInputElement / isSelectorFocused use CDP Isolated Worlds instead of page.evaluate
|
||||||
|
- Shift symbol typing uses CDP Input.dispatchKeyEvent for isTrusted=true events
|
||||||
|
- Falls back to page.evaluate only when CDP session is unavailable
|
||||||
|
|
||||||
Supports both sync and async Playwright APIs.
|
Supports both sync and async Playwright APIs.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from typing import Any
|
from typing import Any, Optional
|
||||||
|
|
||||||
from .config import HumanConfig, HumanPreset, resolve_config
|
from .config import HumanConfig, HumanPreset, resolve_config
|
||||||
from .config import rand, rand_range, sleep_ms, async_sleep_ms
|
from .config import rand, rand_range, sleep_ms, async_sleep_ms
|
||||||
@@ -33,6 +39,153 @@ __all__ = [
|
|||||||
|
|
||||||
logger = logging.getLogger("cloakbrowser.human")
|
logger = logging.getLogger("cloakbrowser.human")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# CDP Isolated World — stealth DOM evaluation
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class _SyncIsolatedWorld:
|
||||||
|
"""Manages a CDP isolated execution context for DOM reads (sync).
|
||||||
|
|
||||||
|
Produces clean Error.stack traces (no 'eval at evaluate :302:')
|
||||||
|
and is invisible to querySelector monkey-patches in the main world.
|
||||||
|
Context ID is invalidated on navigation and auto-recreated on next call.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("_page", "_cdp", "_context_id")
|
||||||
|
|
||||||
|
def __init__(self, page: Any):
|
||||||
|
self._page = page
|
||||||
|
self._cdp: Any = None
|
||||||
|
self._context_id: Optional[int] = None
|
||||||
|
|
||||||
|
def _ensure_cdp(self) -> Any:
|
||||||
|
if self._cdp is None:
|
||||||
|
self._cdp = self._page.context.new_cdp_session(self._page)
|
||||||
|
return self._cdp
|
||||||
|
|
||||||
|
def _create_world(self) -> int:
|
||||||
|
cdp = self._ensure_cdp()
|
||||||
|
tree = cdp.send("Page.getFrameTree")
|
||||||
|
frame_id = tree["frameTree"]["frame"]["id"]
|
||||||
|
result = cdp.send("Page.createIsolatedWorld", {
|
||||||
|
"frameId": frame_id,
|
||||||
|
"worldName": "",
|
||||||
|
"grantUniveralAccess": True,
|
||||||
|
})
|
||||||
|
self._context_id = result["executionContextId"]
|
||||||
|
return self._context_id
|
||||||
|
|
||||||
|
def evaluate(self, expression: str) -> Any:
|
||||||
|
"""Evaluate JS in isolated world. Auto-recreates on stale context."""
|
||||||
|
if self._context_id is None:
|
||||||
|
self._create_world()
|
||||||
|
|
||||||
|
for attempt in range(2):
|
||||||
|
try:
|
||||||
|
result = self._cdp.send("Runtime.evaluate", {
|
||||||
|
"expression": expression,
|
||||||
|
"contextId": self._context_id,
|
||||||
|
"returnByValue": True,
|
||||||
|
})
|
||||||
|
if "exceptionDetails" in result:
|
||||||
|
if attempt == 0:
|
||||||
|
self._create_world()
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
return result.get("result", {}).get("value")
|
||||||
|
except Exception:
|
||||||
|
if attempt == 0:
|
||||||
|
self._context_id = None
|
||||||
|
try:
|
||||||
|
self._create_world()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def invalidate(self) -> None:
|
||||||
|
"""Mark context as stale — call after navigation."""
|
||||||
|
self._context_id = None
|
||||||
|
|
||||||
|
def get_cdp_session(self) -> Any:
|
||||||
|
"""Get the underlying CDP session (reused for Input.dispatchKeyEvent)."""
|
||||||
|
return self._ensure_cdp()
|
||||||
|
|
||||||
|
|
||||||
|
class _AsyncIsolatedWorld:
|
||||||
|
"""Manages a CDP isolated execution context for DOM reads (async).
|
||||||
|
|
||||||
|
Same as _SyncIsolatedWorld but uses await for all CDP calls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("_page", "_cdp", "_context_id")
|
||||||
|
|
||||||
|
def __init__(self, page: Any):
|
||||||
|
self._page = page
|
||||||
|
self._cdp: Any = None
|
||||||
|
self._context_id: Optional[int] = None
|
||||||
|
|
||||||
|
async def _ensure_cdp(self) -> Any:
|
||||||
|
if self._cdp is None:
|
||||||
|
self._cdp = await self._page.context.new_cdp_session(self._page)
|
||||||
|
return self._cdp
|
||||||
|
|
||||||
|
async def _create_world(self) -> int:
|
||||||
|
cdp = await self._ensure_cdp()
|
||||||
|
tree = await cdp.send("Page.getFrameTree")
|
||||||
|
frame_id = tree["frameTree"]["frame"]["id"]
|
||||||
|
result = await cdp.send("Page.createIsolatedWorld", {
|
||||||
|
"frameId": frame_id,
|
||||||
|
"worldName": "",
|
||||||
|
"grantUniveralAccess": True,
|
||||||
|
})
|
||||||
|
self._context_id = result["executionContextId"]
|
||||||
|
return self._context_id
|
||||||
|
|
||||||
|
async def evaluate(self, expression: str) -> Any:
|
||||||
|
"""Evaluate JS in isolated world. Auto-recreates on stale context."""
|
||||||
|
if self._context_id is None:
|
||||||
|
await self._create_world()
|
||||||
|
|
||||||
|
for attempt in range(2):
|
||||||
|
try:
|
||||||
|
result = await self._cdp.send("Runtime.evaluate", {
|
||||||
|
"expression": expression,
|
||||||
|
"contextId": self._context_id,
|
||||||
|
"returnByValue": True,
|
||||||
|
})
|
||||||
|
if "exceptionDetails" in result:
|
||||||
|
if attempt == 0:
|
||||||
|
await self._create_world()
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
return result.get("result", {}).get("value")
|
||||||
|
except Exception:
|
||||||
|
if attempt == 0:
|
||||||
|
self._context_id = None
|
||||||
|
try:
|
||||||
|
await self._create_world()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def invalidate(self) -> None:
|
||||||
|
"""Mark context as stale — call after navigation."""
|
||||||
|
self._context_id = None
|
||||||
|
|
||||||
|
async def get_cdp_session(self) -> Any:
|
||||||
|
"""Get the underlying CDP session (reused for Input.dispatchKeyEvent)."""
|
||||||
|
return await self._ensure_cdp()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Cursor state
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
class _CursorState:
|
class _CursorState:
|
||||||
__slots__ = ("x", "y", "initialized")
|
__slots__ = ("x", "y", "initialized")
|
||||||
|
|
||||||
@@ -42,7 +195,30 @@ class _CursorState:
|
|||||||
self.initialized: bool = False
|
self.initialized: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Stealth DOM queries — isolated world with evaluate fallback
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
def _is_input_element(page: Any, selector: str) -> bool:
|
def _is_input_element(page: Any, selector: str) -> bool:
|
||||||
|
"""Check if selector is an input element. Uses CDP isolated world when available."""
|
||||||
|
world: Optional[_SyncIsolatedWorld] = getattr(page, '_stealth_world', None)
|
||||||
|
if world is not None:
|
||||||
|
try:
|
||||||
|
escaped = json.dumps(selector)
|
||||||
|
result = world.evaluate(
|
||||||
|
f"(() => {{"
|
||||||
|
f" const el = document.querySelector({escaped});"
|
||||||
|
f" if (!el) return false;"
|
||||||
|
f" const tag = el.tagName.toLowerCase();"
|
||||||
|
f" return tag === 'input' || tag === 'textarea'"
|
||||||
|
f" || el.getAttribute('contenteditable') === 'true';"
|
||||||
|
f"}})()"
|
||||||
|
)
|
||||||
|
return bool(result)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: page.evaluate (detectable — should only happen if CDP fails)
|
||||||
try:
|
try:
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
"""(sel) => {
|
"""(sel) => {
|
||||||
@@ -59,6 +235,24 @@ def _is_input_element(page: Any, selector: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def _async_is_input_element(page: Any, selector: str) -> bool:
|
async def _async_is_input_element(page: Any, selector: str) -> bool:
|
||||||
|
"""Check if selector is an input element (async). Uses CDP isolated world when available."""
|
||||||
|
world: Optional[_AsyncIsolatedWorld] = getattr(page, '_stealth_world', None)
|
||||||
|
if world is not None:
|
||||||
|
try:
|
||||||
|
escaped = json.dumps(selector)
|
||||||
|
result = await world.evaluate(
|
||||||
|
f"(() => {{"
|
||||||
|
f" const el = document.querySelector({escaped});"
|
||||||
|
f" if (!el) return false;"
|
||||||
|
f" const tag = el.tagName.toLowerCase();"
|
||||||
|
f" return tag === 'input' || tag === 'textarea'"
|
||||||
|
f" || el.getAttribute('contenteditable') === 'true';"
|
||||||
|
f"}})()"
|
||||||
|
)
|
||||||
|
return bool(result)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return await page.evaluate(
|
return await page.evaluate(
|
||||||
"""(sel) => {
|
"""(sel) => {
|
||||||
@@ -75,7 +269,22 @@ async def _async_is_input_element(page: Any, selector: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _is_selector_focused(page: Any, selector: str) -> bool:
|
def _is_selector_focused(page: Any, selector: str) -> bool:
|
||||||
"""Check if the element matching selector is currently focused."""
|
"""Check if the element matching selector is currently focused.
|
||||||
|
Uses CDP isolated world when available."""
|
||||||
|
world: Optional[_SyncIsolatedWorld] = getattr(page, '_stealth_world', None)
|
||||||
|
if world is not None:
|
||||||
|
try:
|
||||||
|
escaped = json.dumps(selector)
|
||||||
|
result = world.evaluate(
|
||||||
|
f"(() => {{"
|
||||||
|
f" const el = document.querySelector({escaped});"
|
||||||
|
f" return el === document.activeElement;"
|
||||||
|
f"}})()"
|
||||||
|
)
|
||||||
|
return bool(result)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
"""(sel) => {
|
"""(sel) => {
|
||||||
@@ -89,7 +298,22 @@ def _is_selector_focused(page: Any, selector: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def _async_is_selector_focused(page: Any, selector: str) -> bool:
|
async def _async_is_selector_focused(page: Any, selector: str) -> bool:
|
||||||
"""Check if the element matching selector is currently focused (async)."""
|
"""Check if the element matching selector is currently focused (async).
|
||||||
|
Uses CDP isolated world when available."""
|
||||||
|
world: Optional[_AsyncIsolatedWorld] = getattr(page, '_stealth_world', None)
|
||||||
|
if world is not None:
|
||||||
|
try:
|
||||||
|
escaped = json.dumps(selector)
|
||||||
|
result = await world.evaluate(
|
||||||
|
f"(() => {{"
|
||||||
|
f" const el = document.querySelector({escaped});"
|
||||||
|
f" return el === document.activeElement;"
|
||||||
|
f"}})()"
|
||||||
|
)
|
||||||
|
return bool(result)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return await page.evaluate(
|
return await page.evaluate(
|
||||||
"""(sel) => {
|
"""(sel) => {
|
||||||
@@ -216,7 +440,6 @@ def _patch_locator_class_sync():
|
|||||||
def _humanized_press(self, key, **kwargs):
|
def _humanized_press(self, key, **kwargs):
|
||||||
if _is_humanized(self):
|
if _is_humanized(self):
|
||||||
selector = _get_selector(self)
|
selector = _get_selector(self)
|
||||||
# Only click if not already focused — avoids redundant mouse moves
|
|
||||||
if not _is_selector_focused(self.page, selector):
|
if not _is_selector_focused(self.page, selector):
|
||||||
self.page.click(selector)
|
self.page.click(selector)
|
||||||
sleep_ms(rand(50, 150))
|
sleep_ms(rand(50, 150))
|
||||||
@@ -516,6 +739,17 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
|||||||
page._original = originals
|
page._original = originals
|
||||||
page._human_cfg = cfg
|
page._human_cfg = cfg
|
||||||
|
|
||||||
|
# --- Stealth infrastructure ---
|
||||||
|
try:
|
||||||
|
stealth = _SyncIsolatedWorld(page)
|
||||||
|
page._stealth_world = stealth
|
||||||
|
cdp_session = stealth.get_cdp_session()
|
||||||
|
except Exception:
|
||||||
|
stealth = None
|
||||||
|
page._stealth_world = None
|
||||||
|
cdp_session = None
|
||||||
|
logger.debug("Could not create CDP session — stealth features disabled")
|
||||||
|
|
||||||
raw_mouse: RawMouse = type("_RawMouse", (), {
|
raw_mouse: RawMouse = type("_RawMouse", (), {
|
||||||
"move": originals.mouse_move,
|
"move": originals.mouse_move,
|
||||||
"down": originals.mouse_down,
|
"down": originals.mouse_down,
|
||||||
@@ -539,6 +773,9 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
|||||||
|
|
||||||
def _human_goto(url: str, **kwargs: Any) -> Any:
|
def _human_goto(url: str, **kwargs: Any) -> Any:
|
||||||
response = originals.goto(url, **kwargs)
|
response = originals.goto(url, **kwargs)
|
||||||
|
# Invalidate isolated world after navigation (context ID becomes stale)
|
||||||
|
if stealth is not None:
|
||||||
|
stealth.invalidate()
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def _human_click(selector: str, **kwargs: Any) -> None:
|
def _human_click(selector: str, **kwargs: Any) -> None:
|
||||||
@@ -593,7 +830,7 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
|||||||
sleep_ms(rand_range(cfg.field_switch_delay))
|
sleep_ms(rand_range(cfg.field_switch_delay))
|
||||||
_human_click(selector)
|
_human_click(selector)
|
||||||
sleep_ms(rand(100, 250))
|
sleep_ms(rand(100, 250))
|
||||||
human_type(page, raw_keyboard, text, cfg)
|
human_type(page, raw_keyboard, text, cfg, cdp_session=cdp_session)
|
||||||
|
|
||||||
def _human_fill(selector: str, value: str, **kwargs: Any) -> None:
|
def _human_fill(selector: str, value: str, **kwargs: Any) -> None:
|
||||||
sleep_ms(rand_range(cfg.field_switch_delay))
|
sleep_ms(rand_range(cfg.field_switch_delay))
|
||||||
@@ -603,7 +840,7 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
|||||||
sleep_ms(rand(30, 80))
|
sleep_ms(rand(30, 80))
|
||||||
originals.keyboard_press("Backspace")
|
originals.keyboard_press("Backspace")
|
||||||
sleep_ms(rand(50, 150))
|
sleep_ms(rand(50, 150))
|
||||||
human_type(page, raw_keyboard, value, cfg)
|
human_type(page, raw_keyboard, value, cfg, cdp_session=cdp_session)
|
||||||
|
|
||||||
def _human_check(selector: str, **kwargs: Any) -> None:
|
def _human_check(selector: str, **kwargs: Any) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -646,7 +883,7 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
|||||||
human_click(raw_mouse, False, cfg)
|
human_click(raw_mouse, False, cfg)
|
||||||
|
|
||||||
def _human_keyboard_type(text: str, **kwargs: Any) -> None:
|
def _human_keyboard_type(text: str, **kwargs: Any) -> None:
|
||||||
human_type(page, raw_keyboard, text, cfg)
|
human_type(page, raw_keyboard, text, cfg, cdp_session=cdp_session)
|
||||||
|
|
||||||
page.goto = _human_goto
|
page.goto = _human_goto
|
||||||
page.click = _human_click
|
page.click = _human_click
|
||||||
@@ -690,6 +927,10 @@ def _patch_frames_sync(
|
|||||||
|
|
||||||
def _frame_aware_goto(url: str, **kwargs: Any) -> Any:
|
def _frame_aware_goto(url: str, **kwargs: Any) -> Any:
|
||||||
response = _orig_goto(url, **kwargs)
|
response = _orig_goto(url, **kwargs)
|
||||||
|
# Invalidate isolated world after navigation
|
||||||
|
stealth_world = getattr(page, '_stealth_world', None)
|
||||||
|
if stealth_world is not None:
|
||||||
|
stealth_world.invalidate()
|
||||||
for frame in _iter_frames(page):
|
for frame in _iter_frames(page):
|
||||||
if not getattr(frame, "_human_patched", False):
|
if not getattr(frame, "_human_patched", False):
|
||||||
_patch_single_frame_sync(frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals)
|
_patch_single_frame_sync(frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals)
|
||||||
@@ -709,7 +950,6 @@ def _patch_single_frame_sync(
|
|||||||
return
|
return
|
||||||
frame._human_patched = True
|
frame._human_patched = True
|
||||||
|
|
||||||
# Save originals for methods that need fallback
|
|
||||||
_orig_frame_select_option = frame.select_option
|
_orig_frame_select_option = frame.select_option
|
||||||
_orig_frame_drag_and_drop = getattr(frame, 'drag_and_drop', None)
|
_orig_frame_drag_and_drop = getattr(frame, 'drag_and_drop', None)
|
||||||
|
|
||||||
@@ -841,6 +1081,7 @@ def patch_browser(browser: Any, cfg: HumanConfig) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||||
|
"""Replace page methods with human-like implementations (async)."""
|
||||||
originals = type("Originals", (), {
|
originals = type("Originals", (), {
|
||||||
"click": page.click,
|
"click": page.click,
|
||||||
"type": page.type,
|
"type": page.type,
|
||||||
@@ -863,6 +1104,19 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
|||||||
page._original = originals
|
page._original = originals
|
||||||
page._human_cfg = cfg
|
page._human_cfg = cfg
|
||||||
|
|
||||||
|
# --- Stealth infrastructure (lazy-initialized, async) ---
|
||||||
|
stealth = _AsyncIsolatedWorld(page)
|
||||||
|
page._stealth_world = stealth
|
||||||
|
cdp_session_holder: list[Any] = [None] # mutable container for closure
|
||||||
|
|
||||||
|
async def _ensure_cdp() -> Any:
|
||||||
|
if cdp_session_holder[0] is None:
|
||||||
|
try:
|
||||||
|
cdp_session_holder[0] = await stealth.get_cdp_session()
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not create async CDP session")
|
||||||
|
return cdp_session_holder[0]
|
||||||
|
|
||||||
raw_mouse: AsyncRawMouse = type("_AsyncRawMouse", (), {
|
raw_mouse: AsyncRawMouse = type("_AsyncRawMouse", (), {
|
||||||
"move": originals.mouse_move,
|
"move": originals.mouse_move,
|
||||||
"down": originals.mouse_down,
|
"down": originals.mouse_down,
|
||||||
@@ -886,6 +1140,8 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
|||||||
|
|
||||||
async def _human_goto(url: str, **kwargs: Any) -> Any:
|
async def _human_goto(url: str, **kwargs: Any) -> Any:
|
||||||
response = await originals.goto(url, **kwargs)
|
response = await originals.goto(url, **kwargs)
|
||||||
|
# Invalidate isolated world after navigation
|
||||||
|
stealth.invalidate()
|
||||||
return response
|
return response
|
||||||
|
|
||||||
async def _human_click(selector: str, **kwargs: Any) -> None:
|
async def _human_click(selector: str, **kwargs: Any) -> None:
|
||||||
@@ -940,7 +1196,8 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
|||||||
await async_sleep_ms(rand_range(cfg.field_switch_delay))
|
await async_sleep_ms(rand_range(cfg.field_switch_delay))
|
||||||
await _human_click(selector)
|
await _human_click(selector)
|
||||||
await async_sleep_ms(rand(100, 250))
|
await async_sleep_ms(rand(100, 250))
|
||||||
await async_human_type(page, raw_keyboard, text, cfg)
|
cdp = await _ensure_cdp()
|
||||||
|
await async_human_type(page, raw_keyboard, text, cfg, cdp_session=cdp)
|
||||||
|
|
||||||
async def _human_fill(selector: str, value: str, **kwargs: Any) -> None:
|
async def _human_fill(selector: str, value: str, **kwargs: Any) -> None:
|
||||||
await async_sleep_ms(rand_range(cfg.field_switch_delay))
|
await async_sleep_ms(rand_range(cfg.field_switch_delay))
|
||||||
@@ -950,7 +1207,8 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
|||||||
await async_sleep_ms(rand(30, 80))
|
await async_sleep_ms(rand(30, 80))
|
||||||
await originals.keyboard_press("Backspace")
|
await originals.keyboard_press("Backspace")
|
||||||
await async_sleep_ms(rand(50, 150))
|
await async_sleep_ms(rand(50, 150))
|
||||||
await async_human_type(page, raw_keyboard, value, cfg)
|
cdp = await _ensure_cdp()
|
||||||
|
await async_human_type(page, raw_keyboard, value, cfg, cdp_session=cdp)
|
||||||
|
|
||||||
async def _human_check(selector: str, **kwargs: Any) -> None:
|
async def _human_check(selector: str, **kwargs: Any) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -988,7 +1246,8 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
|||||||
await async_human_click(raw_mouse, False, cfg)
|
await async_human_click(raw_mouse, False, cfg)
|
||||||
|
|
||||||
async def _human_keyboard_type(text: str, **kwargs: Any) -> None:
|
async def _human_keyboard_type(text: str, **kwargs: Any) -> None:
|
||||||
await async_human_type(page, raw_keyboard, text, cfg)
|
cdp = await _ensure_cdp()
|
||||||
|
await async_human_type(page, raw_keyboard, text, cfg, cdp_session=cdp)
|
||||||
|
|
||||||
page.goto = _human_goto
|
page.goto = _human_goto
|
||||||
page.click = _human_click
|
page.click = _human_click
|
||||||
@@ -1024,6 +1283,10 @@ def _patch_frames_async(
|
|||||||
|
|
||||||
async def _frame_aware_goto(url: str, **kwargs: Any) -> Any:
|
async def _frame_aware_goto(url: str, **kwargs: Any) -> Any:
|
||||||
response = await _orig_goto(url, **kwargs)
|
response = await _orig_goto(url, **kwargs)
|
||||||
|
# Invalidate isolated world after navigation
|
||||||
|
stealth_world = getattr(page, '_stealth_world', None)
|
||||||
|
if stealth_world is not None:
|
||||||
|
stealth_world.invalidate()
|
||||||
for frame in _iter_frames(page):
|
for frame in _iter_frames(page):
|
||||||
if not getattr(frame, "_human_patched", False):
|
if not getattr(frame, "_human_patched", False):
|
||||||
_patch_single_frame_async(frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals)
|
_patch_single_frame_async(frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals)
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
"""cloakbrowser-human — Human-like keyboard input."""
|
"""cloakbrowser-human — Human-like keyboard input.
|
||||||
|
|
||||||
|
Stealth-aware: when a CDP session is provided, shift symbols are typed
|
||||||
|
via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace).
|
||||||
|
Falls back to page.evaluate when no CDP session is available.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import random
|
import random
|
||||||
from typing import Any, Protocol
|
from typing import Any, Optional, Protocol
|
||||||
|
|
||||||
from .config import HumanConfig, rand, rand_range, sleep_ms
|
from .config import HumanConfig, rand, rand_range, sleep_ms
|
||||||
|
|
||||||
@@ -28,6 +33,25 @@ NEARBY_KEYS = {
|
|||||||
'6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p',
|
'6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# CDP key code for each shift symbol's physical key.
|
||||||
|
_SHIFT_SYMBOL_CODES: dict[str, str] = {
|
||||||
|
'!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4',
|
||||||
|
'%': 'Digit5', '^': 'Digit6', '&': 'Digit7', '*': 'Digit8',
|
||||||
|
'(': 'Digit9', ')': 'Digit0', '_': 'Minus', '+': 'Equal',
|
||||||
|
'{': 'BracketLeft', '}': 'BracketRight', '|': 'Backslash',
|
||||||
|
':': 'Semicolon', '"': 'Quote', '<': 'Comma', '>': 'Period',
|
||||||
|
'?': 'Slash', '~': 'Backquote',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Windows virtual key codes for Input.dispatchKeyEvent.
|
||||||
|
_SHIFT_SYMBOL_KEYCODES: dict[str, int] = {
|
||||||
|
'!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
|
||||||
|
'^': 54, '&': 55, '*': 56, '(': 57, ')': 48,
|
||||||
|
'_': 189, '+': 187, '{': 219, '}': 221, '|': 220,
|
||||||
|
':': 186, '"': 222, '<': 188, '>': 190, '?': 191,
|
||||||
|
'~': 192,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_nearby_key(ch: str) -> str:
|
def _get_nearby_key(ch: str) -> str:
|
||||||
"""Return a random adjacent key for the given character."""
|
"""Return a random adjacent key for the given character."""
|
||||||
@@ -39,7 +63,17 @@ def _get_nearby_key(ch: str) -> str:
|
|||||||
return ch
|
return ch
|
||||||
|
|
||||||
|
|
||||||
def human_type(page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig) -> None:
|
def human_type(
|
||||||
|
page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig,
|
||||||
|
cdp_session: Any = None,
|
||||||
|
) -> None:
|
||||||
|
"""Type text with human-like per-character timing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cdp_session: If provided, shift symbols use CDP Input.dispatchKeyEvent
|
||||||
|
producing isTrusted=true events with no evaluate stack trace.
|
||||||
|
If None, falls back to page.evaluate (detectable).
|
||||||
|
"""
|
||||||
for i, ch in enumerate(text):
|
for i, ch in enumerate(text):
|
||||||
# Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
|
# Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
|
||||||
if not ch.isascii():
|
if not ch.isascii():
|
||||||
@@ -62,7 +96,7 @@ def human_type(page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig) -> None
|
|||||||
if ch.isupper() and ch.isalpha():
|
if ch.isupper() and ch.isalpha():
|
||||||
_type_shifted_char(page, raw, ch, cfg)
|
_type_shifted_char(page, raw, ch, cfg)
|
||||||
elif ch in SHIFT_SYMBOLS:
|
elif ch in SHIFT_SYMBOLS:
|
||||||
_type_shift_symbol(page, raw, ch, cfg)
|
_type_shift_symbol(page, raw, ch, cfg, cdp_session)
|
||||||
else:
|
else:
|
||||||
_type_normal_char(raw, ch, cfg)
|
_type_normal_char(raw, ch, cfg)
|
||||||
|
|
||||||
@@ -86,7 +120,50 @@ def _type_shifted_char(page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig) -
|
|||||||
raw.up("Shift")
|
raw.up("Shift")
|
||||||
|
|
||||||
|
|
||||||
def _type_shift_symbol(page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None:
|
def _type_shift_symbol(
|
||||||
|
page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig,
|
||||||
|
cdp_session: Any = None,
|
||||||
|
) -> None:
|
||||||
|
"""Type a shift symbol character.
|
||||||
|
|
||||||
|
Stealth path (cdp_session provided):
|
||||||
|
Uses CDP Input.dispatchKeyEvent → isTrusted=true, clean stack.
|
||||||
|
|
||||||
|
Fallback path (no cdp_session):
|
||||||
|
Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent.
|
||||||
|
Detectable via isTrusted=false and evaluate stack frame.
|
||||||
|
"""
|
||||||
|
if cdp_session is not None:
|
||||||
|
# --- Stealth path: CDP Input.dispatchKeyEvent ---
|
||||||
|
code = _SHIFT_SYMBOL_CODES.get(ch, '')
|
||||||
|
key_code = _SHIFT_SYMBOL_KEYCODES.get(ch, 0)
|
||||||
|
|
||||||
|
raw.down("Shift")
|
||||||
|
sleep_ms(rand_range(cfg.shift_down_delay))
|
||||||
|
|
||||||
|
cdp_session.send("Input.dispatchKeyEvent", {
|
||||||
|
"type": "keyDown",
|
||||||
|
"modifiers": 8, # Shift modifier flag
|
||||||
|
"key": ch,
|
||||||
|
"code": code,
|
||||||
|
"windowsVirtualKeyCode": key_code,
|
||||||
|
"text": ch,
|
||||||
|
"unmodifiedText": ch,
|
||||||
|
})
|
||||||
|
sleep_ms(rand_range(cfg.key_hold))
|
||||||
|
|
||||||
|
cdp_session.send("Input.dispatchKeyEvent", {
|
||||||
|
"type": "keyUp",
|
||||||
|
"modifiers": 8,
|
||||||
|
"key": ch,
|
||||||
|
"code": code,
|
||||||
|
"windowsVirtualKeyCode": key_code,
|
||||||
|
})
|
||||||
|
|
||||||
|
sleep_ms(rand_range(cfg.shift_up_delay))
|
||||||
|
raw.up("Shift")
|
||||||
|
else:
|
||||||
|
# --- Fallback path: page.evaluate (detectable) ---
|
||||||
raw.down("Shift")
|
raw.down("Shift")
|
||||||
sleep_ms(rand_range(cfg.shift_down_delay))
|
sleep_ms(rand_range(cfg.shift_down_delay))
|
||||||
raw.insert_text(ch)
|
raw.insert_text(ch)
|
||||||
|
|||||||
@@ -2,15 +2,19 @@
|
|||||||
|
|
||||||
Mirrors keyboard.py but uses ``await`` for all Playwright calls and
|
Mirrors keyboard.py but uses ``await`` for all Playwright calls and
|
||||||
``async_sleep_ms`` instead of ``sleep_ms``.
|
``async_sleep_ms`` instead of ``sleep_ms``.
|
||||||
|
|
||||||
|
Stealth-aware: when a CDP session is provided, shift symbols are typed
|
||||||
|
via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import random
|
import random
|
||||||
from typing import Any, Protocol
|
from typing import Any, Optional, Protocol
|
||||||
|
|
||||||
from .config import HumanConfig, rand, rand_range, async_sleep_ms
|
from .config import HumanConfig, rand, rand_range, async_sleep_ms
|
||||||
from .keyboard import SHIFT_SYMBOLS, NEARBY_KEYS, _get_nearby_key
|
from .keyboard import SHIFT_SYMBOLS, NEARBY_KEYS, _get_nearby_key
|
||||||
|
from .keyboard import _SHIFT_SYMBOL_CODES, _SHIFT_SYMBOL_KEYCODES
|
||||||
|
|
||||||
|
|
||||||
class AsyncRawKeyboard(Protocol):
|
class AsyncRawKeyboard(Protocol):
|
||||||
@@ -20,7 +24,17 @@ class AsyncRawKeyboard(Protocol):
|
|||||||
async def insert_text(self, text: str) -> None: ...
|
async def insert_text(self, text: str) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
async def async_human_type(page: Any, raw: AsyncRawKeyboard, text: str, cfg: HumanConfig) -> None:
|
async def async_human_type(
|
||||||
|
page: Any, raw: AsyncRawKeyboard, text: str, cfg: HumanConfig,
|
||||||
|
cdp_session: Any = None,
|
||||||
|
) -> None:
|
||||||
|
"""Type text with human-like per-character timing (async).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cdp_session: If provided, shift symbols use CDP Input.dispatchKeyEvent
|
||||||
|
producing isTrusted=true events with no evaluate stack trace.
|
||||||
|
If None, falls back to page.evaluate (detectable).
|
||||||
|
"""
|
||||||
for i, ch in enumerate(text):
|
for i, ch in enumerate(text):
|
||||||
# Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
|
# Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
|
||||||
if not ch.isascii():
|
if not ch.isascii():
|
||||||
@@ -43,7 +57,7 @@ async def async_human_type(page: Any, raw: AsyncRawKeyboard, text: str, cfg: Hum
|
|||||||
if ch.isupper() and ch.isalpha():
|
if ch.isupper() and ch.isalpha():
|
||||||
await _type_shifted_char(page, raw, ch, cfg)
|
await _type_shifted_char(page, raw, ch, cfg)
|
||||||
elif ch in SHIFT_SYMBOLS:
|
elif ch in SHIFT_SYMBOLS:
|
||||||
await _type_shift_symbol(page, raw, ch, cfg)
|
await _type_shift_symbol(page, raw, ch, cfg, cdp_session)
|
||||||
else:
|
else:
|
||||||
await _type_normal_char(raw, ch, cfg)
|
await _type_normal_char(raw, ch, cfg)
|
||||||
|
|
||||||
@@ -67,7 +81,50 @@ async def _type_shifted_char(page: Any, raw: AsyncRawKeyboard, ch: str, cfg: Hum
|
|||||||
await raw.up("Shift")
|
await raw.up("Shift")
|
||||||
|
|
||||||
|
|
||||||
async def _type_shift_symbol(page: Any, raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig) -> None:
|
async def _type_shift_symbol(
|
||||||
|
page: Any, raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig,
|
||||||
|
cdp_session: Any = None,
|
||||||
|
) -> None:
|
||||||
|
"""Type a shift symbol character (async).
|
||||||
|
|
||||||
|
Stealth path (cdp_session provided):
|
||||||
|
Uses CDP Input.dispatchKeyEvent → isTrusted=true, clean stack.
|
||||||
|
|
||||||
|
Fallback path (no cdp_session):
|
||||||
|
Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent.
|
||||||
|
Detectable via isTrusted=false and evaluate stack frame.
|
||||||
|
"""
|
||||||
|
if cdp_session is not None:
|
||||||
|
# --- Stealth path: CDP Input.dispatchKeyEvent ---
|
||||||
|
code = _SHIFT_SYMBOL_CODES.get(ch, '')
|
||||||
|
key_code = _SHIFT_SYMBOL_KEYCODES.get(ch, 0)
|
||||||
|
|
||||||
|
await raw.down("Shift")
|
||||||
|
await async_sleep_ms(rand_range(cfg.shift_down_delay))
|
||||||
|
|
||||||
|
await cdp_session.send("Input.dispatchKeyEvent", {
|
||||||
|
"type": "keyDown",
|
||||||
|
"modifiers": 8, # Shift modifier flag
|
||||||
|
"key": ch,
|
||||||
|
"code": code,
|
||||||
|
"windowsVirtualKeyCode": key_code,
|
||||||
|
"text": ch,
|
||||||
|
"unmodifiedText": ch,
|
||||||
|
})
|
||||||
|
await async_sleep_ms(rand_range(cfg.key_hold))
|
||||||
|
|
||||||
|
await cdp_session.send("Input.dispatchKeyEvent", {
|
||||||
|
"type": "keyUp",
|
||||||
|
"modifiers": 8,
|
||||||
|
"key": ch,
|
||||||
|
"code": code,
|
||||||
|
"windowsVirtualKeyCode": key_code,
|
||||||
|
})
|
||||||
|
|
||||||
|
await async_sleep_ms(rand_range(cfg.shift_up_delay))
|
||||||
|
await raw.up("Shift")
|
||||||
|
else:
|
||||||
|
# --- Fallback path: page.evaluate (detectable) ---
|
||||||
await raw.down("Shift")
|
await raw.down("Shift")
|
||||||
await async_sleep_ms(rand_range(cfg.shift_down_delay))
|
await async_sleep_ms(rand_range(cfg.shift_down_delay))
|
||||||
await raw.insert_text(ch)
|
await raw.insert_text(ch)
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
Drop-in Playwright/Puppeteer replacement. Same API, same code — just swap the import. **3 lines of code, 30 seconds to unblock.**
|
Drop-in Playwright/Puppeteer replacement. Same API, same code — just swap the import. **3 lines of code, 30 seconds to unblock.**
|
||||||
|
|
||||||
- **42 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals
|
- **48 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, WebRTC, network timing, automation signals
|
||||||
- **0.9 reCAPTCHA v3 score** — human-level, server-verified
|
- **0.9 reCAPTCHA v3 score** — human-level, server-verified
|
||||||
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
|
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
|
||||||
- **`npm install cloakbrowser`** — binary auto-downloads, auto-updates, zero config
|
- **`npm install cloakbrowser`** — binary auto-downloads, auto-updates, zero config
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cloakbrowser",
|
"name": "cloakbrowser",
|
||||||
"version": "0.3.19",
|
"version": "0.3.20",
|
||||||
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
|
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
+2
-2
@@ -27,10 +27,10 @@ export { WRAPPER_VERSION };
|
|||||||
// CHROMIUM_VERSION is the latest across all platforms (for display/reference).
|
// CHROMIUM_VERSION is the latest across all platforms (for display/reference).
|
||||||
// Use getChromiumVersion() for the current platform's actual version.
|
// Use getChromiumVersion() for the current platform's actual version.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export const CHROMIUM_VERSION = "145.0.7632.159.8";
|
export const CHROMIUM_VERSION = "145.0.7632.159.9";
|
||||||
|
|
||||||
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
|
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
|
||||||
"linux-x64": "145.0.7632.159.8",
|
"linux-x64": "145.0.7632.159.9",
|
||||||
"linux-arm64": "145.0.7632.159.7",
|
"linux-arm64": "145.0.7632.159.7",
|
||||||
"darwin-arm64": "145.0.7632.109.2",
|
"darwin-arm64": "145.0.7632.109.2",
|
||||||
"darwin-x64": "145.0.7632.109.2",
|
"darwin-x64": "145.0.7632.109.2",
|
||||||
|
|||||||
+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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+209
-19
@@ -4,12 +4,17 @@
|
|||||||
* Activated via humanize: true in launch() / launchContext().
|
* Activated via humanize: true in launch() / launchContext().
|
||||||
* Patches page methods to use Bezier mouse curves, realistic typing, and smooth scrolling.
|
* Patches page methods to use Bezier mouse curves, realistic typing, and smooth scrolling.
|
||||||
*
|
*
|
||||||
|
* Stealth-aware (fixes #110):
|
||||||
|
* - isInputElement / isSelectorFocused use CDP Isolated Worlds instead of page.evaluate
|
||||||
|
* - Shift symbol typing uses CDP Input.dispatchKeyEvent for isTrusted=true events
|
||||||
|
* - Falls back to page.evaluate only when CDP session is unavailable
|
||||||
|
*
|
||||||
* Patches all interaction methods:
|
* Patches all interaction methods:
|
||||||
* click, dblclick, hover, type, fill, check, uncheck, selectOption,
|
* click, dblclick, hover, type, fill, check, uncheck, selectOption,
|
||||||
* press, pressSequentially, tap, dragTo, clear + Frame-level equivalents.
|
* press, pressSequentially, tap, dragTo, clear + Frame-level equivalents.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Browser, BrowserContext, Page, Frame } from 'playwright-core';
|
import type { Browser, BrowserContext, Page, Frame, CDPSession } from 'playwright-core';
|
||||||
import { HumanConfig, resolveConfig, rand, randRange, sleep } from './config.js';
|
import { HumanConfig, resolveConfig, rand, randRange, sleep } from './config.js';
|
||||||
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||||
import { humanType } from './keyboard.js';
|
import { humanType } from './keyboard.js';
|
||||||
@@ -23,13 +28,148 @@ export { scrollToElement } from './scroll.js';
|
|||||||
// --- Platform-aware select-all shortcut (macOS uses Meta, others use Control) ---
|
// --- Platform-aware select-all shortcut (macOS uses Meta, others use Control) ---
|
||||||
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
|
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CDP Isolated World — stealth DOM evaluation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages a CDP isolated execution context for DOM reads.
|
||||||
|
* Produces clean Error.stack traces (no 'eval at evaluate :302:')
|
||||||
|
* and is invisible to querySelector monkey-patches in the main world.
|
||||||
|
*
|
||||||
|
* Context ID is invalidated on navigation and auto-recreated on next call.
|
||||||
|
*/
|
||||||
|
class StealthEval {
|
||||||
|
private cdp: CDPSession | null = null;
|
||||||
|
private contextId: number | null = null;
|
||||||
|
private page: Page;
|
||||||
|
|
||||||
|
constructor(page: Page) {
|
||||||
|
this.page = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureCdp(): Promise<CDPSession> {
|
||||||
|
if (!this.cdp) {
|
||||||
|
this.cdp = await this.page.context().newCDPSession(this.page);
|
||||||
|
}
|
||||||
|
return this.cdp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createWorld(): Promise<number> {
|
||||||
|
const cdp = await this.ensureCdp();
|
||||||
|
const tree = await cdp.send('Page.getFrameTree');
|
||||||
|
const frameId = tree.frameTree.frame.id;
|
||||||
|
const result = await cdp.send('Page.createIsolatedWorld', {
|
||||||
|
frameId,
|
||||||
|
worldName: '',
|
||||||
|
grantUniveralAccess: true,
|
||||||
|
});
|
||||||
|
const ctxId = result.executionContextId;
|
||||||
|
this.contextId = ctxId;
|
||||||
|
return ctxId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate a JS expression in the isolated world.
|
||||||
|
* Auto-recreates the world if the context was invalidated (navigation).
|
||||||
|
* Returns the result value, or undefined on failure.
|
||||||
|
*/
|
||||||
|
async evaluate(expression: string): Promise<any> {
|
||||||
|
if (this.contextId === null) {
|
||||||
|
await this.createWorld();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
|
try {
|
||||||
|
const cdp = await this.ensureCdp();
|
||||||
|
const result = await cdp.send('Runtime.evaluate', {
|
||||||
|
expression,
|
||||||
|
contextId: this.contextId!,
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.exceptionDetails) {
|
||||||
|
// Context was likely invalidated by navigation
|
||||||
|
if (attempt === 0) {
|
||||||
|
await this.createWorld();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.result?.value;
|
||||||
|
} catch {
|
||||||
|
if (attempt === 0) {
|
||||||
|
this.contextId = null;
|
||||||
|
try {
|
||||||
|
await this.createWorld();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark context as stale — call after navigation. */
|
||||||
|
invalidate(): void {
|
||||||
|
this.contextId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the underlying CDP session (reused for Input.dispatchKeyEvent etc.). */
|
||||||
|
async getCdpSession(): Promise<CDPSession> {
|
||||||
|
return this.ensureCdp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cursor state
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
class CursorState {
|
class CursorState {
|
||||||
x = 0;
|
x = 0;
|
||||||
y = 0;
|
y = 0;
|
||||||
initialized = false;
|
initialized = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isInputElement(page: Page, selector: string): Promise<boolean> {
|
|
||||||
|
// ============================================================================
|
||||||
|
// Stealth DOM queries — isolated world with evaluate fallback
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if selector matches an input/textarea/contenteditable element.
|
||||||
|
* Uses CDP Isolated World when available — invisible to main world.
|
||||||
|
*/
|
||||||
|
async function isInputElement(
|
||||||
|
stealth: StealthEval | null,
|
||||||
|
page: Page,
|
||||||
|
selector: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (stealth) {
|
||||||
|
try {
|
||||||
|
const escaped = JSON.stringify(selector);
|
||||||
|
const result = await stealth.evaluate(`
|
||||||
|
(() => {
|
||||||
|
const el = document.querySelector(${escaped});
|
||||||
|
if (!el) return false;
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
return tag === 'input' || tag === 'textarea'
|
||||||
|
|| el.getAttribute('contenteditable') === 'true';
|
||||||
|
})()
|
||||||
|
`);
|
||||||
|
return !!result;
|
||||||
|
} catch {
|
||||||
|
// Fall through to page.evaluate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: page.evaluate (detectable — should only happen if CDP fails)
|
||||||
return page.evaluate((sel: string) => {
|
return page.evaluate((sel: string) => {
|
||||||
const el = document.querySelector(sel);
|
const el = document.querySelector(sel);
|
||||||
if (!el) return false;
|
if (!el) return false;
|
||||||
@@ -39,13 +179,37 @@ async function isInputElement(page: Page, selector: string): Promise<boolean> {
|
|||||||
}, selector).catch(() => false);
|
}, selector).catch(() => false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isSelectorFocused(page: Page, selector: string): Promise<boolean> {
|
/**
|
||||||
|
* Check if the element matching selector is currently focused.
|
||||||
|
* Uses CDP Isolated World when available — invisible to main world.
|
||||||
|
*/
|
||||||
|
async function isSelectorFocused(
|
||||||
|
stealth: StealthEval | null,
|
||||||
|
page: Page,
|
||||||
|
selector: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (stealth) {
|
||||||
|
try {
|
||||||
|
const escaped = JSON.stringify(selector);
|
||||||
|
const result = await stealth.evaluate(`
|
||||||
|
(() => {
|
||||||
|
const el = document.querySelector(${escaped});
|
||||||
|
return el === document.activeElement;
|
||||||
|
})()
|
||||||
|
`);
|
||||||
|
return !!result;
|
||||||
|
} catch {
|
||||||
|
// Fall through to page.evaluate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return page.evaluate((sel: string) => {
|
return page.evaluate((sel: string) => {
|
||||||
const el = document.querySelector(sel);
|
const el = document.querySelector(sel);
|
||||||
return el === document.activeElement;
|
return el === document.activeElement;
|
||||||
}, selector).catch(() => false);
|
}, selector).catch(() => false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Page-level patching
|
// Page-level patching
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -82,6 +246,21 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
(page as any)._original = originals;
|
(page as any)._original = originals;
|
||||||
(page as any)._humanCfg = cfg;
|
(page as any)._humanCfg = cfg;
|
||||||
|
|
||||||
|
// --- Stealth infrastructure ---
|
||||||
|
const stealth = new StealthEval(page);
|
||||||
|
(page as any)._stealth = stealth;
|
||||||
|
|
||||||
|
// CDP session for shift symbol typing (lazy-initialized, reuses stealth's session)
|
||||||
|
let cdpSession: CDPSession | null = null;
|
||||||
|
const ensureCdp = async (): Promise<CDPSession | null> => {
|
||||||
|
if (!cdpSession) {
|
||||||
|
try {
|
||||||
|
cdpSession = await stealth.getCdpSession();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return cdpSession;
|
||||||
|
};
|
||||||
|
|
||||||
const raw: RawMouse = {
|
const raw: RawMouse = {
|
||||||
move: originals.mouseMove,
|
move: originals.mouseMove,
|
||||||
down: originals.mouseDown,
|
down: originals.mouseDown,
|
||||||
@@ -105,11 +284,11 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- goto ---
|
// --- goto (invalidate isolated world on navigation) ---
|
||||||
const humanGoto = async (url: string, options?: any) => {
|
const humanGoto = async (url: string, options?: any) => {
|
||||||
const response = await originals.goto(url, options);
|
const response = await originals.goto(url, options);
|
||||||
// Patch any new frames after navigation
|
stealth.invalidate();
|
||||||
patchFrames(page, cfg, cursor, raw, rawKb, originals);
|
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -122,7 +301,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
|
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
|
||||||
cursor.x = cursorX;
|
cursor.x = cursorX;
|
||||||
cursor.y = cursorY;
|
cursor.y = cursorY;
|
||||||
const isInput = await isInputElement(page, selector);
|
const isInput = await isInputElement(stealth, page, selector);
|
||||||
const target = clickTarget(box, isInput, cfg);
|
const target = clickTarget(box, isInput, cfg);
|
||||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||||
cursor.x = target.x;
|
cursor.x = target.x;
|
||||||
@@ -139,7 +318,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
|
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
|
||||||
cursor.x = cursorX;
|
cursor.x = cursorX;
|
||||||
cursor.y = cursorY;
|
cursor.y = cursorY;
|
||||||
const isInput = await isInputElement(page, selector);
|
const isInput = await isInputElement(stealth, page, selector);
|
||||||
const target = clickTarget(box, isInput, cfg);
|
const target = clickTarget(box, isInput, cfg);
|
||||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||||
cursor.x = target.x;
|
cursor.x = target.x;
|
||||||
@@ -169,7 +348,8 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
await sleep(randRange(cfg.field_switch_delay));
|
await sleep(randRange(cfg.field_switch_delay));
|
||||||
await humanClickFn(selector);
|
await humanClickFn(selector);
|
||||||
await sleep(rand(100, 250));
|
await sleep(rand(100, 250));
|
||||||
await humanType(page, rawKb, text, cfg);
|
const cdp = await ensureCdp();
|
||||||
|
await humanType(page, rawKb, text, cfg, cdp);
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- fill (clears existing content first) ---
|
// --- fill (clears existing content first) ---
|
||||||
@@ -181,12 +361,13 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
await sleep(rand(30, 80));
|
await sleep(rand(30, 80));
|
||||||
await originals.keyboardPress('Backspace');
|
await originals.keyboardPress('Backspace');
|
||||||
await sleep(rand(50, 150));
|
await sleep(rand(50, 150));
|
||||||
await humanType(page, rawKb, value, cfg);
|
const cdp = await ensureCdp();
|
||||||
|
await humanType(page, rawKb, value, cfg, cdp);
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- clear ---
|
// --- clear ---
|
||||||
const humanClearFn = async (selector: string, options?: any) => {
|
const humanClearFn = async (selector: string, options?: any) => {
|
||||||
if (!await isSelectorFocused(page, selector)) {
|
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||||
await humanClickFn(selector);
|
await humanClickFn(selector);
|
||||||
}
|
}
|
||||||
await sleep(rand(50, 150));
|
await sleep(rand(50, 150));
|
||||||
@@ -226,7 +407,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
|
|
||||||
// --- press (checks focus first — avoids redundant mouse moves) ---
|
// --- press (checks focus first — avoids redundant mouse moves) ---
|
||||||
const humanPressFn = async (selector: string, key: string, options?: any) => {
|
const humanPressFn = async (selector: string, key: string, options?: any) => {
|
||||||
if (!await isSelectorFocused(page, selector)) {
|
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||||
await humanClickFn(selector);
|
await humanClickFn(selector);
|
||||||
}
|
}
|
||||||
await sleep(rand(50, 150));
|
await sleep(rand(50, 150));
|
||||||
@@ -235,11 +416,12 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
|
|
||||||
// --- pressSequentially ---
|
// --- pressSequentially ---
|
||||||
const humanPressSequentiallyFn = async (selector: string, text: string, options?: any) => {
|
const humanPressSequentiallyFn = async (selector: string, text: string, options?: any) => {
|
||||||
if (!await isSelectorFocused(page, selector)) {
|
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||||
await humanClickFn(selector);
|
await humanClickFn(selector);
|
||||||
}
|
}
|
||||||
await sleep(rand(100, 250));
|
await sleep(rand(100, 250));
|
||||||
await humanType(page, rawKb, text, cfg);
|
const cdp = await ensureCdp();
|
||||||
|
await humanType(page, rawKb, text, cfg, cdp);
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- tap ---
|
// --- tap ---
|
||||||
@@ -277,7 +459,8 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
|
|
||||||
// --- keyboard patches ---
|
// --- keyboard patches ---
|
||||||
page.keyboard.type = async (text: string, options?: any) => {
|
page.keyboard.type = async (text: string, options?: any) => {
|
||||||
await humanType(page, rawKb, text, cfg);
|
const cdp = await ensureCdp();
|
||||||
|
await humanType(page, rawKb, text, cfg, cdp);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Store helpers for frame patching
|
// Store helpers for frame patching
|
||||||
@@ -301,7 +484,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
// --- Patch Frame-level methods (for sub-frames) ---
|
// --- Patch Frame-level methods (for sub-frames) ---
|
||||||
patchFrames(page, cfg, cursor, raw, rawKb, originals);
|
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -321,13 +504,20 @@ function patchFrames(
|
|||||||
raw: RawMouse,
|
raw: RawMouse,
|
||||||
rawKb: RawKeyboard,
|
rawKb: RawKeyboard,
|
||||||
originals: any,
|
originals: any,
|
||||||
|
stealth: StealthEval,
|
||||||
): void {
|
): void {
|
||||||
for (const frame of iterFrames(page)) {
|
for (const frame of iterFrames(page)) {
|
||||||
patchSingleFrame(frame, page, cfg, originals);
|
patchSingleFrame(frame, page, cfg, originals, stealth);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchSingleFrame(frame: Frame, page: Page, cfg: HumanConfig, originals: any): void {
|
function patchSingleFrame(
|
||||||
|
frame: Frame,
|
||||||
|
page: Page,
|
||||||
|
cfg: HumanConfig,
|
||||||
|
originals: any,
|
||||||
|
stealth: StealthEval,
|
||||||
|
): void {
|
||||||
if ((frame as any)._humanPatched) return;
|
if ((frame as any)._humanPatched) return;
|
||||||
(frame as any)._humanPatched = true;
|
(frame as any)._humanPatched = true;
|
||||||
|
|
||||||
@@ -374,7 +564,7 @@ function patchSingleFrame(frame: Frame, page: Page, cfg: HumanConfig, originals:
|
|||||||
};
|
};
|
||||||
|
|
||||||
(frame as any).clear = async (selector: string, options?: any) => {
|
(frame as any).clear = async (selector: string, options?: any) => {
|
||||||
if (!await isSelectorFocused(page, selector)) {
|
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||||
await (page as any).click(selector);
|
await (page as any).click(selector);
|
||||||
}
|
}
|
||||||
await sleep(rand(50, 150));
|
await sleep(rand(50, 150));
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* cloakbrowser-human — Human-like keyboard input.
|
* cloakbrowser-human — Human-like keyboard input.
|
||||||
|
*
|
||||||
|
* Stealth-aware: when a CDPSession is provided, shift symbols are typed
|
||||||
|
* via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace).
|
||||||
|
* Falls back to page.evaluate when no CDPSession is available.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Page } from 'playwright-core';
|
import type { Page, CDPSession } from 'playwright-core';
|
||||||
import { RawKeyboard } from './mouse.js';
|
import { RawKeyboard } from './mouse.js';
|
||||||
import { HumanConfig, rand, randRange, sleep } from './config.js';
|
import { HumanConfig, rand, randRange, sleep } from './config.js';
|
||||||
|
|
||||||
@@ -22,6 +26,31 @@ const NEARBY_KEYS: Record<string, string> = {
|
|||||||
'6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p',
|
'6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CDP key code for each shift symbol's physical key.
|
||||||
|
* Used by Input.dispatchKeyEvent to produce isTrusted=true events.
|
||||||
|
*/
|
||||||
|
const SHIFT_SYMBOL_CODES: Record<string, string> = {
|
||||||
|
'!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4',
|
||||||
|
'%': 'Digit5', '^': 'Digit6', '&': 'Digit7', '*': 'Digit8',
|
||||||
|
'(': 'Digit9', ')': 'Digit0', '_': 'Minus', '+': 'Equal',
|
||||||
|
'{': 'BracketLeft', '}': 'BracketRight', '|': 'Backslash',
|
||||||
|
':': 'Semicolon', '"': 'Quote', '<': 'Comma', '>': 'Period',
|
||||||
|
'?': 'Slash', '~': 'Backquote',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows virtual key codes for shift symbols.
|
||||||
|
* Input.dispatchKeyEvent uses these to match real keyboard behavior.
|
||||||
|
*/
|
||||||
|
const SHIFT_SYMBOL_KEYCODES: Record<string, number> = {
|
||||||
|
'!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
|
||||||
|
'^': 54, '&': 55, '*': 56, '(': 57, ')': 48,
|
||||||
|
'_': 189, '+': 187, '{': 219, '}': 221, '|': 220,
|
||||||
|
':': 186, '"': 222, '<': 188, '>': 190, '?': 191,
|
||||||
|
'~': 192,
|
||||||
|
};
|
||||||
|
|
||||||
function isAscii(ch: string): boolean {
|
function isAscii(ch: string): boolean {
|
||||||
const code = ch.codePointAt(0);
|
const code = ch.codePointAt(0);
|
||||||
return code !== undefined && code < 128;
|
return code !== undefined && code < 128;
|
||||||
@@ -37,11 +66,24 @@ function getNearbyKey(ch: string): string {
|
|||||||
return ch;
|
return ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isUpperCase(ch: string): boolean {
|
||||||
|
return ch.length === 1 && ch >= 'A' && ch <= 'Z';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type text with human-like per-character timing, mistype simulation,
|
||||||
|
* and realistic shift handling.
|
||||||
|
*
|
||||||
|
* @param cdpSession - If provided, shift symbols use CDP Input.dispatchKeyEvent
|
||||||
|
* producing isTrusted=true events with no evaluate stack trace.
|
||||||
|
* If null/undefined, falls back to page.evaluate (detectable).
|
||||||
|
*/
|
||||||
export async function humanType(
|
export async function humanType(
|
||||||
page: Page,
|
page: Page,
|
||||||
raw: RawKeyboard,
|
raw: RawKeyboard,
|
||||||
text: string,
|
text: string,
|
||||||
cfg: HumanConfig,
|
cfg: HumanConfig,
|
||||||
|
cdpSession?: CDPSession | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const chars = [...text]; // Handle emoji surrogate pairs correctly
|
const chars = [...text]; // Handle emoji surrogate pairs correctly
|
||||||
|
|
||||||
@@ -72,7 +114,7 @@ export async function humanType(
|
|||||||
if (isUpperCase(ch)) {
|
if (isUpperCase(ch)) {
|
||||||
await typeShiftedChar(raw, ch, cfg);
|
await typeShiftedChar(raw, ch, cfg);
|
||||||
} else if (SHIFT_SYMBOLS.has(ch)) {
|
} else if (SHIFT_SYMBOLS.has(ch)) {
|
||||||
await typeShiftSymbol(page, raw, ch, cfg);
|
await typeShiftSymbol(page, raw, ch, cfg, cdpSession);
|
||||||
} else {
|
} else {
|
||||||
await typeNormalChar(raw, ch, cfg);
|
await typeNormalChar(raw, ch, cfg);
|
||||||
}
|
}
|
||||||
@@ -99,7 +141,54 @@ async function typeShiftedChar(raw: RawKeyboard, ch: string, cfg: HumanConfig):
|
|||||||
await raw.up('Shift');
|
await raw.up('Shift');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function typeShiftSymbol(page: Page, raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
|
/**
|
||||||
|
* Type a shift symbol character.
|
||||||
|
*
|
||||||
|
* Stealth path (cdpSession provided):
|
||||||
|
* Uses CDP Input.dispatchKeyEvent → isTrusted=true, clean stack.
|
||||||
|
*
|
||||||
|
* Fallback path (no cdpSession):
|
||||||
|
* Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent.
|
||||||
|
* Detectable via isTrusted=false and evaluate stack frame.
|
||||||
|
*/
|
||||||
|
async function typeShiftSymbol(
|
||||||
|
page: Page,
|
||||||
|
raw: RawKeyboard,
|
||||||
|
ch: string,
|
||||||
|
cfg: HumanConfig,
|
||||||
|
cdpSession?: CDPSession | null,
|
||||||
|
): Promise<void> {
|
||||||
|
if (cdpSession) {
|
||||||
|
// --- Stealth path: CDP Input.dispatchKeyEvent ---
|
||||||
|
const code = SHIFT_SYMBOL_CODES[ch] || '';
|
||||||
|
const keyCode = SHIFT_SYMBOL_KEYCODES[ch] || 0;
|
||||||
|
|
||||||
|
await raw.down('Shift');
|
||||||
|
await sleep(randRange(cfg.shift_down_delay));
|
||||||
|
|
||||||
|
await cdpSession.send('Input.dispatchKeyEvent', {
|
||||||
|
type: 'keyDown',
|
||||||
|
modifiers: 8, // Shift modifier flag
|
||||||
|
key: ch,
|
||||||
|
code,
|
||||||
|
windowsVirtualKeyCode: keyCode,
|
||||||
|
text: ch,
|
||||||
|
unmodifiedText: ch,
|
||||||
|
});
|
||||||
|
await sleep(randRange(cfg.key_hold));
|
||||||
|
|
||||||
|
await cdpSession.send('Input.dispatchKeyEvent', {
|
||||||
|
type: 'keyUp',
|
||||||
|
modifiers: 8,
|
||||||
|
key: ch,
|
||||||
|
code,
|
||||||
|
windowsVirtualKeyCode: keyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleep(randRange(cfg.shift_up_delay));
|
||||||
|
await raw.up('Shift');
|
||||||
|
} else {
|
||||||
|
// --- Fallback path: page.evaluate (detectable) ---
|
||||||
await raw.down('Shift');
|
await raw.down('Shift');
|
||||||
await sleep(randRange(cfg.shift_down_delay));
|
await sleep(randRange(cfg.shift_down_delay));
|
||||||
await raw.insertText(ch);
|
await raw.insertText(ch);
|
||||||
@@ -112,10 +201,7 @@ async function typeShiftSymbol(page: Page, raw: RawKeyboard, ch: string, cfg: Hu
|
|||||||
}, ch);
|
}, ch);
|
||||||
await sleep(randRange(cfg.shift_up_delay));
|
await sleep(randRange(cfg.shift_up_delay));
|
||||||
await raw.up('Shift');
|
await raw.up('Shift');
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUpperCase(ch: string): boolean {
|
|
||||||
return ch.length === 1 && ch >= 'A' && ch <= 'Z';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function interCharDelay(cfg: HumanConfig): Promise<void> {
|
async function interCharDelay(cfg: HumanConfig): Promise<void> {
|
||||||
|
|||||||
+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", () => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# tests/test_stealth_reproduction_110.py
|
||||||
|
"""
|
||||||
|
Exact reproduction of issue #110 detection vectors.
|
||||||
|
Proves all three leaks (isInputElement, isSelectorFocused, typeShiftSymbol)
|
||||||
|
are fixed with CDP isolated worlds.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
@pytest.mark.slow
|
||||||
|
class TestIssue110Reproduction:
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exact_reproduction_from_issue(self):
|
||||||
|
"""Exact detection script from issue #110 — must produce zero detections."""
|
||||||
|
from cloakbrowser import launch_async
|
||||||
|
|
||||||
|
browser = await launch_async(headless=True, humanize=True)
|
||||||
|
page = await browser.new_page()
|
||||||
|
|
||||||
|
await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
# === EXACT detection from issue #110 ===
|
||||||
|
await page.evaluate("""
|
||||||
|
() => {
|
||||||
|
window.__detections = {
|
||||||
|
evaluateQS: [],
|
||||||
|
untrustedKeydown: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detection 1: querySelector from evaluate context
|
||||||
|
const origQS = document.querySelector.bind(document);
|
||||||
|
document.querySelector = function(sel) {
|
||||||
|
try { throw new Error(); } catch (e) {
|
||||||
|
if (e.stack.includes(':302:')) {
|
||||||
|
window.__detections.evaluateQS.push(sel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return origQS(sel);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detection 2: untrusted keyboard events
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (!e.isTrusted) {
|
||||||
|
window.__detections.untrustedKeydown.push(e.key);
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
# === Trigger all three vectors from issue ===
|
||||||
|
|
||||||
|
# Vector 1: isInputElement — click triggers querySelector check
|
||||||
|
await page.click('#searchInput')
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
# Vector 2+3: typeShiftSymbol — type text with shift symbols
|
||||||
|
await page.keyboard.type('Hello!@#$%^&*()')
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
# === Verify: zero detections ===
|
||||||
|
detections = await page.evaluate('() => window.__detections')
|
||||||
|
|
||||||
|
qs_leaks = detections['evaluateQS']
|
||||||
|
untrusted = detections['untrustedKeydown']
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"Issue #110 Reproduction Results:")
|
||||||
|
print(f" querySelector from evaluate: {len(qs_leaks)} detections")
|
||||||
|
print(f" Untrusted keyboard events: {len(untrusted)} detections")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
assert len(qs_leaks) == 0, (
|
||||||
|
f"LEAK: querySelector called from evaluate context: {qs_leaks}"
|
||||||
|
)
|
||||||
|
assert len(untrusted) == 0, (
|
||||||
|
f"LEAK: Untrusted keyboard events detected: {untrusted}"
|
||||||
|
)
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_all_21_shift_symbols_trusted(self):
|
||||||
|
"""Every single shift symbol must produce isTrusted=true."""
|
||||||
|
from cloakbrowser import launch_async
|
||||||
|
|
||||||
|
browser = await launch_async(headless=True, humanize=True)
|
||||||
|
page = await browser.new_page()
|
||||||
|
|
||||||
|
await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
await page.evaluate("""
|
||||||
|
() => {
|
||||||
|
window.__keyResults = { trusted: [], untrusted: [] };
|
||||||
|
const input = document.querySelector('#searchInput');
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
const list = e.isTrusted ? 'trusted' : 'untrusted';
|
||||||
|
window.__keyResults[list].push(e.key);
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
await page.click('#searchInput')
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
# Type ALL 21 shift symbols
|
||||||
|
all_shift = '!@#$%^&*()_+{}|:"<>?~'
|
||||||
|
await page.keyboard.type(all_shift)
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
results = await page.evaluate('() => window.__keyResults')
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"All 21 Shift Symbols Test:")
|
||||||
|
print(f" Trusted: {results['trusted']}")
|
||||||
|
print(f" Untrusted: {results['untrusted']}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
# Every shift symbol must be trusted
|
||||||
|
for sym in all_shift:
|
||||||
|
assert sym in results['trusted'], f"'{sym}' NOT in trusted events"
|
||||||
|
assert sym not in results['untrusted'], f"'{sym}' IS in untrusted events"
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_clear_uses_isolated_world(self):
|
||||||
|
"""clear() calls isSelectorFocused — must not leak evaluate."""
|
||||||
|
from cloakbrowser import launch_async
|
||||||
|
|
||||||
|
browser = await launch_async(headless=True, humanize=True)
|
||||||
|
page = await browser.new_page()
|
||||||
|
|
||||||
|
await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
await page.evaluate("""
|
||||||
|
() => {
|
||||||
|
window.__evalLeaks = [];
|
||||||
|
const origQS = document.querySelector.bind(document);
|
||||||
|
document.querySelector = function(sel) {
|
||||||
|
try { throw new Error(); } catch (e) {
|
||||||
|
if (e.stack.includes(':302:')) {
|
||||||
|
window.__evalLeaks.push(sel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return origQS(sel);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
# fill → click + type (isInputElement + isSelectorFocused)
|
||||||
|
await page.locator('#searchInput').fill('some text')
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
# clear → isSelectorFocused check
|
||||||
|
await page.locator('#searchInput').clear()
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
leaks = await page.evaluate('() => window.__evalLeaks')
|
||||||
|
assert len(leaks) == 0, f"clear() leaked via evaluate: {leaks}"
|
||||||
|
|
||||||
|
val = await page.locator('#searchInput').input_value()
|
||||||
|
assert val == '', f"clear() didn't clear: '{val}'"
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user