mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b2981c4c1 | ||
|
|
7afe59435e | ||
|
|
1cef71133d | ||
|
|
7a0937cc54 | ||
|
|
5b00ff0325 | ||
|
|
5dd44298ee | ||
|
|
54d8442f20 | ||
|
|
a01adbe26c | ||
|
|
06d77e7261 | ||
|
|
211bd93d3e | ||
|
|
1060772734 | ||
|
|
8eb2e4b905 | ||
|
|
216a7d6a6a | ||
|
|
02359f69c8 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -369,7 +370,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 +452,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 +463,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 +519,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 +565,10 @@ Every `launch()` call sets these automatically. The **wrapper** applies platform
|
||||
|------|--------------|---------------|----------|
|
||||
| `--fingerprint` | Random (10000–99999) | Random (10000–99999) | 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 +576,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 +607,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",
|
||||
@@ -656,11 +663,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 +928,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.
|
||||
|
||||
@@ -1019,7 +1026,7 @@ A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
|
||||
|
||||
| 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 +1050,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 +1075,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
@@ -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 @@
|
||||
__version__ = "0.3.20"
|
||||
__version__ = "0.3.23"
|
||||
|
||||
+27
-6
@@ -24,6 +24,9 @@ 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."""
|
||||
@@ -237,7 +240,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 +267,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'.
|
||||
@@ -310,7 +314,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)
|
||||
@@ -354,7 +363,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 +389,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'.
|
||||
@@ -429,7 +439,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)
|
||||
@@ -472,7 +487,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 +510,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 +543,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)
|
||||
|
||||
+5
-14
@@ -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"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -203,11 +203,11 @@ 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
|
||||
|
||||
|
||||
+5
-1
@@ -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": {
|
||||
|
||||
+5
-16
@@ -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"];
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
+13
-2
@@ -440,6 +440,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) => {
|
||||
@@ -494,8 +497,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,
|
||||
@@ -563,6 +566,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);
|
||||
|
||||
@@ -106,7 +106,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) {
|
||||
@@ -181,7 +181,7 @@ export async function launchPersistentContext(
|
||||
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
|
||||
: {}),
|
||||
...(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,
|
||||
});
|
||||
|
||||
+21
-10
@@ -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";
|
||||
@@ -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,6 +32,7 @@ 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}`];
|
||||
}
|
||||
@@ -82,10 +85,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
@@ -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. */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -451,6 +451,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
|
||||
// =========================================================================
|
||||
|
||||
@@ -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({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+103
-4
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user