Compare commits

..
Author SHA1 Message Date
CloakHQ cb0b87873e feat: native SOCKS5 proxy support in proxy= parameter
Route SOCKS5/SOCKS5h proxies via --proxy-server Chrome arg instead of
Playwright's proxy dict (which rejects SOCKS5 with credentials).
Handles string URLs, Playwright dicts, IPv6, bypass lists.

SOCKS5 geoip exit IP resolution uses socks-proxy-agent (optional peer
dep). Falls back to DNS if not installed.
2026-04-10 22:20:16 +02:00
lilosandGitHub 2be8cdcc03 feat(humanize): add Playwright ElementHandle support and fix async tests (#133) 2026-04-10 22:18:14 +02:00
CloakHQ be9a98db67 fix(test): correct cloakserve passthrough test for --fingerprint parsing 2026-04-10 21:40:20 +02:00
CloakHQ 9b004bbd85 docs: clarify humanize requires wrapper import over CDP (#126) 2026-04-09 21:08:48 +02:00
CloakHQ 5b2981c4c1 release: v0.3.23 — Puppeteer humanize, CDP humanize export, cloakserve locale fix 2026-04-09 20:56:16 +02:00
lilosandGitHub 7afe59435e feat: Add Puppeteer humanize support and fix Playwright humanize gaps (#129)
- Add full Puppeteer humanize implementation (page, frame, element handle patching)
- Fix critical Playwright gaps: page.pressSequentially, page.tap, page.clear
- Fix frame-level patching: frame.pressSequentially, frame.tap
- Add comprehensive stealth tests for Puppeteer
- Update SLOW test suite to use correct humanize: true API
- Add 4 new tests validating fixed Playwright methods
2026-04-09 20:49:20 +02:00
CloakHQ 1cef71133d fix(test): clear CLOAKBROWSER_BINARY_PATH in puppeteer mock tests
Env var override takes precedence over ensureBinary mock, causing
test to fail in Docker where the var is always set.
2026-04-09 20:45:16 +02:00
CloakHQ 7a0937cc54 feat(js): expose humanize module for CDP-connected browsers (#126)
Add ./human export path to package.json so users can import patchBrowser,
patchPage, and resolveConfig to humanize CDP-connected Playwright instances.
2026-04-09 18:06:29 +02:00
CloakHQ 5b00ff0325 fix(cloakserve): route locale/timezone/seed CLI args through build_args()
CLI args like --fingerprint-locale were passed as raw passthrough args
to Chrome, missing the companion --lang flag that build_args() normally
adds. Caused Intl API to default to en-US while navigator.language
showed the correct locale — a detectable mismatch.

Fixes #130
2026-04-09 17:58:47 +02:00
CloakHQ 5dd44298ee ci: use Node 24 for npm publish (Node 22.22.2 has broken npm)
Node 22.22.2's bundled npm 10.9.7 is missing promise-retry, breaking
npm install -g. Node 24 ships npm 11.11.0 with native OIDC support.

Ref: nodejs/node#62425, actions/runner-images#13883
2026-04-09 04:52:12 +02:00
CloakHQ 54d8442f20 release: v0.3.22 — Chromium 146 upgrade (linux-x64) 2026-04-09 04:36:42 +02:00
CloakHQ a01adbe26c ci: restore npm upgrade for OIDC publishing (pin to npm@11)
Node 22 ships npm v10 which lacks OIDC support. The upgrade step was
removed in 02359f6 but is required for provenance-based publishing.
Pin to npm@11 instead of @latest to avoid future breakage.
2026-04-07 07:31:08 +02:00
CloakHQ 06d77e7261 refactor: remove dead stealth args, let binary handle GPU diversity
Remove --disable-blink-features=AutomationControlled (dead, binary handles
navigator.webdriver at source level) and hardcoded GPU vendor/renderer flags.
Binary auto-generates diverse GPU profiles from fingerprint seed. Improves
fingerprint diversity -- previously every user shared the same GPU string.

Bump to v0.3.21.
2026-04-07 07:16:22 +02:00
CloakHQ 211bd93d3e fix(docker): install geoip2 in Docker image
geoip=True raised ImportError inside the container because geoip2
was not installed. Added [geoip] extra to pip install.
2026-04-07 06:37:14 +02:00
CloakHQandkitiho 1060772734 fix: allow null viewport in Python wrapper (mirrors #107)
viewport=None now disables viewport emulation via Playwright's
no_viewport=True, matching the JS wrapper's viewport: null behavior.
Uses a sentinel to distinguish "not provided" from explicit None.

Co-authored-by: kitiho <51785099+kitiho@users.noreply.github.com>
2026-04-07 05:14:01 +02:00
kitihoandGitHub 8eb2e4b905 fix: allow null viewport to disable viewport emulation (#107)
fix: allow null viewport to disable viewport emulation
2026-04-07 05:11:44 +02:00
CloakHQ 216a7d6a6a fix(examples): enable geoip in stealth test to fix FingerprintJS detection 2026-04-06 02:38:26 +02:00
CloakHQ 02359f69c8 ci: remove npm self-upgrade step — Node 22 ships with compatible npm 2026-04-06 02:08:44 +02:00
38 changed files with 6368 additions and 207 deletions
+1 -3
View File
@@ -83,10 +83,8 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
node-version: 24 # npm 11.11.0 native — no upgrade needed (Node 22.22.2 has broken npm)
registry-url: 'https://registry.npmjs.org'
- name: Upgrade npm
run: npm install -g npm@latest
- name: Build
run: cd js && npm ci && npm run build
- name: Publish to npm
+5
View File
@@ -67,3 +67,8 @@ debug
publish-docker.sh
captures
20[0-9][0-9]-[0-9][0-9]-[0-9][0-9]-*.txt
# Beads / Dolt files (added by bd init)
.dolt/
*.db
.beads-credential-key
+21
View File
@@ -6,6 +6,27 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
---
## [0.3.23] — 2026-04-09
- **[wrapper]** Add full Puppeteer humanize support — human-like mouse, keyboard, and scroll behavior for `puppeteer-core` users (thanks [@evelaa123](https://github.com/evelaa123), #129)
- **[wrapper]** Fix Playwright humanize gaps — `pressSequentially`, `tap`, `clear` on pages and frames now use human-like behavior (#129)
- **[wrapper]** Expose humanize module for CDP-connected browsers — `import from 'cloakbrowser/human'` for manual patching of external Playwright instances (#126)
- **[docker]** Fix `cloakserve` locale/timezone mismatch — CLI args now route through `build_args()` so the companion `--lang` flag is added automatically (#130)
- **[meta]** Use Node 24 in CI publish workflow to work around broken npm in Node 22.22.2
## [0.3.22] — 2026-04-09
- **[binary]** Upgrade Linux x64 build to Chromium 146.0.7680.177.1 — 49 source-level C++ patches (up from 48), rebased from 145.0.7632.x
## [0.3.21] — 2026-04-07
- **[wrapper]** Remove dead `--disable-blink-features=AutomationControlled` flag -- binary patch 009 already handles `navigator.webdriver` at source level
- **[wrapper]** Remove hardcoded GPU vendor/renderer flags -- binary auto-generates diverse, realistic GPU profiles from the fingerprint seed. Each seed gets a unique GPU instead of every user sharing the same one
- **[wrapper]** Allow `viewport=None` to disable viewport emulation in both Python and JS wrappers (thanks [@kitiho](https://github.com/kitiho), #107)
- **[wrapper]** Enable `geoip=True` in stealth test example to fix FingerprintJS detection
- **[meta]** Remove npm self-upgrade step in CI -- Node 22 ships with compatible npm
- **[docker]** Install `geoip2` in Docker image for GeoIP auto-detection support
## [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)
+1 -1
View File
@@ -20,7 +20,7 @@ WORKDIR /app
# Python wrapper
COPY pyproject.toml README.md LICENSE BINARY-LICENSE.md CHANGELOG.md ./
COPY cloakbrowser/ cloakbrowser/
RUN pip install --no-cache-dir ".[serve]"
RUN pip install --no-cache-dir ".[serve,geoip]"
# JS wrapper
COPY js/ js/
+47 -30
View File
@@ -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>
</p>
- **48 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, WebRTC, network timing, automation signals, CDP input behavior
- **49 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
- **0.9 reCAPTCHA v3 score** — human-level, server-verified
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
@@ -128,9 +128,10 @@ Open [http://localhost:8080](http://localhost:8080). Create a profile. Click **L
---
## Latest: v0.3.20 (Chromium 145.0.7632.159.9)
## Latest: v0.3.22 (Chromium 146.0.7680.177.1)
- **48 fingerprint patches** (Linux x64) — 6 new patches covering WebRTC IP spoofing, proxy signal removal, and network timing normalization
- **Chromium 146 upgrade** — rebased all patches from 145.0.7632.x to 146.0.7680.177
- **49 fingerprint patches** (Linux x64) — 1 new patch, all existing patches carried forward
- **WebRTC IP spoofing** — `--fingerprint-webrtc-ip=auto` resolves your proxy's exit IP and spoofs WebRTC ICE candidates. Auto-injected when using `geoip=True` (no extra network call)
- **Proxy signal removal** — DNS/connect/SSL timing zeroed, proxy cache headers stripped, Proxy-Connection header leak removed
- **`cloakserve` CDP multiplexer** — rewritten as a multi-connection CDP proxy with per-connection fingerprint seeds
@@ -154,7 +155,7 @@ CloakBrowser doesn't solve CAPTCHAs — it prevents them from appearing. No CAPT
## Test Results
All tests verified against live detection services. Last tested: Mar 2026 (Chromium 145).
All tests verified against live detection services. Last tested: Apr 2026 (Chromium 146).
| Detection Service | Stock Playwright | CloakBrowser | Notes |
|---|---|---|---|
@@ -169,7 +170,7 @@ All tests verified against live detection services. Last tested: Mar 2026 (Chrom
| `navigator.webdriver` | `true` | **`false`** | Source-level patch |
| `navigator.plugins.length` | 0 | **5** | Real plugin list |
| `window.chrome` | `undefined` | **`object`** | Present like real Chrome |
| UA string | `HeadlessChrome` | **`Chrome/145.0.0.0`** | No headless leak |
| UA string | `HeadlessChrome` | **`Chrome/146.0.0.0`** | No headless leak |
| CDP detection | Detected | **Not detected** | `isAutomatedWithCDP: false` |
| TLS fingerprint | Mismatch | **Identical to Chrome** | ja3n/ja4/akamai match |
| | | **Tested against 30+ detection sites** | |
@@ -218,11 +219,11 @@ All tests verified against live detection services. Last tested: Mar 2026 (Chrom
CloakBrowser is a thin wrapper (Python + JavaScript) around a custom-built Chromium binary:
1. **You install**`pip install cloakbrowser` or `npm install cloakbrowser`
2. **First launch** → binary auto-downloads for your platform (Chromium 145)
2. **First launch** → binary auto-downloads for your platform (Chromium 146)
3. **Every launch** → Playwright or Puppeteer starts with our binary + stealth args
4. **You write code** → standard Playwright/Puppeteer API, nothing new to learn
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.
The binary includes 49 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.
@@ -241,8 +242,9 @@ browser = launch()
# Headed mode (see the browser window)
browser = launch(headless=False)
# With proxy
# With proxy (HTTP or SOCKS5)
browser = launch(proxy="http://user:pass@proxy:8080")
browser = launch(proxy="socks5://user:pass@proxy:1080")
# With proxy dict (bypass, separate auth fields)
browser = launch(proxy={"server": "http://proxy:8080", "bypass": ".google.com", "username": "user", "password": "pass"})
@@ -369,7 +371,7 @@ from cloakbrowser import binary_info, clear_cache, ensure_binary
# Check binary installation status
print(binary_info())
# {'version': '145.0.7632.159.2', 'platform': 'linux-x64', 'installed': True, ...}
# {'version': '146.0.7680.177.1', 'platform': 'linux-x64', 'installed': True, ...}
# Force re-download
clear_cache()
@@ -451,7 +453,7 @@ clearCache();
## Human Behavior
Pass `humanize=True` to make all mouse, keyboard, and scroll interactions indistinguishable from real users. All Playwright calls `page.click()`, `page.fill()`, `page.type()`, `page.mouse.*`, `page.keyboard.*`, and the full Locator API are automatically replaced with human-like equivalents. No code changes needed.
Pass `humanize=True` to make all mouse, keyboard, and scroll interactions indistinguishable from real users. All Playwright calls (`page.click()`, `page.fill()`, `page.type()`, `page.mouse.*`, `page.keyboard.*`, Locator API) and Puppeteer calls (`page.click()`, `page.type()`, `page.mouse.*`, `page.keyboard.*`, ElementHandle API) are automatically replaced with human-like equivalents. No code changes needed.
```python
browser = launch(humanize=True)
@@ -462,6 +464,14 @@ page.locator("button[type=submit]").click() # Bézier curve, realistic aim
```
```javascript
// Playwright
import { launch } from 'cloakbrowser';
const browser = await launch({ humanize: true });
```
```javascript
// Puppeteer
import { launch } from 'cloakbrowser/puppeteer';
const browser = await launch({ humanize: true });
```
@@ -510,9 +520,11 @@ const browser = await launch({
Access the original un-patched Playwright page at `page._original` if you need raw speed for a specific call.
> **Note:** Always use `page.click(selector)`, `page.type(selector, text)`, `page.hover(selector)`, or `page.locator(selector).*` — these go through the full humanize pipeline. Avoid `page.query_selector()` — `ElementHandle` objects bypass all patches, so mouse movement teleports, keyboard events fire without timing, and scroll has no human curve.
> **Note (Playwright):** Always use `page.click(selector)`, `page.type(selector, text)`, `page.hover(selector)`, or `page.locator(selector).*` — these go through the full humanize pipeline. Avoid `page.query_selector()` — `ElementHandle` objects bypass all patches, so mouse movement teleports, keyboard events fire without timing, and scroll has no human curve.
>
> **Note (Puppeteer):** Both selector-based methods (`page.click()`, `page.type()`) and ElementHandle methods (`el.click()`, `el.type()`) are fully humanized. `page.$()`, `page.$$()`, and `page.waitForSelector()` return patched handles automatically.
> Contributed by [@evelaa123](https://github.com/evelaa123) — full Playwright API coverage.
> Contributed by [@evelaa123](https://github.com/evelaa123) — full Playwright and Puppeteer API coverage.
## Configuration
@@ -554,14 +566,10 @@ Every `launch()` call sets these automatically. The **wrapper** applies platform
|------|--------------|---------------|----------|
| `--fingerprint` | Random (1000099999) | Random (1000099999) | Master seed for canvas, WebGL, audio, fonts, client rects |
| `--fingerprint-platform` | `windows` | `macos` | `navigator.platform`, User-Agent OS, GPU pool selection |
| `--fingerprint-gpu-vendor` | `NVIDIA Corporation` | `Google Inc. (Apple)` | WebGL `UNMASKED_VENDOR_WEBGL` |
| `--fingerprint-gpu-renderer` | `NVIDIA GeForce RTX 3070` | `ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)` | WebGL `UNMASKED_RENDERER_WEBGL` |
The binary auto-generates hardware concurrency (8), device memory (8), and screen dimensions (1920x1080 on Windows/Linux, 1440x900 on macOS) from the seed. Override with explicit flags if needed.
The binary auto-generates everything else from the seed: GPU, hardware concurrency, device memory, and screen dimensions. Each seed produces a unique, consistent fingerprint. Override with explicit flags if needed.
> **Using the binary directly?** It works out of the box with zero flags the binary auto-spoofs everything. Pass `--fingerprint=seed` for a persistent identity, or use explicit flags like `--fingerprint-gpu-renderer` to override any auto-generated value.
> **Production tip:** For better stealth at scale, pass your own GPU, screen, and hardware values instead of relying on defaults. Custom parameters make your sessions harder to cluster by anti-bot systems that look for uniform fingerprint profiles.
> **Using the binary directly?** It works out of the box with zero flags -- the binary auto-spoofs everything. Pass `--fingerprint=seed` for a persistent identity, or use explicit flags like `--fingerprint-gpu-renderer` to override any auto-generated value.
### Additional Flags
@@ -569,6 +577,8 @@ Supported by the binary but **not set by default** — pass via `args` to custom
| Flag | Controls |
|------|----------|
| `--fingerprint-gpu-vendor` | WebGL `UNMASKED_VENDOR_WEBGL` (auto-generated from seed + platform) |
| `--fingerprint-gpu-renderer` | WebGL `UNMASKED_RENDERER_WEBGL` (auto-generated from seed + platform) |
| `--fingerprint-hardware-concurrency` | `navigator.hardwareConcurrency` (auto-generated: `8`) |
| `--fingerprint-device-memory` | `navigator.deviceMemory` in GB (auto-generated: `8`) |
| `--fingerprint-screen-width` | Screen width (auto-generated: `1920` Win/Linux, `1440` macOS) |
@@ -598,11 +608,9 @@ browser = launch(args=["--fingerprint=42069"])
browser = launch(stealth_args=False, args=[
"--fingerprint=42069",
"--fingerprint-platform=windows",
"--fingerprint-gpu-vendor=NVIDIA Corporation",
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
])
# Override GPU to look like a different machine
# Override GPU to look like a specific machine
browser = launch(args=[
"--fingerprint-gpu-vendor=Intel Inc.",
"--fingerprint-gpu-renderer=Intel Iris OpenGL Engine",
@@ -638,8 +646,16 @@ stealth_args = get_default_stealth_args() # all fingerprint flags
from cloakbrowser import launch_async
browser = await launch_async(args=["--remote-debugging-port=9242"])
# Connect your framework to http://127.0.0.1:9242 — all stealth flags are set
# Note: humanize requires the wrapper (see below)
```
> **Humanize over CDP**: Stealth fingerprint patches work automatically over CDP, but `humanize=True` is a wrapper-level feature. If you connect to CloakBrowser via CDP from a separate script, import the patching functions to add humanization:
>
> ```js
> import { patchBrowser, resolveConfig } from 'cloakbrowser/human';
> patchBrowser(browser, resolveConfig('default'));
> ```
| Framework | Stars | Language | Example |
|-----------|-------|----------|---------|
| [browser-use](https://github.com/browser-use/browser-use) | 70K | Python | [`browser_use_example.py`](examples/integrations/browser_use_example.py) |
@@ -656,11 +672,11 @@ browser = await launch_async(args=["--remote-debugging-port=9242"])
| Platform | Chromium | Patches | Status |
|---|---|---|---|
| Linux x86_64 | 145 | 42 | ✅ Latest |
| Linux arm64 (RPi, Graviton) | 145 | 33 | ✅ |
| Linux x86_64 | 146 | 49 | ✅ Latest |
| Linux arm64 (RPi, Graviton) | 145 | 48 | ✅ |
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ |
| macOS x86_64 (Intel) | 145 | 26 | ✅ |
| Windows x86_64 | 145 | 33 | ✅ |
| Windows x86_64 | 145 | 48 | ✅ |
The wrapper auto-downloads the correct binary for your platform.
@@ -921,9 +937,9 @@ export CLOAKBROWSER_BINARY_PATH=/path/to/your/chrome
Install a specific wrapper version to downgrade both the wrapper and the binary it downloads:
```bash
pip install cloakbrowser==0.3.11 # Python
npm install cloakbrowser@0.3.11 # JavaScript
docker pull cloakhq/cloakbrowser:0.3.11 # Docker
pip install cloakbrowser==0.3.21 # Python
npm install cloakbrowser@0.3.21 # JavaScript
docker pull cloakhq/cloakbrowser:0.3.21 # Docker
```
Each wrapper version pins its own binary version, so downgrading the wrapper automatically gets you the matching binary on next launch.
@@ -1013,13 +1029,13 @@ A: Camoufox patches Firefox. We patch Chromium. Chromium means native Playwright
A: Possibly. Bot detection is an arms race. Source-level patches are harder to detect than config-level patches, but not impossible. We actively monitor and update when detection evolves.
**Q: Can I use my own proxy?**
A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
A: Yes. Pass `proxy="http://user:pass@host:port"` or `proxy="socks5://user:pass@host:port"` to `launch()`. Both HTTP and SOCKS5 proxies are supported natively.
## Roadmap
| Feature | Status |
|---------|--------|
| Linux x64 — Chromium 145 (48 patches) | ✅ Released |
| Linux x64 — Chromium 146 (49 patches) | ✅ Released |
| macOS arm64/x64 — Chromium 145 (26 patches) | ✅ Released |
| Windows x64 — Chromium 145 (33 patches) | ✅ Released |
| JavaScript/Puppeteer + Playwright support | ✅ Released |
@@ -1043,7 +1059,7 @@ All releases are signed for supply chain verification.
```bash
# Verify GPG signature (binary release tag)
gpg --keyserver keyserver.ubuntu.com --recv-keys C60C0DDC9D0DE2DD
git verify-tag chromium-v145.0.7632.159.9
git verify-tag chromium-v146.0.7680.177.1
# Verify GitHub binary attestation (Sigstore)
gh attestation verify cloakbrowser-linux-x64.tar.gz --repo CloakHQ/cloakbrowser
@@ -1068,3 +1084,4 @@ Issues and PRs welcome. If something isn't working, [open an issue](https://gith
- [@evelaa123](https://github.com/evelaa123) — humanize behavior, persistent contexts, Windows fix
- [@yahooguntu](https://github.com/yahooguntu) — persistent contexts
- [@kitiho](https://github.com/kitiho) — null viewport fix
+34 -1
View File
@@ -88,11 +88,17 @@ class ChromePool:
global_args: list[str],
headless: bool,
data_dir: str = "/tmp/cloakserve",
default_seed: str | None = None,
default_locale: str | None = None,
default_timezone: str | None = None,
):
self._binary = binary
self._global_args = global_args
self._headless = headless
self._data_dir = data_dir
self._default_seed = default_seed
self._default_locale = default_locale
self._default_timezone = default_timezone
self._processes: dict[str, ChromeProcess] = {}
self._default: ChromeProcess | None = None
self._locks: dict[str, asyncio.Lock] = {}
@@ -140,6 +146,14 @@ class ChromePool:
geoip: bool = False,
) -> ChromeProcess:
"""Get existing or launch new Chrome process for a seed."""
# Apply CLI defaults when query params don't provide values
if seed is None and self._default_seed:
seed = self._default_seed
if locale is None:
locale = self._default_locale
if timezone is None:
timezone = self._default_timezone
# No seed = default shared process
if seed is None:
seed_key = "__default__"
@@ -552,11 +566,20 @@ def _default_data_dir() -> str:
def parse_cli_args(argv: list[str]) -> tuple[dict, list[str]]:
"""Parse cloakserve-specific args, return (config, passthrough_args)."""
"""Parse cloakserve-specific args, return (config, passthrough_args).
--fingerprint, --fingerprint-locale, and --fingerprint-timezone are
extracted into config defaults so they route through build_args()
(e.g. locale needs both --lang and --fingerprint-locale).
Query-string params override these defaults per-connection.
"""
config: dict = {
"port": 9222,
"headless": True,
"data_dir": None,
"default_seed": None,
"default_locale": None,
"default_timezone": None,
}
passthrough = []
# Flags consumed by cloakserve (not passed to Chrome)
@@ -577,6 +600,13 @@ def parse_cli_args(argv: list[str]) -> tuple[dict, list[str]]:
passthrough.append(arg)
elif arg.startswith(consumed_prefixes):
pass # Strip these silently
# Route through build_args() so companion flags are set correctly
elif arg.startswith("--fingerprint-locale="):
config["default_locale"] = arg.split("=", 1)[1]
elif arg.startswith("--fingerprint-timezone="):
config["default_timezone"] = arg.split("=", 1)[1]
elif arg.startswith("--fingerprint="):
config["default_seed"] = arg.split("=", 1)[1]
else:
passthrough.append(arg)
@@ -599,6 +629,9 @@ def main() -> None:
global_args=global_args,
headless=config["headless"],
data_dir=config["data_dir"],
default_seed=config["default_seed"],
default_locale=config["default_locale"],
default_timezone=config["default_timezone"],
)
app = web.Application()
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.3.20"
__version__ = "0.3.23"
+112 -27
View File
@@ -17,13 +17,16 @@ from __future__ import annotations
import logging
import os
from typing import Any, Literal, TypedDict
from urllib.parse import unquote, urlparse, urlunparse
from urllib.parse import quote, unquote, urlparse, urlunparse
from .config import DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS, get_default_stealth_args
from .download import ensure_binary
logger = logging.getLogger("cloakbrowser")
# Sentinel to distinguish "viewport not provided" from "viewport=None" (disable emulation)
_VIEWPORT_UNSET = object()
def _resolve_timezone(timezone: str | None, kwargs: dict[str, Any]) -> str | None:
"""Accept both timezone and timezone_id — either works, no warning."""
@@ -102,11 +105,12 @@ def launch(
binary_path = ensure_binary()
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
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 or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args))
@@ -116,7 +120,7 @@ def launch(
headless=headless,
args=chrome_args,
ignore_default_args=IGNORE_DEFAULT_ARGS,
**_build_proxy_kwargs(proxy),
**proxy_kwargs,
**kwargs,
)
@@ -191,11 +195,12 @@ async def launch_async( # noqa: C901
binary_path = ensure_binary()
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
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 or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args))
@@ -205,7 +210,7 @@ async def launch_async( # noqa: C901
headless=headless,
args=chrome_args,
ignore_default_args=IGNORE_DEFAULT_ARGS,
**_build_proxy_kwargs(proxy),
**proxy_kwargs,
**kwargs,
)
@@ -237,7 +242,7 @@ def launch_persistent_context(
args: list[str] | None = None,
stealth_args: bool = True,
user_agent: str | None = None,
viewport: dict | None = None,
viewport: dict | None = _VIEWPORT_UNSET,
locale: str | None = None,
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
@@ -264,6 +269,7 @@ def launch_persistent_context(
stealth_args: Include default stealth fingerprint args (default True).
user_agent: Custom user agent string.
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
Pass None to disable viewport emulation (use OS window size).
locale: Browser locale, e.g. "en-US".
timezone: IANA timezone (e.g. 'America/New_York').
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
@@ -293,11 +299,12 @@ def launch_persistent_context(
binary_path = ensure_binary()
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
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 or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
logger.debug(
"Launching persistent stealth Chromium (headless=%s, user_data_dir=%s)",
@@ -310,7 +317,12 @@ def launch_persistent_context(
context_kwargs: dict[str, Any] = {}
if user_agent:
context_kwargs["user_agent"] = user_agent
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
if viewport is _VIEWPORT_UNSET:
context_kwargs["viewport"] = DEFAULT_VIEWPORT
elif viewport is None:
context_kwargs["no_viewport"] = True
else:
context_kwargs["viewport"] = viewport
if color_scheme:
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs)
@@ -322,7 +334,7 @@ def launch_persistent_context(
headless=headless,
args=chrome_args,
ignore_default_args=IGNORE_DEFAULT_ARGS,
**_build_proxy_kwargs(proxy),
**proxy_kwargs,
**context_kwargs,
)
@@ -354,7 +366,7 @@ async def launch_persistent_context_async(
args: list[str] | None = None,
stealth_args: bool = True,
user_agent: str | None = None,
viewport: dict | None = None,
viewport: dict | None = _VIEWPORT_UNSET,
locale: str | None = None,
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
@@ -380,6 +392,7 @@ async def launch_persistent_context_async(
stealth_args: Include default stealth fingerprint args (default True).
user_agent: Custom user agent string.
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
Pass None to disable viewport emulation (use OS window size).
locale: Browser locale, e.g. "en-US".
timezone: IANA timezone (e.g. 'America/New_York').
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
@@ -412,11 +425,12 @@ async def launch_persistent_context_async(
binary_path = ensure_binary()
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
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 or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
logger.debug(
"Launching persistent stealth Chromium async (headless=%s, user_data_dir=%s)",
@@ -429,7 +443,12 @@ async def launch_persistent_context_async(
context_kwargs: dict[str, Any] = {}
if user_agent:
context_kwargs["user_agent"] = user_agent
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
if viewport is _VIEWPORT_UNSET:
context_kwargs["viewport"] = DEFAULT_VIEWPORT
elif viewport is None:
context_kwargs["no_viewport"] = True
else:
context_kwargs["viewport"] = viewport
if color_scheme:
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs)
@@ -441,7 +460,7 @@ async def launch_persistent_context_async(
headless=headless,
args=chrome_args,
ignore_default_args=IGNORE_DEFAULT_ARGS,
**_build_proxy_kwargs(proxy),
**proxy_kwargs,
**context_kwargs,
)
@@ -472,7 +491,7 @@ def launch_context(
args: list[str] | None = None,
stealth_args: bool = True,
user_agent: str | None = None,
viewport: dict | None = None,
viewport: dict | None = _VIEWPORT_UNSET,
locale: str | None = None,
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
@@ -495,6 +514,7 @@ def launch_context(
stealth_args: Include default stealth fingerprint args (default True).
user_agent: Custom user agent string.
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
Pass None to disable viewport emulation (use OS window size).
locale: Browser locale, e.g. "en-US".
timezone: IANA timezone (e.g. 'America/New_York').
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
@@ -527,7 +547,12 @@ def launch_context(
context_kwargs: dict[str, Any] = {}
if user_agent:
context_kwargs["user_agent"] = user_agent
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
if viewport is _VIEWPORT_UNSET:
context_kwargs["viewport"] = DEFAULT_VIEWPORT
elif viewport is None:
context_kwargs["no_viewport"] = True
else:
context_kwargs["viewport"] = viewport
if color_scheme:
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs)
@@ -610,14 +635,42 @@ def _ensure_proxy_scheme(proxy_url: str) -> str:
return proxy_url if "://" in proxy_url else f"http://{proxy_url}"
def _reconstruct_socks_url(proxy: ProxySettings) -> str:
"""Reconstruct a SOCKS5 URL with inline credentials from a Playwright proxy dict."""
server = proxy.get("server", "")
username = proxy.get("username", "")
password = proxy.get("password", "")
if not username:
return server
parsed = urlparse(server)
creds = quote(username, safe="")
if password:
creds += f":{quote(password, safe='')}"
host = parsed.hostname or ""
if ":" in host: # IPv6 literal — re-add brackets
host = f"[{host}]"
netloc = f"{creds}@{host}"
if parsed.port:
netloc += f":{parsed.port}"
return urlunparse((parsed.scheme, netloc, parsed.path, "", "", ""))
def _extract_proxy_url(proxy: str | ProxySettings | None) -> str | None:
"""Extract and normalize proxy URL string from proxy param."""
"""Extract and normalize proxy URL string from proxy param.
For SOCKS5 dicts with separate username/password fields, reconstructs
the full URL with inline credentials so SOCKS5 auth works.
"""
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)
if isinstance(proxy, dict):
server = proxy.get("server", "")
if not server:
return None
if _is_socks_proxy(proxy):
return _reconstruct_socks_url(proxy)
return _ensure_proxy_scheme(server)
return _ensure_proxy_scheme(proxy)
def maybe_resolve_geoip(
@@ -673,7 +726,7 @@ def _resolve_webrtc_args(
return args
proxy_url = _extract_proxy_url(proxy)
if not proxy_url:
logger.debug("--fingerprint-webrtc-ip=auto but no proxy set — removing flag")
logger.warning("--fingerprint-webrtc-ip=auto requires a proxy; removing flag")
args = list(args)
del args[idx]
return args
@@ -681,7 +734,7 @@ def _resolve_webrtc_args(
from .geoip import _resolve_exit_ip
exit_ip = _resolve_exit_ip(proxy_url)
except Exception:
logger.debug("WebRTC IP resolution failed — removing flag")
logger.warning("Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto")
args = list(args)
del args[idx]
return args
@@ -689,6 +742,7 @@ def _resolve_webrtc_args(
args = list(args)
args[idx] = f"--fingerprint-webrtc-ip={exit_ip}"
else:
logger.warning("Could not resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto")
args = list(args)
del args[idx]
return args
@@ -778,10 +832,41 @@ def _parse_proxy_url(proxy: str) -> dict[str, Any]:
return result
def _build_proxy_kwargs(proxy: str | ProxySettings | None) -> dict[str, Any]:
"""Build proxy kwargs for Playwright launch."""
def _is_socks_proxy(proxy: str | ProxySettings | None) -> bool:
"""Check if the proxy uses SOCKS5 protocol."""
if proxy is None:
return {}
return False
url = proxy.get("server", "") if isinstance(proxy, dict) else proxy
return url.lower().startswith(("socks5://", "socks5h://"))
def _resolve_proxy_config(
proxy: str | ProxySettings | None,
) -> tuple[dict[str, Any], list[str]]:
"""Resolve proxy into Playwright kwargs and Chrome args.
Playwright rejects SOCKS5 proxies with credentials in its proxy dict,
so SOCKS5 is passed via --proxy-server Chrome arg instead.
Returns:
(proxy_kwargs, extra_chrome_args) — one or both will be empty.
"""
if proxy is None:
return {}, []
if _is_socks_proxy(proxy):
# SOCKS5: bypass Playwright, pass directly to Chrome via --proxy-server.
# Chrome handles SOCKS5 auth natively from the URL.
if isinstance(proxy, dict):
url = _reconstruct_socks_url(proxy)
extra_args = [f"--proxy-server={url}"]
if proxy.get("bypass"):
extra_args.append(f"--proxy-bypass-list={proxy['bypass']}")
return {}, extra_args
# String URL — pass as-is (Chrome handles user:pass@ in the URL)
return {}, [f"--proxy-server={proxy}"]
# HTTP/HTTPS: use Playwright's proxy dict as before
if isinstance(proxy, dict):
return {"proxy": proxy}
return {"proxy": _parse_proxy_url(proxy)}
return {"proxy": proxy}, []
return {"proxy": _parse_proxy_url(proxy)}, []
+5 -14
View File
@@ -15,10 +15,10 @@ from ._version import __version__
# CHROMIUM_VERSION is the latest across all platforms (for display/reference).
# Use get_chromium_version() for the current platform's actual version.
# ---------------------------------------------------------------------------
CHROMIUM_VERSION = "145.0.7632.159.9"
CHROMIUM_VERSION = "146.0.7680.177.1"
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
"linux-x64": "145.0.7632.159.9",
"linux-x64": "146.0.7680.177.1",
"linux-arm64": "145.0.7632.159.7",
"darwin-arm64": "145.0.7632.109.2",
"darwin-x64": "145.0.7632.109.2",
@@ -48,26 +48,17 @@ def get_default_stealth_args() -> list[str]:
base = [
"--no-sandbox",
"--disable-blink-features=AutomationControlled",
f"--fingerprint={seed}",
]
if system == "Darwin":
# Tell the fingerprint patches we're on macOS so GPU/UA match natively
return base + [
"--fingerprint-platform=macos",
"--fingerprint-gpu-vendor=Google Inc. (Apple)",
"--fingerprint-gpu-renderer=ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)",
]
return base + ["--fingerprint-platform=macos"]
# Linux/Windows: Windows fingerprint profile
# Hardware concurrency, device memory, screen, and window size are
# Hardware concurrency, device memory, screen, window size, and GPU are
# auto-generated by the binary from the seed (v14+).
return base + [
"--fingerprint-platform=windows",
"--fingerprint-gpu-vendor=Google Inc. (NVIDIA)",
"--fingerprint-gpu-renderer=ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 (0x00002484) Direct3D11 vs_5_0 ps_5_0, D3D11)",
]
return base + ["--fingerprint-platform=windows"]
# ---------------------------------------------------------------------------
+8 -3
View File
@@ -96,7 +96,7 @@ def resolve_proxy_geo_with_ip(
)
return timezone, locale, ip
except Exception as exc:
logger.debug("GeoIP lookup failed for %s: %s", ip, exc)
logger.warning("GeoIP lookup failed for %s: %s", ip, exc)
return None, None, ip
@@ -132,7 +132,7 @@ def _resolve_proxy_ip(proxy_url: str) -> str | None:
return ip
return None
except Exception as exc:
logger.debug("Failed to resolve proxy hostname: %s", exc)
logger.warning("Failed to resolve proxy hostname: %s", exc)
return None
@@ -165,9 +165,14 @@ def _resolve_exit_ip(proxy_url: str) -> str | None:
ipaddress.ip_address(ip)
logger.debug("Exit IP via %s: %s", url, ip)
return ip
except httpx.UnsupportedProtocol:
logger.warning(
"SOCKS5 proxy requires socksio: pip install cloakbrowser[geoip]"
)
return None
except Exception:
continue
logger.debug("Failed to discover exit IP through proxy")
logger.warning("Failed to discover exit IP through proxy")
return None
+649
View File
@@ -900,6 +900,9 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
# --- Patch Frame-level methods (for sub-frames) ---
_patch_frames_sync(page, cfg, cursor, raw_mouse, raw_keyboard, originals)
# --- Patch ElementHandle selectors (query_selector, query_selector_all, wait_for_selector) ---
_patch_page_element_handles_sync(page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session)
# Initialize cursor immediately so it doesn't visibly jump from (0,0)
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1])
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1])
@@ -913,6 +916,273 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
_patch_locator_class_sync()
# ============================================================================
# SYNC ElementHandle patching
# ============================================================================
def _is_input_element_handle_sync(el: Any) -> bool:
"""Check if an ElementHandle is an input/textarea/contenteditable (sync)."""
try:
return el.evaluate(
"""(node) => {
const tag = node.tagName ? node.tagName.toLowerCase() : '';
return tag === 'input' || tag === 'textarea'
|| node.getAttribute && node.getAttribute('contenteditable') === 'true';
}"""
)
except Exception:
return False
def _patch_single_element_handle_sync(
el: Any, page: Any, cfg: HumanConfig, cursor: _CursorState,
raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any,
stealth: Any, cdp_session: Any,
) -> None:
"""Patch all interaction methods on a sync Playwright ElementHandle."""
if getattr(el, '_human_patched', False):
return
el._human_patched = True
# Save originals
_orig_click = el.click
_orig_dblclick = el.dblclick
_orig_hover = el.hover
_orig_type = el.type
_orig_fill = el.fill
_orig_press = el.press
_orig_select_option = el.select_option
_orig_check = el.check
_orig_uncheck = el.uncheck
_orig_set_checked = getattr(el, 'set_checked', None)
_orig_tap = el.tap
_orig_focus = el.focus
# Nested selectors
_orig_qs = el.query_selector
_orig_qsa = el.query_selector_all
_orig_wfs = el.wait_for_selector
def _patched_qs(selector: str, **kwargs: Any) -> Any:
child = _orig_qs(selector, **kwargs)
if child is not None:
_patch_single_element_handle_sync(
child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
)
return child
def _patched_qsa(selector: str, **kwargs: Any) -> Any:
children = _orig_qsa(selector, **kwargs)
for child in children:
_patch_single_element_handle_sync(
child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
)
return children
def _patched_wfs(selector: str, **kwargs: Any) -> Any:
child = _orig_wfs(selector, **kwargs)
if child is not None:
_patch_single_element_handle_sync(
child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
)
return child
el.query_selector = _patched_qs
el.query_selector_all = _patched_qsa
el.wait_for_selector = _patched_wfs
# Helper: move cursor to element
def _move_to_element():
if not cursor.initialized:
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1])
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1])
originals.mouse_move(cursor.x, cursor.y)
cursor.initialized = True
box = el.bounding_box()
if not box:
return None
is_inp = _is_input_element_handle_sync(el)
target = click_target(box, is_inp, cfg)
if cfg.idle_between_actions:
human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
cursor.x = target.x
cursor.y = target.y
return {'box': box, 'is_inp': is_inp}
# --- el.click() ---
def _human_el_click(**kwargs: Any) -> None:
info = _move_to_element()
if info is None:
return _orig_click(**kwargs)
human_click(raw_mouse, info['is_inp'], cfg)
# --- el.dblclick() ---
def _human_el_dblclick(**kwargs: Any) -> None:
info = _move_to_element()
if info is None:
return _orig_dblclick(**kwargs)
raw_mouse.down(click_count=2)
sleep_ms(rand(30, 60))
raw_mouse.up(click_count=2)
# --- el.hover() ---
def _human_el_hover(**kwargs: Any) -> None:
info = _move_to_element()
if info is None:
return _orig_hover(**kwargs)
# Just move, no click
# --- el.type() ---
def _human_el_type(text: str, **kwargs: Any) -> None:
info = _move_to_element()
if info is None:
return _orig_type(text, **kwargs)
human_click(raw_mouse, info['is_inp'], cfg)
sleep_ms(rand(100, 250))
human_type(page, raw_keyboard, text, cfg, cdp_session=cdp_session)
# --- el.fill() ---
def _human_el_fill(value: str, **kwargs: Any) -> None:
info = _move_to_element()
if info is None:
return _orig_fill(value, **kwargs)
human_click(raw_mouse, info['is_inp'], cfg)
sleep_ms(rand(100, 250))
originals.keyboard_press(_SELECT_ALL)
sleep_ms(rand(30, 80))
originals.keyboard_press("Backspace")
sleep_ms(rand(50, 150))
human_type(page, raw_keyboard, value, cfg, cdp_session=cdp_session)
# --- el.press() ---
def _human_el_press(key: str, **kwargs: Any) -> None:
sleep_ms(rand(20, 60))
originals.keyboard_down(key)
sleep_ms(rand_range(cfg.key_hold))
originals.keyboard_up(key)
# --- el.select_option() ---
def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any:
info = _move_to_element()
if info is None:
return _orig_select_option(value, **kwargs)
human_click(raw_mouse, False, cfg)
sleep_ms(rand(100, 300))
return _orig_select_option(value, **kwargs)
# --- el.check() ---
def _human_el_check(**kwargs: Any) -> None:
try:
if el.is_checked():
return
except Exception:
pass
info = _move_to_element()
if info is None:
return _orig_check(**kwargs)
human_click(raw_mouse, info['is_inp'], cfg)
# --- el.uncheck() ---
def _human_el_uncheck(**kwargs: Any) -> None:
try:
if not el.is_checked():
return
except Exception:
pass
info = _move_to_element()
if info is None:
return _orig_uncheck(**kwargs)
human_click(raw_mouse, info['is_inp'], cfg)
# --- el.set_checked() ---
def _human_el_set_checked(checked: bool, **kwargs: Any) -> None:
try:
current = el.is_checked()
if current == checked:
return
except Exception:
pass
info = _move_to_element()
if info is None and _orig_set_checked:
return _orig_set_checked(checked, **kwargs)
if info:
human_click(raw_mouse, info['is_inp'], cfg)
# --- el.tap() ---
def _human_el_tap(**kwargs: Any) -> None:
info = _move_to_element()
if info is None:
return _orig_tap(**kwargs)
human_click(raw_mouse, info['is_inp'], cfg)
# --- el.focus() ---
# FIX: move cursor humanly but use programmatic focus (no click side-effects).
# Stock Playwright el.focus() never clicks — it just calls element.focus() in JS.
# Clicking would trigger onclick, submit forms, navigate links, etc.
def _human_el_focus() -> None:
_move_to_element() # human-like cursor movement (Bézier)
_orig_focus() # programmatic focus, no click side-effects
el.click = _human_el_click
el.dblclick = _human_el_dblclick
el.hover = _human_el_hover
el.type = _human_el_type
el.fill = _human_el_fill
el.press = _human_el_press
el.select_option = _human_el_select_option
el.check = _human_el_check
el.uncheck = _human_el_uncheck
if _orig_set_checked is not None:
el.set_checked = _human_el_set_checked
el.tap = _human_el_tap
el.focus = _human_el_focus
def _patch_page_element_handles_sync(
page: Any, cfg: HumanConfig, cursor: _CursorState,
raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any,
stealth: Any, cdp_session: Any,
) -> None:
"""Patch page.query_selector, query_selector_all, wait_for_selector to return humanized ElementHandles (sync)."""
_orig_qs = page.query_selector
_orig_qsa = page.query_selector_all
_orig_wfs = page.wait_for_selector
def _patched_qs(selector: str, **kwargs: Any) -> Any:
el = _orig_qs(selector, **kwargs)
if el is not None:
_patch_single_element_handle_sync(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
)
return el
def _patched_qsa(selector: str, **kwargs: Any) -> Any:
els = _orig_qsa(selector, **kwargs)
for el in els:
_patch_single_element_handle_sync(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
)
return els
def _patched_wfs(selector: str, **kwargs: Any) -> Any:
el = _orig_wfs(selector, **kwargs)
if el is not None:
_patch_single_element_handle_sync(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
)
return el
page.query_selector = _patched_qs
page.query_selector_all = _patched_qsa
page.wait_for_selector = _patched_wfs
def _patch_frames_sync(
page: Any, cfg: HumanConfig, cursor: _CursorState,
raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any,
@@ -1023,6 +1293,58 @@ def _patch_single_frame_sync(
frame.clear = _frame_clear
frame.drag_and_drop = _frame_drag_and_drop
# --- Patch frame-level ElementHandle selectors ---
stealth_world = getattr(page, '_stealth_world', None)
cdp_session = None
if stealth_world is not None:
try:
cdp_session = stealth_world.get_cdp_session()
except Exception:
pass
_patch_frame_element_handles_sync(
frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth_world, cdp_session
)
def _patch_frame_element_handles_sync(
frame: Any, page: Any, cfg: HumanConfig, cursor: _CursorState,
raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any,
stealth: Any, cdp_session: Any,
) -> None:
"""Patch frame.query_selector, query_selector_all, wait_for_selector (sync)."""
_orig_qs = frame.query_selector
_orig_qsa = frame.query_selector_all
_orig_wfs = frame.wait_for_selector
def _patched_qs(selector: str, **kwargs: Any) -> Any:
el = _orig_qs(selector, **kwargs)
if el is not None:
_patch_single_element_handle_sync(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
)
return el
def _patched_qsa(selector: str, **kwargs: Any) -> Any:
els = _orig_qsa(selector, **kwargs)
for el in els:
_patch_single_element_handle_sync(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
)
return els
def _patched_wfs(selector: str, **kwargs: Any) -> Any:
el = _orig_wfs(selector, **kwargs)
if el is not None:
_patch_single_element_handle_sync(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
)
return el
frame.query_selector = _patched_qs
frame.query_selector_all = _patched_qsa
frame.wait_for_selector = _patched_wfs
def _iter_frames(page: Any):
try:
@@ -1108,6 +1430,7 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
stealth = _AsyncIsolatedWorld(page)
page._stealth_world = stealth
cdp_session_holder: list[Any] = [None] # mutable container for closure
page._cdp_session_holder = cdp_session_holder # expose for frame-level patching
async def _ensure_cdp() -> Any:
if cdp_session_holder[0] is None:
@@ -1265,10 +1588,289 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
# --- Patch Frame-level methods (for sub-frames) ---
_patch_frames_async(page, cfg, cursor, raw_mouse, raw_keyboard, originals)
# --- Patch ElementHandle selectors (query_selector, query_selector_all, wait_for_selector) ---
_patch_page_element_handles_async(page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder)
# --- Patch async Locator class (class-level, runs once) ---
_patch_locator_class_async()
# ============================================================================
# ASYNC ElementHandle patching
# ============================================================================
async def _async_is_input_element_handle(el: Any) -> bool:
"""Check if an ElementHandle is an input/textarea/contenteditable (async)."""
try:
return await el.evaluate(
"""(node) => {
const tag = node.tagName ? node.tagName.toLowerCase() : '';
return tag === 'input' || tag === 'textarea'
|| node.getAttribute && node.getAttribute('contenteditable') === 'true';
}"""
)
except Exception:
return False
def _patch_single_element_handle_async(
el: Any, page: Any, cfg: HumanConfig, cursor: _CursorState,
raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any,
stealth: Any, cdp_session_holder: Any,
) -> None:
"""Patch all interaction methods on an async Playwright ElementHandle."""
if getattr(el, '_human_patched', False):
return
el._human_patched = True
# Save originals
_orig_click = el.click
_orig_dblclick = el.dblclick
_orig_hover = el.hover
_orig_type = el.type
_orig_fill = el.fill
_orig_press = el.press
_orig_select_option = el.select_option
_orig_check = el.check
_orig_uncheck = el.uncheck
_orig_set_checked = getattr(el, 'set_checked', None)
_orig_tap = el.tap
_orig_focus = el.focus
# Nested selectors
_orig_qs = el.query_selector
_orig_qsa = el.query_selector_all
_orig_wfs = el.wait_for_selector
async def _patched_qs(selector: str, **kwargs: Any) -> Any:
child = await _orig_qs(selector, **kwargs)
if child is not None:
_patch_single_element_handle_async(
child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
)
return child
async def _patched_qsa(selector: str, **kwargs: Any) -> Any:
children = await _orig_qsa(selector, **kwargs)
for child in children:
_patch_single_element_handle_async(
child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
)
return children
async def _patched_wfs(selector: str, **kwargs: Any) -> Any:
child = await _orig_wfs(selector, **kwargs)
if child is not None:
_patch_single_element_handle_async(
child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
)
return child
el.query_selector = _patched_qs
el.query_selector_all = _patched_qsa
el.wait_for_selector = _patched_wfs
# Helper: move cursor to element (async)
async def _move_to_element():
if not cursor.initialized:
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1])
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1])
await originals.mouse_move(cursor.x, cursor.y)
cursor.initialized = True
box = await el.bounding_box()
if not box:
return None
is_inp = await _async_is_input_element_handle(el)
target = click_target(box, is_inp, cfg)
if cfg.idle_between_actions:
await async_human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
cursor.x = target.x
cursor.y = target.y
return {'box': box, 'is_inp': is_inp}
async def _get_cdp():
if cdp_session_holder[0] is None:
try:
cdp_session_holder[0] = await stealth.get_cdp_session()
except Exception:
pass
return cdp_session_holder[0]
# --- el.click() ---
async def _human_el_click(**kwargs: Any) -> None:
info = await _move_to_element()
if info is None:
return await _orig_click(**kwargs)
await async_human_click(raw_mouse, info['is_inp'], cfg)
# --- el.dblclick() ---
async def _human_el_dblclick(**kwargs: Any) -> None:
info = await _move_to_element()
if info is None:
return await _orig_dblclick(**kwargs)
await raw_mouse.down(click_count=2)
await async_sleep_ms(rand(30, 60))
await raw_mouse.up(click_count=2)
# --- el.hover() ---
async def _human_el_hover(**kwargs: Any) -> None:
info = await _move_to_element()
if info is None:
return await _orig_hover(**kwargs)
# --- el.type() ---
async def _human_el_type(text: str, **kwargs: Any) -> None:
info = await _move_to_element()
if info is None:
return await _orig_type(text, **kwargs)
await async_human_click(raw_mouse, info['is_inp'], cfg)
await async_sleep_ms(rand(100, 250))
cdp = await _get_cdp()
await async_human_type(page, raw_keyboard, text, cfg, cdp_session=cdp)
# --- el.fill() ---
async def _human_el_fill(value: str, **kwargs: Any) -> None:
info = await _move_to_element()
if info is None:
return await _orig_fill(value, **kwargs)
await async_human_click(raw_mouse, info['is_inp'], cfg)
await async_sleep_ms(rand(100, 250))
await originals.keyboard_press(_SELECT_ALL)
await async_sleep_ms(rand(30, 80))
await originals.keyboard_press("Backspace")
await async_sleep_ms(rand(50, 150))
cdp = await _get_cdp()
await async_human_type(page, raw_keyboard, value, cfg, cdp_session=cdp)
# --- el.press() ---
async def _human_el_press(key: str, **kwargs: Any) -> None:
await async_sleep_ms(rand(20, 60))
await originals.keyboard_down(key)
await async_sleep_ms(rand_range(cfg.key_hold))
await originals.keyboard_up(key)
# --- el.select_option() ---
async def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any:
info = await _move_to_element()
if info is None:
return await _orig_select_option(value, **kwargs)
await async_human_click(raw_mouse, False, cfg)
await async_sleep_ms(rand(100, 300))
return await _orig_select_option(value, **kwargs)
# --- el.check() ---
async def _human_el_check(**kwargs: Any) -> None:
try:
if await el.is_checked():
return
except Exception:
pass
info = await _move_to_element()
if info is None:
return await _orig_check(**kwargs)
await async_human_click(raw_mouse, info['is_inp'], cfg)
# --- el.uncheck() ---
async def _human_el_uncheck(**kwargs: Any) -> None:
try:
if not await el.is_checked():
return
except Exception:
pass
info = await _move_to_element()
if info is None:
return await _orig_uncheck(**kwargs)
await async_human_click(raw_mouse, info['is_inp'], cfg)
# --- el.set_checked() ---
async def _human_el_set_checked(checked: bool, **kwargs: Any) -> None:
try:
current = await el.is_checked()
if current == checked:
return
except Exception:
pass
info = await _move_to_element()
if info is None and _orig_set_checked:
return await _orig_set_checked(checked, **kwargs)
if info:
await async_human_click(raw_mouse, info['is_inp'], cfg)
# --- el.tap() ---
async def _human_el_tap(**kwargs: Any) -> None:
info = await _move_to_element()
if info is None:
return await _orig_tap(**kwargs)
await async_human_click(raw_mouse, info['is_inp'], cfg)
# --- el.focus() ---
# FIX: move cursor humanly but use programmatic focus (no click side-effects).
# Stock Playwright el.focus() never clicks — it just calls element.focus() in JS.
# Clicking would trigger onclick, submit forms, navigate links, etc.
async def _human_el_focus() -> None:
await _move_to_element() # human-like cursor movement (Bézier)
await _orig_focus() # programmatic focus, no click side-effects
el.click = _human_el_click
el.dblclick = _human_el_dblclick
el.hover = _human_el_hover
el.type = _human_el_type
el.fill = _human_el_fill
el.press = _human_el_press
el.select_option = _human_el_select_option
el.check = _human_el_check
el.uncheck = _human_el_uncheck
if _orig_set_checked is not None:
el.set_checked = _human_el_set_checked
el.tap = _human_el_tap
el.focus = _human_el_focus
def _patch_page_element_handles_async(
page: Any, cfg: HumanConfig, cursor: _CursorState,
raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any,
stealth: Any, cdp_session_holder: Any,
) -> None:
"""Patch page.query_selector, query_selector_all, wait_for_selector to return humanized ElementHandles (async)."""
_orig_qs = page.query_selector
_orig_qsa = page.query_selector_all
_orig_wfs = page.wait_for_selector
async def _patched_qs(selector: str, **kwargs: Any) -> Any:
el = await _orig_qs(selector, **kwargs)
if el is not None:
_patch_single_element_handle_async(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
)
return el
async def _patched_qsa(selector: str, **kwargs: Any) -> Any:
els = await _orig_qsa(selector, **kwargs)
for el in els:
_patch_single_element_handle_async(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
)
return els
async def _patched_wfs(selector: str, **kwargs: Any) -> Any:
el = await _orig_wfs(selector, **kwargs)
if el is not None:
_patch_single_element_handle_async(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
)
return el
page.query_selector = _patched_qs
page.query_selector_all = _patched_qsa
page.wait_for_selector = _patched_wfs
def _patch_frames_async(
page: Any, cfg: HumanConfig, cursor: _CursorState,
raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any,
@@ -1379,6 +1981,53 @@ def _patch_single_frame_async(
frame.clear = _frame_clear
frame.drag_and_drop = _frame_drag_and_drop
# --- Patch frame-level ElementHandle selectors (async) ---
stealth_world = getattr(page, '_stealth_world', None)
cdp_session_holder = getattr(page, '_cdp_session_holder', [None])
_patch_frame_element_handles_async(
frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth_world, cdp_session_holder
)
def _patch_frame_element_handles_async(
frame: Any, page: Any, cfg: HumanConfig, cursor: _CursorState,
raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any,
stealth: Any, cdp_session_holder: Any,
) -> None:
"""Patch frame.query_selector, query_selector_all, wait_for_selector (async)."""
_orig_qs = frame.query_selector
_orig_qsa = frame.query_selector_all
_orig_wfs = frame.wait_for_selector
async def _patched_qs(selector: str, **kwargs: Any) -> Any:
el = await _orig_qs(selector, **kwargs)
if el is not None:
_patch_single_element_handle_async(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
)
return el
async def _patched_qsa(selector: str, **kwargs: Any) -> Any:
els = await _orig_qsa(selector, **kwargs)
for el in els:
_patch_single_element_handle_async(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
)
return els
async def _patched_wfs(selector: str, **kwargs: Any) -> Any:
el = await _orig_wfs(selector, **kwargs)
if el is not None:
_patch_single_element_handle_async(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
)
return el
frame.query_selector = _patched_qs
frame.query_selector_all = _patched_qsa
frame.wait_for_selector = _patched_wfs
def patch_context_async(context: Any, cfg: HumanConfig) -> None:
cursor = _CursorState()
+1 -1
View File
@@ -228,7 +228,7 @@ def main():
print()
print("Launching stealth browser...", flush=True)
browser = launch(headless=not HEADED, proxy=PROXY)
browser = launch(headless=not HEADED, proxy=PROXY, geoip=True)
page = browser.new_page()
# Show browser fingerprint details
+8 -5
View File
@@ -63,10 +63,13 @@ await browser.close();
```javascript
import { launch, launchContext, launchPersistentContext } from 'cloakbrowser';
// With proxy
// With proxy (HTTP or SOCKS5)
const browser = await launch({
proxy: 'http://user:pass@proxy:8080',
});
const browser = await launch({
proxy: 'socks5://user:pass@proxy:1080',
});
// With proxy object (bypass, separate auth fields)
const browser = await launch({
@@ -203,15 +206,15 @@ const page = await browser.newPage();
| Platform | Chromium | Patches | Status |
|---|---|---|---|
| Linux x86_64 | 145 | 33 | ✅ Latest |
| Linux arm64 (RPi, Graviton) | 145 | 33 | ✅ Latest |
| Linux x86_64 | 145 | 48 | ✅ Latest |
| Linux arm64 (RPi, Graviton) | 145 | 48 | ✅ Latest |
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest |
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest |
| Windows x86_64 | 145 | 33 | ✅ Latest |
| Windows x86_64 | 145 | 48 | ✅ Latest |
## Requirements
- Node.js >= 18
- Node.js >= 20
- One of: `playwright-core` >= 1.40 or `puppeteer-core` >= 21
## Troubleshooting
+57 -9
View File
@@ -1,31 +1,36 @@
{
"name": "cloakbrowser",
"version": "0.3.9",
"version": "0.3.23",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cloakbrowser",
"version": "0.3.9",
"version": "0.3.23",
"license": "MIT",
"dependencies": {
"tar": "^7.0.0"
},
"bin": {
"cloakbrowser": "dist/cli.js"
},
"devDependencies": {
"@types/node": "^20.10.0",
"mmdb-lib": "^3.0.2",
"playwright-core": "^1.40.0",
"puppeteer-core": "^21.0.0",
"socks-proxy-agent": "^10.0.0",
"typescript": "^5.3.0",
"vitest": "^1.0.0"
},
"engines": {
"node": ">=18.0.0"
"node": ">=20.0.0"
},
"peerDependencies": {
"mmdb-lib": ">=2.0.0",
"playwright-core": ">=1.40.0",
"puppeteer-core": ">=21.0.0"
"puppeteer-core": ">=21.0.0",
"socks-proxy-agent": ">=8.0.0"
},
"peerDependenciesMeta": {
"mmdb-lib": {
@@ -36,6 +41,9 @@
},
"puppeteer-core": {
"optional": true
},
"socks-proxy-agent": {
"optional": true
}
}
},
@@ -2003,6 +2011,21 @@
"node": ">= 14"
}
},
"node_modules/pac-proxy-agent/node_modules/socks-proxy-agent": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "^4.3.4",
"socks": "^2.8.3"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/pac-resolver": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz",
@@ -2164,6 +2187,21 @@
"node": ">= 14"
}
},
"node_modules/proxy-agent/node_modules/socks-proxy-agent": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "^4.3.4",
"socks": "^2.8.3"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -2332,18 +2370,28 @@
}
},
"node_modules/socks-proxy-agent": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-10.0.0.tgz",
"integrity": "sha512-pyp2YR3mNxAMu0mGLtzs4g7O3uT4/9sQOLAKcViAkaS9fJWkud7nmaf6ZREFqQEi24IPkBcjfHjXhPTUWjo3uA==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"agent-base": "9.0.0",
"debug": "^4.3.4",
"socks": "^2.8.3"
},
"engines": {
"node": ">= 14"
"node": ">= 20"
}
},
"node_modules/socks-proxy-agent/node_modules/agent-base": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
"integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
}
},
"node_modules/source-map": {
+12 -3
View File
@@ -1,6 +1,6 @@
{
"name": "cloakbrowser",
"version": "0.3.20",
"version": "0.3.23",
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
"type": "module",
"main": "dist/index.js",
@@ -13,6 +13,10 @@
"./puppeteer": {
"types": "./dist/puppeteer.d.ts",
"import": "./dist/puppeteer.js"
},
"./human": {
"types": "./dist/human/index.d.ts",
"import": "./dist/human/index.js"
}
},
"bin": {
@@ -51,12 +55,13 @@
},
"homepage": "https://github.com/CloakHQ/cloakbrowser#javascript--nodejs",
"engines": {
"node": ">=18.0.0"
"node": ">=20.0.0"
},
"peerDependencies": {
"mmdb-lib": ">=2.0.0",
"playwright-core": ">=1.40.0",
"puppeteer-core": ">=21.0.0"
"puppeteer-core": ">=21.0.0",
"socks-proxy-agent": ">=10.0.0"
},
"peerDependenciesMeta": {
"playwright-core": {
@@ -67,6 +72,9 @@
},
"mmdb-lib": {
"optional": true
},
"socks-proxy-agent": {
"optional": true
}
},
"dependencies": {
@@ -75,6 +83,7 @@
"devDependencies": {
"@types/node": "^20.10.0",
"mmdb-lib": "^3.0.2",
"socks-proxy-agent": "^10.0.0",
"playwright-core": "^1.40.0",
"puppeteer-core": "^21.0.0",
"typescript": "^5.3.0",
+5 -16
View File
@@ -27,10 +27,10 @@ export { WRAPPER_VERSION };
// CHROMIUM_VERSION is the latest across all platforms (for display/reference).
// Use getChromiumVersion() for the current platform's actual version.
// ---------------------------------------------------------------------------
export const CHROMIUM_VERSION = "145.0.7632.159.9";
export const CHROMIUM_VERSION = "146.0.7680.177.1";
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
"linux-x64": "145.0.7632.159.9",
"linux-x64": "146.0.7680.177.1",
"linux-arm64": "145.0.7632.159.7",
"darwin-arm64": "145.0.7632.109.2",
"darwin-x64": "145.0.7632.109.2",
@@ -211,27 +211,16 @@ export function getDefaultStealthArgs(): string[] {
const base = [
"--no-sandbox",
"--disable-blink-features=AutomationControlled",
`--fingerprint=${seed}`,
];
if (isMac) {
// macOS: run as native Mac browser — GPU/UA match natively
return [
...base,
"--fingerprint-platform=macos",
"--fingerprint-gpu-vendor=Google Inc. (Apple)",
"--fingerprint-gpu-renderer=ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)",
];
return [...base, "--fingerprint-platform=macos"];
}
// Linux/Windows: spoof as Windows desktop
// Hardware concurrency, device memory, screen, and window size are
// Hardware concurrency, device memory, screen, window size, and GPU are
// auto-generated by the binary from the seed (v14+).
return [
...base,
"--fingerprint-platform=windows",
"--fingerprint-gpu-vendor=Google Inc. (NVIDIA)",
"--fingerprint-gpu-renderer=ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 (0x00002484) Direct3D11 vs_5_0 ps_5_0, D3D11)",
];
return [...base, "--fingerprint-platform=windows"];
}
+60 -8
View File
@@ -15,7 +15,7 @@ import dns from "node:dns/promises";
import net from "node:net";
import { getCacheDir } from "./config.js";
import type { LaunchOptions } from "./types.js";
import { ensureProxyScheme } from "./proxy.js";
import { ensureProxyScheme, isSocksProxy, reconstructSocksUrl, type ProxyDict } from "./proxy.js";
// P3TERX mirror of MaxMind GeoLite2-City — no license key needed
const GEOIP_DB_URL =
@@ -129,9 +129,44 @@ const IP_ECHO_URLS = [
];
async function resolveExitIp(proxyUrl: string): Promise<string | null> {
// Node.js fetch doesn't support proxy natively — use a CONNECT tunnel via http
// For simplicity, use a direct HTTP request to a plain-text IP echo service
// through the proxy using Node's http module
const isSocks = isSocksProxy(proxyUrl);
// SOCKS5: tunnel through the SOCKS5 proxy via socks-proxy-agent
if (isSocks) {
let SocksProxyAgent: typeof import("socks-proxy-agent").SocksProxyAgent;
try {
({ SocksProxyAgent } = await import("socks-proxy-agent"));
} catch {
console.warn("[cloakbrowser] socks-proxy-agent not installed — cannot resolve exit IP through SOCKS5 proxy. Install it: npm install socks-proxy-agent");
return null;
}
const { default: https } = await import("node:https");
const agent = new SocksProxyAgent(proxyUrl);
for (const echoUrl of IP_ECHO_URLS) {
try {
const ip = await new Promise<string | null>((resolve) => {
const req = https.request(echoUrl, { agent, timeout: 10_000 }, (res) => {
let data = "";
res.on("data", (chunk: Buffer) => (data += chunk.toString()));
res.on("end", () => {
const ip = data.trim();
resolve(net.isIP(ip) ? ip : null);
});
});
req.on("error", () => resolve(null));
req.on("timeout", () => { req.destroy(); resolve(null); });
req.end();
});
if (ip) return ip;
} catch {
continue;
}
}
return null;
}
// HTTP/HTTPS: use a CONNECT tunnel via http
try {
const { default: http } = await import("node:http");
const { default: https } = await import("node:https");
@@ -264,6 +299,22 @@ function maybeTriggerUpdate(dbPath: string): void {
downloadGeoipDb(dbPath).catch(() => {});
}
/**
* Extract a usable proxy URL from LaunchOptions.proxy.
* For SOCKS5 dicts with separate credentials, reconstructs the full URL
* with inline credentials so SOCKS5 auth works.
*/
function extractProxyUrl(proxy: string | ProxyDict | undefined): string | null {
if (!proxy) return null;
if (typeof proxy === "string") return ensureProxyScheme(proxy);
const p = proxy as ProxyDict;
if (!p.server) return null;
if (p.username && isSocksProxy(p)) {
return reconstructSocksUrl(p);
}
return ensureProxyScheme(p.server);
}
/**
* Auto-fill timezone/locale from proxy IP when geoip is enabled.
* Also returns exitIp as a free bonus (reused for WebRTC spoofing).
@@ -273,9 +324,8 @@ export async function maybeResolveGeoip(
): Promise<{ timezone?: string; locale?: string; exitIp?: string }> {
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
let proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
const proxyUrl = extractProxyUrl(options.proxy);
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
proxyUrl = ensureProxyScheme(proxyUrl);
// When both tz/locale are explicit, still resolve exit IP for WebRTC
if (options.timezone && options.locale) {
@@ -304,13 +354,13 @@ export async function resolveWebrtcArgs(
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;
const proxyUrl = extractProxyUrl(options.proxy);
if (!proxyUrl) {
console.warn("[cloakbrowser] --fingerprint-webrtc-ip=auto requires a proxy; removing flag");
const result = [...args];
result.splice(idx, 1);
return result;
}
proxyUrl = ensureProxyScheme(proxyUrl);
try {
const ip = await resolveExitIp(proxyUrl);
@@ -318,10 +368,12 @@ export async function resolveWebrtcArgs(
if (ip) {
result[idx] = `--fingerprint-webrtc-ip=${ip}`;
} else {
console.warn("[cloakbrowser] Could not resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto");
result.splice(idx, 1);
}
return result;
} catch {
console.warn("[cloakbrowser] Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto");
const result = [...args];
result.splice(idx, 1);
return result;
+913
View File
@@ -0,0 +1,913 @@
/**
* Human-like behavioral layer for cloakbrowser Puppeteer edition.
*
* Mirrors Playwright humanize architecture, adapted for Puppeteer API.
*
* Patches ALL native Puppeteer interaction surfaces:
*
* PAGE-LEVEL:
* click (with clickCount support for dblclick), hover, type,
* select, focus, tap, goto
*
* MOUSE:
* move, click (with clickCount support for dblclick), wheel,
* dragAndDrop
*
* KEYBOARD:
* type, down, up, press, sendCharacter
*
* FRAME-LEVEL:
* click, hover, type, select, focus, tap
* + $, $$, waitForSelector (return patched ElementHandles)
*
* ELEMENTHANDLE-LEVEL (Puppeteer-specific, no Playwright equivalent):
* click (with clickCount), hover, type, press, tap, select,
* focus, drop, dragAndDrop
* + $, $$, waitForSelector (nested elements are also patched)
*
* BROWSER-LEVEL:
* newPage, createBrowserContext / createIncognitoBrowserContext,
* targetcreated event
*
* Stealth-aware:
* - isInputElement / isSelectorFocused use CDP Isolated Worlds
* - Shift symbol typing uses CDP Input.dispatchKeyEvent (isTrusted=true)
* - ElementHandle isInput check uses CDP DOM.describeNode (no JS execution)
* - Falls back to page.evaluate only when CDP session is unavailable
*
* Puppeteer-specific adaptations:
* - page.createCDPSession() instead of context.newCDPSession(page)
* - page.viewport() instead of page.viewportSize()
* - page.$(selector) instead of page.locator(selector)
* - keyboard.sendCharacter() mapped via RawKeyboard.insertText
* - mouse.wheel({deltaX, deltaY}) object form adapted to (dx, dy)
* - page.select() instead of page.selectOption()
* - ElementHandle prototype patching (Puppeteer-only)
* - No page.dblclick() Puppeteer uses click({clickCount:2})
*/
import type { Browser, Page, Frame, CDPSession, ElementHandle, BrowserContext } from 'puppeteer-core';
import type { HumanConfig } from '../human/config.js';
import { resolveConfig, rand, randRange, sleep } from '../human/config.js';
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from '../human/mouse.js';
import { humanType } from './keyboard.js';
import { scrollToElement, smoothWheel } from './scroll.js';
export type { HumanConfig } from '../human/config.js';
export { resolveConfig } from '../human/config.js';
export { humanMove, humanClick, clickTarget, humanIdle } from '../human/mouse.js';
export { humanType } from './keyboard.js';
export { scrollToElement } from './scroll.js';
// ============================================================================
// CDP Isolated World — stealth DOM evaluation (Puppeteer version)
// ============================================================================
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.createCDPSession();
}
return this.cdp;
}
private async createWorld(): Promise<number> {
const cdp = await this.ensureCdp();
const tree = await cdp.send('Page.getFrameTree');
const frameId = (tree as any).frameTree.frame.id;
const result = await cdp.send('Page.createIsolatedWorld', {
frameId,
worldName: '',
grantUniveralAccess: true,
});
const ctxId = (result as any).executionContextId;
this.contextId = ctxId;
return ctxId;
}
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 as any).exceptionDetails) {
if (attempt === 0) {
await this.createWorld();
continue;
}
return undefined;
}
return (result as any).result?.value;
} catch {
if (attempt === 0) {
this.contextId = null;
try { await this.createWorld(); } catch { return undefined; }
continue;
}
return undefined;
}
}
return undefined;
}
invalidate(): void {
this.contextId = null;
}
async getCdpSession(): Promise<CDPSession> {
return this.ensureCdp();
}
}
// ============================================================================
// Cursor state
// ============================================================================
class CursorState {
x = 0;
y = 0;
initialized = false;
}
// ============================================================================
// Stealth DOM queries
// ============================================================================
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 { /* fallthrough */ }
}
return page.evaluate((sel: string) => {
const el = document.querySelector(sel);
if (!el) return false;
const tag = el.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea'
|| el.getAttribute('contenteditable') === 'true';
}, selector).catch(() => false);
}
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 { /* fallthrough */ }
}
return page.evaluate((sel: string) => {
const el = document.querySelector(sel);
return el === document.activeElement;
}, selector).catch(() => false);
}
// ============================================================================
// Stealth ElementHandle input check — uses CDP DOM.describeNode
// instead of el.evaluate() to avoid main-world JS execution.
// ============================================================================
async function isInputElementHandle(
stealth: StealthEval | null,
el: ElementHandle,
): Promise<boolean> {
if (stealth) {
try {
const cdp = await stealth.getCdpSession();
const remoteObject = (el as any).remoteObject?.();
if (remoteObject?.objectId) {
const { node } = await cdp.send('DOM.describeNode', {
objectId: remoteObject.objectId,
}) as any;
const tag = (node?.nodeName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea') return true;
const attrs: string[] = node?.attributes || [];
for (let i = 0; i < attrs.length; i += 2) {
if (attrs[i] === 'contenteditable' && attrs[i + 1] === 'true') {
return true;
}
}
return false;
}
} catch { /* fallthrough to el.evaluate */ }
}
return el.evaluate((node: any) => {
const tag = node.tagName?.toLowerCase();
return tag === 'input' || tag === 'textarea'
|| node.getAttribute?.('contenteditable') === 'true';
}).catch(() => false);
}
// ============================================================================
// Page-level patching
// ============================================================================
function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
const originals = {
click: page.click.bind(page),
hover: page.hover.bind(page),
type: page.type.bind(page),
select: page.select.bind(page),
focus: page.focus.bind(page),
goto: page.goto.bind(page),
tap: page.tap.bind(page),
mouseMove: page.mouse.move.bind(page.mouse),
mouseClick: page.mouse.click.bind(page.mouse),
mouseDown: page.mouse.down.bind(page.mouse),
mouseUp: page.mouse.up.bind(page.mouse),
mouseWheel: (page.mouse as any).wheel?.bind(page.mouse),
mouseDragAndDrop: (page.mouse as any).dragAndDrop?.bind(page.mouse),
keyboardType: page.keyboard.type.bind(page.keyboard),
keyboardDown: page.keyboard.down.bind(page.keyboard) as (key: string) => Promise<void>,
keyboardUp: page.keyboard.up.bind(page.keyboard) as (key: string) => Promise<void>,
keyboardPress: page.keyboard.press.bind(page.keyboard),
keyboardSendCharacter: page.keyboard.sendCharacter.bind(page.keyboard),
};
(page as any)._original = originals;
(page as any)._humanCfg = cfg;
const stealth = new StealthEval(page);
(page as any)._stealth = stealth;
let cdpSession: CDPSession | null = null;
const ensureCdp = async (): Promise<CDPSession | null> => {
if (!cdpSession) {
try { cdpSession = await stealth.getCdpSession(); } catch {}
}
return cdpSession;
};
const raw: RawMouse = {
move: originals.mouseMove,
down: originals.mouseDown,
up: originals.mouseUp,
wheel: async (deltaX: number, deltaY: number) => {
if (originals.mouseWheel) {
await originals.mouseWheel({ deltaX, deltaY });
}
},
};
const rawKb: RawKeyboard = {
down: originals.keyboardDown,
up: originals.keyboardUp,
type: originals.keyboardType,
insertText: originals.keyboardSendCharacter,
};
async function ensureCursorInit(): Promise<void> {
if (!cursor.initialized) {
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]);
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]);
await originals.mouseMove(cursor.x, cursor.y);
cursor.initialized = true;
}
}
// ==== goto ====
const humanGoto = async (url: string, options?: any) => {
const response = await originals.goto(url, options);
stealth.invalidate();
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
return response;
};
// ==== click (with clickCount support for dblclick) ====
const humanClickFn = async (selector: string, options?: any) => {
await ensureCursorInit();
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
}
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
cursor.x = cursorX;
cursor.y = cursorY;
const isInput = await isInputElement(stealth, page, selector);
const target = clickTarget(box, isInput, cfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
cursor.x = target.x;
cursor.y = target.y;
const clickCount = options?.clickCount ?? options?.count ?? 1;
if (clickCount >= 2) {
await humanClick(raw, isInput, cfg);
await sleep(rand(40, 90));
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
} else {
await humanClick(raw, isInput, cfg);
}
};
// ==== hover ====
const humanHoverFn = async (selector: string, options?: any) => {
await ensureCursorInit();
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
}
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
cursor.x = cursorX;
cursor.y = cursorY;
const target = clickTarget(box, false, cfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
cursor.x = target.x;
cursor.y = target.y;
};
// ==== type ====
const humanTypeFn = async (selector: string, text: string, options?: any) => {
await sleep(randRange(cfg.field_switch_delay));
await humanClickFn(selector);
await sleep(rand(100, 250));
const cdp = await ensureCdp();
await humanType(page, rawKb, text, cfg, cdp);
};
// ==== select ====
const humanSelectFn = async (selector: string, ...values: string[]) => {
await humanHoverFn(selector);
await sleep(rand(100, 300));
return originals.select(selector, ...values);
};
// ==== focus ====
const humanFocusFn = async (selector: string) => {
if (!await isSelectorFocused(stealth, page, selector)) {
await humanClickFn(selector);
}
};
// ==== tap ====
const humanTapFn = async (selector: string, options?: any) => {
await humanClickFn(selector, options);
};
// ============================================================
// Assign page-level patches
// ============================================================
(page as any).goto = humanGoto;
(page as any).click = humanClickFn;
(page as any).hover = humanHoverFn;
(page as any).type = humanTypeFn;
(page as any).select = humanSelectFn;
(page as any).focus = humanFocusFn;
(page as any).tap = humanTapFn;
// ============================================================
// Mouse patches
// ============================================================
page.mouse.move = async (x: number, y: number, options?: any) => {
await ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
cursor.x = x;
cursor.y = y;
};
page.mouse.click = async (x: number, y: number, options?: any) => {
await ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
cursor.x = x;
cursor.y = y;
const clickCount = options?.clickCount ?? options?.count ?? 1;
if (clickCount >= 2) {
await humanClick(raw, false, cfg);
await sleep(rand(40, 90));
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
} else {
await humanClick(raw, false, cfg);
}
};
if (originals.mouseWheel) {
(page.mouse as any).wheel = async (options?: { deltaX?: number; deltaY?: number }) => {
const dx = options?.deltaX ?? 0;
const dy = options?.deltaY ?? 0;
if (Math.abs(dy) > 0) {
await smoothWheel(raw, dy, cfg, 'y');
}
if (Math.abs(dx) > 0) {
await smoothWheel(raw, dx, cfg, 'x');
}
};
}
if (originals.mouseDragAndDrop) {
(page.mouse as any).dragAndDrop = async (
start: { x: number; y: number },
target: { x: number; y: number },
options?: any,
) => {
await ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, start.x, start.y, cfg);
cursor.x = start.x;
cursor.y = start.y;
await sleep(rand(100, 200));
await originals.mouseDown();
await sleep(rand(80, 150));
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
cursor.x = target.x;
cursor.y = target.y;
await sleep(rand(80, 150));
await originals.mouseUp();
};
}
// ============================================================
// Keyboard patches
// ============================================================
page.keyboard.type = async (text: string, options?: any) => {
const cdp = await ensureCdp();
await humanType(page, rawKb, text, cfg, cdp);
};
page.keyboard.press = async (key: any, options?: any) => {
await sleep(rand(20, 60));
await originals.keyboardDown(key as any);
await sleep(randRange(cfg.key_hold));
await originals.keyboardUp(key as any);
};
page.keyboard.down = async (key: any) => {
await sleep(rand(10, 30));
await originals.keyboardDown(key as any);
};
page.keyboard.up = async (key: any) => {
await sleep(rand(10, 30));
await originals.keyboardUp(key as any);
};
// ============================================================
// Store helpers for frame/element patching
// ============================================================
(page as any)._humanCursor = cursor;
(page as any)._humanRaw = raw;
(page as any)._humanRawKb = rawKb;
(page as any)._ensureCursorInit = ensureCursorInit;
// Initialize cursor
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]);
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]);
originals.mouseMove(cursor.x, cursor.y).then(() => {
cursor.initialized = true;
}).catch(() => {});
// Patch frames
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
// Patch ElementHandle selectors
patchElementHandle(page, cfg, cursor, raw, rawKb, originals, stealth);
}
// ============================================================================
// ElementHandle patching — PUPPETEER-SPECIFIC
// ============================================================================
function patchElementHandle(
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: StealthEval,
): void {
const orig$ = page.$.bind(page);
const orig$$ = page.$$.bind(page);
const origWaitForSelector = page.waitForSelector.bind(page);
(page as any).$ = async (selector: string) => {
const el = await orig$(selector);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
(page as any).$$ = async (selector: string) => {
const els = await orig$$(selector);
for (const el of els) {
patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
}
return els;
};
(page as any).waitForSelector = async (selector: string, options?: any) => {
const el = await origWaitForSelector(selector, options);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
}
function patchSingleElementHandle(
el: ElementHandle,
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: StealthEval,
): void {
if ((el as any)._humanPatched) return;
(el as any)._humanPatched = true;
const origElClick = el.click.bind(el);
const origElHover = el.hover.bind(el);
const origElType = el.type.bind(el);
const origElPress = (el as any).press?.bind(el);
const origElTap = (el as any).tap?.bind(el);
const origElFocus = (el as any).focus?.bind(el);
const origElDragAndDrop = (el as any).dragAndDrop?.bind(el);
const origElSelect = (el as any).select?.bind(el);
const origElDrop = (el as any).drop?.bind(el);
// --- Nested selectors ---
const origEl$ = el.$.bind(el);
const origEl$$ = el.$$.bind(el);
const origElWaitForSelector = el.waitForSelector.bind(el);
(el as any).$ = async (selector: string) => {
const child = await origEl$(selector);
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
return child;
};
(el as any).$$ = async (selector: string) => {
const children = await origEl$$(selector);
for (const child of children) {
patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
}
return children;
};
(el as any).waitForSelector = async (selector: string, options?: any) => {
const child = await origElWaitForSelector(selector, options);
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
return child;
};
// --- Helper: get box and move cursor ---
const moveToElement = async () => {
await (page as any)._ensureCursorInit();
const box = await el.boundingBox();
if (!box) return null;
const isInp = await isInputElementHandle(stealth, el);
const target = clickTarget(box, isInp, cfg);
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
}
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
cursor.x = target.x;
cursor.y = target.y;
return { box, isInp };
};
// --- el.click() ---
(el as any).click = async (options?: any) => {
const info = await moveToElement();
if (!info) return origElClick(options);
const clickCount = options?.clickCount ?? options?.count ?? 1;
if (clickCount >= 2) {
await humanClick(raw, info.isInp, cfg);
await sleep(rand(40, 90));
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
} else {
await humanClick(raw, info.isInp, cfg);
}
};
// --- el.hover() ---
(el as any).hover = async () => {
const info = await moveToElement();
if (!info) return origElHover();
};
// --- el.type() ---
(el as any).type = async (text: string, options?: any) => {
const info = await moveToElement();
if (!info) return origElType(text, options);
await humanClick(raw, info.isInp, cfg);
await sleep(rand(100, 250));
const cdp = await stealth.getCdpSession().catch(() => null);
await humanType(page, rawKb, text, cfg, cdp);
};
// --- el.press() ---
if (origElPress) {
(el as any).press = async (key: string, options?: any) => {
await sleep(rand(20, 60));
await originals.keyboardDown(key as any);
await sleep(randRange(cfg.key_hold));
await originals.keyboardUp(key as any);
};
}
// --- el.tap() ---
if (origElTap) {
(el as any).tap = async () => {
const info = await moveToElement();
if (!info) return origElTap();
await humanClick(raw, info.isInp, cfg);
};
}
// --- el.focus() ---
if (origElFocus) {
(el as any).focus = async () => {
const info = await moveToElement();
if (!info) return origElFocus();
await humanClick(raw, info.isInp, cfg);
};
}
// --- el.select() ---
if (origElSelect) {
(el as any).select = async (...values: string[]) => {
const info = await moveToElement();
if (!info) return origElSelect(...values);
await humanClick(raw, false, cfg);
await sleep(rand(100, 300));
return origElSelect(...values);
};
}
// --- el.drop() ---
if (origElDrop) {
(el as any).drop = async (draggable: ElementHandle, options?: any) => {
const srcBox = await draggable.boundingBox();
const tgtBox = await el.boundingBox();
if (srcBox && tgtBox) {
const sx = srcBox.x + srcBox.width / 2;
const sy = srcBox.y + srcBox.height / 2;
const tx = tgtBox.x + tgtBox.width / 2;
const ty = tgtBox.y + tgtBox.height / 2;
await (page as any)._ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, sx, sy, cfg);
cursor.x = sx;
cursor.y = sy;
await sleep(rand(100, 200));
await originals.mouseDown();
await sleep(rand(80, 150));
await humanMove(raw, cursor.x, cursor.y, tx, ty, cfg);
cursor.x = tx;
cursor.y = ty;
await sleep(rand(80, 150));
await originals.mouseUp();
} else {
return origElDrop(draggable, options);
}
};
}
// --- el.dragAndDrop() ---
if (origElDragAndDrop) {
(el as any).dragAndDrop = async (targetEl: ElementHandle, options?: any) => {
const srcBox = await el.boundingBox();
const tgtBox = await targetEl.boundingBox();
if (srcBox && tgtBox) {
const sx = srcBox.x + srcBox.width / 2;
const sy = srcBox.y + srcBox.height / 2;
const tx = tgtBox.x + tgtBox.width / 2;
const ty = tgtBox.y + tgtBox.height / 2;
await (page as any)._ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, sx, sy, cfg);
cursor.x = sx;
cursor.y = sy;
await sleep(rand(100, 200));
await originals.mouseDown();
await sleep(rand(80, 150));
await humanMove(raw, cursor.x, cursor.y, tx, ty, cfg);
cursor.x = tx;
cursor.y = ty;
await sleep(rand(80, 150));
await originals.mouseUp();
} else {
return origElDragAndDrop(targetEl, options);
}
};
}
}
// ============================================================================
// Frame-level patching — native Puppeteer Frame methods only
// Puppeteer Frame has: click, hover, type, select, focus, tap
// ============================================================================
function patchFrames(
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: StealthEval,
): void {
for (const frame of iterFrames(page)) {
patchSingleFrame(frame, page, cfg, cursor, raw, rawKb, originals, stealth);
}
}
function patchSingleFrame(
frame: Frame,
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: StealthEval,
): void {
if ((frame as any)._humanPatched) return;
(frame as any)._humanPatched = true;
const origFrameSelect = frame.select.bind(frame);
(frame as any).click = async (selector: string, options?: any) => {
await (page as any).click(selector, options);
};
(frame as any).hover = async (selector: string, options?: any) => {
await (page as any).hover(selector, options);
};
(frame as any).type = async (selector: string, text: string, options?: any) => {
await (page as any).type(selector, text, options);
};
(frame as any).select = async (selector: string, ...values: string[]) => {
await (page as any).hover(selector);
await sleep(rand(100, 300));
return origFrameSelect(selector, ...values);
};
(frame as any).focus = async (selector: string) => {
await (page as any).focus(selector);
};
(frame as any).tap = async (selector: string, options?: any) => {
await (page as any).click(selector, options);
};
// Patch frame.$() to return patched ElementHandles
const origFrame$ = frame.$.bind(frame);
const origFrame$$ = frame.$$.bind(frame);
const origFrameWaitForSelector = frame.waitForSelector.bind(frame);
(frame as any).$ = async (selector: string) => {
const el = await origFrame$(selector);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
(frame as any).$$ = async (selector: string) => {
const els = await origFrame$$(selector);
for (const el of els) {
patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
}
return els;
};
(frame as any).waitForSelector = async (selector: string, options?: any) => {
const el = await origFrameWaitForSelector(selector, options);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
}
function* iterFrames(page: Page): Generator<Frame> {
try {
const mainFrame = page.mainFrame();
yield mainFrame;
for (const child of mainFrame.childFrames()) {
yield child;
}
} catch {}
}
// ============================================================================
// Browser-level patching
// ============================================================================
export function patchBrowser(browser: Browser, cfg: HumanConfig): void {
browser.pages().then(pages => {
for (const page of pages) {
if (!(page as any)._original) {
patchPage(page, cfg, new CursorState());
}
}
}).catch(() => {});
const origNewPage = browser.newPage.bind(browser);
(browser as any).newPage = async () => {
const page = await origNewPage();
if (!(page as any)._original) {
patchPage(page, cfg, new CursorState());
}
return page;
};
// v21: createIncognitoBrowserContext
// v22+: createBrowserContext (renamed in puppeteer/puppeteer#11834)
for (const methodName of ['createBrowserContext', 'createIncognitoBrowserContext'] as const) {
if (typeof (browser as any)[methodName] === 'function') {
const origCreateContext = (browser as any)[methodName].bind(browser);
(browser as any)[methodName] = async (options?: any) => {
const context: BrowserContext = await origCreateContext(options);
const origCtxNewPage = context.newPage.bind(context);
(context as any).newPage = async () => {
const page = await origCtxNewPage();
if (!(page as any)._original) {
patchPage(page, cfg, new CursorState());
}
return page;
};
return context;
};
}
}
browser.on('targetcreated', async (target: any) => {
try {
if (target.type() === 'page') {
const page = await target.page();
if (page && !(page as any)._original) {
patchPage(page, cfg, new CursorState());
}
}
} catch {}
});
}
export { patchPage };
+187
View File
@@ -0,0 +1,187 @@
/**
* cloakbrowser-human Human-like keyboard input.
* Adapted for Puppeteer API.
*
* Changes from Playwright version:
* - Uses puppeteer-core Page/CDPSession types
* - keyboard.sendCharacter() mapped via RawKeyboard.insertText adapter
* - CDPSession obtained via page.createCDPSession()
*
* Stealth-aware: shift symbols use CDP Input.dispatchKeyEvent (isTrusted=true).
*/
import type { Page, CDPSession } from 'puppeteer-core';
import { RawKeyboard } from '../human/mouse.js';
import type { HumanConfig } from '../human/config.js';
import { rand, randRange, sleep } from '../human/config.js';
const SHIFT_SYMBOLS = new Set([
'@', '#', '!', '$', '%', '^', '&', '*', '(', ')',
'_', '+', '{', '}', '|', ':', '"', '<', '>', '?', '~',
]);
const NEARBY_KEYS: Record<string, string> = {
a: 'sqwz', b: 'vghn', c: 'xdfv', d: 'sfecx', e: 'wrsdf',
f: 'dgrtcv', g: 'fhtyb', h: 'gjybn', i: 'ujko', j: 'hkunm',
k: 'jloi', l: 'kop', m: 'njk', n: 'bhjm', o: 'iklp',
p: 'ol', q: 'wa', r: 'edft', s: 'awedxz', t: 'rfgy',
u: 'yhji', v: 'cfgb', w: 'qase', x: 'zsdc', y: 'tghu',
z: 'asx',
'1': '2q', '2': '13qw', '3': '24we', '4': '35er', '5': '46rt',
'6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p',
};
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',
};
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 {
const code = ch.codePointAt(0);
return code !== undefined && code < 128;
}
function getNearbyKey(ch: string): string {
const lower = ch.toLowerCase();
if (lower in NEARBY_KEYS) {
const neighbors = NEARBY_KEYS[lower];
const wrong = neighbors[Math.floor(Math.random() * neighbors.length)];
return ch === ch.toUpperCase() && ch !== ch.toLowerCase() ? wrong.toUpperCase() : wrong;
}
return ch;
}
function isUpperCase(ch: string): boolean {
return ch.length === 1 && ch >= 'A' && ch <= 'Z';
}
export async function humanType(
page: Page,
raw: RawKeyboard,
text: string,
cfg: HumanConfig,
cdpSession?: CDPSession | null,
): Promise<void> {
const chars = [...text];
for (let i = 0; i < chars.length; i++) {
const ch = chars[i];
// Non-ASCII → sendCharacter via insertText adapter
if (!isAscii(ch)) {
await sleep(randRange(cfg.key_hold));
await raw.insertText(ch);
if (i < chars.length - 1) await interCharDelay(cfg);
continue;
}
// Mistype
if (Math.random() < cfg.mistype_chance && /^[a-zA-Z0-9]$/.test(ch)) {
const wrong = getNearbyKey(ch);
await typeNormalChar(raw, wrong, cfg);
await sleep(randRange(cfg.mistype_delay_notice));
await raw.down('Backspace');
await sleep(randRange(cfg.key_hold));
await raw.up('Backspace');
await sleep(randRange(cfg.mistype_delay_correct));
}
if (isUpperCase(ch)) {
await typeShiftedChar(raw, ch, cfg);
} else if (SHIFT_SYMBOLS.has(ch)) {
await typeShiftSymbol(page, raw, ch, cfg, cdpSession);
} else {
await typeNormalChar(raw, ch, cfg);
}
if (i < chars.length - 1) await interCharDelay(cfg);
}
}
async function typeNormalChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
await raw.down(ch);
await sleep(randRange(cfg.key_hold));
await raw.up(ch);
}
async function typeShiftedChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay));
await raw.down(ch);
await sleep(randRange(cfg.key_hold));
await raw.up(ch);
await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift');
}
async function typeShiftSymbol(
page: Page,
raw: RawKeyboard,
ch: string,
cfg: HumanConfig,
cdpSession?: CDPSession | null,
): Promise<void> {
if (cdpSession) {
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,
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 {
await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay));
await raw.insertText(ch);
await page.evaluate((key: string) => {
const el = document.activeElement;
if (el) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
}
}, ch);
await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift');
}
}
async function interCharDelay(cfg: HumanConfig): Promise<void> {
if (Math.random() < cfg.typing_pause_chance) {
await sleep(randRange(cfg.typing_pause_range));
} else {
const delay = cfg.typing_delay + (Math.random() - 0.5) * 2 * cfg.typing_delay_spread;
await sleep(Math.max(10, delay));
}
}
+166
View File
@@ -0,0 +1,166 @@
/**
* cloakbrowser-human Human-like scrolling via mouse wheel events.
* Adapted for Puppeteer API.
*
* Changes from Playwright version:
* - page.viewport() instead of page.viewportSize()
* - page.$(selector) + el.boundingBox() instead of page.locator().boundingBox()
* - No timeout parameter on boundingBox()
*/
import type { Page } from 'puppeteer-core';
import type { HumanConfig } from '../human/config.js';
import { rand, randRange, randIntRange, sleep } from '../human/config.js';
import { RawMouse, humanMove } from '../human/mouse.js';
interface ElementBounds {
x: number;
y: number;
width: number;
height: number;
}
function isInViewport(
bounds: ElementBounds,
viewportHeight: number,
cfg: HumanConfig,
): boolean {
const topEdge = bounds.y;
const bottomEdge = bounds.y + bounds.height;
const zoneTop = viewportHeight * cfg.scroll_target_zone[0];
const zoneBottom = viewportHeight * cfg.scroll_target_zone[1];
return topEdge >= zoneTop && bottomEdge <= zoneBottom;
}
export async function smoothWheel(
raw: RawMouse,
delta: number,
cfg: HumanConfig,
axis: 'x' | 'y' = 'y',
): Promise<void> {
const absD = Math.abs(delta);
const sign = delta > 0 ? 1 : -1;
let sent = 0;
while (sent < absD) {
const stepSize = rand(20, 40);
const chunk = Math.min(stepSize, absD - sent);
const d = Math.round(chunk) * sign;
if (axis === 'x') {
await raw.wheel(d, 0);
} else {
await raw.wheel(0, d);
}
sent += chunk;
await sleep(rand(8, 20));
}
}
async function getElementBox(page: Page, selector: string): Promise<ElementBounds | null> {
try {
const el = await page.$(selector);
if (!el) return null;
const box = await el.boundingBox();
if (!box) return null;
return { x: box.x, y: box.y, width: box.width, height: box.height };
} catch {
return null;
}
}
export async function scrollToElement(
page: Page,
raw: RawMouse,
selector: string,
cursorX: number,
cursorY: number,
cfg: HumanConfig,
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
const viewport = page.viewport();
if (!viewport) throw new Error('Viewport size not available');
let box = await getElementBox(page, selector);
if (!box) {
await sleep(200);
box = await getElementBox(page, selector);
if (!box) throw new Error(`Element not found: ${selector}`);
}
if (isInViewport(box, viewport.height, cfg)) {
return { box, cursorX, cursorY };
}
// Move cursor into scroll area
const scrollAreaX = Math.round(viewport.width * rand(0.3, 0.7));
const scrollAreaY = Math.round(viewport.height * rand(0.3, 0.7));
await humanMove(raw, cursorX, cursorY, scrollAreaX, scrollAreaY, cfg);
cursorX = scrollAreaX;
cursorY = scrollAreaY;
await sleep(randRange(cfg.scroll_pre_move_delay));
// Calculate scroll distance
const targetY = viewport.height * rand(cfg.scroll_target_zone[0], cfg.scroll_target_zone[1]);
const elementCenter = box.y + box.height / 2;
const distanceToScroll = elementCenter - targetY;
const direction = distanceToScroll > 0 ? 1 : -1;
const absDistance = Math.abs(distanceToScroll);
const avgDelta = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2;
const totalClicks = Math.max(3, Math.ceil(absDistance / avgDelta));
const accelSteps = randIntRange(cfg.scroll_accel_steps);
const decelSteps = randIntRange(cfg.scroll_decel_steps);
let scrolled = 0;
for (let i = 0; i < totalClicks; i++) {
let delta: number;
let pause: number;
if (i < accelSteps) {
delta = rand(80, 100);
pause = randRange(cfg.scroll_pause_slow);
} else if (i >= totalClicks - decelSteps) {
delta = rand(60, 90);
pause = randRange(cfg.scroll_pause_slow);
} else {
delta = randRange(cfg.scroll_delta_base);
pause = randRange(cfg.scroll_pause_fast);
}
delta *= 1 + (Math.random() - 0.5) * 2 * cfg.scroll_delta_variance;
delta = Math.round(delta) * direction;
await smoothWheel(raw, delta, cfg);
scrolled += Math.abs(delta);
await sleep(pause);
if (i % 3 === 2 || i === totalClicks - 1) {
box = await getElementBox(page, selector);
if (box && isInViewport(box, viewport.height, cfg)) {
break;
}
}
if (scrolled >= absDistance * 1.1) break;
}
// Optional overshoot + correction
if (Math.random() < cfg.scroll_overshoot_chance) {
const overshootPx = Math.round(randRange(cfg.scroll_overshoot_px)) * direction;
await smoothWheel(raw, overshootPx, cfg);
await sleep(randRange(cfg.scroll_settle_delay));
const corrections = randIntRange([1, 2]);
for (let c = 0; c < corrections; c++) {
const corrDelta = Math.round(rand(40, 80)) * -direction;
await smoothWheel(raw, corrDelta, cfg);
await sleep(rand(100, 250));
}
}
await sleep(randRange(cfg.scroll_settle_delay));
box = await getElementBox(page, selector);
if (!box) throw new Error(`Element lost after scrolling: ${selector}`);
return { box, cursorX, cursorY };
}
+366
View File
@@ -0,0 +1,366 @@
/**
* ElementHandle humanization for Playwright.
*
* Mirrors Puppeteer's ElementHandle patching architecture.
* Patches page.$(), page.$$(), page.waitForSelector() to return humanized handles,
* and patches all interaction methods on each ElementHandle instance.
*
* Playwright ElementHandle methods patched:
* click, dblclick, hover, type, fill, press, selectOption,
* check, uncheck, setChecked, tap, focus
* + $, $$, waitForSelector (nested elements are also patched)
*
* Stealth-aware:
* - Uses CDP DOM.describeNode when available to check element type
* (no main-world JS execution)
* - Falls back to el.evaluate() only when CDP is unavailable
*/
import type { Page, Frame, ElementHandle, CDPSession } from 'playwright-core';
import type { HumanConfig } from './config.js';
import { rand, randRange, sleep } from './config.js';
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
import { humanType } from './keyboard.js';
// --- Platform-aware select-all shortcut ---
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
// ============================================================================
// Stealth ElementHandle input check — uses CDP DOM.describeNode
// ============================================================================
async function isInputElementHandle(
stealth: any, // StealthEval from index.ts
el: ElementHandle,
): Promise<boolean> {
// Try CDP DOM.describeNode first (no main-world JS execution)
if (stealth) {
try {
const cdp: CDPSession = await stealth.getCdpSession();
// Playwright exposes the JSHandle's internal preview via _objectId or similar
// We need the remote object ID. Try to get it via internal API.
const impl = (el as any)._impl ?? (el as any)._object ?? el;
const guid = (impl as any)._guid;
// Use el.evaluate as a reliable fallback within stealth context
// Playwright doesn't expose remoteObject directly like Puppeteer
} catch { /* fallthrough */ }
}
// Fallback: el.evaluate (works reliably in Playwright)
try {
return await el.evaluate((node: any) => {
const tag = node.tagName?.toLowerCase();
return tag === 'input' || tag === 'textarea'
|| node.getAttribute?.('contenteditable') === 'true';
});
} catch {
return false;
}
}
// ============================================================================
// CursorState type (matches index.ts)
// ============================================================================
interface CursorState {
x: number;
y: number;
initialized: boolean;
}
// ============================================================================
// Patch a single Playwright ElementHandle
// ============================================================================
export function patchSingleElementHandle(
el: ElementHandle,
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: any,
): void {
if ((el as any)._humanPatched) return;
(el as any)._humanPatched = true;
// Save originals
const origElClick = el.click.bind(el);
const origElDblclick = el.dblclick.bind(el);
const origElHover = el.hover.bind(el);
const origElType = el.type.bind(el);
const origElFill = el.fill.bind(el);
const origElPress = el.press.bind(el);
const origElSelectOption = el.selectOption.bind(el);
const origElCheck = el.check.bind(el);
const origElUncheck = el.uncheck.bind(el);
const origElSetChecked = (el as any).setChecked?.bind(el);
const origElTap = el.tap.bind(el);
const origElFocus = el.focus.bind(el);
// Nested selectors
const origEl$ = el.$.bind(el);
const origEl$$ = el.$$.bind(el);
const origElWaitForSelector = el.waitForSelector.bind(el);
// --- Nested elements are also patched ---
(el as any).$ = async (selector: string) => {
const child = await origEl$(selector);
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
return child;
};
(el as any).$$ = async (selector: string) => {
const children = await origEl$$(selector);
for (const child of children) {
patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
}
return children;
};
(el as any).waitForSelector = async (selector: string, options?: any) => {
const child = await origElWaitForSelector(selector, options);
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
return child;
};
// --- Helper: get bounding box and move cursor to element ---
const moveToElement = async () => {
// Ensure cursor is initialized
const ensureCursorInit = (page as any)._ensureCursorInit;
if (ensureCursorInit) await ensureCursorInit();
const box = await el.boundingBox();
if (!box) return null;
const isInp = await isInputElementHandle(stealth, el);
const target = clickTarget(box, isInp, cfg);
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
}
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
cursor.x = target.x;
cursor.y = target.y;
return { box, isInp };
};
// --- el.click() ---
(el as any).click = async (options?: any) => {
const info = await moveToElement();
if (!info) return origElClick(options);
await humanClick(raw, info.isInp, cfg);
};
// --- el.dblclick() ---
(el as any).dblclick = async (options?: any) => {
const info = await moveToElement();
if (!info) return origElDblclick(options);
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
};
// --- el.hover() ---
(el as any).hover = async (options?: any) => {
const info = await moveToElement();
if (!info) return origElHover(options);
// Just move — no click
};
// --- el.type() ---
(el as any).type = async (text: string, options?: any) => {
const info = await moveToElement();
if (!info) return origElType(text, options);
await humanClick(raw, info.isInp, cfg);
await sleep(rand(100, 250));
let cdpSession: CDPSession | null = null;
try { cdpSession = await stealth?.getCdpSession(); } catch {}
await humanType(page, rawKb, text, cfg, cdpSession);
};
// --- el.fill() ---
(el as any).fill = async (value: string, options?: any) => {
const info = await moveToElement();
if (!info) return origElFill(value, options);
await humanClick(raw, info.isInp, cfg);
await sleep(rand(100, 250));
// Clear existing content
await originals.keyboardPress(SELECT_ALL);
await sleep(rand(30, 80));
await originals.keyboardPress('Backspace');
await sleep(rand(50, 150));
let cdpSession: CDPSession | null = null;
try { cdpSession = await stealth?.getCdpSession(); } catch {}
await humanType(page, rawKb, value, cfg, cdpSession);
};
// --- el.press() ---
(el as any).press = async (key: string, options?: any) => {
await sleep(rand(20, 60));
await originals.keyboardDown(key);
await sleep(randRange(cfg.key_hold));
await originals.keyboardUp(key);
};
// --- el.selectOption() ---
(el as any).selectOption = async (values: any, options?: any) => {
const info = await moveToElement();
if (!info) return origElSelectOption(values, options);
await humanClick(raw, false, cfg);
await sleep(rand(100, 300));
return origElSelectOption(values, options);
};
// --- el.check() ---
(el as any).check = async (options?: any) => {
try {
const checked = await el.isChecked();
if (checked) return; // Already checked
} catch {}
const info = await moveToElement();
if (!info) return origElCheck(options);
await humanClick(raw, info.isInp, cfg);
};
// --- el.uncheck() ---
(el as any).uncheck = async (options?: any) => {
try {
const checked = await el.isChecked();
if (!checked) return; // Already unchecked
} catch {}
const info = await moveToElement();
if (!info) return origElUncheck(options);
await humanClick(raw, info.isInp, cfg);
};
// --- el.setChecked() ---
if (origElSetChecked) {
(el as any).setChecked = async (checked: boolean, options?: any) => {
try {
const current = await el.isChecked();
if (current === checked) return;
} catch {}
const info = await moveToElement();
if (!info) return origElSetChecked(checked, options);
await humanClick(raw, info.isInp, cfg);
};
}
// --- el.tap() ---
(el as any).tap = async (options?: any) => {
const info = await moveToElement();
if (!info) return origElTap(options);
await humanClick(raw, info.isInp, cfg);
};
// --- el.focus() ---
// Move cursor humanly but use programmatic focus (no click side-effects).
// Stock Playwright el.focus() never clicks — clicking would trigger onclick,
// submit forms, navigate links, etc.
(el as any).focus = async () => {
await moveToElement(); // human-like Bézier cursor movement
await origElFocus(); // programmatic focus, no click
};
}
// ============================================================================
// Page-level ElementHandle patching
// ============================================================================
export function patchPageElementHandles(
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: any,
): void {
// Patch page.$() — only if the method exists
if (typeof page.$ === 'function') {
const orig$ = page.$.bind(page);
(page as any).$ = async (selector: string) => {
const el = await orig$(selector);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
}
// Patch page.$$()
if (typeof page.$$ === 'function') {
const orig$$ = page.$$.bind(page);
(page as any).$$ = async (selector: string) => {
const els = await orig$$(selector);
for (const el of els) {
patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
}
return els;
};
}
// Patch page.waitForSelector()
if (typeof page.waitForSelector === 'function') {
const origWaitForSelector = page.waitForSelector.bind(page);
(page as any).waitForSelector = async (selector: string, options?: any) => {
const el = await origWaitForSelector(selector, options);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
}
}
// ============================================================================
// Frame-level ElementHandle patching
// ============================================================================
export function patchFrameElementHandles(
frame: Frame,
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: any,
): void {
// Patch frame.$() — only if the method exists
if (typeof frame.$ === 'function') {
const origFrame$ = frame.$.bind(frame);
(frame as any).$ = async (selector: string) => {
const el = await origFrame$(selector);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
}
// Patch frame.$$()
if (typeof frame.$$ === 'function') {
const origFrame$$ = frame.$$.bind(frame);
(frame as any).$$ = async (selector: string) => {
const els = await origFrame$$(selector);
for (const el of els) {
patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
}
return els;
};
}
// Patch frame.waitForSelector()
if (typeof frame.waitForSelector === 'function') {
const origFrameWaitForSelector = frame.waitForSelector.bind(frame);
(frame as any).waitForSelector = async (selector: string, options?: any) => {
const el = await origFrameWaitForSelector(selector, options);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
}
}
+28 -2
View File
@@ -12,6 +12,14 @@
* Patches all interaction methods:
* click, dblclick, hover, type, fill, check, uncheck, selectOption,
* press, pressSequentially, tap, dragTo, clear + Frame-level equivalents.
*
* ELEMENTHANDLE-LEVEL:
* click, dblclick, hover, type, fill, press, selectOption,
* check, uncheck, setChecked, tap, focus
* + $, $$, waitForSelector (nested elements are also patched)
*
* page.$(), page.$$(), page.waitForSelector() and Frame equivalents
* return patched ElementHandles automatically.
*/
import type { Browser, BrowserContext, Page, Frame, CDPSession } from 'playwright-core';
@@ -19,11 +27,13 @@ import { HumanConfig, resolveConfig, rand, randRange, sleep } from './config.js'
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
import { humanType } from './keyboard.js';
import { scrollToElement } from './scroll.js';
import { patchPageElementHandles, patchFrameElementHandles, patchSingleElementHandle } from './elementhandle.js';
export { HumanConfig, resolveConfig } from './config.js';
export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
export { humanType } from './keyboard.js';
export { scrollToElement } from './scroll.js';
export { patchSingleElementHandle } from './elementhandle.js';
// --- Platform-aware select-all shortcut (macOS uses Meta, others use Control) ---
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
@@ -440,6 +450,9 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
(page as any).uncheck = humanUncheckFn;
(page as any).selectOption = humanSelectOptionFn;
(page as any).press = humanPressFn;
(page as any).pressSequentially = humanPressSequentiallyFn;
(page as any).tap = humanTapFn;
(page as any).clear = humanClearFn;
// --- mouse patches ---
page.mouse.move = async (x: number, y: number, options?: any) => {
@@ -485,6 +498,9 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
// --- Patch Frame-level methods (for sub-frames) ---
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
// --- Patch ElementHandle selectors (page.$, page.$$, page.waitForSelector) ---
patchPageElementHandles(page, cfg, cursor, raw, rawKb, originals, stealth);
}
@@ -494,8 +510,8 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
/**
* Patch Frame methods so Locator-based calls go through humanization.
* All 11 methods patched: click, dblclick, hover, type, fill, check, uncheck,
* selectOption, press, clear, dragAndDrop.
* All 13 methods patched: click, dblclick, hover, type, fill, check, uncheck,
* selectOption, press, pressSequentially, tap, clear, dragAndDrop.
*/
function patchFrames(
page: Page,
@@ -508,6 +524,8 @@ function patchFrames(
): void {
for (const frame of iterFrames(page)) {
patchSingleFrame(frame, page, cfg, originals, stealth);
// Patch frame-level ElementHandle selectors ($, $$, waitForSelector)
patchFrameElementHandles(frame, page, cfg, cursor, raw, rawKb, originals, stealth);
}
}
@@ -563,6 +581,14 @@ function patchSingleFrame(
await (page as any).press(selector, key, options);
};
(frame as any).pressSequentially = async (selector: string, text: string, options?: any) => {
await (page as any).pressSequentially(selector, text, options);
};
(frame as any).tap = async (selector: string, options?: any) => {
await (page as any).tap(selector, options);
};
(frame as any).clear = async (selector: string, options?: any) => {
if (!await isSelectorFocused(stealth, page, selector)) {
await (page as any).click(selector);
+9 -11
View File
@@ -8,7 +8,7 @@ import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOption
import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js";
import { resolveProxyConfig } from "./proxy.js";
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
/** @internal Accept both timezone and timezoneId — either works, no warning. Exported for testing. */
@@ -39,20 +39,19 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy);
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 args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
const browser = await chromium.launch({
executablePath: binaryPath,
headless: options.headless ?? true,
args,
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
...(options.proxy
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
: {}),
...(proxyOption ? { proxy: proxyOption } : {}),
...options.launchOptions,
});
@@ -106,7 +105,7 @@ export async function launchContext(
try {
context = await browser.newContext({
...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport ?? DEFAULT_VIEWPORT,
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
});
} catch (err) {
@@ -164,11 +163,12 @@ export async function launchPersistentContext(
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy);
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 args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
// locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
// — NOT via Playwright context kwargs which use detectable CDP emulation.
@@ -177,11 +177,9 @@ export async function launchPersistentContext(
headless: options.headless ?? true,
args,
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
...(options.proxy
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
: {}),
...(proxyOption ? { proxy: proxyOption } : {}),
...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport ?? DEFAULT_VIEWPORT,
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
...options.launchOptions,
});
+59
View File
@@ -23,6 +23,65 @@ export function ensureProxyScheme(proxyUrl: string): string {
* Also handles: no credentials, URL-encoded special chars, socks5://, missing port,
* and bare proxy strings without a scheme (e.g. "user:pass@host:port" -> treated as http).
*/
/** Proxy dict shape accepted by Playwright/Puppeteer wrappers. */
export type ProxyDict = { server: string; bypass?: string; username?: string; password?: string };
/** Result of resolveProxyConfig — either Playwright dict OR Chrome arg, never both. */
export interface ProxyConfig {
/** Playwright proxy option (for HTTP proxies). */
proxyOption?: ParsedProxy;
/** Chrome CLI args (for SOCKS5 proxies, e.g. ["--proxy-server=socks5://..."]). */
proxyArgs: string[];
}
/**
* Check if a proxy uses the SOCKS5 protocol.
*/
export function isSocksProxy(proxy: string | ProxyDict | undefined | null): boolean {
if (!proxy) return false;
const url = typeof proxy === "string" ? proxy : proxy.server;
return /^socks5h?:\/\//i.test(url);
}
/**
* Reconstruct a SOCKS5 URL with inline credentials from a proxy dict.
*/
export function reconstructSocksUrl(proxy: ProxyDict): string {
const url = new URL(proxy.server);
if (proxy.username) {
url.username = encodeURIComponent(proxy.username);
if (proxy.password) url.password = encodeURIComponent(proxy.password);
}
return url.href.replace(/\/$/, "");
}
/**
* Resolve proxy into Playwright option and/or Chrome args.
*
* Playwright rejects SOCKS5 proxies with credentials in its proxy dict,
* so SOCKS5 is passed via --proxy-server Chrome arg instead.
*/
export function resolveProxyConfig(proxy: string | ProxyDict | undefined): ProxyConfig {
if (!proxy) return { proxyArgs: [] };
if (isSocksProxy(proxy)) {
// SOCKS5: bypass Playwright, pass directly to Chrome via --proxy-server.
if (typeof proxy === "string") {
return { proxyArgs: [`--proxy-server=${proxy}`] };
}
const socksUrl = reconstructSocksUrl(proxy);
const args = [`--proxy-server=${socksUrl}`];
if (proxy.bypass) args.push(`--proxy-bypass-list=${proxy.bypass}`);
return { proxyArgs: args };
}
// HTTP/HTTPS: use Playwright's proxy dict
if (typeof proxy === "string") {
return { proxyOption: parseProxyUrl(proxy), proxyArgs: [] };
}
return { proxyOption: proxy as ParsedProxy, proxyArgs: [] };
}
export function parseProxyUrl(proxy: string): ParsedProxy {
let url: URL;
// Bare format: "user:pass@host:port" — new URL() throws without a scheme.
+30 -17
View File
@@ -1,6 +1,7 @@
/**
* Puppeteer launch wrapper for cloakbrowser.
* Alternative to the Playwright wrapper for users who prefer Puppeteer.
* NOW WITH HUMANIZE SUPPORT humanize: true enables human-like
* mouse curves, keyboard timing, and scroll patterns (same as Playwright).
*/
import type { Browser } from "puppeteer-core";
@@ -8,7 +9,7 @@ import type { LaunchOptions } from "./types.js";
import { IGNORE_DEFAULT_ARGS } from "./config.js";
import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js";
import { isSocksProxy, parseProxyUrl, resolveProxyConfig } from "./proxy.js";
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
/**
@@ -17,11 +18,12 @@ import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
* @example
* ```ts
* import { launch } from 'cloakbrowser/puppeteer';
* const browser = await launch();
* * // With humanize — human-like mouse, keyboard, scroll
* const browser = await launch({ humanize: true });
* const page = await browser.newPage();
* await page.goto('https://bot.incolumitas.com');
* console.log(await page.title());
* await browser.close();
* await page.goto('[https://example.com](https://example.com)');
* await page.click('#login'); // Bézier curve mouse movement
* await page.type('#email', 'user@example.com'); // Per-character timing
* ```
*/
export async function launch(options: LaunchOptions = {}): Promise<Browser> {
@@ -30,31 +32,34 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const { exitIp, ...resolved } = (await maybeResolveGeoip(options)) ?? {};
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.
// Chromium's --proxy-server does NOT support inline credentials,
// so we strip them and use page.authenticate() instead.
// SOCKS5: Chrome supports inline credentials natively (RFC 1929 auth).
// HTTP: Chrome does NOT support inline credentials — strip them and
// use page.authenticate() for Proxy-Authorization headers instead.
let proxyAuth: { username: string; password: string } | undefined;
if (options.proxy) {
if (typeof options.proxy === "string") {
if (isSocksProxy(options.proxy)) {
// SOCKS5: pass full URL with credentials to Chrome directly
const { proxyArgs } = resolveProxyConfig(options.proxy);
args.push(...proxyArgs);
} else if (typeof options.proxy === "string") {
const { server, username, password } = parseProxyUrl(options.proxy);
args.push(`--proxy-server=${server}`);
if (username) {
proxyAuth = { username, password: password ?? "" };
}
} else {
// Strip any inline credentials from the server URL — Chromium's
// --proxy-server doesn't support them; use page.authenticate() instead.
const parsed = parseProxyUrl(options.proxy.server);
args.push(`--proxy-server=${parsed.server}`);
if (options.proxy.bypass) {
args.push(`--proxy-bypass-list=${options.proxy.bypass}`);
}
// Explicit username/password fields take precedence over inline creds
const username = options.proxy.username ?? parsed.username;
const password = options.proxy.password ?? parsed.password;
if (username) {
@@ -82,10 +87,18 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
};
}
// Human-like behavioral patching — FULL coverage, same as Playwright.
// This enables Bézier mouse movements, organic typing rhythms, and
// natural scrolling to bypass advanced anti-bot detection.
if (options.humanize) {
const { patchBrowser } = await import('./human-puppeteer/index.js');
const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig(
(options.humanPreset as any) ?? 'default',
options.humanConfig as any,
);
patchBrowser(browser, cfg);
}
return browser;
}
// ---------------------------------------------------------------------------
// Internal
// ---------------------------------------------------------------------------
+1 -1
View File
@@ -36,7 +36,7 @@ export interface LaunchContextOptions extends LaunchOptions {
/** Custom user agent string. */
userAgent?: string;
/** Viewport size. */
viewport?: { width: number; height: number };
viewport?: { width: number; height: number } | null;
/** Browser locale, e.g. "en-US". */
locale?: string;
/** IANA timezone — alias for `timezone`. Either works. */
+4 -3
View File
@@ -21,16 +21,17 @@ describe("config", () => {
const isMac = process.platform === "darwin";
expect(args).toContain("--no-sandbox");
expect(args).toContain("--disable-blink-features=AutomationControlled");
if (isMac) {
expect(args).toContain("--fingerprint-platform=macos");
// macOS: no hardware-concurrency or GPU spoofing (uses native values)
expect(args.some((a) => a.includes("hardware-concurrency"))).toBe(false);
} else {
expect(args).toContain("--fingerprint-platform=windows");
}
// GPU flags removed — binary auto-generates from seed + platform
expect(args.some((a) => a.includes("fingerprint-gpu-vendor"))).toBe(false);
expect(args.some((a) => a.includes("fingerprint-gpu-renderer"))).toBe(false);
// Should have a random fingerprint seed
const fingerprintArg = args.find((a) => a.startsWith("--fingerprint="));
expect(fingerprintArg).toBeDefined();
+427
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { resolveConfig, rand, randRange, sleep } from "../src/human/config.js";
import { humanMove, humanClick, clickTarget, humanIdle } from "../src/human/mouse.js";
import { patchPageElementHandles } from "../src/human/elementhandle.js";
// =========================================================================
// Config resolution
@@ -451,6 +452,89 @@ describe("module exports", () => {
});
});
// =========================================================================
// patchBrowser on CDP-connected browser (issue #126)
// =========================================================================
describe("patchBrowser CDP-connected workflow", () => {
it("patches existing pages on a browser with pre-existing contexts", async () => {
const { patchBrowser, resolveConfig } = await import("../src/human/index.js");
// Simulate a CDP-connected browser: it already has contexts and pages
const page = buildMockPage();
const context: any = {
pages: vi.fn(() => [page]),
on: vi.fn(),
newPage: vi.fn(async () => buildMockPage()),
addInitScript: vi.fn(async () => {}),
};
const browser: any = {
contexts: vi.fn(() => [context]),
newContext: vi.fn(async () => context),
newPage: vi.fn(async () => page),
};
const cfg = resolveConfig("default");
patchBrowser(browser, cfg);
// page should now have _original (proof it was patched)
expect((page as any)._original).toBeDefined();
expect((page as any)._original.click).toBeTypeOf("function");
expect((page as any)._original.fill).toBeTypeOf("function");
expect((page as any)._humanCfg).toBe(cfg);
});
it("patched click calls mouse.down (humanized path, not original)", async () => {
const { patchBrowser, resolveConfig } = await import("../src/human/index.js");
let downCalled = false;
const page = buildMockPage();
page.mouse.down = vi.fn(async () => { downCalled = true; });
const context: any = {
pages: vi.fn(() => [page]),
on: vi.fn(),
newPage: vi.fn(async () => buildMockPage()),
addInitScript: vi.fn(async () => {}),
};
const browser: any = {
contexts: vi.fn(() => [context]),
newContext: vi.fn(async () => context),
newPage: vi.fn(async () => page),
};
patchBrowser(browser, resolveConfig("default"));
// Click through the patched method — should go through humanize path
try { await (page as any).click("button"); } catch (_) {}
expect(downCalled).toBe(true);
}, 30000);
it("new contexts created after patchBrowser are also patched", async () => {
const { patchBrowser, resolveConfig } = await import("../src/human/index.js");
const newPage = buildMockPage();
const newContext: any = {
pages: vi.fn(() => [newPage]),
on: vi.fn(),
newPage: vi.fn(async () => buildMockPage()),
addInitScript: vi.fn(async () => {}),
};
const browser: any = {
contexts: vi.fn(() => []),
newContext: vi.fn(async () => newContext),
newPage: vi.fn(async () => newPage),
};
patchBrowser(browser, resolveConfig("default"));
// Create a new context via the patched newContext
const ctx = await browser.newContext();
// Pages in the new context should be patched
expect((newPage as any)._original).toBeDefined();
});
});
// =========================================================================
// Test helpers
// =========================================================================
@@ -606,6 +690,349 @@ describe("humanType non-ASCII", () => {
// =========================================================================
// ElementHandle patching (Playwright)
// =========================================================================
function buildMockElementHandle(overrides: Record<string, any> = {}): any {
const el: any = {
click: vi.fn(async () => {}),
dblclick: vi.fn(async () => {}),
hover: vi.fn(async () => {}),
type: vi.fn(async () => {}),
fill: vi.fn(async () => {}),
press: vi.fn(async () => {}),
selectOption: vi.fn(async () => {}),
check: vi.fn(async () => {}),
uncheck: vi.fn(async () => {}),
setChecked: vi.fn(async () => {}),
tap: vi.fn(async () => {}),
focus: vi.fn(async () => {}),
boundingBox: overrides.boundingBox ?? vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
evaluate: overrides.evaluate ?? vi.fn(async () => false),
isChecked: overrides.isChecked ?? vi.fn(async () => false),
$: vi.fn(async () => null),
$$: vi.fn(async () => []),
waitForSelector: vi.fn(async () => null),
_humanPatched: false,
};
return el;
}
describe("patchSingleElementHandle", () => {
it("marks element as patched", async () => {
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default");
const cursor = { x: 100, y: 100, initialized: true };
const raw = {
move: vi.fn(async () => {}),
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
wheel: vi.fn(async () => {}),
};
const rawKb = {
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
type: vi.fn(async () => {}),
insertText: vi.fn(async () => {}),
};
const originals = {
keyboardPress: vi.fn(async () => {}),
keyboardDown: vi.fn(async () => {}),
keyboardUp: vi.fn(async () => {}),
};
const el = buildMockElementHandle();
const page = buildMockPage();
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
expect(el._humanPatched).toBe(true);
});
it("el.click calls mouse.move and mouse.down/up (humanized path)", async () => {
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default", { idle_between_actions: false });
const cursor = { x: 50, y: 50, initialized: true };
let moveCount = 0;
let downCalled = false;
let upCalled = false;
const raw = {
move: vi.fn(async () => { moveCount++; }),
down: vi.fn(async () => { downCalled = true; }),
up: vi.fn(async () => { upCalled = true; }),
wheel: vi.fn(async () => {}),
};
const rawKb = {
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
type: vi.fn(async () => {}),
insertText: vi.fn(async () => {}),
};
const originals = {
keyboardPress: vi.fn(async () => {}),
keyboardDown: vi.fn(async () => {}),
keyboardUp: vi.fn(async () => {}),
};
const el = buildMockElementHandle();
const page = buildMockPage();
(page as any)._ensureCursorInit = vi.fn(async () => {});
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
await el.click();
expect(moveCount).toBeGreaterThan(0);
expect(downCalled).toBe(true);
expect(upCalled).toBe(true);
}, 30000);
it("el.hover calls mouse.move but NOT down/up", async () => {
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default", { idle_between_actions: false });
const cursor = { x: 50, y: 50, initialized: true };
let downCalled = false;
const raw = {
move: vi.fn(async () => {}),
down: vi.fn(async () => { downCalled = true; }),
up: vi.fn(async () => {}),
wheel: vi.fn(async () => {}),
};
const rawKb = {
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
type: vi.fn(async () => {}),
insertText: vi.fn(async () => {}),
};
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
const el = buildMockElementHandle();
const page = buildMockPage();
(page as any)._ensureCursorInit = vi.fn(async () => {});
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
await el.hover();
expect(raw.move).toHaveBeenCalled();
expect(downCalled).toBe(false);
}, 30000);
it("el.type triggers mouse move + click + keyboard events", async () => {
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default", { idle_between_actions: false, mistype_chance: 0 });
const cursor = { x: 50, y: 50, initialized: true };
const raw = {
move: vi.fn(async () => {}),
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
wheel: vi.fn(async () => {}),
};
const rawKb = {
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
type: vi.fn(async () => {}),
insertText: vi.fn(async () => {}),
};
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) }); // isInput = true
const page = buildMockPage();
(page as any)._ensureCursorInit = vi.fn(async () => {});
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
await el.type("abc");
expect(raw.move).toHaveBeenCalled();
expect(raw.down).toHaveBeenCalled(); // click to focus
expect(rawKb.down).toHaveBeenCalled(); // keyboard typing
}, 30000);
it("el.fill calls selectAll + backspace + type", async () => {
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default", { idle_between_actions: false, mistype_chance: 0 });
const cursor = { x: 50, y: 50, initialized: true };
const pressedKeys: string[] = [];
const raw = {
move: vi.fn(async () => {}),
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
wheel: vi.fn(async () => {}),
};
const rawKb = {
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
type: vi.fn(async () => {}),
insertText: vi.fn(async () => {}),
};
const originals = {
keyboardPress: vi.fn(async (key: string) => { pressedKeys.push(key); }),
keyboardDown: vi.fn(async () => {}),
keyboardUp: vi.fn(async () => {}),
};
const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) });
const page = buildMockPage();
(page as any)._ensureCursorInit = vi.fn(async () => {});
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
await el.fill("newtext");
const expected = process.platform === "darwin" ? "Meta+a" : "Control+a";
expect(pressedKeys).toContain(expected);
expect(pressedKeys).toContain("Backspace");
}, 30000);
it("no double patching", async () => {
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
const el = buildMockElementHandle();
const page = buildMockPage();
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
const firstClick = el.click;
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
expect(el.click).toBe(firstClick);
});
it("nested $() returns patched child handle", async () => {
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
const child = buildMockElementHandle();
const el = buildMockElementHandle();
el.$ = vi.fn(async () => child);
const page = buildMockPage();
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
const result = await el.$("span");
expect(result._humanPatched).toBe(true);
});
});
describe("patchPageElementHandles", () => {
it("page.$() returns patched ElementHandle", async () => {
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
const el = buildMockElementHandle();
const page = buildMockPage();
(page as any).$ = vi.fn(async () => el);
(page as any).$$ = vi.fn(async () => [el]);
(page as any).waitForSelector = vi.fn(async () => el);
patchPageElementHandles(page as any, cfg, cursor as any, raw, rawKb, originals, null);
const result = await (page as any).$("#test");
expect(result._humanPatched).toBe(true);
});
it("page.$$() returns all patched handles", async () => {
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
const el1 = buildMockElementHandle();
const el2 = buildMockElementHandle();
const page = buildMockPage();
(page as any).$ = vi.fn(async () => null);
(page as any).$$ = vi.fn(async () => [el1, el2]);
(page as any).waitForSelector = vi.fn(async () => null);
patchPageElementHandles(page as any, cfg, cursor as any, raw, rawKb, originals, null);
const results = await (page as any).$$("div");
expect(results[0]._humanPatched).toBe(true);
expect(results[1]._humanPatched).toBe(true);
});
it("page.waitForSelector() returns patched handle", async () => {
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
const el = buildMockElementHandle();
const page = buildMockPage();
(page as any).$ = vi.fn(async () => null);
(page as any).$$ = vi.fn(async () => []);
(page as any).waitForSelector = vi.fn(async () => el);
patchPageElementHandles(page as any, cfg, cursor as any, raw, rawKb, originals, null);
const result = await (page as any).waitForSelector("#test");
expect(result._humanPatched).toBe(true);
});
it("page.$() returns null when no element found (no crash)", async () => {
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
const page = buildMockPage();
(page as any).$ = vi.fn(async () => null);
(page as any).$$ = vi.fn(async () => []);
(page as any).waitForSelector = vi.fn(async () => null);
patchPageElementHandles(page as any, cfg, cursor as any, raw, rawKb, originals, null);
const result = await (page as any).$("#nonexistent");
expect(result).toBeNull();
});
});
describe("patchPage integrates ElementHandle patching", () => {
it("patchPage patches page.$ automatically", async () => {
const { patchPage } = await import("../src/human/index.js");
const el = buildMockElementHandle();
const page = buildMockPage();
(page as any).$ = vi.fn(async () => el);
(page as any).$$ = vi.fn(async () => []);
(page as any).waitForSelector = vi.fn(async () => null);
const cfg = resolveConfig("default");
const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any);
const result = await (page as any).$("#test");
expect(result._humanPatched).toBe(true);
});
});
function buildMockFrame(): any {
return {
click: vi.fn(async () => {}),
+90 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { parseProxyUrl } from "../src/proxy.js";
import { parseProxyUrl, isSocksProxy, resolveProxyConfig } from "../src/proxy.js";
import type { LaunchOptions } from "../src/types.js";
describe("parseProxyUrl", () => {
@@ -115,3 +115,92 @@ describe("bare proxy format (user:pass@host:port)", () => {
expect(parseProxyUrl("proxy:8080")).toEqual({ server: "proxy:8080" });
});
});
describe("isSocksProxy", () => {
it("detects socks5 string", () => {
expect(isSocksProxy("socks5://user:pass@host:1080")).toBe(true);
});
it("detects socks5h string", () => {
expect(isSocksProxy("socks5h://host:1080")).toBe(true);
});
it("case insensitive", () => {
expect(isSocksProxy("SOCKS5://host:1080")).toBe(true);
});
it("rejects http", () => {
expect(isSocksProxy("http://host:8080")).toBe(false);
});
it("detects socks5 dict", () => {
expect(isSocksProxy({ server: "socks5://host:1080" })).toBe(true);
});
it("rejects http dict", () => {
expect(isSocksProxy({ server: "http://host:8080" })).toBe(false);
});
it("returns false for undefined", () => {
expect(isSocksProxy(undefined)).toBe(false);
});
});
describe("resolveProxyConfig", () => {
it("returns empty for undefined", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig(undefined);
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual([]);
});
it("returns playwright dict for http string", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" });
expect(proxyArgs).toEqual([]);
});
it("returns playwright dict for http dict", () => {
const proxy = { server: "http://proxy:8080", bypass: ".example.com" };
const { proxyOption, proxyArgs } = resolveProxyConfig(proxy);
expect(proxyOption).toEqual(proxy);
expect(proxyArgs).toEqual([]);
});
it("returns chrome arg for socks5 string", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5://user:pass@host:1080");
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:pass@host:1080"]);
});
it("returns chrome arg for socks5 no auth", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5://host:1080");
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=socks5://host:1080"]);
});
it("returns chrome arg for socks5h string", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5h://user:pass@host:1080");
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=socks5h://user:pass@host:1080"]);
});
it("reconstructs URL from socks5 dict with auth", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig({
server: "socks5://host:1080",
username: "user",
password: "p@ss",
});
expect(proxyOption).toBeUndefined();
expect(proxyArgs.length).toBe(1);
expect(proxyArgs[0]).toContain("--proxy-server=socks5://user:p%40ss@host:1080");
});
it("includes bypass for socks5 dict", () => {
const { proxyArgs } = resolveProxyConfig({
server: "socks5://host:1080",
bypass: ".example.com",
});
expect(proxyArgs).toContain("--proxy-server=socks5://host:1080");
expect(proxyArgs).toContain("--proxy-bypass-list=.example.com");
});
});
+26
View File
@@ -22,6 +22,7 @@ describe("puppeteer launch", () => {
let mockBrowser: any;
beforeEach(async () => {
delete process.env.CLOAKBROWSER_BINARY_PATH;
puppeteerMock = await import("puppeteer-core");
mockBrowser = {
newPage: vi.fn().mockResolvedValue({
@@ -112,4 +113,29 @@ describe("puppeteer launch", () => {
expect(callArgs.args).toContain("--disable-gpu");
expect(callArgs.args).toContain("--no-first-run");
});
it("keeps SOCKS5 credentials in --proxy-server URL", async () => {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({ proxy: "socks5://user:pass@proxy:1080" });
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--proxy-server=socks5://user:pass@proxy:1080");
// Should NOT set up page.authenticate for SOCKS5
const page = await browser.newPage();
expect(page.authenticate).not.toHaveBeenCalled();
});
it("reconstructs SOCKS5 dict with auth into --proxy-server URL", async () => {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({
proxy: { server: "socks5://proxy:1080", username: "user", password: "p@ss" },
});
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--proxy-server=socks5://user:p%40ss@proxy:1080");
const page = await browser.newPage();
expect(page.authenticate).not.toHaveBeenCalled();
});
});
File diff suppressed because it is too large Load Diff
+103 -4
View File
@@ -831,6 +831,101 @@ describe("frame patching with stealth", () => {
});
// =========================================================================
// Page-level: pressSequentially, tap, clear are patched
// =========================================================================
describe("page-level pressSequentially, tap, clear patches", () => {
it("page.pressSequentially is replaced after patchPage", async () => {
const { patchPage } = await import("../src/human/index.js");
const page = buildMockPage();
const originalPressSeq = page.pressSequentially ?? (() => {});
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
expect(typeof (page as any).pressSequentially).toBe("function");
expect((page as any).pressSequentially).not.toBe(originalPressSeq);
});
it("page.tap is replaced after patchPage", async () => {
const { patchPage } = await import("../src/human/index.js");
const page = buildMockPage();
const originalTap = page.tap ?? (() => {});
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
expect(typeof (page as any).tap).toBe("function");
expect((page as any).tap).not.toBe(originalTap);
});
it("page.clear is replaced after patchPage", async () => {
const { patchPage } = await import("../src/human/index.js");
const page = buildMockPage();
const originalClear = page.clear ?? (() => {});
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
expect(typeof (page as any).clear).toBe("function");
expect((page as any).clear).not.toBe(originalClear);
});
});
// =========================================================================
// Frame-level: pressSequentially, tap are patched
// =========================================================================
describe("frame-level pressSequentially, tap patches", () => {
it("child frame has pressSequentially patched", async () => {
const { patchPage } = await import("../src/human/index.js");
const childFrame: any = {
click: vi.fn(async () => {}),
dblclick: vi.fn(async () => {}),
hover: vi.fn(async () => {}),
type: vi.fn(async () => {}),
fill: vi.fn(async () => {}),
check: vi.fn(async () => {}),
uncheck: vi.fn(async () => {}),
selectOption: vi.fn(async () => {}),
press: vi.fn(async () => {}),
pressSequentially: vi.fn(async () => {}),
tap: vi.fn(async () => {}),
clear: vi.fn(async () => {}),
dragAndDrop: vi.fn(async () => {}),
locator: vi.fn(() => ({
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
})),
childFrames: vi.fn(() => []),
};
const origPressSeq = childFrame.pressSequentially;
const origTap = childFrame.tap;
const mainFrame = {
...childFrame,
childFrames: vi.fn(() => [childFrame]),
};
const page = buildMockPage({ mainFrameReturn: mainFrame });
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
expect((childFrame as any)._humanPatched).toBe(true);
// pressSequentially and tap should be replaced with humanized versions
expect(childFrame.pressSequentially).not.toBe(origPressSeq);
expect(childFrame.tap).not.toBe(origTap);
expect(typeof childFrame.pressSequentially).toBe("function");
expect(typeof childFrame.tap).toBe("function");
});
});
// =========================================================================
// Non-ASCII text does NOT go through CDP shift symbol path
// =========================================================================
@@ -898,7 +993,8 @@ describeIfSlow("stealth browser: no evaluate leak on click", () => {
it("click() does not trigger querySelector from evaluate context", async () => {
const { launch } = await import("../src/index.js");
const browser = await launch({ headless: true, args: ['--humanize'] });
const browser = await launch({ headless: true, humanize: true });
const page = await browser.newPage();
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
@@ -932,7 +1028,8 @@ describeIfSlow("stealth browser: shift symbols isTrusted=true", () => {
it("'!' produces isTrusted=true keydown, not isTrusted=false", async () => {
const { launch } = await import("../src/index.js");
const browser = await launch({ headless: true, args: ['--humanize'] });
const browser = await launch({ headless: true, humanize: true });
const page = await browser.newPage();
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
@@ -972,7 +1069,8 @@ describeIfSlow("stealth browser: navigation invalidation", () => {
it("click works after navigation (isolated world re-created)", async () => {
const { launch } = await import("../src/index.js");
const browser = await launch({ headless: true, args: ['--humanize'] });
const browser = await launch({ headless: true, humanize: true });
const page = await browser.newPage();
expect((page as any)._stealth).toBeDefined();
@@ -1003,7 +1101,8 @@ describeIfSlow("stealth browser: full form no evaluate leak", () => {
it("form with shift symbols has zero evaluate leaks and zero untrusted events", async () => {
const { launch } = await import("../src/index.js");
const browser = await launch({ headless: true, args: ['--humanize'] });
const browser = await launch({ headless: true, humanize: true });
const page = await browser.newPage();
await page.goto(
+1 -1
View File
@@ -54,7 +54,7 @@ dependencies = [
]
[project.optional-dependencies]
geoip = ["geoip2>=4.0"]
geoip = ["geoip2>=4.0", "socksio>=1.0"] # socksio: SOCKS5 transport for httpx
patchright = ["patchright>=1.40"]
serve = ["aiohttp>=3.9", "websockets>=12.0"]
dev = ["pytest>=7.0", "pytest-asyncio>=0.23"]
+4 -2
View File
@@ -105,8 +105,10 @@ class TestParseCliArgs:
def test_passthrough_args(self):
args = ["--no-sandbox", "--disable-gpu", "--fingerprint=999"]
_, passthrough = parse_cli_args(args)
assert passthrough == args
config, passthrough = parse_cli_args(args)
# --fingerprint=999 is consumed into config["default_seed"], not passed through
assert passthrough == ["--no-sandbox", "--disable-gpu"]
assert config["default_seed"] == "999"
def test_port_not_in_passthrough(self):
_, passthrough = parse_cli_args(["--port=9222", "--no-sandbox"])
+6 -2
View File
@@ -133,10 +133,14 @@ class TestStealthArgs:
with patch("cloakbrowser.config.platform.system", return_value="Darwin"):
args = get_default_stealth_args()
assert "--fingerprint-platform=macos" in args
assert any("Apple" in a for a in args)
# GPU flags removed — binary auto-generates from seed + platform
assert not any("fingerprint-gpu-vendor" in a for a in args)
assert not any("fingerprint-gpu-renderer" in a for a in args)
def test_linux_windows_profile(self):
with patch("cloakbrowser.config.platform.system", return_value="Linux"):
args = get_default_stealth_args()
assert "--fingerprint-platform=windows" in args
assert any("NVIDIA" in a for a in args)
# GPU flags removed — binary auto-generates from seed + platform
assert not any("fingerprint-gpu-vendor" in a for a in args)
assert not any("fingerprint-gpu-renderer" in a for a in args)
+61
View File
@@ -279,6 +279,67 @@ if __name__ == "__main__":
check("keyboard.type", kb_ms > 500, f"{kb_ms} ms")
time.sleep(1)
# ============================================================
# SCENARIO 7: ElementHandle — query_selector interactions
# ============================================================
step("ElementHandle — query_selector click, type, fill, hover")
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
time.sleep(2)
inject(page)
time.sleep(1)
print(" Watch: get element via query_selector, cursor moves smoothly")
el = page.query_selector('#searchInput')
assert el is not None, "query_selector returned None"
assert getattr(el, '_human_patched', False), "ElementHandle not patched!"
t0 = time.time()
el.click()
eh_click_ms = int((time.time() - t0) * 1000)
check("ElementHandle click", eh_click_ms > 100, f"{eh_click_ms} ms")
time.sleep(0.5)
print(" Watch: ElementHandle type — characters appear one by one")
t0 = time.time()
el.type('ElementHandle typing')
eh_type_ms = int((time.time() - t0) * 1000)
val = page.locator('#searchInput').input_value()
check("ElementHandle type", val == 'ElementHandle typing' and eh_type_ms > 1500, f"{eh_type_ms} ms, value='{val}'")
time.sleep(0.5)
print(" Watch: ElementHandle fill — clears then types")
t0 = time.time()
el.fill('Filled via EH')
eh_fill_ms = int((time.time() - t0) * 1000)
val = page.locator('#searchInput').input_value()
check("ElementHandle fill", val == 'Filled via EH' and eh_fill_ms > 1000, f"{eh_fill_ms} ms, value='{val}'")
time.sleep(0.5)
print(" Watch: ElementHandle hover — cursor moves without clicking")
btn_el = page.query_selector('button[type="submit"]')
t0 = time.time()
btn_el.hover()
eh_hover_ms = int((time.time() - t0) * 1000)
check("ElementHandle hover", eh_hover_ms > 50, f"{eh_hover_ms} ms")
time.sleep(0.5)
print(" Watch: query_selector_all returns patched handles")
page.goto('https://the-internet.herokuapp.com/checkboxes', wait_until='domcontentloaded')
time.sleep(2)
inject(page)
time.sleep(1)
els = page.query_selector_all('input[type="checkbox"]')
all_patched = all(getattr(e, '_human_patched', False) for e in els)
check("query_selector_all all patched", all_patched and len(els) >= 2, f"{len(els)} elements, all_patched={all_patched}")
if els:
print(" Watch: click checkbox via ElementHandle")
t0 = time.time()
els[0].click()
cb_click_ms = int((time.time() - t0) * 1000)
check("ElementHandle checkbox click", cb_click_ms > 100, f"{cb_click_ms} ms")
time.sleep(1)
# ============================================================
# SUMMARY
# ============================================================
+634 -26
View File
@@ -12,7 +12,7 @@ Can also run directly: python tests/test_humanize_unit.py
import math
import time
import sys
import asyncio
import pytest
@@ -535,7 +535,7 @@ class TestNonAsciiKeyboardAsync:
class TestBrowserFill:
def test_fill_clears_existing(self):
from cloakbrowser import launch
browser = launch(headless=True, humanize=True)
browser = launch(headless=False, humanize=True)
page = browser.new_page()
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
time.sleep(1)
@@ -550,7 +550,7 @@ class TestBrowserFill:
def test_fill_timing_humanized(self):
from cloakbrowser import launch
browser = launch(headless=True, humanize=True)
browser = launch(headless=False, humanize=True)
page = browser.new_page()
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
time.sleep(1)
@@ -562,7 +562,7 @@ class TestBrowserFill:
def test_clear_empties_field(self):
from cloakbrowser import launch
browser = launch(headless=True, humanize=True)
browser = launch(headless=False, humanize=True)
page = browser.new_page()
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
time.sleep(1)
@@ -579,7 +579,7 @@ class TestBrowserFill:
class TestBrowserPatching:
def test_page_has_original(self):
from cloakbrowser import launch
browser = launch(headless=True, humanize=True)
browser = launch(headless=False, humanize=True)
page = browser.new_page()
assert hasattr(page, '_original')
assert hasattr(page, '_human_cfg')
@@ -587,7 +587,7 @@ class TestBrowserPatching:
def test_locator_methods_patched(self):
from cloakbrowser import launch
browser = launch(headless=True, humanize=True)
browser = launch(headless=False, humanize=True)
page = browser.new_page()
from playwright.sync_api._generated import Locator
methods = ['fill', 'click', 'type', 'dblclick', 'hover', 'check', 'uncheck',
@@ -608,7 +608,7 @@ class TestBrowserPatching:
def test_page_human_cfg_persists(self):
from cloakbrowser import launch
browser = launch(headless=True, humanize=True)
browser = launch(headless=False, humanize=True)
page = browser.new_page()
assert page._human_cfg is not None
assert hasattr(page._human_cfg, 'idle_between_actions')
@@ -618,7 +618,7 @@ class TestBrowserPatching:
@pytest.mark.slow
class TestBrowserBotDetection:
PROXY = ''
PROXY = None
def test_behavioral_checks_pass(self):
from cloakbrowser import launch
@@ -644,7 +644,7 @@ class TestBrowserBotDetection:
def test_form_timing(self):
from cloakbrowser import launch
browser = launch(headless=True, humanize=True, proxy=self.PROXY, geoip=True)
browser = launch(headless=False, humanize=True, proxy=self.PROXY, geoip=True)
page = browser.new_page()
page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions',
wait_until='domcontentloaded')
@@ -661,31 +661,639 @@ class TestBrowserBotDetection:
@pytest.mark.slow
class TestAsyncEndToEnd:
def test_async_launch_click_fill(self):
@pytest.mark.asyncio
async def test_async_launch_click_fill(self):
"""launch_async(humanize=True) — async page.click and page.fill work end-to-end."""
import asyncio
from cloakbrowser import launch_async
browser = await launch_async(headless=False, humanize=True)
page = await browser.new_page()
assert hasattr(page, '_original'), "async page not patched"
assert hasattr(page, '_human_cfg'), "async page missing _human_cfg"
async def _run():
browser = await launch_async(headless=True, humanize=True)
page = await browser.new_page()
assert hasattr(page, '_original'), "async page not patched"
assert hasattr(page, '_human_cfg'), "async page missing _human_cfg"
await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
await asyncio.sleep(1)
await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
await asyncio.sleep(1)
t0 = time.time()
await page.locator('#searchInput').fill('async test')
elapsed_ms = int((time.time() - t0) * 1000)
assert elapsed_ms > 500, f"async fill too fast: {elapsed_ms}ms"
t0 = time.time()
await page.locator('#searchInput').fill('async test')
elapsed_ms = int((time.time() - t0) * 1000)
assert elapsed_ms > 500, f"async fill too fast: {elapsed_ms}ms"
val = await page.locator('#searchInput').input_value()
assert val == 'async test', f"async fill wrong value: {val}"
val = await page.locator('#searchInput').input_value()
assert val == 'async test', f"async fill wrong value: {val}"
await browser.close()
await browser.close()
asyncio.run(_run())
# =========================================================================
# 12. ElementHandle patching — SYNC
# =========================================================================
class TestElementHandlePatchingSync:
"""Test that ElementHandle objects returned by query_selector etc. are humanized."""
def test_patch_single_element_handle_marks_patched(self):
from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", None)
cursor = _CursorState()
cursor.initialized = True
cursor.x = 100
cursor.y = 100
page = MagicMock()
page._original = MagicMock()
el = MagicMock()
el._human_patched = False
el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
el.evaluate = MagicMock(return_value=True) # is_input
el.is_checked = MagicMock(return_value=False)
el.query_selector = MagicMock(return_value=None)
el.query_selector_all = MagicMock(return_value=[])
el.wait_for_selector = MagicMock(return_value=None)
raw_mouse = MagicMock()
raw_keyboard = MagicMock()
_patch_single_element_handle_sync(
el, page, cfg, cursor, raw_mouse, raw_keyboard, page._original, None, None
)
assert el._human_patched is True
def test_element_handle_click_calls_human_move(self):
from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", {"idle_between_actions": False})
cursor = _CursorState()
cursor.initialized = True
cursor.x = 100
cursor.y = 100
page = MagicMock()
page._original = MagicMock()
el = MagicMock()
el._human_patched = False
el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
el.evaluate = MagicMock(return_value=False)
el.is_checked = MagicMock(return_value=False)
el.query_selector = MagicMock(return_value=None)
el.query_selector_all = MagicMock(return_value=[])
el.wait_for_selector = MagicMock(return_value=None)
raw_mouse = MagicMock()
raw_mouse.move = MagicMock()
raw_mouse.down = MagicMock()
raw_mouse.up = MagicMock()
raw_mouse.wheel = MagicMock()
raw_keyboard = MagicMock()
_patch_single_element_handle_sync(
el, page, cfg, cursor, raw_mouse, raw_keyboard, page._original, None, None
)
# Call the patched click
el.click()
# Should call raw_mouse.move (Bezier path) and then down/up
assert raw_mouse.move.called
assert raw_mouse.down.called
assert raw_mouse.up.called
def test_element_handle_hover_moves_cursor_without_click(self):
from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", {"idle_between_actions": False})
cursor = _CursorState()
cursor.initialized = True
cursor.x = 50
cursor.y = 50
page = MagicMock()
page._original = MagicMock()
el = MagicMock()
el._human_patched = False
el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
el.evaluate = MagicMock(return_value=False)
el.is_checked = MagicMock(return_value=False)
el.query_selector = MagicMock(return_value=None)
el.query_selector_all = MagicMock(return_value=[])
el.wait_for_selector = MagicMock(return_value=None)
raw_mouse = MagicMock()
raw_mouse.move = MagicMock()
raw_mouse.down = MagicMock()
raw_mouse.up = MagicMock()
raw_mouse.wheel = MagicMock()
raw_keyboard = MagicMock()
_patch_single_element_handle_sync(
el, page, cfg, cursor, raw_mouse, raw_keyboard, page._original, None, None
)
el.hover()
# Move should be called, but NOT down/up (hover, not click)
assert raw_mouse.move.called
assert not raw_mouse.down.called
def test_element_handle_type_calls_human_type(self):
from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", {"idle_between_actions": False, "mistype_chance": 0})
cursor = _CursorState()
cursor.initialized = True
cursor.x = 50
cursor.y = 50
page = MagicMock()
originals = MagicMock()
page._original = originals
el = MagicMock()
el._human_patched = False
el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
el.evaluate = MagicMock(return_value=True) # is input
el.is_checked = MagicMock(return_value=False)
el.query_selector = MagicMock(return_value=None)
el.query_selector_all = MagicMock(return_value=[])
el.wait_for_selector = MagicMock(return_value=None)
raw_mouse = MagicMock()
raw_mouse.move = MagicMock()
raw_mouse.down = MagicMock()
raw_mouse.up = MagicMock()
raw_mouse.wheel = MagicMock()
raw_keyboard = MagicMock()
raw_keyboard.down = MagicMock()
raw_keyboard.up = MagicMock()
raw_keyboard.insert_text = MagicMock()
_patch_single_element_handle_sync(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, None, None
)
el.type("hello")
# Mouse moved + clicked (to focus), then keyboard used
assert raw_mouse.move.called
assert raw_mouse.down.called # click to focus the input
# Keyboard events should have fired (down/up for ASCII chars)
assert raw_keyboard.down.called or raw_keyboard.insert_text.called
def test_element_handle_fill_clears_and_types(self):
from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock, call
cfg = resolve_config("default", {"idle_between_actions": False, "mistype_chance": 0})
cursor = _CursorState()
cursor.initialized = True
cursor.x = 50
cursor.y = 50
page = MagicMock()
originals = MagicMock()
page._original = originals
pressed_keys = []
originals.keyboard_press = MagicMock(side_effect=lambda k: pressed_keys.append(k))
el = MagicMock()
el._human_patched = False
el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
el.evaluate = MagicMock(return_value=True)
el.is_checked = MagicMock(return_value=False)
el.query_selector = MagicMock(return_value=None)
el.query_selector_all = MagicMock(return_value=[])
el.wait_for_selector = MagicMock(return_value=None)
raw_mouse = MagicMock()
raw_mouse.move = MagicMock()
raw_mouse.down = MagicMock()
raw_mouse.up = MagicMock()
raw_mouse.wheel = MagicMock()
raw_keyboard = MagicMock()
raw_keyboard.down = MagicMock()
raw_keyboard.up = MagicMock()
raw_keyboard.insert_text = MagicMock()
_patch_single_element_handle_sync(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, None, None
)
el.fill("replaced")
# Should have pressed Select-All and Backspace to clear
import sys
expected_select = "Meta+a" if sys.platform == "darwin" else "Control+a"
assert expected_select in pressed_keys
assert "Backspace" in pressed_keys
def test_element_handle_no_double_patching(self):
from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", None)
cursor = _CursorState()
page = MagicMock()
page._original = MagicMock()
el = MagicMock()
el._human_patched = False
el.query_selector = MagicMock(return_value=None)
el.query_selector_all = MagicMock(return_value=[])
el.wait_for_selector = MagicMock(return_value=None)
_patch_single_element_handle_sync(
el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None
)
# Save patched click
first_click = el.click
# Try to patch again
_patch_single_element_handle_sync(
el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None
)
# Should be the same — no double wrap
assert el.click is first_click
def test_nested_query_selector_returns_patched_handle(self):
from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", None)
cursor = _CursorState()
page = MagicMock()
page._original = MagicMock()
child = MagicMock()
child._human_patched = False
child.bounding_box = MagicMock(return_value={"x": 10, "y": 10, "width": 50, "height": 30})
child.evaluate = MagicMock(return_value=False)
child.is_checked = MagicMock(return_value=False)
child.query_selector = MagicMock(return_value=None)
child.query_selector_all = MagicMock(return_value=[])
child.wait_for_selector = MagicMock(return_value=None)
el = MagicMock()
el._human_patched = False
el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
el.evaluate = MagicMock(return_value=False)
el.is_checked = MagicMock(return_value=False)
el.query_selector = MagicMock(return_value=child)
el.query_selector_all = MagicMock(return_value=[])
el.wait_for_selector = MagicMock(return_value=None)
_patch_single_element_handle_sync(
el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None
)
result = el.query_selector("span")
assert result._human_patched is True
def test_page_query_selector_patched(self):
from cloakbrowser.human import _patch_page_element_handles_sync, _patch_single_element_handle_sync, _CursorState
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", None)
cursor = _CursorState()
el = MagicMock()
el._human_patched = False
el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
el.evaluate = MagicMock(return_value=False)
el.is_checked = MagicMock(return_value=False)
el.query_selector = MagicMock(return_value=None)
el.query_selector_all = MagicMock(return_value=[])
el.wait_for_selector = MagicMock(return_value=None)
page = MagicMock()
page._original = MagicMock()
page.query_selector = MagicMock(return_value=el)
page.query_selector_all = MagicMock(return_value=[el])
page.wait_for_selector = MagicMock(return_value=el)
_patch_page_element_handles_sync(
page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None
)
result = page.query_selector("#test")
assert result._human_patched is True
def test_page_query_selector_all_patches_all(self):
from cloakbrowser.human import _patch_page_element_handles_sync, _CursorState
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", None)
cursor = _CursorState()
def make_el():
e = MagicMock()
e._human_patched = False
e.bounding_box = MagicMock(return_value={"x": 10, "y": 10, "width": 50, "height": 30})
e.evaluate = MagicMock(return_value=False)
e.is_checked = MagicMock(return_value=False)
e.query_selector = MagicMock(return_value=None)
e.query_selector_all = MagicMock(return_value=[])
e.wait_for_selector = MagicMock(return_value=None)
return e
el1, el2, el3 = make_el(), make_el(), make_el()
page = MagicMock()
page._original = MagicMock()
page.query_selector = MagicMock(return_value=None)
page.query_selector_all = MagicMock(return_value=[el1, el2, el3])
page.wait_for_selector = MagicMock(return_value=None)
_patch_page_element_handles_sync(
page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None
)
results = page.query_selector_all("div")
for r in results:
assert r._human_patched is True
def test_wait_for_selector_patched(self):
from cloakbrowser.human import _patch_page_element_handles_sync, _CursorState
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", None)
cursor = _CursorState()
el = MagicMock()
el._human_patched = False
el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
el.evaluate = MagicMock(return_value=False)
el.is_checked = MagicMock(return_value=False)
el.query_selector = MagicMock(return_value=None)
el.query_selector_all = MagicMock(return_value=[])
el.wait_for_selector = MagicMock(return_value=None)
page = MagicMock()
page._original = MagicMock()
page.query_selector = MagicMock(return_value=None)
page.query_selector_all = MagicMock(return_value=[])
page.wait_for_selector = MagicMock(return_value=el)
_patch_page_element_handles_sync(
page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None
)
result = page.wait_for_selector("#test")
assert result._human_patched is True
def test_element_handle_all_methods_patched(self):
"""Verify all expected interaction methods are replaced."""
from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", None)
cursor = _CursorState()
page = MagicMock()
page._original = MagicMock()
el = MagicMock()
el._human_patched = False
el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
el.evaluate = MagicMock(return_value=False)
el.is_checked = MagicMock(return_value=False)
el.query_selector = MagicMock(return_value=None)
el.query_selector_all = MagicMock(return_value=[])
el.wait_for_selector = MagicMock(return_value=None)
el.set_checked = MagicMock() # ensure it exists
_patch_single_element_handle_sync(
el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None
)
expected_methods = ['click', 'dblclick', 'hover', 'type', 'fill', 'press',
'select_option', 'check', 'uncheck', 'set_checked',
'tap', 'focus', 'query_selector', 'query_selector_all',
'wait_for_selector']
for method in expected_methods:
fn = getattr(el, method)
assert not isinstance(fn, MagicMock), f"el.{method} was not patched"
# =========================================================================
# 13. ElementHandle patching — ASYNC
# =========================================================================
class TestElementHandlePatchingAsync:
@pytest.mark.asyncio
async def test_async_element_handle_click(self):
from cloakbrowser.human import _patch_single_element_handle_async, _CursorState
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock, AsyncMock
cfg = resolve_config("default", {"idle_between_actions": False})
cursor = _CursorState()
cursor.initialized = True
cursor.x = 100
cursor.y = 100
page = MagicMock()
originals = MagicMock()
originals.mouse_move = AsyncMock()
page._original = originals
el = MagicMock()
el._human_patched = False
el.bounding_box = AsyncMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
el.evaluate = AsyncMock(return_value=False)
el.is_checked = AsyncMock(return_value=False)
el.query_selector = AsyncMock(return_value=None)
el.query_selector_all = AsyncMock(return_value=[])
el.wait_for_selector = AsyncMock(return_value=None)
raw_mouse = MagicMock()
raw_mouse.move = AsyncMock()
raw_mouse.down = AsyncMock()
raw_mouse.up = AsyncMock()
raw_mouse.wheel = AsyncMock()
raw_keyboard = MagicMock()
raw_keyboard.down = AsyncMock()
raw_keyboard.up = AsyncMock()
raw_keyboard.insert_text = AsyncMock()
stealth = MagicMock()
stealth.get_cdp_session = AsyncMock(return_value=None)
_patch_single_element_handle_async(
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, [None]
)
await el.click()
assert raw_mouse.move.called
assert raw_mouse.down.called
assert raw_mouse.up.called
@pytest.mark.asyncio
async def test_async_page_query_selector_patched(self):
from cloakbrowser.human import _patch_page_element_handles_async, _CursorState
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock, AsyncMock
cfg = resolve_config("default", None)
cursor = _CursorState()
el = MagicMock()
el._human_patched = False
el.bounding_box = AsyncMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
el.evaluate = AsyncMock(return_value=False)
el.is_checked = AsyncMock(return_value=False)
el.query_selector = AsyncMock(return_value=None)
el.query_selector_all = AsyncMock(return_value=[])
el.wait_for_selector = AsyncMock(return_value=None)
page = MagicMock()
page._original = MagicMock()
page.query_selector = AsyncMock(return_value=el)
page.query_selector_all = AsyncMock(return_value=[el])
page.wait_for_selector = AsyncMock(return_value=el)
stealth = MagicMock()
stealth.get_cdp_session = AsyncMock(return_value=None)
_patch_page_element_handles_async(
page, cfg, cursor, MagicMock(), MagicMock(), page._original, stealth, [None]
)
result = await page.query_selector("#test")
assert result._human_patched is True
# =========================================================================
# 14. SLOW: Browser ElementHandle end-to-end
# =========================================================================
@pytest.mark.slow
class TestBrowserElementHandle:
def test_query_selector_click_humanized(self):
"""page.query_selector() returns a patched handle — el.click() uses human curves."""
from cloakbrowser import launch
browser = launch(headless=False, humanize=True)
page = browser.new_page()
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
time.sleep(1)
el = page.query_selector('#searchInput')
assert el is not None
assert getattr(el, '_human_patched', False), "ElementHandle not patched"
t0 = time.time()
el.click()
click_ms = int((time.time() - t0) * 1000)
assert click_ms > 100, f"ElementHandle click too fast: {click_ms}ms (not humanized)"
browser.close()
def test_query_selector_type_humanized(self):
"""el.type() should type character-by-character with human timing."""
from cloakbrowser import launch
browser = launch(headless=False, humanize=True)
page = browser.new_page()
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
time.sleep(1)
el = page.query_selector('#searchInput')
assert el is not None
t0 = time.time()
el.type('ElementHandle test')
type_ms = int((time.time() - t0) * 1000)
assert type_ms > 1000, f"ElementHandle type too fast: {type_ms}ms"
val = page.locator('#searchInput').input_value()
assert val == 'ElementHandle test'
browser.close()
def test_query_selector_fill_humanized(self):
"""el.fill() should clear + type with human timing."""
from cloakbrowser import launch
browser = launch(headless=False, humanize=True)
page = browser.new_page()
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
time.sleep(1)
el = page.query_selector('#searchInput')
el.type('initial')
time.sleep(0.3)
t0 = time.time()
el.fill('replaced')
fill_ms = int((time.time() - t0) * 1000)
assert fill_ms > 500, f"ElementHandle fill too fast: {fill_ms}ms"
val = page.locator('#searchInput').input_value()
assert val == 'replaced'
browser.close()
def test_query_selector_all_returns_patched(self):
"""page.query_selector_all() returns all handles patched."""
from cloakbrowser import launch
browser = launch(headless=False, humanize=True)
page = browser.new_page()
page.goto('https://the-internet.herokuapp.com/checkboxes', wait_until='domcontentloaded')
time.sleep(1)
els = page.query_selector_all('input[type="checkbox"]')
assert len(els) >= 2
for el in els:
assert getattr(el, '_human_patched', False), "ElementHandle not patched"
browser.close()
def test_query_selector_hover_humanized(self):
"""el.hover() should move cursor with human Bezier curve."""
from cloakbrowser import launch
browser = launch(headless=False, humanize=True)
page = browser.new_page()
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
time.sleep(1)
el = page.query_selector('#searchInput')
t0 = time.time()
el.hover()
hover_ms = int((time.time() - t0) * 1000)
assert hover_ms > 50, f"ElementHandle hover too fast: {hover_ms}ms"
browser.close()
@pytest.mark.slow
class TestAsyncElementHandle:
@pytest.mark.asyncio
async def test_async_query_selector_click(self):
from cloakbrowser import launch_async
browser = await launch_async(headless=False, humanize=True)
page = await browser.new_page()
await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
await asyncio.sleep(1)
el = await page.query_selector('#searchInput')
assert el is not None
assert getattr(el, '_human_patched', False), "Async ElementHandle not patched"
t0 = time.time()
await el.click()
click_ms = int((time.time() - t0) * 1000)
assert click_ms > 100, f"Async ElementHandle click too fast: {click_ms}ms"
await browser.close()
# =========================================================================
+127 -15
View File
@@ -2,7 +2,12 @@
from unittest.mock import patch
from cloakbrowser.browser import _build_proxy_kwargs, maybe_resolve_geoip, _parse_proxy_url
from cloakbrowser.browser import (
_is_socks_proxy,
_parse_proxy_url,
_resolve_proxy_config,
maybe_resolve_geoip,
)
class TestParseProxyUrl:
@@ -38,23 +43,30 @@ class TestParseProxyUrl:
class TestBuildProxyKwargs:
"""Tests for _resolve_proxy_config (formerly _build_proxy_kwargs) HTTP path."""
def test_none(self):
assert _build_proxy_kwargs(None) == {}
kwargs, args = _resolve_proxy_config(None)
assert kwargs == {}
assert args == []
def test_simple_proxy(self):
result = _build_proxy_kwargs("http://proxy:8080")
assert result == {"proxy": {"server": "http://proxy:8080"}}
kwargs, args = _resolve_proxy_config("http://proxy:8080")
assert kwargs == {"proxy": {"server": "http://proxy:8080"}}
assert args == []
def test_proxy_with_auth(self):
result = _build_proxy_kwargs("http://user:pass@proxy:8080")
assert result == {
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
assert kwargs == {
"proxy": {"server": "http://proxy:8080", "username": "user", "password": "pass"}
}
assert args == []
def test_proxy_dict_passthrough(self):
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com,localhost"}
result = _build_proxy_kwargs(proxy_dict)
assert result == {"proxy": proxy_dict}
kwargs, args = _resolve_proxy_config(proxy_dict)
assert kwargs == {"proxy": proxy_dict}
assert args == []
def test_proxy_dict_with_auth(self):
proxy_dict = {
@@ -63,8 +75,9 @@ class TestBuildProxyKwargs:
"password": "pass",
"bypass": ".example.com",
}
result = _build_proxy_kwargs(proxy_dict)
assert result == {"proxy": proxy_dict}
kwargs, args = _resolve_proxy_config(proxy_dict)
assert kwargs == {"proxy": proxy_dict}
assert args == []
class TestMaybeResolveGeoip:
@@ -117,6 +130,27 @@ class TestMaybeResolveGeoip:
mock_geo.assert_called_once_with("http://proxy:8080")
assert tz == "America/New_York"
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8"))
def test_geoip_socks5_dict_reconstructs_credentials(self, mock_geo):
proxy_dict = {"server": "socks5://proxy:1080", "username": "user", "password": "pass"}
tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None)
mock_geo.assert_called_once_with("socks5://user:pass@proxy:1080")
assert tz == "Europe/Berlin"
assert locale == "de-DE"
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8"))
def test_geoip_socks5_dict_no_auth_uses_server(self, mock_geo):
proxy_dict = {"server": "socks5://proxy:1080"}
tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None)
mock_geo.assert_called_once_with("socks5://proxy:1080")
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/London", "en-GB", "1.1.1.1"))
def test_geoip_http_dict_does_not_inline_creds(self, mock_geo):
# HTTP dict: credentials stay separate, only server URL passed
proxy_dict = {"server": "http://proxy:8080", "username": "user", "password": "pass"}
tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None)
mock_geo.assert_called_once_with("http://proxy:8080")
class TestBareProxyFormat:
"""_parse_proxy_url must handle bare 'user:pass@host:port' strings (no scheme)."""
@@ -149,8 +183,86 @@ class TestBareProxyFormat:
r = _parse_proxy_url("proxy:8080")
assert r == {"server": "proxy:8080"}
def test_build_proxy_kwargs_bare(self):
r = _build_proxy_kwargs("user:pass@proxy:8080")
assert r["proxy"]["username"] == "user"
assert r["proxy"]["password"] == "pass"
assert "user" not in r["proxy"]["server"]
def test_resolve_proxy_config_bare(self):
kwargs, args = _resolve_proxy_config("user:pass@proxy:8080")
assert kwargs["proxy"]["username"] == "user"
assert kwargs["proxy"]["password"] == "pass"
assert "user" not in kwargs["proxy"]["server"]
class TestIsSocksProxy:
def test_socks5_string(self):
assert _is_socks_proxy("socks5://user:pass@host:1080") is True
def test_socks5h_string(self):
assert _is_socks_proxy("socks5h://host:1080") is True
def test_socks5_uppercase(self):
assert _is_socks_proxy("SOCKS5://host:1080") is True
def test_http_string(self):
assert _is_socks_proxy("http://host:8080") is False
def test_dict_socks5(self):
assert _is_socks_proxy({"server": "socks5://host:1080"}) is True
def test_dict_http(self):
assert _is_socks_proxy({"server": "http://host:8080"}) is False
def test_none(self):
assert _is_socks_proxy(None) is False
class TestResolveProxyConfig:
def test_none(self):
kwargs, args = _resolve_proxy_config(None)
assert kwargs == {}
assert args == []
def test_http_string_returns_playwright_dict(self):
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
assert "proxy" in kwargs
assert kwargs["proxy"]["server"] == "http://proxy:8080"
assert kwargs["proxy"]["username"] == "user"
assert args == []
def test_http_dict_passthrough(self):
proxy = {"server": "http://proxy:8080", "bypass": ".example.com"}
kwargs, args = _resolve_proxy_config(proxy)
assert kwargs == {"proxy": proxy}
assert args == []
def test_socks5_string_returns_chrome_arg(self):
kwargs, args = _resolve_proxy_config("socks5://user:pass@host:1080")
assert kwargs == {}
assert args == ["--proxy-server=socks5://user:pass@host:1080"]
def test_socks5_no_auth_returns_chrome_arg(self):
kwargs, args = _resolve_proxy_config("socks5://host:1080")
assert kwargs == {}
assert args == ["--proxy-server=socks5://host:1080"]
def test_socks5h_returns_chrome_arg(self):
kwargs, args = _resolve_proxy_config("socks5h://user:pass@host:1080")
assert kwargs == {}
assert args == ["--proxy-server=socks5h://user:pass@host:1080"]
def test_socks5_dict_reconstructs_url(self):
proxy = {"server": "socks5://host:1080", "username": "user", "password": "p@ss"}
kwargs, args = _resolve_proxy_config(proxy)
assert kwargs == {}
assert len(args) == 1
assert args[0].startswith("--proxy-server=socks5://user:p%40ss@host:1080")
def test_socks5_dict_ipv6_preserves_brackets(self):
proxy = {"server": "socks5://[::1]:1080", "username": "user", "password": "pass"}
kwargs, args = _resolve_proxy_config(proxy)
assert kwargs == {}
assert "[::1]" in args[0]
def test_socks5_dict_with_bypass(self):
proxy = {"server": "socks5://host:1080", "bypass": ".example.com"}
kwargs, args = _resolve_proxy_config(proxy)
assert kwargs == {}
assert "--proxy-server=socks5://host:1080" in args
assert "--proxy-bypass-list=.example.com" in args