Compare commits

..
12 Commits
Author SHA1 Message Date
CloakHQ 5d35fb9e4c release: v0.3.13 — suppress SwiftShader default arg, upgrade Linux binary to .159.5 2026-03-10 23:11:12 +01:00
CloakHQ c966e046e7 docs: add Docker signature verification, deployment environments, improve troubleshooting
- Add cosign verify command to README Security section for Docker image verification
- Add GitHub deployment environments (pypi, npm, docker) to publish workflow for sidebar status tracking
- Simplify downgrade instructions: version-pinned pip/npm/docker instead of manual binary paths
- Improve troubleshooting section with headings and dividers for readability
- Update Latest section to v0.3.12 with new binary features
2026-03-10 07:27:18 +01:00
CloakHQ 767eb16a82 release: v0.3.12 — locale spoofing patch, WebGPU hardening, binary flags for tz/locale
Binary: 145.0.7632.159.4 (linux), 32 patches.
Wrapper: bare proxy format, ANGLE GPU strings, README updates.
2026-03-10 06:27:16 +01:00
CloakHQ 04255cf412 fix: use binary flags for timezone/locale instead of detectable CDP emulation
- Remove locale and timezone_id from Playwright context kwargs (CDP)
- Pass timezone via --fingerprint-timezone binary flag (process-wide)
- Pass locale via --lang + --fingerprint-locale binary flags
- Accept both timezone and timezone_id param names silently (no deprecation)
- Update all wrapper tests to verify binary args, not CDP context params
2026-03-10 03:56:57 +01:00
CloakHQ 1fb554e061 fix: support bare proxy format (user:pass@host:port) without scheme
Normalize bare proxy strings by prepending http:// before parsing when
@ is present but :// is absent. Tests added for Python and JS.
2026-03-09 19:35:07 +01:00
CloakHQ 748013bf83 fix: use ANGLE-wrapped GPU strings for realistic WebGL fingerprint
Bare vendor/renderer strings are detectable — real Chrome reports
ANGLE-wrapped values through WebGL's getParameter API.
2026-03-09 02:25:33 +01:00
CloakHQ eeea366047 ci: upgrade npm for OIDC trusted publishing (requires npm >= 11.5.1) 2026-03-08 23:54:18 +01:00
CloakHQ 858c0d0e85 ci: fix publish version check — read _version.py without importing 2026-03-08 23:49:10 +01:00
Cloak-HQandGitHub e615349f1e Merge pull request #30 from evelaa123/feature/humanize
feat: add humanize option  human-like mouse, keyboard, scroll behavio…
2026-03-08 23:22:17 +01:00
CloakHQ 1c93951f23 release: v0.3.11 — Linux build 145.0.7632.159.3
- Version bump: 0.3.10 → 0.3.11 (Python + JS)
- Linux Chromium: 145.0.7632.159.2 → 159.3
- CHANGELOG: v0.3.11 entry
- README: patch count 26→31, humanize docs
- bin/cloakserve: use --remote-debugging-address, remove socat
- Dockerfile: remove socat dependency
2026-03-08 23:19:39 +01:00
lilos 7bf8836683 feat: add human-like behavioral layer (humanize option)
Bezier mouse curves, per-character typing with mistype simulation,
smooth micro-step scrolling, idle micro-movements between actions.

Supports both sync and async Playwright APIs. Patches page, frame,
context, browser, and Locator class methods.

Two presets: 'default' (normal speed) and 'careful' (slower, deliberate).
Configurable via HumanConfig dataclass / interface with full override support.

Bug fixes (from PR review):
- fill()/clear(): platform-aware select-all (Meta+a on macOS, Control+a elsewhere)
- sync Locator check()/uncheck(): wrap mouse_move in RawMouse-compatible object
- resolve_config(): raise error on unknown preset name
- Lazy-load human.config via __getattr__ in __init__.py
- humanPreset typed as 'default' | 'careful' literal union
- browser.newPage() patches implicit context

Tests: Python 36/36, JS Vitest 34/34, visual Python 17/17, JS 13/13
2026-03-08 12:49:42 +03:00
CloakHQ 23a9c4d4bd ci: add publish workflow, binary attestation, and dev extras
- publish.yml: automated PyPI/npm/Docker on v* tag push; OIDC trusted publishing for PyPI/npm; Docker signed with Cosign keyless + provenance attested
- attest-release.yml: manual workflow to attest binary release assets via Sigstore (actions/attest-build-provenance@v2)
- pyproject.toml: add dev extras (pytest, pytest-asyncio)
2026-03-08 02:32:33 +01:00
49 changed files with 6146 additions and 311 deletions
+28
View File
@@ -0,0 +1,28 @@
name: Attest Release Binary
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. chromium-v145.0.7632.159.2)'
required: true
jobs:
attest:
runs-on: ubuntu-latest
permissions:
id-token: write # Sigstore OIDC
attestations: write # GitHub attestation API
contents: write # Download release assets
steps:
- name: Download release binaries
run: gh release download ${{ github.event.inputs.tag }} --repo CloakHQ/cloakbrowser --pattern "cloakbrowser-*.tar.gz" --pattern "cloakbrowser-*.zip"
env:
GH_TOKEN: ${{ github.token }}
- name: Attest build provenance
uses: actions/attest-build-provenance@v2
with:
subject-path: |
cloakbrowser-*.tar.gz
cloakbrowser-*.zip
+143
View File
@@ -0,0 +1,143 @@
name: Publish
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
job:
description: 'Job to run (leave empty to run all)'
required: false
type: choice
options:
- ''
- publish-pypi
- publish-npm
- publish-docker
concurrency:
group: publish
cancel-in-progress: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Python tests
run: |
pip install -e ".[dev]" pytest pytest-asyncio
pytest tests/ -v -m "not slow"
- uses: actions/setup-node@v4
with:
node-version: 22
- name: JavaScript tests
run: cd js && npm ci && npm run build && npm test
validate-version:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Check tag matches package versions
run: |
TAG="${GITHUB_REF_NAME#v}"
PY=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))')
JS=$(python -c 'import json; print(json.load(open("js/package.json"))["version"])')
echo "Tag: $TAG | Python: $PY | npm: $JS"
[ "$TAG" = "$PY" ] || { echo "ERROR: tag v$TAG != _version.py $PY"; exit 1; }
[ "$TAG" = "$JS" ] || { echo "ERROR: tag v$TAG != package.json $JS"; exit 1; }
publish-pypi:
needs: [test, validate-version]
if: always() && needs.test.result == 'success' && (needs.validate-version.result == 'success' || needs.validate-version.result == 'skipped')
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/cloakbrowser
permissions:
id-token: write # OIDC trusted publishing — no PYPI_TOKEN needed
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Build
run: |
pip install build
python -m build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
publish-npm:
needs: [test, validate-version]
if: always() && needs.test.result == 'success' && (needs.validate-version.result == 'success' || needs.validate-version.result == 'skipped')
runs-on: ubuntu-latest
environment:
name: npm
url: https://www.npmjs.com/package/cloakbrowser
permissions:
id-token: write # OIDC trusted publishing + provenance — no NPM_TOKEN needed
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
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
run: cd js && npm publish --provenance --access public
publish-docker:
needs: [test, validate-version]
if: always() && needs.test.result == 'success' && (needs.validate-version.result == 'success' || needs.validate-version.result == 'skipped')
runs-on: ubuntu-latest
environment:
name: docker
url: https://hub.docker.com/r/cloakhq/cloakbrowser
permissions:
id-token: write # Cosign keyless signing + attestations
contents: read
attestations: write
packages: write
steps:
- uses: actions/checkout@v4
- name: Extract version
run: |
VERSION=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))')
echo "VERSION=$VERSION" >> $GITHUB_ENV
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PAT }}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
cloakhq/cloakbrowser:${{ env.VERSION }}
cloakhq/cloakbrowser:latest
provenance: true
sbom: true
- uses: sigstore/cosign-installer@v3
- name: Sign image
run: cosign sign --yes cloakhq/cloakbrowser@${{ steps.build.outputs.digest }}
- name: Attest build provenance
uses: actions/attest-build-provenance@v2
with:
subject-name: index.docker.io/cloakhq/cloakbrowser
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
-45
View File
@@ -1,45 +0,0 @@
name: Release Binary
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. chromium-v145.0.7718.0)'
required: true
title:
description: 'Release title (e.g. Chromium v145 — Stealth Build)'
required: true
default: 'Stealth Chromium Build'
patch_count:
description: 'Number of fingerprint patches'
required: true
default: '16'
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Create release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.inputs.tag }}
name: "${{ github.event.inputs.title }}"
body: |
## Stealth Chromium Build
Pre-built Chromium with ${{ github.event.inputs.patch_count }} source-level fingerprint patches.
### Install
```bash
pip install cloakbrowser # Python
npm install cloakbrowser # JavaScript
# Binary auto-downloads on first launch
```
> Binary integrity is verified automatically via SHA-256 checksums on download.
>
> Release signed with CloakHQ GPG key: `C60C0DDC9D0DE2DD`
+28
View File
@@ -6,6 +6,34 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
---
## [0.3.13] — 2026-03-10
- **[wrapper]** Suppress Playwright's `--enable-unsafe-swiftshader` default arg — eliminates SwiftShader software renderer detection signal, letting the binary's GPU spoofing work cleanly
- **[binary]** Upgrade Linux build to 145.0.7632.159.5 — fix WebGPU adapter limits and features for NVIDIA profiles
## [0.3.12] — 2026-03-10
- **[binary]** Upgrade Linux build to 145.0.7632.159.4
- **[binary]** Native locale spoofing — new C++ patch replaces detectable CDP-level locale emulation
- **[binary]** WebGPU fingerprint hardening — spoof adapter features, limits, device ID, and subgroup sizes for cross-API consistency
- **[binary]** Restore WebGPU blocklist bypass auto-injection (safe now with full adapter spoofing)
- **[binary]** Fix WebGL renderer suffix — remove driver version string flagged by BrowserLeaks
- **[wrapper]** Use binary flags for timezone/locale instead of CDP emulation — eliminates a detection vector
- **[wrapper]** Support bare proxy format (`user:pass@host:port`) without scheme prefix
- **[wrapper]** Use ANGLE-wrapped GPU strings in default stealth args for realistic WebGL fingerprint
## [0.3.11] — 2026-03-08
- **[wrapper]** `humanize=True` — human-like mouse (Bézier curves, overshoot), keyboard (per-character timing, thinking pauses), scroll (accelerate/cruise/decelerate), and click behavior. Two presets: `default` and `careful`. Works in Python and JS. (thanks [@evelaa123](https://github.com/evelaa123))
- **[binary]** CDP input stealth — 4 new source-level C++ patches removing automation signals from input events
- **[binary]** Support `--remote-debugging-address` flag for CDP bind address — eliminates the socat workaround in `cloakserve` Docker mode
- **[wrapper]** `cloakserve` updated to use `--remote-debugging-address=0.0.0.0` directly — socat dependency removed from Docker image
- **[binary]** GPU fingerprint accuracy improvements — renderer suffix strings now match real Chrome output across Windows and Linux profiles
- **[binary]** GPU capability accuracy fix for NVIDIA profiles — spoofed values now reflect actual hardware limits
- **[binary]** macOS GPU accuracy fix — GPU model database reference corrected for Apple Silicon profiles
- **[binary]** Fix CDP input synthesis — a guard condition prevented the patch from activating; now fires correctly on all input events
- **[binary]** Code quality hardening across patches — correctness and reliability fixes
## [0.3.10] — 2026-03-07
- **[binary]** Upgrade Linux build to 145.0.7632.159.2
+1 -1
View File
@@ -10,7 +10,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0 libgtk-3-0 libpangocairo-1.0-0 libcairo-gobject2 \
libgdk-pixbuf-2.0-0 libxss1 libxtst6 fonts-liberation \
xvfb xdotool \
curl ca-certificates socat \
curl ca-certificates \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
+154 -31
View File
@@ -11,6 +11,7 @@
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/stars/cloakhq/cloakbrowser" alt="Stars"></a>
<a href="https://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pepy/dt/cloakbrowser?label=pypi&logo=pypi&logoColor=white" alt="PyPI Downloads"></a>
<a href="https://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/dt/cloakbrowser?label=npm&logo=npm&logoColor=white" alt="npm Downloads"></a>
<a href="https://hub.docker.com/r/cloakhq/cloakbrowser"><img src="https://img.shields.io/docker/pulls/cloakhq/cloakbrowser?label=docker&logo=docker&logoColor=white" alt="Docker Pulls"></a>
</p>
<br>
@@ -35,7 +36,8 @@ 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>
- **26 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals
- **32 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, 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
- **Auto-updating binary** — background update checks, always on the latest stealth build
@@ -108,15 +110,15 @@ page.goto("https://example.com")
> ⭐ **Star** to show support — **[Watch releases](https://github.com/CloakHQ/CloakBrowser/subscription)** to get notified when new builds drop.
## Latest: v0.3.10 (Chromium 145.0.7632.159.2)
## Latest: v0.3.13 (Chromium 145.0.7632.159.5)
- **All 4 platforms** — Linux x64, macOS arm64, macOS x64, and Windows x64 all on Chromium 145
- **26 fingerprint patches** — 10 new patches since v142 (screen, device memory, audio, WebGL, auto-spoof, and more)
- **`humanize=True`** — one flag makes all mouse, keyboard, and scroll interactions behave like a real user. Bézier curves, per-character typing, realistic scroll patterns. Two presets: `default` and `careful`
- **CDP input behavior mimicking** — input events sent via CDP now produce the same signals as real user interactions. 5 new source-level patches covering pointer, keyboard, and mouse behavior
- **Native locale spoofing** — new C++ patch replaces detectable CDP-level locale emulation
- **WebGPU fingerprint hardening** — adapter features, limits, and device ID spoofed for cross-API consistency
- **32 fingerprint patches** (Linux x64) — all 4 platforms on Chromium 145
- **Stealthy with zero flags** — binary auto-generates a random fingerprint seed at startup. No configuration required
- **Full stealth audit** — every patch reviewed for detection vectors, multiple fixes shipped
- **CDP hardening** — audited and patched known automation detection vectors
- **Timezone & locale from proxy IP** — `launch(proxy="...", geoip=True)` auto-detects timezone and locale
- **Playwright + Puppeteer from one package** — `import from 'cloakbrowser'` or `import from 'cloakbrowser/puppeteer'`. Same binary, your choice of API
- **Persistent profiles** — `launch_persistent_context()` keeps cookies and localStorage across sessions, bypasses incognito detection
See the full [CHANGELOG.md](CHANGELOG.md) for details.
@@ -127,7 +129,7 @@ See the full [CHANGELOG.md](CHANGELOG.md) for details.
- **CloakBrowser patches Chromium source code** — fingerprints are modified at the C++ level, compiled into the binary. Detection sites see a real browser because it *is* a real browser.
- **Source-level stealth** — C++ patches handle fingerprints (GPU, screen, UA, hardware reporting) at the binary level. No JavaScript injection, no config-level hacks. Most stealth tools only patch at the surface.
- **Same behavior everywhere** — works identically local, in Docker, and on VPS. No environment-specific patches or config needed.
- **Works with any browser automation framework** — tested and passing stealth checks with Playwright, Puppeteer, Selenium, undetected-chromedriver, browser-use, Crawl4AI, and agent-browser. Just point any Chromium-based framework at the binary path.
- **Works with AI agents and automation frameworks** — drop-in stealth for browser-use, Crawl4AI, agent-browser, Claude computer use, and OpenAI Operator. Also tested with Playwright, Puppeteer, and Selenium point any Chromium-based framework at the binary path.
CloakBrowser doesn't solve CAPTCHAs — it prevents them from appearing. No CAPTCHA-solving services, no proxy rotation built in — bring your own proxies, use the Playwright API you already know.
@@ -175,6 +177,11 @@ All tests verified against live detection services. Last tested: Mar 2026 (Chrom
<br><em>FingerprintJS web-scraping demo — data served, not blocked</em>
</p>
<p align="center">
<img src="https://i.imgur.com/srCcFtK.png" width="600" alt="deviceandbrowserinfo.com — You are human!">
<br><em>deviceandbrowserinfo.com behavioral bot detection — "You are human!" with humanize=True (24/24 signals passed)</em>
</p>
## Comparison
| Feature | Playwright | playwright-stealth | undetected-chromedriver | Camoufox | CloakBrowser |
@@ -196,7 +203,7 @@ CloakBrowser is a thin wrapper (Python + JavaScript) around a custom-built Chrom
3. **Every launch** → Playwright or Puppeteer starts with our binary + stealth args
4. **You write code** → standard Playwright/Puppeteer API, nothing new to learn
The binary includes 26 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, hardware reporting, and automation signal removal.
The binary includes 32 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, 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.
@@ -224,7 +231,7 @@ browser = launch(proxy={"server": "http://proxy:8080", "bypass": ".google.com",
# With extra Chrome args
browser = launch(args=["--disable-gpu"])
# With timezone and locale (sets both binary flags and Playwright context)
# With timezone and locale (sets binary flags — no detectable CDP emulation)
browser = launch(timezone="America/New_York", locale="en-US")
# Auto-detect timezone/locale from proxy IP (requires: pip install cloakbrowser[geoip])
@@ -233,6 +240,12 @@ browser = launch(proxy="http://proxy:8080", geoip=True)
# Explicit timezone/locale always win over auto-detection
browser = launch(proxy="http://proxy:8080", geoip=True, timezone="Europe/London")
# Human-like mouse, keyboard, and scroll behavior
browser = launch(humanize=True)
# With slower, more deliberate movements
browser = launch(humanize=True, human_preset="careful")
# Without default stealth args (bring your own fingerprint flags)
browser = launch(stealth_args=False, args=["--fingerprint=12345"])
```
@@ -335,6 +348,7 @@ const browser = await launch({
args: ['--fingerprint=12345'],
timezone: 'America/New_York',
locale: 'en-US',
humanize: true,
});
// Convenience: browser + context in one call
@@ -386,6 +400,69 @@ console.log(binaryInfo());
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.
```python
browser = launch(humanize=True)
page = browser.new_page()
page.goto("https://example.com")
page.locator("#email").fill("user@example.com") # per-character timing, thinking pauses
page.locator("button[type=submit]").click() # Bézier curve, realistic aim point
```
```javascript
const browser = await launch({ humanize: true });
```
**What changes:**
| Interaction | Default | With `humanize=True` |
|---|---|---|
| Mouse movement | Instant teleport | Bézier curve with easing and slight overshoot |
| Clicks | Instant | Realistic aim point + hold duration |
| Keyboard | Instant fill | Per-character timing, thinking pauses, occasional typos with self-correction |
| Scroll | Jump | Accelerate → cruise → decelerate micro-steps |
| `fill()` | Instant value set | Clears existing content, types character by character |
**Presets**`default` (normal speed) or `careful` (slower, more deliberate, idle micro-movements between actions):
```python
browser = launch(humanize=True, human_preset="careful")
```
```javascript
const browser = await launch({ humanize: true, humanPreset: 'careful' });
```
**Custom config** — override any parameter:
```python
browser = launch(humanize=True, human_config={
"mistype_chance": 0.05, # 5% typo rate with self-correction
"typing_delay": 100, # slower typing (ms per character)
"idle_between_actions": True, # micro-movements between clicks
"idle_between_duration": [0.3, 0.8], # idle duration range (seconds)
})
```
```javascript
const browser = await launch({
humanize: true,
humanConfig: {
mistype_chance: 0.05,
typing_delay: 100,
idle_between_actions: true,
idle_between_duration: [0.3, 0.8],
}
});
```
Access the original un-patched Playwright page at `page._original` if you need raw speed for a specific call.
> Contributed by [@evelaa123](https://github.com/evelaa123) — full Playwright API coverage.
## Configuration
| Env Variable | Default | Description |
@@ -450,6 +527,7 @@ Supported by the binary but **not set by default** — pass via `args` to custom
| `--fingerprint-platform-version` | Client Hints platform version |
| `--fingerprint-location` | Geolocation coordinates |
| `--fingerprint-timezone` | Timezone (e.g. `America/New_York`) |
| `--fingerprint-locale` | Locale (e.g. `en-US`) |
| `--fingerprint-taskbar-height` | Override taskbar height (binary defaults: Win=48, Mac=95, Linux=0) |
| `--fingerprint-fonts-dir` | Path to cross-platform font directory |
| `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements |
@@ -495,7 +573,7 @@ browser = launch(args=[
| Platform | Chromium | Patches | Status |
|---|---|---|---|
| Linux x86_64 | 145 | 26 | ✅ Latest |
| Linux x86_64 | 145 | 31 | ✅ Latest |
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest |
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest |
| Windows x86_64 | 145 | 26 | ✅ Latest |
@@ -640,7 +718,9 @@ CloakBrowser works identically local, in Docker, and on VPS. No environment-spec
## Troubleshooting
**Still getting blocked on aggressive sites (DataDome, Turnstile)?**
---
### Still getting blocked on aggressive sites (DataDome, Turnstile)?
Some sites detect headless mode even with our C++ patches. Run in **headed mode** with a virtual display:
@@ -665,7 +745,9 @@ browser.close()
This runs a real headed browser rendered on a virtual display — no physical monitor needed. Combined with a residential proxy, this passes even the most aggressive detection services. Datacenter IPs are often flagged by IP reputation regardless of browser fingerprint — a residential proxy makes the difference.
**Sites challenge fresh sessions but work after first visit**
---
### Sites challenge fresh sessions but work after first visit
Some sites challenge first-time visitors with no cookies over HTTP/2. This affects all Chromium browsers, not just CloakBrowser. Use a persistent profile to warm up cookies once, then reuse across sessions:
@@ -699,7 +781,10 @@ ctx = await launchPersistentContext({ userDataDir: './profile' });
For stateless/ephemeral use cases, `launch(args=["--disable-http2"])` forces HTTP/1.1 which bypasses the check. Only use this flag for sites that require it — most work fine with HTTP/2.
**Something not working? Make sure you're on the latest version**
---
### Something not working? Make sure you're on the latest version
Older versions may use outdated stealth args or download an older binary:
```bash
pip install -U cloakbrowser # Python
@@ -707,42 +792,54 @@ npm install cloakbrowser@latest # JavaScript
docker pull cloakhq/cloakbrowser:latest # Docker
```
**Binary download fails / timeout**
---
### Binary download fails / timeout
Set a custom download URL or use a local binary:
```bash
export CLOAKBROWSER_BINARY_PATH=/path/to/your/chrome
```
**New update broke something? Roll back to the previous version**
When auto-update downloads a newer binary, the previous version stays in `~/.cloakbrowser/`. Point `CLOAKBROWSER_BINARY_PATH` to the older cached binary:
---
### New update broke something? Roll back to the previous version
Install a specific wrapper version to downgrade both the wrapper and the binary it downloads:
```bash
# Linux
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.159.2/chrome
# macOS
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.109.2/Chromium.app/Contents/MacOS/Chromium
# Windows
set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-145.0.7632.109.2\chrome.exe
pip install cloakbrowser==0.3.11 # Python
npm install cloakbrowser@0.3.11 # JavaScript
docker pull cloakhq/cloakbrowser:0.3.11 # Docker
```
Each wrapper version pins its own binary version, so downgrading the wrapper automatically gets you the matching binary on next launch.
---
### macOS: "App is damaged" or Gatekeeper blocks launch
**macOS: "App is damaged" or Gatekeeper blocks launch**
The binary is ad-hoc signed. macOS quarantines downloaded files. Run once to clear it:
```bash
xattr -cr ~/.cloakbrowser/chromium-*/Chromium.app
```
**"playwright install" vs CloakBrowser binary**
---
### "playwright install" vs CloakBrowser binary
You do NOT need `playwright install chromium`. CloakBrowser downloads its own binary. You only need Playwright's system deps:
```bash
playwright install-deps chromium
```
**macOS: Blocked on some sites that pass on Linux**
---
### macOS: Blocked on some sites that pass on Linux
The macOS fingerprint profile has known inconsistencies that aggressive bot detection catches. If a site blocks you on macOS but works on Linux, switch to a Windows fingerprint profile by passing `stealth_args=False` and manually setting `--fingerprint-platform=windows` with matching GPU flags (see [Fingerprint Management](#fingerprint-management) for the full flag list).
**Site detects incognito / private browsing mode**
---
### Site detects incognito / private browsing mode
By default, `launch()` opens an incognito context. Some sites (like BrowserScan) detect this. Use `launch_persistent_context()` instead — it runs with a real user profile, so incognito detection passes:
@@ -764,7 +861,9 @@ const ctx = await launchPersistentContext({
This also gives you cookie and localStorage persistence across sessions.
**reCAPTCHA v3 scores are low (0.10.3)**
---
### reCAPTCHA v3 scores are low (0.10.3)
Avoid `page.wait_for_timeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
@@ -786,7 +885,7 @@ await new Promise(r => setTimeout(r, 3000));
```
Other tips for maximizing reCAPTCHA scores:
- **Try the Patchright backend** — suppresses CDP automation signals that reCAPTCHA Enterprise detects. Install with `pip install cloakbrowser[patchright]`, then use `launch(backend="patchright")` or set `CLOAKBROWSER_BACKEND=patchright` globally. Note: Patchright breaks proxy auth and `add_init_script` — only use it when you need the extra CDP stealth
- **Try the Patchright backend** — suppresses additional CDP automation signals at the Playwright protocol layer. Install with `pip install cloakbrowser[patchright]`, then use `launch(backend="patchright")` or set `CLOAKBROWSER_BACKEND=patchright` globally. Note: Patchright breaks proxy auth and `add_init_script` — only use it if you're still seeing low scores after trying the steps above
- **Use Playwright, not Puppeteer** — Puppeteer sends more CDP protocol traffic that reCAPTCHA detects ([details](#puppeteer))
- **Use residential proxies** — datacenter IPs are flagged by IP reputation, not browser fingerprint
- **Spend 15+ seconds on the page** before triggering reCAPTCHA — short visits score lower
@@ -832,6 +931,25 @@ A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
- 📦 **npm** — [npmjs.com/package/cloakbrowser](https://www.npmjs.com/package/cloakbrowser)
- 📧 **Contact** — cloakhq@pm.me
## Security
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.5
# Verify GitHub binary attestation (Sigstore)
gh attestation verify cloakbrowser-linux-x64.tar.gz --repo CloakHQ/cloakbrowser
# Verify Docker image signature (Cosign/Sigstore)
cosign verify \
--certificate-identity-regexp "https://github.com/CloakHQ/CloakBrowser/" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
cloakhq/cloakbrowser:latest
```
## License
- **Wrapper code** (this repository) — MIT. See [LICENSE](https://github.com/CloakHQ/CloakBrowser/blob/main/LICENSE).
@@ -840,3 +958,8 @@ A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
## Contributing
Issues and PRs welcome. If something isn't working, [open an issue](https://github.com/CloakHQ/CloakBrowser/issues) — we respond fast.
## Contributors
- [@evelaa123](https://github.com/evelaa123) — humanize behavior, persistent contexts, Windows fix
- [@yahooguntu](https://github.com/yahooguntu) — persistent contexts
+4 -20
View File
@@ -9,7 +9,6 @@ Usage:
Connect from host:
playwright.chromium.connect_over_cdp("http://localhost:9222")
"""
import os
import signal
import subprocess
import sys
@@ -18,15 +17,14 @@ import time
from cloakbrowser.config import get_default_stealth_args
from cloakbrowser.download import ensure_binary
EXPOSE_PORT = 9222 # External, 0.0.0.0 (user connects here)
CHROME_PORT = 9223 # Internal, 127.0.0.1 (Chrome binds here)
PORT = 9222
binary = ensure_binary()
chrome_args = [
binary,
f"--remote-debugging-port={CHROME_PORT}",
# "--remote-debugging-address=0.0.0.0", # TODO: enable after binary patch 032
f"--remote-debugging-port={PORT}",
"--remote-debugging-address=0.0.0.0",
# Sane defaults for running Chrome directly (outside Playwright)
"--no-first-run",
"--no-default-browser-check",
@@ -37,33 +35,19 @@ chrome_args = [
"--metrics-recording-only",
] + get_default_stealth_args() + sys.argv[1:]
# Launch Chrome
chrome = subprocess.Popen(chrome_args)
# Wait for Chrome to start listening
time.sleep(2)
# Forward 0.0.0.0:9222 -> 127.0.0.1:9223
# Chrome hardcodes 127.0.0.1 for security; socat exposes it to Docker network.
# TODO: Remove socat after patching binary to support --remote-debugging-address
socat = subprocess.Popen([
"socat",
f"TCP-LISTEN:{EXPOSE_PORT},fork,reuseaddr,bind=0.0.0.0",
f"TCP:127.0.0.1:{CHROME_PORT}",
])
print(f"CloakBrowser CDP server ready on port {EXPOSE_PORT}", flush=True)
print(f"CloakBrowser CDP server ready on port {PORT}", flush=True)
def cleanup(sig, frame):
chrome.terminate()
socat.terminate()
sys.exit(0)
signal.signal(signal.SIGTERM, cleanup)
signal.signal(signal.SIGINT, cleanup)
# Wait for Chrome to exit, then clean up socat
chrome.wait()
socat.terminate()
+15
View File
@@ -16,6 +16,18 @@ from .config import CHROMIUM_VERSION, get_default_stealth_args
from .download import binary_info, check_for_update, clear_cache, ensure_binary
from ._version import __version__
# Human-like behavioral layer (optional)
def __getattr__(name):
if name == "HumanConfig":
from .human.config import HumanConfig
globals()["HumanConfig"] = HumanConfig
return HumanConfig
if name == "resolve_human_config":
from .human.config import resolve_config
globals()["resolve_human_config"] = resolve_config
return resolve_config
raise AttributeError(f"module 'cloakbrowser' has no attribute {name}")
__all__ = [
"launch",
"launch_async",
@@ -29,5 +41,8 @@ __all__ = [
"CHROMIUM_VERSION",
"get_default_stealth_args",
"ProxySettings",
"HumanConfig",
"resolve_human_config",
"__version__",
]
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.3.10"
__version__ = "0.3.13"
+105 -38
View File
@@ -16,20 +16,18 @@ from __future__ import annotations
import logging
import os
import warnings
from typing import Any, Literal, TypedDict
from urllib.parse import unquote, urlparse, urlunparse
from .config import DEFAULT_VIEWPORT, get_default_stealth_args
from .config import DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS, get_default_stealth_args
from .download import ensure_binary
logger = logging.getLogger("cloakbrowser")
def _migrate_timezone_id(timezone: str | None, kwargs: dict[str, Any]) -> str | None:
"""Pop deprecated timezone_id from kwargs, warn, return resolved timezone."""
def _resolve_timezone(timezone: str | None, kwargs: dict[str, Any]) -> str | None:
"""Accept both timezone and timezone_id — either works, no warning."""
if "timezone_id" in kwargs:
warnings.warn("timezone_id is deprecated, use timezone instead", FutureWarning, stacklevel=3)
if timezone is None:
timezone = kwargs.pop("timezone_id")
else:
@@ -58,6 +56,9 @@ def launch(
locale: str | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: str = "default",
human_config: dict | None = None,
**kwargs: Any,
) -> Any:
"""Launch stealth Chromium browser. Returns a Playwright Browser object.
@@ -81,6 +82,9 @@ def launch(
Patchright suppresses CDP signals (helps reCAPTCHA v3 Enterprise)
but breaks proxy auth and add_init_script.
Override globally with CLOAKBROWSER_BACKEND env var.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
human_config: Custom humanize config dict to override preset values.
**kwargs: Passed directly to playwright.chromium.launch().
Returns:
@@ -107,7 +111,7 @@ def launch(
executable_path=binary_path,
headless=headless,
args=chrome_args,
ignore_default_args=["--enable-automation"],
ignore_default_args=IGNORE_DEFAULT_ARGS,
**_build_proxy_kwargs(proxy),
**kwargs,
)
@@ -121,10 +125,17 @@ def launch(
browser.close = _close_with_cleanup
# Human-like behavioral patching
if humanize:
from .human import patch_browser
from .human.config import resolve_config
cfg = resolve_config(human_preset, human_config)
patch_browser(browser, cfg)
return browser
async def launch_async(
async def launch_async( # noqa: C901
headless: bool = True,
proxy: str | ProxySettings | None = None,
args: list[str] | None = None,
@@ -133,6 +144,9 @@ async def launch_async(
locale: str | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: str = "default",
human_config: dict | None = None,
**kwargs: Any,
) -> Any:
"""Async version of launch(). Returns a Playwright Browser object.
@@ -146,6 +160,9 @@ async def launch_async(
locale: BCP 47 locale (e.g. 'en-US'). Sets --lang binary flag.
geoip: Auto-detect timezone/locale from proxy IP (default False).
backend: Playwright backend — 'playwright' (default) or 'patchright'.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
human_config: Custom humanize config dict to override preset values.
**kwargs: Passed directly to playwright.chromium.launch().
Returns:
@@ -177,7 +194,7 @@ async def launch_async(
executable_path=binary_path,
headless=headless,
args=chrome_args,
ignore_default_args=["--enable-automation"],
ignore_default_args=IGNORE_DEFAULT_ARGS,
**_build_proxy_kwargs(proxy),
**kwargs,
)
@@ -191,6 +208,13 @@ async def launch_async(
browser.close = _close_with_cleanup
# Human-like behavioral patching (async variant)
if humanize:
from .human import patch_browser_async
from .human.config import resolve_config
cfg = resolve_config(human_preset, human_config)
patch_browser_async(browser, cfg)
return browser
@@ -207,6 +231,9 @@ def launch_persistent_context(
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: str = "default",
human_config: dict | None = None,
**kwargs: Any,
) -> Any:
"""Launch stealth browser with a persistent profile and return a BrowserContext.
@@ -232,6 +259,9 @@ def launch_persistent_context(
geoip: Auto-detect timezone/locale from proxy IP (default False).
Requires ``pip install cloakbrowser[geoip]``.
backend: Playwright backend — 'playwright' (default) or 'patchright'.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
human_config: Custom humanize config dict to override preset values.
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
Returns:
@@ -247,7 +277,7 @@ def launch_persistent_context(
"""
sync_playwright = _import_sync_playwright(_resolve_backend(backend))
timezone = _migrate_timezone_id(timezone, kwargs)
timezone = _resolve_timezone(timezone, kwargs)
binary_path = ensure_binary()
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
@@ -259,14 +289,12 @@ def launch_persistent_context(
user_data_dir,
)
# locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
# — NOT via Playwright context kwargs which use detectable CDP emulation.
context_kwargs: dict[str, Any] = {}
if user_agent:
context_kwargs["user_agent"] = user_agent
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
if locale:
context_kwargs["locale"] = locale
if timezone:
context_kwargs["timezone_id"] = timezone
if color_scheme:
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs)
@@ -277,7 +305,7 @@ def launch_persistent_context(
executable_path=binary_path,
headless=headless,
args=chrome_args,
ignore_default_args=["--enable-automation"],
ignore_default_args=IGNORE_DEFAULT_ARGS,
**_build_proxy_kwargs(proxy),
**context_kwargs,
)
@@ -291,6 +319,13 @@ def launch_persistent_context(
context.close = _close_with_cleanup
# Human-like behavioral patching
if humanize:
from .human import patch_context
from .human.config import resolve_config
cfg = resolve_config(human_preset, human_config)
patch_context(context, cfg)
return context
@@ -307,6 +342,9 @@ async def launch_persistent_context_async(
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: str = "default",
human_config: dict | None = None,
**kwargs: Any,
) -> Any:
"""Async version of launch_persistent_context().
@@ -329,6 +367,9 @@ async def launch_persistent_context_async(
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
geoip: Auto-detect timezone/locale from proxy IP (default False).
backend: Playwright backend — 'playwright' (default) or 'patchright'.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
human_config: Custom humanize config dict to override preset values.
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
Returns:
@@ -349,7 +390,7 @@ async def launch_persistent_context_async(
"""
async_playwright = _import_async_playwright(_resolve_backend(backend))
timezone = _migrate_timezone_id(timezone, kwargs)
timezone = _resolve_timezone(timezone, kwargs)
binary_path = ensure_binary()
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
@@ -361,14 +402,12 @@ async def launch_persistent_context_async(
user_data_dir,
)
# locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
# — NOT via Playwright context kwargs which use detectable CDP emulation.
context_kwargs: dict[str, Any] = {}
if user_agent:
context_kwargs["user_agent"] = user_agent
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
if locale:
context_kwargs["locale"] = locale
if timezone:
context_kwargs["timezone_id"] = timezone
if color_scheme:
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs)
@@ -379,7 +418,7 @@ async def launch_persistent_context_async(
executable_path=binary_path,
headless=headless,
args=chrome_args,
ignore_default_args=["--enable-automation"],
ignore_default_args=IGNORE_DEFAULT_ARGS,
**_build_proxy_kwargs(proxy),
**context_kwargs,
)
@@ -393,6 +432,13 @@ async def launch_persistent_context_async(
context.close = _close_with_cleanup
# Human-like behavioral patching (async variant)
if humanize:
from .human import patch_context_async
from .human.config import resolve_config
cfg = resolve_config(human_preset, human_config)
patch_context_async(context, cfg)
return context
@@ -408,6 +454,9 @@ def launch_context(
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: str = "default",
human_config: dict | None = None,
**kwargs: Any,
) -> Any:
"""Launch stealth browser and return a BrowserContext with common options pre-set.
@@ -428,30 +477,29 @@ def launch_context(
Default: None (uses Chromium default, which is 'light').
geoip: Auto-detect timezone/locale from proxy IP (default False).
backend: Playwright backend — 'playwright' (default) or 'patchright'.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
human_config: Custom humanize config dict to override preset values.
**kwargs: Passed to browser.new_context().
Returns:
Playwright BrowserContext object.
"""
timezone = _migrate_timezone_id(timezone, kwargs)
timezone = _resolve_timezone(timezone, kwargs)
# Resolve geoip BEFORE launch() to avoid double-resolution and ensure
# resolved values flow to both binary flags AND context params
# resolved values flow to binary flags
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
# Skip --fingerprint-timezone binary flag: it only applies to the default
# context and interferes with Playwright's timezone_id on new contexts.
# Timezone is set via browser.new_context(timezone_id=...) below instead.
# --fingerprint-timezone is process-wide (reads CommandLine in renderer),
# so it applies to ALL contexts, not just the default one.
# locale and timezone are set via binary flags only — no CDP emulation.
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
timezone=None, locale=locale, backend=backend)
timezone=timezone, locale=locale, backend=backend)
context_kwargs: dict[str, Any] = {}
if user_agent:
context_kwargs["user_agent"] = user_agent
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
if locale:
context_kwargs["locale"] = locale
if timezone:
context_kwargs["timezone_id"] = timezone
if color_scheme:
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs)
@@ -471,6 +519,13 @@ def launch_context(
context.close = _close_context_with_cleanup
# Human-like behavioral patching
if humanize:
from .human import patch_context
from .human.config import resolve_config
cfg = resolve_config(human_preset, human_config)
patch_context(context, cfg)
return context
@@ -520,6 +575,11 @@ def _import_async_playwright(backend: str):
# ---------------------------------------------------------------------------
def _ensure_proxy_scheme(proxy_url: str) -> str:
"""Prepend http:// to schemeless proxy URLs so parsers can extract hostname."""
return proxy_url if "://" in proxy_url else f"http://{proxy_url}"
def _maybe_resolve_geoip(
geoip: bool,
proxy: str | ProxySettings | None,
@@ -535,6 +595,7 @@ def _maybe_resolve_geoip(
proxy_url = proxy.get("server") if isinstance(proxy, dict) else proxy
if not proxy_url:
return timezone, locale
proxy_url = _ensure_proxy_scheme(proxy_url)
geo_tz, geo_locale = resolve_proxy_geo(proxy_url)
if timezone is None:
timezone = geo_tz
@@ -575,11 +636,11 @@ def _build_args(
logger.debug("Arg override: %s -> %s", seen[key], flag)
seen[key] = flag
if locale:
key = "--lang"
flag = f"{key}={locale}"
if key in seen:
logger.debug("Arg override: %s -> %s", seen[key], flag)
seen[key] = flag
for key in ("--lang", "--fingerprint-locale"):
flag = f"{key}={locale}"
if key in seen:
logger.debug("Arg override: %s -> %s", seen[key], flag)
seen[key] = flag
return list(seen.values())
@@ -588,12 +649,18 @@ def _parse_proxy_url(proxy: str) -> dict[str, Any]:
"""Parse proxy URL, extracting credentials into separate Playwright fields.
Handles: http://user:pass@host:port -> {server: "http://host:port", username: "user", password: "pass"}
Also handles: no credentials, URL-encoded special chars, socks5://, missing port.
Also handles: no credentials, URL-encoded special chars, socks5://, missing port,
and bare proxy strings without a scheme (e.g. 'user:pass@host:port' -> treated as http).
"""
parsed = urlparse(proxy)
# Bare format: "user:pass@host:port" — urlparse needs a scheme to extract credentials.
normalized = proxy
if "@" in proxy and "://" not in proxy:
normalized = f"http://{proxy}"
parsed = urlparse(normalized)
if not parsed.username:
return {"server": proxy}
return {"server": proxy} # no creds — return original unchanged
# Rebuild server URL without credentials
netloc = parsed.hostname or ""
+12 -4
View File
@@ -15,15 +15,23 @@ 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.2"
CHROMIUM_VERSION = "145.0.7632.159.5"
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
"linux-x64": "145.0.7632.159.2",
"linux-x64": "145.0.7632.159.5",
"darwin-arm64": "145.0.7632.109.2",
"darwin-x64": "145.0.7632.109.2",
"windows-x64": "145.0.7632.109.2",
}
# ---------------------------------------------------------------------------
# Playwright default args to suppress — these leak automation signals.
# --enable-automation: exposes navigator.webdriver = true
# --enable-unsafe-swiftshader: forces software WebGL rendering via SwiftShader,
# producing a distinctive renderer string that no real user browser has
# ---------------------------------------------------------------------------
IGNORE_DEFAULT_ARGS = ["--enable-automation", "--enable-unsafe-swiftshader"]
# ---------------------------------------------------------------------------
# Default stealth arguments passed to the patched Chromium binary.
# These activate source-level fingerprint patches compiled into the binary.
@@ -56,8 +64,8 @@ def get_default_stealth_args() -> list[str]:
# auto-generated by the binary from the seed (v14+).
return base + [
"--fingerprint-platform=windows",
"--fingerprint-gpu-vendor=NVIDIA Corporation",
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
"--fingerprint-gpu-vendor=Google Inc. (NVIDIA)",
"--fingerprint-gpu-renderer=ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 (0x00002484) Direct3D11 vs_5_0 ps_5_0, D3D11)",
]
File diff suppressed because it is too large Load Diff
+194
View File
@@ -0,0 +1,194 @@
"""cloakbrowser-human — Configuration and presets.
All numeric parameters for human-like behavior are centralized here.
Two built-in presets: 'default' (normal human speed) and 'careful' (slower, more cautious).
"""
from __future__ import annotations
import math
import random
import time
from dataclasses import dataclass, field
from typing import Literal, Tuple
# ---------------------------------------------------------------------------
# Type alias
# ---------------------------------------------------------------------------
Range = Tuple[float, float]
HumanPreset = Literal["default", "careful"]
# ---------------------------------------------------------------------------
# Configuration dataclass
# ---------------------------------------------------------------------------
@dataclass
class HumanConfig:
"""All tunable parameters for human-like behavior."""
# Keyboard
typing_delay: float = 70
typing_delay_spread: float = 40
typing_pause_chance: float = 0.1
typing_pause_range: Range = (400, 1000)
shift_down_delay: Range = (30, 70)
shift_up_delay: Range = (20, 50)
key_hold: Range = (15, 35)
# Mistype (typo simulation)
mistype_chance: float = 0.02
mistype_delay_notice: Range = (100, 300)
mistype_delay_correct: Range = (50, 150)
field_switch_delay: Range = (800, 1500)
# Mouse — movement
mouse_steps_divisor: float = 8
mouse_min_steps: int = 25
mouse_max_steps: int = 80
mouse_wobble_max: float = 1.5
mouse_overshoot_chance: float = 0.15
mouse_overshoot_px: Range = (3, 6)
mouse_burst_size: Range = (3, 5)
mouse_burst_pause: Range = (8, 18)
# Mouse — clicks
click_aim_delay_input: Range = (60, 140)
click_aim_delay_button: Range = (80, 200)
click_hold_input: Range = (40, 100)
click_hold_button: Range = (60, 150)
click_input_x_range: Range = (0.05, 0.30)
# Mouse — idle
idle_drift_px: float = 3
idle_pause_range: Range = (300, 1000)
# Scroll
scroll_delta_base: Range = (80, 130)
scroll_delta_variance: float = 0.2
scroll_pause_fast: Range = (30, 80)
scroll_pause_slow: Range = (80, 200)
scroll_accel_steps: Range = (2, 3)
scroll_decel_steps: Range = (2, 3)
scroll_overshoot_chance: float = 0.1
scroll_overshoot_px: Range = (50, 150)
scroll_settle_delay: Range = (300, 600)
scroll_target_zone: Range = (0.20, 0.80)
scroll_pre_move_delay: Range = (100, 300)
# Initial cursor position (as if coming from the address bar area)
initial_cursor_x: Range = (400, 700)
initial_cursor_y: Range = (45, 60)
# Idle micro-movements between actions (opt-in, adds latency)
idle_between_actions: bool = False
idle_between_duration: Range = (0.3, 0.8)
# ---------------------------------------------------------------------------
# Presets
# ---------------------------------------------------------------------------
def _careful_config() -> HumanConfig:
"""Careful preset — everything slower and more deliberate."""
return HumanConfig(
# Keyboard — slower typing
typing_delay=100,
typing_delay_spread=50,
typing_pause_chance=0.15,
typing_pause_range=(500, 1200),
shift_down_delay=(40, 90),
shift_up_delay=(30, 70),
key_hold=(20, 45),
field_switch_delay=(1000, 2000),
# Mouse — slower, more precise
mouse_overshoot_chance=0.10,
mouse_burst_pause=(12, 25),
# Mouse — clicks (longer aiming and holding)
click_aim_delay_input=(80, 180),
click_aim_delay_button=(120, 280),
click_hold_input=(60, 140),
click_hold_button=(80, 200),
# Scroll — slower
scroll_pause_fast=(100, 200),
scroll_pause_slow=(250, 600),
scroll_settle_delay=(400, 800),
scroll_pre_move_delay=(150, 400),
# Idle between actions enabled for careful preset
idle_between_actions=True,
idle_between_duration=(0.4, 1.0),
)
_PRESETS: dict[str, HumanConfig] = {
"default": HumanConfig(),
"careful": _careful_config(),
}
def resolve_config(
preset: HumanPreset = "default",
overrides: dict | None = None,
) -> HumanConfig:
"""Resolve a preset name + optional overrides into a full HumanConfig.
Args:
preset: 'default' or 'careful'.
overrides: Dict of field names to override values.
Returns:
A new HumanConfig instance.
Raises:
ValueError: If preset is not a recognized name.
"""
if preset not in _PRESETS:
raise ValueError(
f"Unknown humanize preset {preset!r}. "
f"Valid presets: {', '.join(sorted(_PRESETS.keys()))}"
)
base = _PRESETS[preset]
if not overrides:
return HumanConfig(**{k: getattr(base, k) for k in base.__dataclass_fields__})
merged = {k: getattr(base, k) for k in base.__dataclass_fields__}
merged.update(overrides)
return HumanConfig(**merged)
# ---------------------------------------------------------------------------
# Utility functions
# ---------------------------------------------------------------------------
def rand(lo: float, hi: float) -> float:
"""Random float in [lo, hi]."""
return random.uniform(lo, hi)
def rand_int(lo: int, hi: int) -> int:
"""Random integer in [lo, hi] inclusive."""
return random.randint(lo, hi)
def rand_range(r: Range) -> float:
"""Random float from a (min, max) tuple."""
return random.uniform(r[0], r[1])
def rand_int_range(r: Range) -> int:
"""Random integer from a (min, max) tuple, inclusive."""
return random.randint(int(r[0]), int(r[1]))
def sleep_ms(ms: float) -> None:
"""Sleep for `ms` milliseconds."""
if ms > 0:
time.sleep(ms / 1000.0)
async def async_sleep_ms(ms: float) -> None:
"""Async sleep for `ms` milliseconds."""
if ms > 0:
import asyncio
await asyncio.sleep(ms / 1000.0)
+104
View File
@@ -0,0 +1,104 @@
"""cloakbrowser-human — Human-like keyboard input."""
from __future__ import annotations
import random
from typing import Any, Protocol
from .config import HumanConfig, rand, rand_range, sleep_ms
class RawKeyboard(Protocol):
def down(self, key: str) -> None: ...
def up(self, key: str) -> None: ...
def type(self, text: str) -> None: ...
def insert_text(self, text: str) -> None: ...
SHIFT_SYMBOLS = frozenset('@#!$%^&*()_+{}|:"<>?~')
NEARBY_KEYS = {
'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',
}
def _get_nearby_key(ch: str) -> str:
"""Return a random adjacent key for the given character."""
lower = ch.lower()
if lower in NEARBY_KEYS:
neighbors = NEARBY_KEYS[lower]
wrong = random.choice(neighbors)
return wrong.upper() if ch.isupper() else wrong
return ch
def human_type(page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig) -> None:
for i, ch in enumerate(text):
# Mistype chance — press wrong key, notice, backspace, then correct
if random.random() < cfg.mistype_chance and ch.isalnum():
wrong = _get_nearby_key(ch)
_type_normal_char(raw, wrong, cfg)
sleep_ms(rand_range(cfg.mistype_delay_notice))
raw.down("Backspace")
sleep_ms(rand_range(cfg.key_hold))
raw.up("Backspace")
sleep_ms(rand_range(cfg.mistype_delay_correct))
if ch.isupper() and ch.isalpha():
_type_shifted_char(page, raw, ch, cfg)
elif ch in SHIFT_SYMBOLS:
_type_shift_symbol(page, raw, ch, cfg)
else:
_type_normal_char(raw, ch, cfg)
if i < len(text) - 1:
_inter_char_delay(cfg)
def _type_normal_char(raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None:
raw.down(ch)
sleep_ms(rand_range(cfg.key_hold))
raw.up(ch)
def _type_shifted_char(page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None:
raw.down("Shift")
sleep_ms(rand_range(cfg.shift_down_delay))
raw.down(ch)
sleep_ms(rand_range(cfg.key_hold))
raw.up(ch)
sleep_ms(rand_range(cfg.shift_up_delay))
raw.up("Shift")
def _type_shift_symbol(page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None:
raw.down("Shift")
sleep_ms(rand_range(cfg.shift_down_delay))
raw.insert_text(ch)
page.evaluate(
"""(key) => {
const el = document.activeElement;
if (el) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
}
}""",
ch,
)
sleep_ms(rand_range(cfg.shift_up_delay))
raw.up("Shift")
def _inter_char_delay(cfg: HumanConfig) -> None:
if random.random() < cfg.typing_pause_chance:
sleep_ms(rand_range(cfg.typing_pause_range))
else:
delay = cfg.typing_delay + (random.random() - 0.5) * 2 * cfg.typing_delay_spread
sleep_ms(max(10, delay))
+85
View File
@@ -0,0 +1,85 @@
"""cloakbrowser-human — Async human-like keyboard input.
Mirrors keyboard.py but uses ``await`` for all Playwright calls and
``async_sleep_ms`` instead of ``sleep_ms``.
"""
from __future__ import annotations
import random
from typing import Any, Protocol
from .config import HumanConfig, rand, rand_range, async_sleep_ms
from .keyboard import SHIFT_SYMBOLS, NEARBY_KEYS, _get_nearby_key
class AsyncRawKeyboard(Protocol):
async def down(self, key: str) -> None: ...
async def up(self, key: str) -> None: ...
async def type(self, text: str) -> None: ...
async def insert_text(self, text: str) -> None: ...
async def async_human_type(page: Any, raw: AsyncRawKeyboard, text: str, cfg: HumanConfig) -> None:
for i, ch in enumerate(text):
# Mistype chance — press wrong key, notice, backspace, then correct
if random.random() < cfg.mistype_chance and ch.isalnum():
wrong = _get_nearby_key(ch)
await _type_normal_char(raw, wrong, cfg)
await async_sleep_ms(rand_range(cfg.mistype_delay_notice))
await raw.down("Backspace")
await async_sleep_ms(rand_range(cfg.key_hold))
await raw.up("Backspace")
await async_sleep_ms(rand_range(cfg.mistype_delay_correct))
if ch.isupper() and ch.isalpha():
await _type_shifted_char(page, raw, ch, cfg)
elif ch in SHIFT_SYMBOLS:
await _type_shift_symbol(page, raw, ch, cfg)
else:
await _type_normal_char(raw, ch, cfg)
if i < len(text) - 1:
await _inter_char_delay(cfg)
async def _type_normal_char(raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig) -> None:
await raw.down(ch)
await async_sleep_ms(rand_range(cfg.key_hold))
await raw.up(ch)
async def _type_shifted_char(page: Any, raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig) -> None:
await raw.down("Shift")
await async_sleep_ms(rand_range(cfg.shift_down_delay))
await raw.down(ch)
await async_sleep_ms(rand_range(cfg.key_hold))
await raw.up(ch)
await async_sleep_ms(rand_range(cfg.shift_up_delay))
await raw.up("Shift")
async def _type_shift_symbol(page: Any, raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig) -> None:
await raw.down("Shift")
await async_sleep_ms(rand_range(cfg.shift_down_delay))
await raw.insert_text(ch)
await page.evaluate(
"""(key) => {
const el = document.activeElement;
if (el) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
}
}""",
ch,
)
await async_sleep_ms(rand_range(cfg.shift_up_delay))
await raw.up("Shift")
async def _inter_char_delay(cfg: HumanConfig) -> None:
if random.random() < cfg.typing_pause_chance:
await async_sleep_ms(rand_range(cfg.typing_pause_range))
else:
delay = cfg.typing_delay + (random.random() - 0.5) * 2 * cfg.typing_delay_spread
await async_sleep_ms(max(10, delay))
+132
View File
@@ -0,0 +1,132 @@
"""cloakbrowser-human — Human-like mouse movement and clicking."""
from __future__ import annotations
import math
import random
from typing import Any, Protocol, Tuple
from .config import HumanConfig, rand, rand_range, rand_int_range, sleep_ms
class RawMouse(Protocol):
def move(self, x: float, y: float) -> None: ...
def down(self) -> None: ...
def up(self) -> None: ...
def wheel(self, delta_x: float, delta_y: float) -> None: ...
class Point:
__slots__ = ("x", "y")
def __init__(self, x: float, y: float):
self.x = x
self.y = y
def _ease_in_out(t: float) -> float:
if t < 0.5:
return 4 * t * t * t
return 1 - pow(-2 * t + 2, 3) / 2
def _bezier(p0: Point, p1: Point, p2: Point, p3: Point, t: float) -> Point:
u = 1 - t
uu = u * u
uuu = uu * u
tt = t * t
ttt = tt * t
return Point(
uuu * p0.x + 3 * uu * t * p1.x + 3 * u * tt * p2.x + ttt * p3.x,
uuu * p0.y + 3 * uu * t * p1.y + 3 * u * tt * p2.y + ttt * p3.y,
)
def _random_control_points(start: Point, end: Point) -> Tuple[Point, Point]:
dx = end.x - start.x
dy = end.y - start.y
dist = math.hypot(dx, dy) or 1
px = -dy / dist
py = dx / dist
bias1 = rand(-0.3, 0.3) * dist
bias2 = rand(-0.3, 0.3) * dist
return (
Point(start.x + dx * 0.25 + px * bias1, start.y + dy * 0.25 + py * bias1),
Point(start.x + dx * 0.75 + px * bias2, start.y + dy * 0.75 + py * bias2),
)
def human_move(
raw: RawMouse,
start_x: float, start_y: float,
end_x: float, end_y: float,
cfg: HumanConfig,
) -> None:
dist = math.hypot(end_x - start_x, end_y - start_y)
if dist < 1:
return
steps = max(cfg.mouse_min_steps, min(cfg.mouse_max_steps, round(dist / cfg.mouse_steps_divisor)))
start = Point(start_x, start_y)
end = Point(end_x, end_y)
cp1, cp2 = _random_control_points(start, end)
burst_counter = 0
burst_size = rand_int_range(cfg.mouse_burst_size)
for i in range(steps + 1):
progress = i / steps
eased_t = _ease_in_out(progress)
pt = _bezier(start, cp1, cp2, end, eased_t)
wobble_amp = math.sin(math.pi * progress) * cfg.mouse_wobble_max
wx = pt.x + (random.random() - 0.5) * 2 * wobble_amp
wy = pt.y + (random.random() - 0.5) * 2 * wobble_amp
raw.move(round(wx), round(wy))
burst_counter += 1
if burst_counter >= burst_size and i < steps:
sleep_ms(rand_range(cfg.mouse_burst_pause))
burst_counter = 0
if random.random() < cfg.mouse_overshoot_chance:
overshoot_dist = rand_range(cfg.mouse_overshoot_px)
angle = math.atan2(end_y - start_y, end_x - start_x)
raw.move(round(end_x + math.cos(angle) * overshoot_dist),
round(end_y + math.sin(angle) * overshoot_dist))
sleep_ms(rand(30, 70))
raw.move(round(end_x + (random.random() - 0.5) * 4),
round(end_y + (random.random() - 0.5) * 4))
def click_target(box: dict, is_input: bool, cfg: HumanConfig) -> Point:
if is_input:
x_frac = rand_range(cfg.click_input_x_range)
y_frac = rand(0.30, 0.70)
else:
x_frac = rand(0.35, 0.65)
y_frac = rand(0.35, 0.65)
return Point(round(box["x"] + box["width"] * x_frac),
round(box["y"] + box["height"] * y_frac))
def human_click(raw: RawMouse, is_input: bool, cfg: HumanConfig) -> None:
aim_delay = rand_range(cfg.click_aim_delay_input) if is_input else rand_range(cfg.click_aim_delay_button)
sleep_ms(aim_delay)
hold_time = rand_range(cfg.click_hold_input) if is_input else rand_range(cfg.click_hold_button)
raw.down()
sleep_ms(hold_time)
raw.up()
def human_idle(raw: RawMouse, seconds: float, cx: float, cy: float, cfg: HumanConfig) -> None:
import time as _time
end_time = _time.monotonic() + seconds
x, y = cx, cy
while _time.monotonic() < end_time:
dx = (random.random() - 0.5) * 2 * cfg.idle_drift_px
dy = (random.random() - 0.5) * 2 * cfg.idle_drift_px
x += dx
y += dy
raw.move(round(x), round(y))
sleep_ms(rand_range(cfg.idle_pause_range))
+87
View File
@@ -0,0 +1,87 @@
"""cloakbrowser-human — Async human-like mouse movement and clicking.
Mirrors mouse.py but uses ``await`` for all Playwright calls and
``async_sleep_ms`` instead of ``sleep_ms``.
"""
from __future__ import annotations
import math
import random
from typing import Any, Protocol
from .config import HumanConfig, rand, rand_range, rand_int_range, async_sleep_ms
from .mouse import Point, _ease_in_out, _bezier, _random_control_points, click_target # noqa: reuse pure math
class AsyncRawMouse(Protocol):
async def move(self, x: float, y: float) -> None: ...
async def down(self) -> None: ...
async def up(self) -> None: ...
async def wheel(self, delta_x: float, delta_y: float) -> None: ...
async def async_human_move(
raw: AsyncRawMouse,
start_x: float, start_y: float,
end_x: float, end_y: float,
cfg: HumanConfig,
) -> None:
dist = math.hypot(end_x - start_x, end_y - start_y)
if dist < 1:
return
steps = max(cfg.mouse_min_steps, min(cfg.mouse_max_steps, round(dist / cfg.mouse_steps_divisor)))
start = Point(start_x, start_y)
end = Point(end_x, end_y)
cp1, cp2 = _random_control_points(start, end)
burst_counter = 0
burst_size = rand_int_range(cfg.mouse_burst_size)
for i in range(steps + 1):
progress = i / steps
eased_t = _ease_in_out(progress)
pt = _bezier(start, cp1, cp2, end, eased_t)
wobble_amp = math.sin(math.pi * progress) * cfg.mouse_wobble_max
wx = pt.x + (random.random() - 0.5) * 2 * wobble_amp
wy = pt.y + (random.random() - 0.5) * 2 * wobble_amp
await raw.move(round(wx), round(wy))
burst_counter += 1
if burst_counter >= burst_size and i < steps:
await async_sleep_ms(rand_range(cfg.mouse_burst_pause))
burst_counter = 0
if random.random() < cfg.mouse_overshoot_chance:
overshoot_dist = rand_range(cfg.mouse_overshoot_px)
angle = math.atan2(end_y - start_y, end_x - start_x)
await raw.move(round(end_x + math.cos(angle) * overshoot_dist),
round(end_y + math.sin(angle) * overshoot_dist))
await async_sleep_ms(rand(30, 70))
await raw.move(round(end_x + (random.random() - 0.5) * 4),
round(end_y + (random.random() - 0.5) * 4))
async def async_human_click(raw: AsyncRawMouse, is_input: bool, cfg: HumanConfig) -> None:
aim_delay = rand_range(cfg.click_aim_delay_input) if is_input else rand_range(cfg.click_aim_delay_button)
await async_sleep_ms(aim_delay)
hold_time = rand_range(cfg.click_hold_input) if is_input else rand_range(cfg.click_hold_button)
await raw.down()
await async_sleep_ms(hold_time)
await raw.up()
async def async_human_idle(raw: AsyncRawMouse, seconds: float, cx: float, cy: float, cfg: HumanConfig) -> None:
import time as _time
end_time = _time.monotonic() + seconds
x, y = cx, cy
while _time.monotonic() < end_time:
dx = (random.random() - 0.5) * 2 * cfg.idle_drift_px
dy = (random.random() - 0.5) * 2 * cfg.idle_drift_px
x += dx
y += dy
await raw.move(round(x), round(y))
await async_sleep_ms(rand_range(cfg.idle_pause_range))
+132
View File
@@ -0,0 +1,132 @@
"""cloakbrowser-human — Human-like scrolling via mouse wheel events."""
from __future__ import annotations
import math
import random
from typing import Any, Optional, Tuple
from .config import HumanConfig, rand, rand_range, rand_int_range, sleep_ms
from .mouse import RawMouse, human_move
def _is_in_viewport(bounds: dict, viewport_height: int, cfg: HumanConfig) -> bool:
top_edge = bounds["y"]
bottom_edge = bounds["y"] + bounds["height"]
zone_top = viewport_height * cfg.scroll_target_zone[0]
zone_bottom = viewport_height * cfg.scroll_target_zone[1]
return top_edge >= zone_top and bottom_edge <= zone_bottom
def _get_element_box(page: Any, selector: str) -> Optional[dict]:
try:
el = page.locator(selector).first
return el.bounding_box(timeout=2000)
except Exception:
return None
def _smooth_wheel(raw: RawMouse, delta: int, cfg: HumanConfig) -> None:
"""Send one logical scroll as a burst of small wheel events (like real inertia)."""
abs_d = abs(delta)
sign = 1 if delta > 0 else -1
sent = 0
while sent < abs_d:
step_size = rand(20, 40)
chunk = min(step_size, abs_d - sent)
raw.wheel(0, round(chunk) * sign)
sent += chunk
sleep_ms(rand(8, 20))
def scroll_to_element(
page: Any,
raw: RawMouse,
selector: str,
cursor_x: float, cursor_y: float,
cfg: HumanConfig,
) -> Tuple[dict, float, float]:
viewport = page.viewport_size
if not viewport:
raise RuntimeError("Viewport size not available")
viewport_height = viewport["height"]
viewport_width = viewport["width"]
box = _get_element_box(page, selector)
if box is None:
sleep_ms(200)
box = _get_element_box(page, selector)
if box is None:
raise RuntimeError(f"Element not found: {selector}")
if _is_in_viewport(box, viewport_height, cfg):
return box, cursor_x, cursor_y
# Move cursor into scroll area
scroll_area_x = round(viewport_width * rand(0.3, 0.7))
scroll_area_y = round(viewport_height * rand(0.3, 0.7))
human_move(raw, cursor_x, cursor_y, scroll_area_x, scroll_area_y, cfg)
cursor_x = scroll_area_x
cursor_y = scroll_area_y
sleep_ms(rand_range(cfg.scroll_pre_move_delay))
# Calculate scroll distance
target_y = viewport_height * rand(cfg.scroll_target_zone[0], cfg.scroll_target_zone[1])
element_center = box["y"] + box["height"] / 2
distance_to_scroll = element_center - target_y
direction = 1 if distance_to_scroll > 0 else -1
abs_distance = abs(distance_to_scroll)
avg_delta = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2
total_clicks = max(3, math.ceil(abs_distance / avg_delta))
accel_steps = rand_int_range(cfg.scroll_accel_steps)
decel_steps = rand_int_range(cfg.scroll_decel_steps)
# Scroll loop: accelerate → cruise → decelerate
scrolled = 0
for i in range(total_clicks):
if i < accel_steps:
delta = rand(80, 100)
pause = rand_range(cfg.scroll_pause_slow)
elif i >= total_clicks - decel_steps:
delta = rand(60, 90)
pause = rand_range(cfg.scroll_pause_slow)
else:
delta = rand_range(cfg.scroll_delta_base)
pause = rand_range(cfg.scroll_pause_fast)
delta *= 1 + (random.random() - 0.5) * 2 * cfg.scroll_delta_variance
delta = round(delta) * direction
_smooth_wheel(raw, delta, cfg)
scrolled += abs(delta)
sleep_ms(pause)
# Check visibility every 3 steps
if i % 3 == 2 or i == total_clicks - 1:
box = _get_element_box(page, selector)
if box and _is_in_viewport(box, viewport_height, cfg):
break
if scrolled >= abs_distance * 1.1:
break
# Optional overshoot + correction
if random.random() < cfg.scroll_overshoot_chance:
overshoot_px = round(rand_range(cfg.scroll_overshoot_px)) * direction
_smooth_wheel(raw, overshoot_px, cfg)
sleep_ms(rand_range(cfg.scroll_settle_delay))
corrections = rand_int_range((1, 2))
for _ in range(corrections):
corr_delta = round(rand(40, 80)) * -direction
_smooth_wheel(raw, corr_delta, cfg)
sleep_ms(rand(100, 250))
# Settle
sleep_ms(rand_range(cfg.scroll_settle_delay))
box = _get_element_box(page, selector)
if box is None:
raise RuntimeError(f"Element lost after scrolling: {selector}")
return box, cursor_x, cursor_y
+129
View File
@@ -0,0 +1,129 @@
"""cloakbrowser-human — Async human-like scrolling via mouse wheel events.
Mirrors scroll.py but uses ``await`` for all Playwright calls and
``async_sleep_ms`` instead of ``sleep_ms``.
"""
from __future__ import annotations
import math
import random
from typing import Any, Optional, Tuple
from .config import HumanConfig, rand, rand_range, rand_int_range, async_sleep_ms
from .mouse_async import AsyncRawMouse, async_human_move
from .scroll import _is_in_viewport
async def _get_element_box_async(page: Any, selector: str) -> Optional[dict]:
try:
el = page.locator(selector).first
return await el.bounding_box(timeout=2000)
except Exception:
return None
async def _async_smooth_wheel(raw: AsyncRawMouse, delta: int, cfg: HumanConfig) -> None:
"""Send one logical scroll as a burst of small wheel events (like real inertia)."""
abs_d = abs(delta)
sign = 1 if delta > 0 else -1
sent = 0
while sent < abs_d:
step_size = rand(20, 40)
chunk = min(step_size, abs_d - sent)
await raw.wheel(0, round(chunk) * sign)
sent += chunk
await async_sleep_ms(rand(8, 20))
async def async_scroll_to_element(
page: Any,
raw: AsyncRawMouse,
selector: str,
cursor_x: float, cursor_y: float,
cfg: HumanConfig,
) -> Tuple[dict, float, float]:
viewport = page.viewport_size
if not viewport:
raise RuntimeError("Viewport size not available")
viewport_height = viewport["height"]
viewport_width = viewport["width"]
box = await _get_element_box_async(page, selector)
if box is None:
await async_sleep_ms(200)
box = await _get_element_box_async(page, selector)
if box is None:
raise RuntimeError(f"Element not found: {selector}")
if _is_in_viewport(box, viewport_height, cfg):
return box, cursor_x, cursor_y
# Move cursor into scroll area
scroll_area_x = round(viewport_width * rand(0.3, 0.7))
scroll_area_y = round(viewport_height * rand(0.3, 0.7))
await async_human_move(raw, cursor_x, cursor_y, scroll_area_x, scroll_area_y, cfg)
cursor_x = scroll_area_x
cursor_y = scroll_area_y
await async_sleep_ms(rand_range(cfg.scroll_pre_move_delay))
# Calculate scroll distance
target_y = viewport_height * rand(cfg.scroll_target_zone[0], cfg.scroll_target_zone[1])
element_center = box["y"] + box["height"] / 2
distance_to_scroll = element_center - target_y
direction = 1 if distance_to_scroll > 0 else -1
abs_distance = abs(distance_to_scroll)
avg_delta = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2
total_clicks = max(3, math.ceil(abs_distance / avg_delta))
accel_steps = rand_int_range(cfg.scroll_accel_steps)
decel_steps = rand_int_range(cfg.scroll_decel_steps)
# Scroll loop: accelerate → cruise → decelerate
scrolled = 0
for i in range(total_clicks):
if i < accel_steps:
delta = rand(80, 100)
pause = rand_range(cfg.scroll_pause_slow)
elif i >= total_clicks - decel_steps:
delta = rand(60, 90)
pause = rand_range(cfg.scroll_pause_slow)
else:
delta = rand_range(cfg.scroll_delta_base)
pause = rand_range(cfg.scroll_pause_fast)
delta *= 1 + (random.random() - 0.5) * 2 * cfg.scroll_delta_variance
delta = round(delta) * direction
await _async_smooth_wheel(raw, delta, cfg)
scrolled += abs(delta)
await async_sleep_ms(pause)
# Check visibility every 3 steps
if i % 3 == 2 or i == total_clicks - 1:
box = await _get_element_box_async(page, selector)
if box and _is_in_viewport(box, viewport_height, cfg):
break
if scrolled >= abs_distance * 1.1:
break
# Optional overshoot + correction
if random.random() < cfg.scroll_overshoot_chance:
overshoot_px = round(rand_range(cfg.scroll_overshoot_px)) * direction
await _async_smooth_wheel(raw, overshoot_px, cfg)
await async_sleep_ms(rand_range(cfg.scroll_settle_delay))
corrections = rand_int_range((1, 2))
for _ in range(corrections):
corr_delta = round(rand(40, 80)) * -direction
await _async_smooth_wheel(raw, corr_delta, cfg)
await async_sleep_ms(rand(100, 250))
# Settle
await async_sleep_ms(rand_range(cfg.scroll_settle_delay))
box = await _get_element_box_async(page, selector)
if box is None:
raise RuntimeError(f"Element lost after scrolling: {selector}")
return box, cursor_x, cursor_y
+3 -3
View File
@@ -11,7 +11,7 @@
Drop-in Playwright/Puppeteer replacement. Same API, same code — just swap the import. **3 lines of code, 30 seconds to unblock.**
- **26 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals
- **32 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals
- **0.9 reCAPTCHA v3 score** — human-level, server-verified
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
- **`npm install cloakbrowser`** — binary auto-downloads, auto-updates, zero config
@@ -81,7 +81,7 @@ const browser = await launch({
args: ['--fingerprint=12345'],
});
// With timezone and locale (sets --fingerprint-timezone and --lang binary flags)
// With timezone and locale
const browser = await launch({
timezone: 'America/New_York',
locale: 'en-US',
@@ -93,7 +93,7 @@ const browser = await launch({
geoip: true,
});
// Browser + context in one call (timezone/locale set both binary flags AND context)
// Browser + context in one call (timezone/locale set via binary flags)
const context = await launchContext({
userAgent: 'Custom UA',
viewport: { width: 1920, height: 1080 },
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "cloakbrowser",
"version": "0.2.0",
"version": "0.3.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cloakbrowser",
"version": "0.2.0",
"version": "0.3.9",
"license": "MIT",
"dependencies": {
"tar": "^7.0.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cloakbrowser",
"version": "0.3.10",
"version": "0.3.13",
"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",
+6 -5
View File
@@ -38,12 +38,13 @@ export function buildArgs(options: LaunchOptions): string[] {
seen.set(key, flag);
}
if (options.locale) {
const key = "--lang";
const flag = `${key}=${options.locale}`;
if (seen.has(key)) {
if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(key)} -> ${flag}`);
for (const k of ["--lang", "--fingerprint-locale"] as const) {
const flag = `${k}=${options.locale}`;
if (seen.has(k)) {
if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(k)} -> ${flag}`);
}
seen.set(k, flag);
}
seen.set(key, flag);
}
return [...seen.values()];
}
+12 -4
View File
@@ -27,10 +27,10 @@ export { WRAPPER_VERSION };
// CHROMIUM_VERSION is the latest across all platforms (for display/reference).
// Use getChromiumVersion() for the current platform's actual version.
// ---------------------------------------------------------------------------
export const CHROMIUM_VERSION = "145.0.7632.159.2";
export const CHROMIUM_VERSION = "145.0.7632.159.5";
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
"linux-x64": "145.0.7632.159.2",
"linux-x64": "145.0.7632.159.5",
"darwin-arm64": "145.0.7632.109.2",
"darwin-x64": "145.0.7632.109.2",
"windows-x64": "145.0.7632.109.2",
@@ -188,6 +188,14 @@ export function getLocalBinaryOverride(): string | undefined {
return process.env.CLOAKBROWSER_BINARY_PATH || undefined;
}
// ---------------------------------------------------------------------------
// Playwright default args to suppress — these leak automation signals.
// --enable-automation: exposes navigator.webdriver = true
// --enable-unsafe-swiftshader: forces software WebGL rendering via SwiftShader,
// producing a distinctive renderer string that no real user browser has
// ---------------------------------------------------------------------------
export const IGNORE_DEFAULT_ARGS = ["--enable-automation", "--enable-unsafe-swiftshader"];
// ---------------------------------------------------------------------------
// Default stealth arguments
// ---------------------------------------------------------------------------
@@ -222,7 +230,7 @@ export function getDefaultStealthArgs(): string[] {
return [
...base,
"--fingerprint-platform=windows",
"--fingerprint-gpu-vendor=NVIDIA Corporation",
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
"--fingerprint-gpu-vendor=Google Inc. (NVIDIA)",
"--fingerprint-gpu-renderer=ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 (0x00002484) Direct3D11 vs_5_0 ps_5_0, D3D11)",
];
}
+22
View File
@@ -14,6 +14,8 @@ import { createWriteStream } from "node:fs";
import dns from "node:dns/promises";
import net from "node:net";
import { getCacheDir } from "./config.js";
import type { LaunchOptions } from "./types.js";
import { ensureProxyScheme } from "./proxy.js";
// P3TERX mirror of MaxMind GeoLite2-City — no license key needed
const GEOIP_DB_URL =
@@ -260,3 +262,23 @@ function maybeTriggerUpdate(dbPath: string): void {
// Fire-and-forget background update
downloadGeoipDb(dbPath).catch(() => {});
}
/**
* Auto-fill timezone/locale from proxy IP when geoip is enabled.
* Shared by the Playwright and Puppeteer wrappers.
*/
export async function maybeResolveGeoip(
options: LaunchOptions
): Promise<{ timezone?: string; locale?: string }> {
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
let proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
proxyUrl = ensureProxyScheme(proxyUrl);
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
return {
timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined,
};
}
+232
View File
@@ -0,0 +1,232 @@
/**
* cloakbrowser-human — Configuration and presets.
*
* All numeric parameters for human-like behavior are centralized here.
* Two built-in presets: 'default' (normal human speed) and 'careful' (slower, more cautious).
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface HumanConfig {
// Keyboard
typing_delay: number;
typing_delay_spread: number;
typing_pause_chance: number;
typing_pause_range: [number, number];
shift_down_delay: [number, number];
shift_up_delay: [number, number];
key_hold: [number, number];
field_switch_delay: [number, number];
mistype_chance: number;
mistype_delay_notice: [number, number];
mistype_delay_correct: [number, number];
// Mouse — movement
mouse_steps_divisor: number;
mouse_min_steps: number;
mouse_max_steps: number;
mouse_wobble_max: number;
mouse_overshoot_chance: number;
mouse_overshoot_px: [number, number];
mouse_burst_size: [number, number];
mouse_burst_pause: [number, number];
// Mouse — clicks
click_aim_delay_input: [number, number];
click_aim_delay_button: [number, number];
click_hold_input: [number, number];
click_hold_button: [number, number];
click_input_x_range: [number, number];
// Mouse — idle
idle_drift_px: number;
idle_pause_range: [number, number];
// Scroll
scroll_delta_base: [number, number];
scroll_delta_variance: number;
scroll_pause_fast: [number, number];
scroll_pause_slow: [number, number];
scroll_accel_steps: [number, number];
scroll_decel_steps: [number, number];
scroll_overshoot_chance: number;
scroll_overshoot_px: [number, number];
scroll_settle_delay: [number, number];
scroll_target_zone: [number, number];
scroll_pre_move_delay: [number, number];
// Initial cursor position
initial_cursor_x: [number, number];
initial_cursor_y: [number, number];
// Idle micro-movements between actions (opt-in, adds latency)
idle_between_actions: boolean;
idle_between_duration: [number, number];
}
export type HumanPreset = 'default' | 'careful';
// ---------------------------------------------------------------------------
// Default preset
// ---------------------------------------------------------------------------
const DEFAULT_CONFIG: HumanConfig = {
// Keyboard
typing_delay: 70,
typing_delay_spread: 40,
typing_pause_chance: 0.1,
typing_pause_range: [400, 1000],
shift_down_delay: [30, 70],
shift_up_delay: [20, 50],
key_hold: [15, 35],
field_switch_delay: [800, 1500],
// Mistype (typo simulation)
mistype_chance: 0.02,
mistype_delay_notice: [100, 300],
mistype_delay_correct: [50, 150],
// Mouse — movement
mouse_steps_divisor: 8,
mouse_min_steps: 25,
mouse_max_steps: 80,
mouse_wobble_max: 1.5,
mouse_overshoot_chance: 0.15,
mouse_overshoot_px: [3, 6],
mouse_burst_size: [3, 5],
mouse_burst_pause: [8, 18],
// Mouse — clicks
click_aim_delay_input: [60, 140],
click_aim_delay_button: [80, 200],
click_hold_input: [40, 100],
click_hold_button: [60, 150],
click_input_x_range: [0.05, 0.30],
// Mouse — idle
idle_drift_px: 3,
idle_pause_range: [300, 1000],
// Scroll
scroll_delta_base: [80, 130],
scroll_delta_variance: 0.2,
scroll_pause_fast: [30, 80],
scroll_pause_slow: [80, 200],
scroll_accel_steps: [2, 3],
scroll_decel_steps: [2, 3],
scroll_overshoot_chance: 0.1,
scroll_overshoot_px: [50, 150],
scroll_settle_delay: [300, 600],
scroll_target_zone: [0.20, 0.80],
scroll_pre_move_delay: [100, 300],
// Initial cursor position (as if coming from the address bar area)
initial_cursor_x: [400, 700],
initial_cursor_y: [45, 60],
// Idle micro-movements between actions (off by default)
idle_between_actions: false,
idle_between_duration: [0.3, 0.8],
};
// ---------------------------------------------------------------------------
// Careful preset — everything slower and more deliberate
// ---------------------------------------------------------------------------
const CAREFUL_CONFIG: HumanConfig = {
...DEFAULT_CONFIG,
// Keyboard — slower typing
typing_delay: 100,
typing_delay_spread: 50,
typing_pause_chance: 0.15,
typing_pause_range: [500, 1200],
shift_down_delay: [40, 90],
shift_up_delay: [30, 70],
key_hold: [20, 45],
field_switch_delay: [1000, 2000],
mistype_chance: 0.03,
mistype_delay_notice: [150, 400],
mistype_delay_correct: [80, 200],
// Mouse — slower, more precise
mouse_overshoot_chance: 0.10,
mouse_burst_pause: [12, 25],
// Mouse — clicks (longer aiming and holding)
click_aim_delay_input: [80, 180],
click_aim_delay_button: [120, 280],
click_hold_input: [60, 140],
click_hold_button: [80, 200],
// Scroll — slower
scroll_pause_fast: [100, 200],
scroll_pause_slow: [250, 600],
scroll_settle_delay: [400, 800],
scroll_pre_move_delay: [150, 400],
// Idle between actions enabled for careful preset
idle_between_actions: true,
idle_between_duration: [0.4, 1.0],
};
// ---------------------------------------------------------------------------
// Preset map
// ---------------------------------------------------------------------------
const PRESETS: Record<HumanPreset, HumanConfig> = {
default: DEFAULT_CONFIG,
careful: CAREFUL_CONFIG,
};
/**
* Resolve a preset name or partial config into a full HumanConfig.
* If `preset` is a string, returns the corresponding built-in config.
* Any keys in `overrides` replace the preset values.
*/
export function resolveConfig(
preset: HumanPreset = 'default',
overrides?: Partial<HumanConfig>,
): HumanConfig {
const base = PRESETS[preset];
if (!base) {
throw new Error(
`Unknown humanize preset "${preset}". Valid presets: ${Object.keys(PRESETS).join(', ')}`
);
}
if (!overrides) return { ...base };
return { ...base, ...overrides };
}
// ---------------------------------------------------------------------------
// Utility: random number in range
// ---------------------------------------------------------------------------
/** Random float in [min, max]. */
export function rand(min: number, max: number): number {
return min + Math.random() * (max - min);
}
/** Random integer in [min, max] (inclusive). */
export function randInt(min: number, max: number): number {
return Math.floor(rand(min, max + 1));
}
/** Random value from a [min, max] tuple. */
export function randRange(range: [number, number]): number {
return rand(range[0], range[1]);
}
/** Random integer from a [min, max] tuple. */
export function randIntRange(range: [number, number]): number {
return randInt(range[0], range[1]);
}
/** Sleep for `ms` milliseconds. */
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
+478
View File
@@ -0,0 +1,478 @@
/**
* Human-like behavioral layer for cloakbrowser (JS/TS).
*
* Activated via humanize: true in launch() / launchContext().
* Patches page methods to use Bezier mouse curves, realistic typing, and smooth scrolling.
*
* Patches all interaction methods:
* click, dblclick, hover, type, fill, check, uncheck, selectOption,
* press, pressSequentially, tap, dragTo, clear + Frame-level equivalents.
*/
import type { Browser, BrowserContext, Page, Frame } from 'playwright-core';
import { HumanConfig, resolveConfig, rand, randRange, sleep } from './config.js';
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
import { humanType } from './keyboard.js';
import { scrollToElement } from './scroll.js';
export { HumanConfig, resolveConfig } from './config.js';
export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
export { humanType } from './keyboard.js';
export { scrollToElement } from './scroll.js';
// --- Platform-aware select-all shortcut (macOS uses Meta, others use Control) ---
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
class CursorState {
x = 0;
y = 0;
initialized = false;
}
async function isInputElement(page: Page, selector: string): Promise<boolean> {
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(page: Page, selector: string): Promise<boolean> {
return page.evaluate((sel: string) => {
const el = document.querySelector(sel);
return el === document.activeElement;
}, selector).catch(() => false);
}
// ============================================================================
// Page-level patching
// ============================================================================
/**
* Replace page methods with human-like implementations.
*/
function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
const originals = {
click: page.click.bind(page),
dblclick: page.dblclick.bind(page),
hover: page.hover.bind(page),
type: page.type.bind(page),
fill: page.fill.bind(page),
check: page.check.bind(page),
uncheck: page.uncheck.bind(page),
selectOption: page.selectOption.bind(page),
press: page.press.bind(page),
goto: page.goto.bind(page),
isChecked: page.isChecked.bind(page),
mouseMove: page.mouse.move.bind(page.mouse),
mouseClick: page.mouse.click.bind(page.mouse),
mouseDblclick: page.mouse.dblclick.bind(page.mouse),
mouseWheel: page.mouse.wheel.bind(page.mouse),
mouseDown: page.mouse.down.bind(page.mouse),
mouseUp: page.mouse.up.bind(page.mouse),
keyboardType: page.keyboard.type.bind(page.keyboard),
keyboardDown: page.keyboard.down.bind(page.keyboard),
keyboardUp: page.keyboard.up.bind(page.keyboard),
keyboardPress: page.keyboard.press.bind(page.keyboard),
keyboardInsertText: page.keyboard.insertText.bind(page.keyboard),
};
(page as any)._original = originals;
(page as any)._humanCfg = cfg;
const raw: RawMouse = {
move: originals.mouseMove,
down: originals.mouseDown,
up: originals.mouseUp,
wheel: originals.mouseWheel,
};
const rawKb: RawKeyboard = {
down: originals.keyboardDown,
up: originals.keyboardUp,
type: originals.keyboardType,
insertText: originals.keyboardInsertText,
};
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);
// Patch any new frames after navigation
patchFrames(page, cfg, cursor, raw, rawKb, originals);
return response;
};
// --- click ---
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(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;
await humanClick(raw, isInput, cfg);
};
// --- dblclick ---
const humanDblclickFn = 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(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;
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
};
// --- 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));
await humanType(page, rawKb, text, cfg);
};
// --- fill (clears existing content first) ---
const humanFillFn = async (selector: string, value: string, options?: any) => {
await sleep(randRange(cfg.field_switch_delay));
await humanClickFn(selector);
await sleep(rand(100, 250));
await originals.keyboardPress(SELECT_ALL);
await sleep(rand(30, 80));
await originals.keyboardPress('Backspace');
await sleep(rand(50, 150));
await humanType(page, rawKb, value, cfg);
};
// --- clear ---
const humanClearFn = async (selector: string, options?: any) => {
if (!await isSelectorFocused(page, selector)) {
await humanClickFn(selector);
}
await sleep(rand(50, 150));
await originals.keyboardPress(SELECT_ALL);
await sleep(rand(30, 80));
await originals.keyboardPress('Backspace');
};
// --- check ---
const humanCheckFn = async (selector: string, options?: any) => {
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 checked = await originals.isChecked(selector).catch(() => false);
if (!checked) {
await humanClickFn(selector);
}
};
// --- uncheck ---
const humanUncheckFn = async (selector: string, options?: any) => {
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 checked = await originals.isChecked(selector).catch(() => true);
if (checked) {
await humanClickFn(selector);
}
};
// --- selectOption ---
const humanSelectOptionFn = async (selector: string, values: any, options?: any) => {
await humanHoverFn(selector);
await sleep(rand(100, 300));
return originals.selectOption(selector, values, options);
};
// --- press (checks focus first — avoids redundant mouse moves) ---
const humanPressFn = async (selector: string, key: string, options?: any) => {
if (!await isSelectorFocused(page, selector)) {
await humanClickFn(selector);
}
await sleep(rand(50, 150));
await originals.keyboardPress(key);
};
// --- pressSequentially ---
const humanPressSequentiallyFn = async (selector: string, text: string, options?: any) => {
if (!await isSelectorFocused(page, selector)) {
await humanClickFn(selector);
}
await sleep(rand(100, 250));
await humanType(page, rawKb, text, cfg);
};
// --- 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).dblclick = humanDblclickFn;
(page as any).hover = humanHoverFn;
(page as any).type = humanTypeFn;
(page as any).fill = humanFillFn;
(page as any).check = humanCheckFn;
(page as any).uncheck = humanUncheckFn;
(page as any).selectOption = humanSelectOptionFn;
(page as any).press = humanPressFn;
// --- 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;
await humanClick(raw, false, cfg);
};
// --- keyboard patches ---
page.keyboard.type = async (text: string, options?: any) => {
await humanType(page, rawKb, text, cfg);
};
// Store helpers for frame patching
(page as any)._humanCursor = cursor;
(page as any)._humanRaw = raw;
(page as any)._humanRawKb = rawKb;
(page as any)._humanOriginals = originals;
(page as any)._humanClickFn = humanClickFn;
(page as any)._humanHoverFn = humanHoverFn;
(page as any)._humanClearFn = humanClearFn;
(page as any)._humanPressFn = humanPressFn;
(page as any)._humanPressSequentiallyFn = humanPressSequentiallyFn;
(page as any)._humanTapFn = humanTapFn;
(page as any)._ensureCursorInit = ensureCursorInit;
// Initialize cursor immediately so it doesn't visibly jump from (0,0)
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]);
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]);
originals.mouseMove(cursor.x, cursor.y).then(() => {
cursor.initialized = true;
}).catch(() => {});
// --- Patch Frame-level methods (for sub-frames) ---
patchFrames(page, cfg, cursor, raw, rawKb, originals);
}
// ============================================================================
// Frame-level patching
// ============================================================================
/**
* 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.
*/
function patchFrames(
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
): void {
for (const frame of iterFrames(page)) {
patchSingleFrame(frame, page, cfg, originals);
}
}
function patchSingleFrame(frame: Frame, page: Page, cfg: HumanConfig, originals: any): void {
if ((frame as any)._humanPatched) return;
(frame as any)._humanPatched = true;
// Save originals for methods that need fallback
const origFrameSelectOption = frame.selectOption.bind(frame);
const origFrameDragAndDrop = frame.dragAndDrop.bind(frame);
(frame as any).click = async (selector: string, options?: any) => {
await (page as any).click(selector, options);
};
(frame as any).dblclick = async (selector: string, options?: any) => {
await (page as any).dblclick(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).fill = async (selector: string, value: string, options?: any) => {
await (page as any).fill(selector, value, options);
};
(frame as any).check = async (selector: string, options?: any) => {
await (page as any).check(selector, options);
};
(frame as any).uncheck = async (selector: string, options?: any) => {
await (page as any).uncheck(selector, options);
};
(frame as any).selectOption = async (selector: string, values: any, options?: any) => {
await (page as any).hover(selector);
await sleep(rand(100, 300));
return origFrameSelectOption(selector, values, options);
};
(frame as any).press = async (selector: string, key: string, options?: any) => {
await (page as any).press(selector, key, options);
};
(frame as any).clear = async (selector: string, options?: any) => {
if (!await isSelectorFocused(page, selector)) {
await (page as any).click(selector);
}
await sleep(rand(50, 150));
await originals.keyboardPress(SELECT_ALL);
await sleep(rand(30, 80));
await originals.keyboardPress('Backspace');
};
(frame as any).dragAndDrop = async (source: string, target: string, options?: any) => {
const srcBox = await frame.locator(source).boundingBox().catch(() => null);
const tgtBox = await frame.locator(target).boundingBox().catch(() => null);
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.mouse.move(sx, sy);
await sleep(rand(100, 200));
await originals.mouseDown();
await sleep(rand(80, 150));
await page.mouse.move(tx, ty);
await sleep(rand(80, 150));
await originals.mouseUp();
} else {
return origFrameDragAndDrop(source, target, options);
}
};
}
function* iterFrames(page: Page): Generator<Frame> {
try {
const mainFrame = page.mainFrame();
yield mainFrame;
for (const child of mainFrame.childFrames()) {
yield child;
}
} catch {}
}
// ============================================================================
// Context-level patching
// ============================================================================
function patchContext(context: BrowserContext, cfg: HumanConfig): void {
const cursor = new CursorState();
for (const page of context.pages()) {
patchPage(page, cfg, cursor);
}
context.on('page', (page: Page) => {
if (!(page as any)._original) {
patchPage(page, cfg, new CursorState());
}
});
const origNewPage = context.newPage.bind(context);
(context as any).newPage = async () => {
const page = await origNewPage();
if (!(page as any)._original) {
patchPage(page, cfg, new CursorState());
}
return page;
};
}
// ============================================================================
// Browser-level patching
// ============================================================================
export function patchBrowser(browser: Browser, cfg: HumanConfig): void {
for (const context of browser.contexts()) {
patchContext(context, cfg);
}
const origNewContext = browser.newContext.bind(browser);
(browser as any).newContext = async (options?: any) => {
const context = await origNewContext(options);
patchContext(context, cfg);
return context;
};
const origNewPage = browser.newPage.bind(browser);
(browser as any).newPage = async (options?: any) => {
const page = await origNewPage(options);
if (!(page as any)._original) {
const ctx = page.context();
if (!(ctx as any)._humanPatched) {
patchContext(ctx, cfg);
(ctx as any)._humanPatched = true;
}
patchPage(page, cfg, new CursorState());
}
return page;
};
}
export { patchContext, patchPage };
+111
View File
@@ -0,0 +1,111 @@
/**
* cloakbrowser-human — Human-like keyboard input.
*/
import type { Page } from 'playwright-core';
import { RawKeyboard } from './mouse.js';
import { HumanConfig, rand, randRange, sleep } from './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',
};
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;
}
export async function humanType(
page: Page,
raw: RawKeyboard,
text: string,
cfg: HumanConfig,
): Promise<void> {
for (let i = 0; i < text.length; i++) {
const ch = text[i];
// Mistype chance — press wrong key, notice, backspace, then correct
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);
} else {
await typeNormalChar(raw, ch, cfg);
}
if (i < text.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): Promise<void> {
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');
}
function isUpperCase(ch: string): boolean {
return ch.length === 1 && ch >= 'A' && ch <= 'Z';
}
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));
}
}
+193
View File
@@ -0,0 +1,193 @@
/**
* cloakbrowser-human — Human-like mouse movement and clicking.
*/
import { HumanConfig, rand, randRange, randIntRange, sleep } from './config.js';
// ---------------------------------------------------------------------------
// Raw interface — original Playwright methods, bypassing the wrapper
// ---------------------------------------------------------------------------
export interface RawMouse {
move: (x: number, y: number) => Promise<void>;
down: (options?: any) => Promise<void>;
up: (options?: any) => Promise<void>;
wheel: (deltaX: number, deltaY: number) => Promise<void>;
}
export interface RawKeyboard {
down: (key: string) => Promise<void>;
up: (key: string) => Promise<void>;
type: (text: string) => Promise<void>;
insertText: (text: string) => Promise<void>;
}
// ---------------------------------------------------------------------------
// Easing
// ---------------------------------------------------------------------------
function easeInOut(t: number): number {
return t < 0.5
? 4 * t * t * t
: 1 - Math.pow(-2 * t + 2, 3) / 2;
}
// ---------------------------------------------------------------------------
// Bezier
// ---------------------------------------------------------------------------
interface Point {
x: number;
y: number;
}
function bezier(p0: Point, p1: Point, p2: Point, p3: Point, t: number): Point {
const u = 1 - t;
const uu = u * u;
const uuu = uu * u;
const tt = t * t;
const ttt = tt * t;
return {
x: uuu * p0.x + 3 * uu * t * p1.x + 3 * u * tt * p2.x + ttt * p3.x,
y: uuu * p0.y + 3 * uu * t * p1.y + 3 * u * tt * p2.y + ttt * p3.y,
};
}
function randomControlPoints(start: Point, end: Point): [Point, Point] {
const dx = end.x - start.x;
const dy = end.y - start.y;
const dist = Math.hypot(dx, dy);
const px = -dy / (dist || 1);
const py = dx / (dist || 1);
const bias1 = rand(-0.3, 0.3) * dist;
const bias2 = rand(-0.3, 0.3) * dist;
return [
{ x: start.x + dx * 0.25 + px * bias1, y: start.y + dy * 0.25 + py * bias1 },
{ x: start.x + dx * 0.75 + px * bias2, y: start.y + dy * 0.75 + py * bias2 },
];
}
// ---------------------------------------------------------------------------
// Human mouse movement
// ---------------------------------------------------------------------------
export async function humanMove(
raw: RawMouse,
startX: number,
startY: number,
endX: number,
endY: number,
cfg: HumanConfig,
): Promise<void> {
const dist = Math.hypot(endX - startX, endY - startY);
if (dist < 1) return;
const steps = Math.max(
cfg.mouse_min_steps,
Math.min(cfg.mouse_max_steps, Math.round(dist / cfg.mouse_steps_divisor)),
);
const start: Point = { x: startX, y: startY };
const end: Point = { x: endX, y: endY };
const [cp1, cp2] = randomControlPoints(start, end);
let burstCounter = 0;
const burstSize = randIntRange(cfg.mouse_burst_size);
for (let i = 0; i <= steps; i++) {
const progress = i / steps;
const easedT = easeInOut(progress);
const pt = bezier(start, cp1, cp2, end, easedT);
const wobbleAmp = Math.sin(Math.PI * progress) * cfg.mouse_wobble_max;
const wx = pt.x + (Math.random() - 0.5) * 2 * wobbleAmp;
const wy = pt.y + (Math.random() - 0.5) * 2 * wobbleAmp;
await raw.move(Math.round(wx), Math.round(wy));
burstCounter++;
if (burstCounter >= burstSize && i < steps) {
await sleep(randRange(cfg.mouse_burst_pause));
burstCounter = 0;
}
}
if (Math.random() < cfg.mouse_overshoot_chance) {
const overshootDist = randRange(cfg.mouse_overshoot_px);
const angle = Math.atan2(endY - startY, endX - startX);
const ovX = Math.round(endX + Math.cos(angle) * overshootDist);
const ovY = Math.round(endY + Math.sin(angle) * overshootDist);
await raw.move(ovX, ovY);
await sleep(rand(30, 70));
const corrX = Math.round(endX + (Math.random() - 0.5) * 4);
const corrY = Math.round(endY + (Math.random() - 0.5) * 4);
await raw.move(corrX, corrY);
}
}
// ---------------------------------------------------------------------------
// Human click
// ---------------------------------------------------------------------------
export function clickTarget(
box: { x: number; y: number; width: number; height: number },
isInput: boolean,
cfg: HumanConfig,
): Point {
if (isInput) {
const xFrac = randRange(cfg.click_input_x_range);
const yFrac = rand(0.30, 0.70);
return {
x: Math.round(box.x + box.width * xFrac),
y: Math.round(box.y + box.height * yFrac),
};
}
const xFrac = rand(0.35, 0.65);
const yFrac = rand(0.35, 0.65);
return {
x: Math.round(box.x + box.width * xFrac),
y: Math.round(box.y + box.height * yFrac),
};
}
export async function humanClick(
raw: RawMouse,
isInput: boolean,
cfg: HumanConfig,
): Promise<void> {
const aimDelay = isInput
? randRange(cfg.click_aim_delay_input)
: randRange(cfg.click_aim_delay_button);
await sleep(aimDelay);
const holdTime = isInput
? randRange(cfg.click_hold_input)
: randRange(cfg.click_hold_button);
await raw.down();
await sleep(holdTime);
await raw.up();
}
// ---------------------------------------------------------------------------
// Human idle / drift
// ---------------------------------------------------------------------------
export async function humanIdle(
raw: RawMouse,
seconds: number,
cx: number,
cy: number,
cfg: HumanConfig,
): Promise<void> {
const endTime = Date.now() + seconds * 1000;
let x = cx;
let y = cy;
while (Date.now() < endTime) {
const dx = (Math.random() - 0.5) * 2 * cfg.idle_drift_px;
const dy = (Math.random() - 0.5) * 2 * cfg.idle_drift_px;
x += dx;
y += dy;
await raw.move(Math.round(x), Math.round(y));
await sleep(randRange(cfg.idle_pause_range));
}
}
+150
View File
@@ -0,0 +1,150 @@
/**
* cloakbrowser-human — Human-like scrolling via mouse wheel events.
*/
import type { Page } from 'playwright-core';
import { HumanConfig, rand, randRange, randIntRange, sleep } from './config.js';
import { RawMouse, humanMove } from './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;
}
async function smoothWheel(raw: RawMouse, delta: number, cfg: HumanConfig): 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);
await raw.wheel(0, Math.round(chunk) * sign);
sent += chunk;
await sleep(rand(8, 20));
}
}
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.viewportSize();
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;
// Scroll loop: accelerate → cruise → decelerate
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);
// Check visibility every 3 steps
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));
}
}
// Settle
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 };
}
async function getElementBox(page: Page, selector: string): Promise<ElementBounds | null> {
const el = page.locator(selector).first();
try {
const box = await el.boundingBox({ timeout: 2000 });
return box;
} catch {
return null;
}
}
+47 -32
View File
@@ -5,15 +5,15 @@
import type { Browser, BrowserContext } from "playwright-core";
import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js";
import { DEFAULT_VIEWPORT } from "./config.js";
import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js";
import { maybeResolveGeoip } from "./geoip.js";
/** @internal Migrate deprecated timezoneId → timezone, warn once. Exported for testing. */
export function migrateTimezoneId<T extends { timezone?: string; timezoneId?: string }>(options: T): T {
/** @internal Accept both timezone and timezoneId — either works, no warning. Exported for testing. */
export function resolveTimezone<T extends { timezone?: string; timezoneId?: string }>(options: T): T {
if (options.timezoneId != null) {
console.warn("[cloakbrowser] timezoneId is deprecated, use timezone instead");
const merged = { ...options, timezone: options.timezone ?? options.timezoneId };
delete (merged as any).timezoneId;
return merged;
@@ -45,13 +45,24 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
executablePath: binaryPath,
headless: options.headless ?? true,
args,
ignoreDefaultArgs: ["--enable-automation"],
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
...(options.proxy
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
: {}),
...options.launchOptions,
});
// Human-like behavioral patching
if (options.humanize) {
const { patchBrowser } = await import('./human/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;
}
@@ -74,21 +85,19 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
export async function launchContext(
options: LaunchContextOptions = {}
): Promise<BrowserContext> {
options = migrateTimezoneId(options);
options = resolveTimezone(options);
// Resolve geoip BEFORE launch() to avoid double-resolution
const resolved = await maybeResolveGeoip(options);
// Skip --fingerprint-timezone binary flag: it only applies to the default
// context and interferes with Playwright's timezoneId on new contexts.
// Timezone is set via browser.newContext(timezoneId: ...) below instead.
const browser = await launch({ ...options, ...resolved, geoip: false, timezone: undefined });
// --fingerprint-timezone is process-wide (reads CommandLine in renderer),
// so it applies to ALL contexts, not just the default one.
// locale and timezone are set via binary flags only — no CDP emulation.
const browser = await launch({ ...options, ...resolved, geoip: false });
let context: BrowserContext;
try {
context = await browser.newContext({
...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport ?? DEFAULT_VIEWPORT,
...(resolved.locale ? { locale: resolved.locale } : {}),
...(resolved.timezone ? { timezoneId: resolved.timezone } : {}),
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
});
} catch (err) {
@@ -103,6 +112,17 @@ export async function launchContext(
await browser.close();
};
// Human-like behavioral patching
if (options.humanize) {
const { patchContext } = await import('./human/index.js');
const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig(
(options.humanPreset as any) ?? 'default',
options.humanConfig as any,
);
patchContext(context, cfg);
}
return context;
}
@@ -130,29 +150,40 @@ export async function launchContext(
export async function launchPersistentContext(
options: LaunchPersistentContextOptions
): Promise<BrowserContext> {
options = migrateTimezoneId(options);
options = resolveTimezone(options);
const { chromium } = await import("playwright-core");
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const resolved = await maybeResolveGeoip(options);
const args = buildArgs({ ...options, ...resolved });
// locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
// — NOT via Playwright context kwargs which use detectable CDP emulation.
const context = await chromium.launchPersistentContext(options.userDataDir, {
executablePath: binaryPath,
headless: options.headless ?? true,
args,
ignoreDefaultArgs: ["--enable-automation"],
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
...(options.proxy
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
: {}),
...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport ?? DEFAULT_VIEWPORT,
...(resolved.locale ? { locale: resolved.locale } : {}),
...(resolved.timezone ? { timezoneId: resolved.timezone } : {}),
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
...options.launchOptions,
});
// Human-like behavioral patching
if (options.humanize) {
const { patchContext } = await import('./human/index.js');
const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig(
(options.humanPreset as any) ?? 'default',
options.humanConfig as any,
);
patchContext(context, cfg);
}
return context;
}
@@ -160,21 +191,5 @@ export async function launchPersistentContext(
// Internal
// ---------------------------------------------------------------------------
async function maybeResolveGeoip(
options: LaunchOptions
): Promise<{ timezone?: string; locale?: string }> {
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
const { resolveProxyGeo } = await import("./geoip.js");
const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
return {
timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined,
};
}
/** @internal Exposed for unit tests only. */
export { buildArgs as _buildArgsForTest } from "./args.js";
+14 -2
View File
@@ -8,16 +8,28 @@ export interface ParsedProxy {
password?: string;
}
/**
* Prepend http:// to schemeless proxy URLs so parsers can extract hostname.
* Used by geoip resolution which only needs a valid hostname, not auth fields.
*/
export function ensureProxyScheme(proxyUrl: string): string {
return proxyUrl.includes("://") ? proxyUrl : `http://${proxyUrl}`;
}
/**
* Parse a proxy URL, extracting credentials into separate fields.
*
* Handles: "http://user:pass@host:port" -> { server: "http://host:port", username: "user", password: "pass" }
* Also handles: no credentials, URL-encoded special chars, socks5://, missing port.
* Also handles: no credentials, URL-encoded special chars, socks5://, missing port,
* and bare proxy strings without a scheme (e.g. "user:pass@host:port" -> treated as http).
*/
export function parseProxyUrl(proxy: string): ParsedProxy {
let url: URL;
// Bare format: "user:pass@host:port" — new URL() throws without a scheme.
const normalized =
proxy.includes("@") && !proxy.includes("://") ? `http://${proxy}` : proxy;
try {
url = new URL(proxy);
url = new URL(normalized);
} catch {
// Not a parseable URL (e.g. bare "host:port") — pass through as-is
return { server: proxy };
+3 -17
View File
@@ -5,9 +5,11 @@
import type { Browser } from "puppeteer-core";
import type { LaunchOptions } from "./types.js";
import { IGNORE_DEFAULT_ARGS } from "./config.js";
import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js";
import { maybeResolveGeoip } from "./geoip.js";
/**
* Launch stealth Chromium browser via Puppeteer.
@@ -61,7 +63,7 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
executablePath: binaryPath,
headless: options.headless ?? true,
args,
ignoreDefaultArgs: ["--enable-automation"],
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
...options.launchOptions,
});
@@ -83,19 +85,3 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
// Internal
// ---------------------------------------------------------------------------
async function maybeResolveGeoip(
options: LaunchOptions
): Promise<{ timezone?: string; locale?: string }> {
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
const { resolveProxyGeo } = await import("./geoip.js");
const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
return {
timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined,
};
}
+7 -1
View File
@@ -24,6 +24,12 @@ export interface LaunchOptions {
geoip?: boolean;
/** Raw options passed directly to playwright/puppeteer launch(). */
launchOptions?: Record<string, unknown>;
/** Enable human-like mouse, keyboard, and scroll behavior. */
humanize?: boolean;
/** Human behavior preset: 'default' or 'careful'. */
humanPreset?: 'default' | 'careful';
/** Override individual human behavior parameters. */
humanConfig?: Record<string, unknown>;
}
export interface LaunchContextOptions extends LaunchOptions {
@@ -33,7 +39,7 @@ export interface LaunchContextOptions extends LaunchOptions {
viewport?: { width: number; height: number };
/** Browser locale, e.g. "en-US". */
locale?: string;
/** @deprecated Use `timezone` (inherited from LaunchOptions) instead. */
/** IANA timezone — alias for `timezone`. Either works. */
timezoneId?: string;
/** Color scheme preference — 'light', 'dark', or 'no-preference'. */
colorScheme?: "light" | "dark" | "no-preference";
+17 -10
View File
@@ -9,7 +9,7 @@ import {
getDownloadUrl,
getFallbackDownloadUrl,
} from "../src/config.js";
import { _buildArgsForTest, migrateTimezoneId } from "../src/playwright.js";
import { _buildArgsForTest, resolveTimezone } from "../src/playwright.js";
describe("config", () => {
it("CHROMIUM_VERSION matches expected format", () => {
@@ -97,21 +97,24 @@ describe("buildArgs timezone/locale", () => {
expect(args).toContain("--fingerprint-timezone=America/New_York");
});
it("injects --lang when locale is set", () => {
it("injects --lang and --fingerprint-locale when locale is set", () => {
const args = _buildArgsForTest({ locale: "en-US" });
expect(args).toContain("--lang=en-US");
expect(args).toContain("--fingerprint-locale=en-US");
});
it("injects both when both are set", () => {
const args = _buildArgsForTest({ timezone: "Europe/Berlin", locale: "de-DE" });
expect(args).toContain("--fingerprint-timezone=Europe/Berlin");
expect(args).toContain("--lang=de-DE");
expect(args).toContain("--fingerprint-locale=de-DE");
});
it("injects timezone/locale even when stealthArgs=false", () => {
const args = _buildArgsForTest({ stealthArgs: false, timezone: "America/New_York", locale: "en-US" });
expect(args).toContain("--fingerprint-timezone=America/New_York");
expect(args).toContain("--lang=en-US");
expect(args).toContain("--fingerprint-locale=en-US");
expect(args.some(a => a.startsWith("--fingerprint="))).toBe(false);
});
@@ -119,6 +122,7 @@ describe("buildArgs timezone/locale", () => {
const args = _buildArgsForTest({});
expect(args.some(a => a.startsWith("--fingerprint-timezone="))).toBe(false);
expect(args.some(a => a.startsWith("--lang="))).toBe(false);
expect(args.some(a => a.startsWith("--fingerprint-locale="))).toBe(false);
});
});
@@ -147,14 +151,17 @@ describe("buildArgs deduplication", () => {
expect(tzArgs[0]).toBe("--fingerprint-timezone=America/New_York");
});
it("locale param overrides user --lang arg", () => {
it("locale param overrides user --lang and --fingerprint-locale args", () => {
const args = _buildArgsForTest({
args: ["--lang=de-DE"],
args: ["--lang=de-DE", "--fingerprint-locale=de-DE"],
locale: "en-US",
});
const langArgs = args.filter(a => a.startsWith("--lang="));
expect(langArgs).toHaveLength(1);
expect(langArgs[0]).toBe("--lang=en-US");
const localeArgs = args.filter(a => a.startsWith("--fingerprint-locale="));
expect(localeArgs).toHaveLength(1);
expect(localeArgs[0]).toBe("--fingerprint-locale=en-US");
});
it("no duplicate flag keys in output", () => {
@@ -175,29 +182,29 @@ describe("buildArgs deduplication", () => {
});
});
describe("migrateTimezoneId deprecation", () => {
it("migrates timezoneId to timezone", () => {
const result = migrateTimezoneId({ timezoneId: "Europe/Paris" });
describe("resolveTimezone alias", () => {
it("resolves timezoneId to timezone", () => {
const result = resolveTimezone({ timezoneId: "Europe/Paris" });
expect(result.timezone).toBe("Europe/Paris");
expect(result).not.toHaveProperty("timezoneId");
});
it("preserves explicit timezone over timezoneId", () => {
const result = migrateTimezoneId({ timezone: "UTC", timezoneId: "Europe/Paris" });
const result = resolveTimezone({ timezone: "UTC", timezoneId: "Europe/Paris" });
expect(result.timezone).toBe("UTC");
expect(result).not.toHaveProperty("timezoneId");
});
it("returns options unchanged when no timezoneId", () => {
const opts = { timezone: "UTC" };
const result = migrateTimezoneId(opts);
const result = resolveTimezone(opts);
expect(result).toBe(opts); // same reference, no copy
expect(result.timezone).toBe("UTC");
});
it("returns options unchanged when neither is set", () => {
const opts = {};
const result = migrateTimezoneId(opts);
const result = resolveTimezone(opts);
expect(result).toBe(opts);
});
});
+11
View File
@@ -25,6 +25,17 @@ describe("resolveProxyIp", () => {
it("returns null for empty string", async () => {
expect(await resolveProxyIp("")).toBeNull();
});
it("returns null for schemeless proxy (shows why normalization is needed)", async () => {
// no scheme — new URL() gives empty hostname for both bare formats
expect(await resolveProxyIp("user:pass@10.50.96.5:8888")).toBeNull();
expect(await resolveProxyIp("10.50.96.5:8888")).toBeNull();
});
it("extracts IP after normalization (http:// prepended by maybeResolveGeoip)", async () => {
expect(await resolveProxyIp("http://user:pass@10.50.96.5:8888")).toBe("10.50.96.5");
expect(await resolveProxyIp("http://10.50.96.5:8888")).toBe("10.50.96.5");
});
});
describe("COUNTRY_LOCALE_MAP", () => {
+552
View File
@@ -0,0 +1,552 @@
import { describe, it, expect, vi } from "vitest";
import { resolveConfig, rand, randRange, sleep } from "../src/human/config.js";
import { humanMove, humanClick, clickTarget, humanIdle } from "../src/human/mouse.js";
// =========================================================================
// Config resolution
// =========================================================================
describe("resolveConfig", () => {
it("returns valid default config", () => {
const cfg = resolveConfig("default");
expect(cfg).toBeDefined();
expect(cfg.mouse_min_steps).toBeGreaterThan(0);
expect(cfg.mouse_max_steps).toBeGreaterThan(cfg.mouse_min_steps);
expect(cfg.typing_delay).toBeGreaterThan(0);
expect(cfg.initial_cursor_x).toHaveLength(2);
expect(cfg.initial_cursor_y).toHaveLength(2);
});
it("returns valid careful config with slower typing", () => {
const cfg = resolveConfig("careful");
const def = resolveConfig("default");
expect(cfg).toBeDefined();
expect(cfg.typing_delay).toBeGreaterThanOrEqual(def.typing_delay);
});
it("applies custom overrides", () => {
const cfg = resolveConfig("default", { mouse_min_steps: 100, mouse_max_steps: 200 });
expect(cfg.mouse_min_steps).toBe(100);
expect(cfg.mouse_max_steps).toBe(200);
});
it("preserves idle_between_actions override", () => {
const cfg = resolveConfig("default", {
idle_between_actions: true,
idle_between_duration: [50, 100],
});
expect(cfg.idle_between_actions).toBe(true);
expect(cfg.idle_between_duration[0]).toBe(50);
expect(cfg.idle_between_duration[1]).toBe(100);
});
it("throws on unknown preset name", () => {
expect(() => resolveConfig("nonexistent" as any)).toThrow(/Unknown humanize preset/);
});
it("returns all required fields including mistype", () => {
const cfg = resolveConfig("default");
const required = [
"mouse_min_steps", "mouse_max_steps", "typing_delay",
"initial_cursor_x", "initial_cursor_y", "idle_between_actions",
"idle_between_duration", "field_switch_delay",
"mistype_chance", "mistype_delay_notice", "mistype_delay_correct",
];
for (const f of required) {
expect(cfg).toHaveProperty(f);
}
});
it("mistype_delay fields are [min, max] tuples", () => {
const cfg = resolveConfig("default");
expect(Array.isArray(cfg.mistype_delay_notice)).toBe(true);
expect(cfg.mistype_delay_notice).toHaveLength(2);
expect(cfg.mistype_delay_notice[0]).toBeLessThanOrEqual(cfg.mistype_delay_notice[1]);
expect(Array.isArray(cfg.mistype_delay_correct)).toBe(true);
expect(cfg.mistype_delay_correct).toHaveLength(2);
expect(cfg.mistype_delay_correct[0]).toBeLessThanOrEqual(cfg.mistype_delay_correct[1]);
});
});
// =========================================================================
// rand / randRange / sleep
// =========================================================================
describe("rand helpers", () => {
it("rand stays within bounds over many iterations", () => {
for (let i = 0; i < 500; i++) {
const v = rand(10, 20);
expect(v).toBeGreaterThanOrEqual(10);
expect(v).toBeLessThanOrEqual(20);
}
});
it("randRange stays within bounds", () => {
for (let i = 0; i < 500; i++) {
const v = randRange([5, 15]);
expect(v).toBeGreaterThanOrEqual(5);
expect(v).toBeLessThanOrEqual(15);
}
});
it("sleep pauses for approximately correct duration", async () => {
const t0 = Date.now();
await sleep(50);
const elapsed = Date.now() - t0;
expect(elapsed).toBeGreaterThanOrEqual(40);
expect(elapsed).toBeLessThan(200);
});
});
// =========================================================================
// Bézier mouse movement (behavioral with vi.fn mocks)
// =========================================================================
describe("humanMove", () => {
function makeFakeRaw() {
const moves: Array<{ x: number; y: number }> = [];
return {
raw: {
move: vi.fn(async (x: number, y: number) => { moves.push({ x, y }); }),
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
wheel: vi.fn(async () => {}),
},
moves,
};
}
it("generates multiple intermediate points", async () => {
const cfg = resolveConfig("default");
const { raw, moves } = makeFakeRaw();
await humanMove(raw, 0, 0, 500, 300, cfg);
expect(moves.length).toBeGreaterThanOrEqual(10);
const last = moves[moves.length - 1];
expect(Math.abs(last.x - 500)).toBeLessThan(10);
expect(Math.abs(last.y - 300)).toBeLessThan(10);
});
it("raw.move called exactly once per step", async () => {
const cfg = resolveConfig("default");
const { raw, moves } = makeFakeRaw();
await humanMove(raw, 0, 0, 400, 400, cfg);
expect(raw.move).toHaveBeenCalledTimes(moves.length);
});
it("no single jump exceeds 50% of total distance", async () => {
const cfg = resolveConfig("default");
const { raw, moves } = makeFakeRaw();
await humanMove(raw, 0, 0, 400, 400, cfg);
const totalDist = Math.sqrt(400 ** 2 + 400 ** 2);
const maxJump = totalDist * 0.5;
for (let i = 1; i < moves.length; i++) {
const dx = moves[i].x - moves[i - 1].x;
const dy = moves[i].y - moves[i - 1].y;
expect(Math.sqrt(dx * dx + dy * dy)).toBeLessThan(maxJump);
}
});
it("produces curved path (not a straight line)", async () => {
const cfg = resolveConfig("default");
let maxDev = 0;
for (let trial = 0; trial < 10; trial++) {
const { raw, moves } = makeFakeRaw();
await humanMove(raw, 0, 0, 500, 0, cfg);
const dev = Math.max(...moves.map(m => Math.abs(m.y)));
if (dev > maxDev) maxDev = dev;
}
expect(maxDev).toBeGreaterThan(0.5);
});
it("handles very short distances", async () => {
const cfg = resolveConfig("default");
const { raw, moves } = makeFakeRaw();
await humanMove(raw, 100, 100, 103, 102, cfg);
expect(moves.length).toBeGreaterThanOrEqual(1);
});
it("handles zero distance without crashing", async () => {
const cfg = resolveConfig("default");
const { raw } = makeFakeRaw();
await humanMove(raw, 200, 200, 200, 200, cfg);
// Completes without error; may or may not call move (both valid)
expect(true).toBe(true);
});
});
// =========================================================================
// humanClick behavioral
// =========================================================================
describe("humanClick", () => {
it("calls down then up in correct order", async () => {
const cfg = resolveConfig("default");
const callOrder: string[] = [];
const raw = {
move: vi.fn(async () => {}),
down: vi.fn(async () => { callOrder.push("down"); }),
up: vi.fn(async () => { callOrder.push("up"); }),
wheel: vi.fn(async () => {}),
};
await humanClick(raw, false, cfg);
expect(raw.down).toHaveBeenCalledTimes(1);
expect(raw.up).toHaveBeenCalledTimes(1);
expect(callOrder).toEqual(["down", "up"]);
});
});
// =========================================================================
// humanIdle behavioral
// =========================================================================
describe("humanIdle", () => {
it("calls raw.move at least once during idle", async () => {
const cfg = resolveConfig("default");
const raw = {
move: vi.fn(async () => {}),
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
wheel: vi.fn(async () => {}),
};
await humanIdle(raw, 10, 100, 100, cfg);
expect(raw.move).toHaveBeenCalled();
}, 15000);
});
// =========================================================================
// clickTarget
// =========================================================================
describe("clickTarget", () => {
it("returns point within bounding box", () => {
const cfg = resolveConfig("default");
const box = { x: 100, y: 200, width: 150, height: 40 };
for (let i = 0; i < 100; i++) {
const t = clickTarget(box, false, cfg);
expect(t.x).toBeGreaterThanOrEqual(100);
expect(t.x).toBeLessThanOrEqual(250);
expect(t.y).toBeGreaterThanOrEqual(200);
expect(t.y).toBeLessThanOrEqual(240);
}
});
it("isInput=true biases click toward left side of box", () => {
const cfg = resolveConfig("default");
const box = { x: 50, y: 50, width: 200, height: 30 };
let sumX = 0;
const N = 300;
for (let i = 0; i < N; i++) {
const t = clickTarget(box, true, cfg);
expect(t.x).toBeGreaterThanOrEqual(50);
expect(t.x).toBeLessThanOrEqual(250);
sumX += t.x;
}
const avgX = sumX / N;
expect(avgX).toBeLessThan(175);
});
it("does not crash with 1x1 box", () => {
const cfg = resolveConfig("default");
const t = clickTarget({ x: 0, y: 0, width: 1, height: 1 }, false, cfg);
expect(t.x).toBeGreaterThanOrEqual(0);
expect(t.x).toBeLessThanOrEqual(1);
});
});
// =========================================================================
// patchPage behavioral: fill uses platform SELECT_ALL
// =========================================================================
describe("patchPage fill", () => {
it("fill calls keyboard.press with platform-correct select-all", async () => {
const { patchPage } = await import("../src/human/index.js");
const pressedKeys: string[] = [];
const page = buildMockPage({
keyboardPress: async (key: string) => { pressedKeys.push(key); },
evaluate: async () => false,
});
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
try { await (page as any).fill("input#name", "hello"); } catch (_) {}
const expected = process.platform === "darwin" ? "Meta+a" : "Control+a";
const wrong = process.platform === "darwin" ? "Control+a" : "Meta+a";
if (pressedKeys.length > 0) {
expect(pressedKeys).toContain(expected);
expect(pressedKeys).not.toContain(wrong);
}
}, 30000);
});
// =========================================================================
// patchPage behavioral: check/uncheck with idle_between_actions
// =========================================================================
describe("patchPage check/uncheck idle", () => {
it("check with idle=true calls humanClickFn and does not crash on idle", async () => {
const { patchPage } = await import("../src/human/index.js");
let downCalled = false;
const page = buildMockPage({
isChecked: async () => false,
evaluate: async () => false,
});
page.mouse.down = vi.fn(async () => { downCalled = true; });
const cfg = resolveConfig("default", {
idle_between_actions: true,
idle_between_duration: [1, 2],
});
const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any);
try { await (page as any).check("input#cb"); } catch (_) {}
// humanCheckFn → humanIdle → humanClickFn → humanClick → raw.down
expect(downCalled).toBe(true);
}, 30000);
it("uncheck with idle=true calls humanClickFn and does not crash on idle", async () => {
const { patchPage } = await import("../src/human/index.js");
let downCalled = false;
const page = buildMockPage({
isChecked: async () => true,
evaluate: async () => false,
});
page.mouse.down = vi.fn(async () => { downCalled = true; });
const cfg = resolveConfig("default", {
idle_between_actions: true,
idle_between_duration: [1, 2],
});
const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any);
try { await (page as any).uncheck("input#cb"); } catch (_) {}
expect(downCalled).toBe(true);
}, 30000);
it("config with idle=true is accepted by resolveConfig", () => {
const cfg = resolveConfig("default", {
idle_between_actions: true,
idle_between_duration: [5, 10],
});
expect(cfg.idle_between_actions).toBe(true);
expect(cfg.idle_between_duration).toEqual([5, 10]);
});
});
// =========================================================================
// patchPage behavioral: press focus check
// =========================================================================
describe("patchPage press focus", () => {
it("press clicks element when NOT focused (mouse.down called)", async () => {
const { patchPage } = await import("../src/human/index.js");
let downCount = 0;
const page = buildMockPage({
evaluate: async () => false,
});
// Intercept mouse.down before patching so raw captures it
page.mouse.down = vi.fn(async () => { downCount++; });
const cfg = resolveConfig("default");
const cursor = { x: 50, y: 50, initialized: true };
patchPage(page as any, cfg, cursor as any);
try { await (page as any).press("input#field", "Enter"); } catch (_) {}
expect(downCount).toBeGreaterThan(0);
});
it("press skips click when element IS focused (no mouse.down)", async () => {
const { patchPage } = await import("../src/human/index.js");
let downCount = 0;
const page = buildMockPage({
evaluate: async () => true,
});
page.mouse.down = vi.fn(async () => { downCount++; });
const cfg = resolveConfig("default");
const cursor = { x: 50, y: 50, initialized: true };
patchPage(page as any, cfg, cursor as any);
try { await (page as any).press("input#field", "Enter"); } catch (_) {}
expect(downCount).toBe(0);
});
});
// =========================================================================
// patchPage behavioral: frame patching
// =========================================================================
describe("patchPage frame patching", () => {
it("patches child frames with _humanPatched flag", async () => {
const { patchPage } = await import("../src/human/index.js");
const childFrame = buildMockFrame();
const mainFrame = {
...buildMockFrame(),
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);
});
});
// =========================================================================
// Mistype config
// =========================================================================
describe("mistype config", () => {
it("default config has valid mistype fields", () => {
const cfg = resolveConfig("default");
expect(typeof cfg.mistype_chance).toBe("number");
expect(cfg.mistype_chance).toBeGreaterThanOrEqual(0);
expect(cfg.mistype_chance).toBeLessThanOrEqual(1);
// mistype_delay_notice and mistype_delay_correct are [min, max] tuples
expect(Array.isArray(cfg.mistype_delay_notice)).toBe(true);
expect(cfg.mistype_delay_notice).toHaveLength(2);
expect(Array.isArray(cfg.mistype_delay_correct)).toBe(true);
expect(cfg.mistype_delay_correct).toHaveLength(2);
});
it("mistype_chance can be overridden to 0 (disabled)", () => {
const cfg = resolveConfig("default", { mistype_chance: 0 });
expect(cfg.mistype_chance).toBe(0);
});
it("mistype_chance can be overridden to higher value", () => {
const cfg = resolveConfig("default", { mistype_chance: 0.15 });
expect(cfg.mistype_chance).toBe(0.15);
});
});
// =========================================================================
// Module exports
// =========================================================================
describe("module exports", () => {
it("patchBrowser, patchContext, patchPage are all exported functions", async () => {
const mod = await import("../src/human/index.js");
expect(typeof mod.patchBrowser).toBe("function");
expect(typeof mod.patchContext).toBe("function");
expect(typeof mod.patchPage).toBe("function");
});
it("humanMove, humanClick, clickTarget, humanIdle are exported", async () => {
const mod = await import("../src/human/index.js");
expect(typeof mod.humanMove).toBe("function");
expect(typeof mod.humanClick).toBe("function");
expect(typeof mod.clickTarget).toBe("function");
expect(typeof mod.humanIdle).toBe("function");
});
it("resolveConfig is re-exported from index", async () => {
const mod = await import("../src/human/index.js");
expect(typeof mod.resolveConfig).toBe("function");
});
});
// =========================================================================
// Test helpers
// =========================================================================
function buildMockPage(overrides: Record<string, any> = {}): any {
const mainFrameObj = overrides.mainFrameReturn ?? {
childFrames: vi.fn(() => []),
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 () => {}),
clear: vi.fn(async () => {}),
dragAndDrop: vi.fn(async () => {}),
locator: vi.fn(() => ({
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
first: vi.fn(function(this: any) { return this; }),
})),
};
const makeLocator = () => {
const loc: any = {
boundingBox: vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
scrollIntoViewIfNeeded: vi.fn(async () => {}),
isChecked: overrides.isChecked ?? vi.fn(async () => false),
};
loc.first = vi.fn(() => loc);
return loc;
};
const page: any = {
evaluate: overrides.evaluate ?? vi.fn(async () => false),
addInitScript: vi.fn(async () => {}),
mouse: {
move: vi.fn(async () => {}),
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
click: vi.fn(async () => {}),
dblclick: vi.fn(async () => {}),
wheel: vi.fn(async () => {}),
},
keyboard: {
press: overrides.keyboardPress
? vi.fn(overrides.keyboardPress)
: vi.fn(async () => {}),
type: vi.fn(async () => {}),
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
insertText: vi.fn(async () => {}),
},
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 () => {}),
goto: vi.fn(async () => ({})),
isChecked: overrides.isChecked ?? vi.fn(async () => false),
locator: vi.fn(() => makeLocator()),
viewportSize: vi.fn(() => ({ width: 1280, height: 720 })),
mainFrame: vi.fn(() => mainFrameObj),
frames: vi.fn(() => []),
context: vi.fn(() => ({
pages: vi.fn(() => []),
addInitScript: vi.fn(async () => {}),
})),
url: vi.fn(() => "about:blank"),
waitForTimeout: vi.fn(async () => {}),
};
return page;
}
function buildMockFrame(): any {
return {
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 () => {}),
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(() => []),
};
}
+11 -10
View File
@@ -96,20 +96,20 @@ describe("launchContext (unit)", () => {
expect(ctxArgs.userAgent).toBe("Custom/1.0");
});
it("passes timezone to context timezoneId, not to launch", async () => {
it("passes timezone via binary flag, not CDP context", async () => {
const { launchContext } = await import("../src/playwright.js");
await launchContext({ timezone: "America/New_York" });
// launch() called with timezone: undefined (skipped for binary flag)
// launch() called with --fingerprint-timezone binary flag
const launchArgs = mockChromium.launch.mock.calls[0][0];
const hasTimezoneFlag = launchArgs.args.some((a: string) =>
a.startsWith("--fingerprint-timezone=")
a.startsWith("--fingerprint-timezone=America/New_York")
);
expect(hasTimezoneFlag).toBe(false);
expect(hasTimezoneFlag).toBe(true);
// newContext() gets timezoneId
// NOT in newContext() — no CDP emulation
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
expect(ctxArgs.timezoneId).toBe("America/New_York");
expect(ctxArgs.timezoneId).toBeUndefined();
});
it("forwards colorScheme to newContext", async () => {
@@ -165,7 +165,7 @@ describe("launchPersistentContext (unit)", () => {
expect(args.viewport).toEqual(DEFAULT_VIEWPORT);
});
it("passes timezone and locale to context", async () => {
it("passes timezone and locale via binary args, not CDP context", async () => {
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({
userDataDir: "/tmp/profile",
@@ -174,11 +174,12 @@ describe("launchPersistentContext (unit)", () => {
});
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.timezoneId).toBe("Asia/Tokyo");
expect(args.locale).toBe("ja-JP");
// Also in binary args
// Binary args (native, undetectable)
expect(args.args).toContain("--fingerprint-timezone=Asia/Tokyo");
expect(args.args).toContain("--lang=ja-JP");
// NOT in context kwargs (would trigger detectable CDP emulation)
expect(args.timezoneId).toBeUndefined();
expect(args.locale).toBeUndefined();
});
it("forwards proxy string", async () => {
+33
View File
@@ -82,3 +82,36 @@ describe("proxy dict type", () => {
}
});
});
describe("bare proxy format (user:pass@host:port)", () => {
it("extracts credentials from bare format", () => {
expect(parseProxyUrl("user:pass@proxy:8080")).toEqual({
server: "http://proxy:8080",
username: "user",
password: "pass",
});
});
it("credentials not in server", () => {
const r = parseProxyUrl("user:pass@proxy1.example.com:5610");
expect(r.server).not.toContain("user");
expect(r.server).not.toContain("pass");
});
it("bare username only", () => {
const r = parseProxyUrl("user@proxy:8080");
expect(r.username).toBe("user");
expect(r.password).toBeUndefined();
expect(r.server).toBe("http://proxy:8080");
});
it("bare no port", () => {
const r = parseProxyUrl("user:pass@proxy.example.com");
expect(r.username).toBe("user");
expect(r.server).toBe("http://proxy.example.com");
});
it("bare no credentials passes through unchanged", () => {
expect(parseProxyUrl("proxy:8080")).toEqual({ server: "proxy:8080" });
});
});
+1
View File
@@ -13,6 +13,7 @@ vi.mock("../src/download.js", () => ({
vi.mock("../src/geoip.js", () => ({
resolveProxyGeo: vi.fn().mockResolvedValue({ timezone: null, locale: null }),
maybeResolveGeoip: vi.fn().mockResolvedValue({}),
}));
describe("puppeteer launch", () => {
+1
View File
@@ -56,6 +56,7 @@ dependencies = [
[project.optional-dependencies]
geoip = ["geoip2>=4.0"]
patchright = ["patchright>=1.40"]
dev = ["pytest>=7.0", "pytest-asyncio>=0.23"]
[project.urls]
Homepage = "https://github.com/CloakHQ/CloakBrowser"
+25 -31
View File
@@ -1,8 +1,6 @@
"""Unit tests for _build_args timezone/locale injection and deprecation compat."""
"""Unit tests for _build_args timezone/locale injection and timezone alias."""
import warnings
from cloakbrowser.browser import _build_args, _migrate_timezone_id
from cloakbrowser.browser import _build_args, _resolve_timezone
def test_timezone_injected():
@@ -12,9 +10,10 @@ def test_timezone_injected():
def test_locale_injected():
"""--lang flag should appear when locale is set."""
"""--lang and --fingerprint-locale flags should appear when locale is set."""
args = _build_args(stealth_args=True, extra_args=None, locale="en-US")
assert "--lang=en-US" in args
assert "--fingerprint-locale=en-US" in args
def test_both_injected():
@@ -22,6 +21,7 @@ def test_both_injected():
args = _build_args(stealth_args=True, extra_args=None, timezone="Europe/Berlin", locale="de-DE")
assert "--fingerprint-timezone=Europe/Berlin" in args
assert "--lang=de-DE" in args
assert "--fingerprint-locale=de-DE" in args
def test_timezone_independent_of_stealth_args():
@@ -29,15 +29,17 @@ def test_timezone_independent_of_stealth_args():
args = _build_args(stealth_args=False, extra_args=None, timezone="America/New_York", locale="en-US")
assert "--fingerprint-timezone=America/New_York" in args
assert "--lang=en-US" in args
assert "--fingerprint-locale=en-US" in args
# No stealth fingerprint args
assert not any(a.startswith("--fingerprint=") for a in args)
def test_no_flags_when_not_set():
"""No timezone/lang flags when params are None."""
"""No timezone/lang/fingerprint-locale flags when params are None."""
args = _build_args(stealth_args=True, extra_args=None)
assert not any(a.startswith("--fingerprint-timezone=") for a in args)
assert not any(a.startswith("--lang=") for a in args)
assert not any(a.startswith("--fingerprint-locale=") for a in args)
def test_extra_args_preserved():
@@ -46,52 +48,41 @@ def test_extra_args_preserved():
assert "--disable-gpu" in args
assert "--fingerprint-timezone=Asia/Tokyo" in args
assert "--lang=ja-JP" in args
assert "--fingerprint-locale=ja-JP" in args
# --- _migrate_timezone_id deprecation compat ---
# --- _resolve_timezone alias ---
def test_migrate_old_param_only():
def test_resolve_timezone_id_alias():
"""timezone_id in kwargs should be promoted to timezone."""
kwargs = {"timezone_id": "Europe/Paris"}
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = _migrate_timezone_id(None, kwargs)
result = _resolve_timezone(None, kwargs)
assert result == "Europe/Paris"
assert "timezone_id" not in kwargs
assert len(w) == 1 and issubclass(w[0].category, FutureWarning)
def test_migrate_new_param_wins():
def test_resolve_timezone_wins_over_alias():
"""Explicit timezone takes precedence; timezone_id is still popped."""
kwargs = {"timezone_id": "Europe/Paris"}
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = _migrate_timezone_id("UTC", kwargs)
result = _resolve_timezone("UTC", kwargs)
assert result == "UTC"
assert "timezone_id" not in kwargs
assert len(w) == 1
def test_migrate_no_old_param():
"""No warning when timezone_id is absent."""
def test_resolve_no_alias():
"""No-op when timezone_id is absent."""
kwargs = {"other": "value"}
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = _migrate_timezone_id("UTC", kwargs)
result = _resolve_timezone("UTC", kwargs)
assert result == "UTC"
assert "other" in kwargs
assert len(w) == 0
def test_migrate_both_none():
"""Neither param set — returns None, no warning."""
def test_resolve_both_none():
"""Neither param set — returns None."""
kwargs = {}
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = _migrate_timezone_id(None, kwargs)
result = _resolve_timezone(None, kwargs)
assert result is None
assert len(w) == 0
# --- Deduplication tests ---
@@ -126,15 +117,18 @@ def test_timezone_param_overrides_user_arg():
def test_locale_param_overrides_user_arg():
"""Dedicated locale param should override user --lang arg."""
"""Dedicated locale param should override user --lang and --fingerprint-locale args."""
args = _build_args(
stealth_args=True,
extra_args=["--lang=de-DE"],
extra_args=["--lang=de-DE", "--fingerprint-locale=de-DE"],
locale="en-US",
)
lang_args = [a for a in args if a.startswith("--lang=")]
assert len(lang_args) == 1
assert lang_args[0] == "--lang=en-US"
locale_args = [a for a in args if a.startswith("--fingerprint-locale=")]
assert len(locale_args) == 1
assert locale_args[0] == "--fingerprint-locale=en-US"
def test_no_duplicate_flags():
+256
View File
@@ -0,0 +1,256 @@
// test_human_visual.mjs
/**
* Visual + functional test for humanize (JS).
* Red dot = cursor, yellow = mouse held.
* Trail dots show the path taken.
*/
import { launch } from '../js/dist/index.js';
const CURSOR_JS = `
(() => {
if (document.getElementById('__hc')) return;
const el = document.createElement('div');
el.id = '__hc';
el.style.cssText = 'width:14px;height:14px;background:red;border:2px solid darkred;border-radius:50%;position:fixed;z-index:2147483647;pointer-events:none;display:none;transition:background 0.05s;';
document.body.appendChild(el);
const trail = document.createElement('div');
trail.id = '__hcTrail';
trail.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483646;pointer-events:none;overflow:hidden;';
document.body.appendChild(trail);
let dotCount = 0;
const maxDots = 500;
function updatePos(x, y) {
el.style.display = 'block';
el.style.left = (x - 9) + 'px';
el.style.top = (y - 9) + 'px';
if (dotCount < maxDots) {
const dot = document.createElement('div');
dot.style.cssText = 'width:3px;height:3px;background:rgba(255,0,0,0.3);border-radius:50%;position:fixed;pointer-events:none;left:'+(x-1)+'px;top:'+(y-1)+'px;';
trail.appendChild(dot);
dotCount++;
}
}
document.addEventListener('mousemove', e => updatePos(e.clientX, e.clientY));
document.addEventListener('drag', e => { if (e.clientX > 0) updatePos(e.clientX, e.clientY); });
document.addEventListener('dragover', e => { if (e.clientX > 0) updatePos(e.clientX, e.clientY); });
document.addEventListener('mousedown', () => { el.style.background = 'yellow'; });
document.addEventListener('mouseup', () => { el.style.background = 'red'; });
document.addEventListener('dragend', () => { el.style.background = 'red'; });
})();
`;
const results = [];
const delay = ms => new Promise(r => setTimeout(r, ms));
async function inject(page) {
try { await page.evaluate(CURSOR_JS); } catch {}
await delay(300);
}
function step(name) {
console.log(`\n${'='.repeat(60)}`);
console.log(` STEP: ${name}`);
console.log('='.repeat(60));
}
function check(name, passed, detail = '') {
const status = passed ? 'PASS' : 'FAIL';
let msg = ` [${status}] ${name}`;
if (detail) msg += `${detail}`;
console.log(msg);
results.push({ name, status });
}
async function main() {
console.log('='.repeat(70));
console.log(' HUMAN-LIKE BEHAVIOR VISUAL TEST (JS)');
console.log(' Watch the red dot — it should move smoothly like a real cursor');
console.log('='.repeat(70));
const browser = await launch({
headless: false,
humanize: true,
});
const page = await browser.newPage();
// ============================================================
// SCENARIO 1: Wikipedia search
// ============================================================
step('Wikipedia — navigate and search');
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
await delay(2000);
await inject(page);
await delay(1000);
console.log(' Watch: cursor moves to search box (Bezier curve)');
let t0 = Date.now();
await page.locator('#searchInput').click();
let ms = Date.now() - t0;
check('click on search input', ms > 200, `${ms} ms`);
await delay(500);
console.log(' Watch: characters appear one by one');
t0 = Date.now();
await page.locator('#searchInput').fill('Python programming language');
ms = Date.now() - t0;
let val = await page.locator('#searchInput').inputValue();
check('fill search box', val === 'Python programming language' && ms > 2000, `${ms} ms, value='${val}'`);
await delay(500);
console.log(' Watch: double click selects word');
t0 = Date.now();
await page.locator('#searchInput').dblclick();
ms = Date.now() - t0;
let sel = await page.evaluate(() => window.getSelection().toString().trim());
check('dblclick selects word', sel.length > 0 && ms > 200, `${ms} ms, selected='${sel}'`);
await delay(500);
console.log(' Watch: old text replaced');
t0 = Date.now();
await page.locator('#searchInput').fill('Artificial intelligence');
ms = Date.now() - t0;
val = await page.locator('#searchInput').inputValue();
check('fill replaces text', val === 'Artificial intelligence' && ms > 1500, `${ms} ms, value='${val}'`);
await delay(500);
console.log(' Watch: cursor hovers button without clicking');
t0 = Date.now();
await page.locator('button[type="submit"]').hover();
ms = Date.now() - t0;
check('hover search button', ms > 100, `${ms} ms`);
await delay(1000);
// ============================================================
// SCENARIO 2: Checkboxes
// ============================================================
step('Checkboxes — check and uncheck');
await page.goto('https://the-internet.herokuapp.com/checkboxes', { waitUntil: 'domcontentloaded' });
await delay(2000);
await inject(page);
await delay(1000);
const cb1 = page.locator('input[type="checkbox"]').nth(0);
const cb2 = page.locator('input[type="checkbox"]').nth(1);
if (await cb1.isChecked()) { await cb1.uncheck(); await delay(500); }
console.log(' Watch: cursor moves to checkbox, clicks');
t0 = Date.now();
await cb1.check();
ms = Date.now() - t0;
check('check checkbox 1', await cb1.isChecked() && ms > 200, `${ms} ms`);
await delay(500);
if (!(await cb2.isChecked())) { await cb2.check(); await delay(500); }
t0 = Date.now();
await cb2.uncheck();
ms = Date.now() - t0;
check('uncheck checkbox 2', !(await cb2.isChecked()) && ms > 200, `${ms} ms`);
await delay(1000);
// ============================================================
// SCENARIO 3: Dropdown
// ============================================================
step('Dropdown — select option');
await page.goto('https://the-internet.herokuapp.com/dropdown', { waitUntil: 'domcontentloaded' });
await delay(2000);
await inject(page);
await delay(1000);
console.log(' Watch: cursor hovers dropdown, option selected');
t0 = Date.now();
await page.locator('#dropdown').selectOption('2');
ms = Date.now() - t0;
val = await page.locator('#dropdown').inputValue();
check('select option', val === '2' && ms > 100, `${ms} ms, value='${val}'`);
await delay(1000);
// ============================================================
// SCENARIO 4: Drag and Drop
// ============================================================
step('Drag and Drop');
await page.goto('https://the-internet.herokuapp.com/drag_and_drop', { waitUntil: 'domcontentloaded' });
await delay(2000);
await inject(page);
await delay(1000);
const beforeA = (await page.locator('#column-a header').textContent()).trim();
console.log(` Before: A='${beforeA}'`);
console.log(' Watch: cursor to A, yellow (held), moves to B, releases');
t0 = Date.now();
await page.locator('#column-a').dragTo(page.locator('#column-b'));
ms = Date.now() - t0;
await delay(1000);
const afterA = (await page.locator('#column-a header').textContent()).trim();
const swapped = beforeA !== afterA;
check('drag A to B', swapped && ms > 300, `${ms} ms, swapped=${swapped}`);
await delay(1000);
// ============================================================
// SCENARIO 5: Text editing
// ============================================================
step('Text editing — type, press, clear');
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
await delay(2000);
await inject(page);
await delay(1000);
console.log(' Watch: types character by character');
t0 = Date.now();
await page.locator('#searchInput').type('Hello World');
ms = Date.now() - t0;
val = await page.locator('#searchInput').inputValue();
check("type 'Hello World'", val === 'Hello World' && ms > 1000, `${ms} ms`);
await delay(500);
console.log(' Watch: field cleared');
t0 = Date.now();
await page.locator('#searchInput').clear();
ms = Date.now() - t0;
val = await page.locator('#searchInput').inputValue();
check('clear field', val === '' && ms > 100, `${ms} ms`);
await delay(500);
console.log(' Watch: mouse moves in Bezier curve');
t0 = Date.now();
await page.mouse.move(600, 400);
ms = Date.now() - t0;
check('mouse.move', ms > 100, `${ms} ms`);
await delay(500);
t0 = Date.now();
await page.mouse.click(300, 300);
ms = Date.now() - t0;
check('mouse.click', ms > 100, `${ms} ms`);
await delay(1000);
// ============================================================
// SUMMARY
// ============================================================
console.log('\n' + '='.repeat(70));
console.log(' SUMMARY');
console.log('='.repeat(70));
const passed = results.filter(r => r.status === 'PASS').length;
const failed = results.filter(r => r.status === 'FAIL').length;
for (const r of results) {
const icon = r.status === 'PASS' ? 'OK' : 'XX';
console.log(` [${icon}] ${r.name}`);
}
console.log(`\n ${passed}/${results.length} passed, ${failed} failed`);
if (failed === 0) console.log(' *** ALL TESTS PASSED ***');
console.log('='.repeat(70));
await browser.close();
}
main().catch(console.error);
+302
View File
@@ -0,0 +1,302 @@
"""
Visual + functional test for humanize.
Red dot = cursor, yellow = mouse held.
"""
import pytest
pytestmark = pytest.mark.slow
if __name__ == "__main__":
from cloakbrowser import launch
import time
CURSOR_JS = """
() => {
if (document.getElementById('__hc')) return;
const el = document.createElement('div');
el.id = '__hc';
el.style.cssText = 'width:14px;height:14px;background:red;border:2px solid darkred;border-radius:50%;position:fixed;z-index:2147483647;pointer-events:none;display:none;transition:background 0.05s;';
document.body.appendChild(el);
const trail = document.createElement('div');
trail.id = '__hcTrail';
trail.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483646;pointer-events:none;overflow:hidden;';
document.body.appendChild(trail);
let dotCount = 0;
const maxDots = 500;
function updatePos(x, y) {
el.style.display = 'block';
el.style.left = (x - 9) + 'px';
el.style.top = (y - 9) + 'px';
if (dotCount < maxDots) {
const dot = document.createElement('div');
dot.style.cssText = 'width:3px;height:3px;background:rgba(255,0,0,0.3);border-radius:50%;position:fixed;pointer-events:none;left:'+(x-1)+'px;top:'+(y-1)+'px;';
trail.appendChild(dot);
dotCount++;
}
}
document.addEventListener('mousemove', e => updatePos(e.clientX, e.clientY));
document.addEventListener('drag', e => { if (e.clientX > 0) updatePos(e.clientX, e.clientY); });
document.addEventListener('dragover', e => { if (e.clientX > 0) updatePos(e.clientX, e.clientY); });
document.addEventListener('mousedown', () => { el.style.background = 'yellow'; });
document.addEventListener('mouseup', () => { el.style.background = 'red'; });
document.addEventListener('dragend', () => { el.style.background = 'red'; });
}
"""
def inject(page):
try:
page.evaluate(CURSOR_JS)
except:
pass
time.sleep(0.3)
results = []
def step(name):
print(f"\n{'='*60}")
print(f" STEP: {name}")
print(f"{'='*60}")
def check(name, passed, detail=""):
status = "PASS" if passed else "FAIL"
msg = f" [{status}] {name}"
if detail:
msg += f"{detail}"
print(msg)
results.append((name, status))
print("=" * 70)
print(" HUMAN-LIKE BEHAVIOR VISUAL TEST")
print(" Watch the red dot — it should move smoothly like a real cursor")
print(" Yellow = mouse button held")
print(" Red trail dots = path taken")
print("=" * 70)
browser = launch(headless=False, humanize=True)
page = browser.new_page()
# ============================================================
# SCENARIO 1: Wikipedia search
# ============================================================
step("Wikipedia — navigate and search")
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
time.sleep(2)
inject(page)
time.sleep(1)
print(" Watch: cursor moves to search box (Bezier curve)")
t0 = time.time()
page.locator('#searchInput').click()
click_ms = int((time.time() - t0) * 1000)
check("click on search input", click_ms > 200, f"{click_ms} ms")
time.sleep(0.5)
print(" Watch: characters appear one by one with varying speed")
t0 = time.time()
page.locator('#searchInput').fill('Python programming language')
fill_ms = int((time.time() - t0) * 1000)
val = page.locator('#searchInput').input_value()
check("fill search box", val == 'Python programming language' and fill_ms > 2000, f"{fill_ms} ms, value='{val}'")
time.sleep(0.5)
print(" Watch: cursor moves to search box, double yellow flash, word selected")
t0 = time.time()
page.locator('#searchInput').dblclick()
dbl_ms = int((time.time() - t0) * 1000)
sel = page.evaluate('() => window.getSelection().toString().trim()')
check("dblclick selects word", len(sel) > 0 and dbl_ms > 200, f"{dbl_ms} ms, selected='{sel}'")
time.sleep(0.5)
print(" Watch: old text cleared, new text typed")
t0 = time.time()
page.locator('#searchInput').fill('Artificial intelligence')
fill2_ms = int((time.time() - t0) * 1000)
val2 = page.locator('#searchInput').input_value()
check("fill replaces text", val2 == 'Artificial intelligence' and fill2_ms > 1500, f"{fill2_ms} ms, value='{val2}'")
time.sleep(0.5)
print(" Watch: cursor moves to button without clicking")
t0 = time.time()
page.locator('button[type="submit"]').hover()
hover_ms = int((time.time() - t0) * 1000)
check("hover search button", hover_ms > 100, f"{hover_ms} ms")
time.sleep(1)
# ============================================================
# SCENARIO 2: Form interaction — checkboxes
# ============================================================
step("Checkboxes — check and uncheck")
page.goto('https://the-internet.herokuapp.com/checkboxes', wait_until='domcontentloaded')
time.sleep(2)
inject(page)
time.sleep(1)
cb1 = page.locator('input[type="checkbox"]').nth(0)
cb2 = page.locator('input[type="checkbox"]').nth(1)
print(" Watch: cursor moves to first checkbox, clicks")
if cb1.is_checked():
cb1.uncheck()
time.sleep(0.5)
t0 = time.time()
cb1.check()
check_ms = int((time.time() - t0) * 1000)
check("check checkbox 1", cb1.is_checked() and check_ms > 200, f"{check_ms} ms, checked={cb1.is_checked()}")
time.sleep(0.5)
print(" Watch: cursor moves to second checkbox, clicks to uncheck")
if not cb2.is_checked():
cb2.check()
time.sleep(0.5)
t0 = time.time()
cb2.uncheck()
uncheck_ms = int((time.time() - t0) * 1000)
check("uncheck checkbox 2", not cb2.is_checked() and uncheck_ms > 200, f"{uncheck_ms} ms, checked={cb2.is_checked()}")
time.sleep(1)
# ============================================================
# SCENARIO 3: Dropdown
# ============================================================
step("Dropdown — select option")
page.goto('https://the-internet.herokuapp.com/dropdown', wait_until='domcontentloaded')
time.sleep(2)
inject(page)
time.sleep(1)
print(" Watch: cursor moves to dropdown, hovers, option selected")
t0 = time.time()
page.locator('#dropdown').select_option('1')
sel_ms = int((time.time() - t0) * 1000)
val = page.locator('#dropdown').input_value()
check("select option 1", val == '1' and sel_ms > 100, f"{sel_ms} ms, value='{val}'")
time.sleep(0.5)
t0 = time.time()
page.locator('#dropdown').select_option('2')
sel2_ms = int((time.time() - t0) * 1000)
val2 = page.locator('#dropdown').input_value()
check("select option 2", val2 == '2' and sel2_ms > 100, f"{sel2_ms} ms, value='{val2}'")
time.sleep(1)
# ============================================================
# SCENARIO 4: Drag and drop
# ============================================================
step("Drag and Drop — move column A to B")
page.goto('https://the-internet.herokuapp.com/drag_and_drop', wait_until='domcontentloaded')
time.sleep(2)
inject(page)
time.sleep(1)
before_a = page.locator('#column-a header').text_content().strip()
before_b = page.locator('#column-b header').text_content().strip()
print(f" Before: A='{before_a}', B='{before_b}'")
print(" Watch: cursor moves to A, turns yellow (held), moves to B, releases")
t0 = time.time()
page.locator('#column-a').drag_to(page.locator('#column-b'))
drag_ms = int((time.time() - t0) * 1000)
time.sleep(1)
after_a = page.locator('#column-a header').text_content().strip()
after_b = page.locator('#column-b header').text_content().strip()
swapped = before_a != after_a
print(f" After: A='{after_a}', B='{after_b}'")
check("drag A to B", swapped and drag_ms > 300, f"{drag_ms} ms, swapped={swapped}")
time.sleep(1)
# ============================================================
# SCENARIO 5: Text editing
# ============================================================
step("Text editing — type, press keys, clear")
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
time.sleep(2)
inject(page)
time.sleep(1)
print(" Watch: cursor clicks input, types character by character")
t0 = time.time()
page.locator('#searchInput').type('Hello World')
type_ms = int((time.time() - t0) * 1000)
val = page.locator('#searchInput').input_value()
check("type 'Hello World'", val == 'Hello World' and type_ms > 1000, f"{type_ms} ms, value='{val}'")
time.sleep(0.5)
print(" Watch: cursor clicks, presses single key")
t0 = time.time()
page.locator('#searchInput').press('End')
page.locator('#searchInput').press('!')
press_ms = int((time.time() - t0) * 1000)
val = page.locator('#searchInput').input_value()
check("press '!' at end", '!' in val and press_ms > 100, f"{press_ms} ms, value='{val}'")
time.sleep(0.5)
print(" Watch: field gets cleared (Ctrl+A, Backspace)")
t0 = time.time()
page.locator('#searchInput').clear()
clear_ms = int((time.time() - t0) * 1000)
val = page.locator('#searchInput').input_value()
check("clear field", val == '' and clear_ms > 100, f"{clear_ms} ms, value='{repr(val)}'")
time.sleep(0.5)
print(" Watch: press_sequentially types each key individually")
t0 = time.time()
page.locator('#searchInput').press_sequentially('Sequential')
pseq_ms = int((time.time() - t0) * 1000)
val = page.locator('#searchInput').input_value()
check("press_sequentially", val == 'Sequential' and pseq_ms > 500, f"{pseq_ms} ms, value='{val}'")
time.sleep(1)
# ============================================================
# SCENARIO 6: Mouse precision
# ============================================================
step("Mouse precision — move to coordinates")
print(" Watch: cursor moves in a Bezier curve to (600, 400)")
t0 = time.time()
page.mouse.move(600, 400)
move_ms = int((time.time() - t0) * 1000)
check("mouse.move to (600,400)", move_ms > 100, f"{move_ms} ms")
time.sleep(0.5)
print(" Watch: cursor moves to (200, 200), clicks")
t0 = time.time()
page.mouse.click(200, 200)
mclick_ms = int((time.time() - t0) * 1000)
check("mouse.click at (200,200)", mclick_ms > 100, f"{mclick_ms} ms")
time.sleep(0.5)
print(" Watch: keyboard types directly (no click needed)")
page.locator('#searchInput').click()
time.sleep(0.3)
t0 = time.time()
page.keyboard.type('Direct keyboard')
kb_ms = int((time.time() - t0) * 1000)
check("keyboard.type", kb_ms > 500, f"{kb_ms} ms")
time.sleep(1)
# ============================================================
# SUMMARY
# ============================================================
print("\n" + "=" * 70)
print(" SUMMARY")
print("=" * 70)
passed = sum(1 for _, s in results if s == "PASS")
failed = sum(1 for _, s in results if s == "FAIL")
total = len(results)
for name, status in results:
icon = "OK" if status == "PASS" else "XX"
print(f" [{icon}] {name}")
print(f"\n {passed}/{total} passed, {failed} failed")
if failed == 0:
print(" *** ALL TESTS PASSED ***")
print("=" * 70)
input("\nPress Enter to close browser...")
browser.close()
+470
View File
@@ -0,0 +1,470 @@
/**
* Unit + integration tests for the humanize layer (JS).
* Covers: config resolution, Bézier math, fill clearing,
* bot-detection form, and patching integrity.
*
* Run: node tests/test_humanize_unit.mjs
*/
import { launch } from '../js/dist/index.js';
import { resolveConfig, rand, randRange, sleep } from '../js/dist/human/config.js';
import { humanMove, clickTarget } from '../js/dist/human/mouse.js';
const PROXY = {
};
const delay = ms => new Promise(r => setTimeout(r, ms));
const results = [];
async function test(name, fn) {
try {
await fn();
console.log(` [PASS] ${name}`);
results.push({ name, status: 'PASS' });
} catch (e) {
console.log(` [FAIL] ${name}${e.message || e}`);
results.push({ name, status: 'FAIL' });
}
}
// =========================================================================
// 1. Config resolution
// =========================================================================
console.log('\n' + '='.repeat(60));
console.log(' CONFIG RESOLUTION');
console.log('='.repeat(60));
await test('default config resolves', async () => {
const cfg = resolveConfig('default');
if (!cfg) throw new Error('resolveConfig returned null');
if (cfg.mouse_min_steps <= 0) throw new Error('mouse_min_steps should be > 0');
if (cfg.mouse_max_steps <= cfg.mouse_min_steps) throw new Error('mouse_max_steps should be > min');
if (cfg.typing_delay <= 0) throw new Error('typing_delay should be > 0');
if (!Array.isArray(cfg.initial_cursor_x) || cfg.initial_cursor_x.length !== 2) throw new Error('initial_cursor_x invalid');
if (!Array.isArray(cfg.initial_cursor_y) || cfg.initial_cursor_y.length !== 2) throw new Error('initial_cursor_y invalid');
});
await test('careful config resolves', async () => {
const cfg = resolveConfig('careful');
const def = resolveConfig('default');
if (!cfg) throw new Error('resolveConfig returned null');
if (cfg.typing_delay < def.typing_delay) throw new Error('careful should have >= typing_delay');
});
await test('custom config override', async () => {
const cfg = resolveConfig('default', { mouse_min_steps: 100, mouse_max_steps: 200 });
if (cfg.mouse_min_steps !== 100) throw new Error(`Override failed: ${cfg.mouse_min_steps}`);
if (cfg.mouse_max_steps !== 200) throw new Error(`Override failed: ${cfg.mouse_max_steps}`);
});
await test('rand within bounds', async () => {
for (let i = 0; i < 100; i++) {
const v = rand(10, 20);
if (v < 10 || v > 20) throw new Error(`rand out of range: ${v}`);
}
});
await test('randRange within bounds', async () => {
for (let i = 0; i < 100; i++) {
const v = randRange([5, 15]);
if (v < 5 || v > 15) throw new Error(`randRange out of range: ${v}`);
}
});
await test('sleep timing', async () => {
const t0 = Date.now();
await sleep(50);
const elapsed = Date.now() - t0;
if (elapsed < 40) throw new Error(`sleep too short: ${elapsed} ms`);
if (elapsed > 200) throw new Error(`sleep too long: ${elapsed} ms`);
});
// =========================================================================
// 2. Bézier math (via humanMove recording)
// =========================================================================
console.log('\n' + '='.repeat(60));
console.log(' BÉZIER MATH (via mouse movement recording)');
console.log('='.repeat(60));
await test('humanMove generates multiple points', async () => {
const cfg = resolveConfig('default');
const moves = [];
const fakeRaw = {
move: async (x, y) => moves.push({ x, y }),
down: async () => {},
up: async () => {},
wheel: async () => {},
};
await humanMove(fakeRaw, 0, 0, 500, 300, cfg);
if (moves.length < 10) throw new Error(`Expected >= 10 moves, got ${moves.length}`);
const last = moves[moves.length - 1];
if (Math.abs(last.x - 500) > 10) throw new Error(`Last x too far: ${last.x}`);
if (Math.abs(last.y - 300) > 10) throw new Error(`Last y too far: ${last.y}`);
});
await test('humanMove smoothness (no large jumps)', async () => {
const cfg = resolveConfig('default');
const moves = [];
const fakeRaw = {
move: async (x, y) => moves.push({ x, y }),
down: async () => {},
up: async () => {},
wheel: async () => {},
};
await humanMove(fakeRaw, 0, 0, 400, 400, cfg);
const totalDist = Math.sqrt(400 * 400 + 400 * 400);
const maxJump = totalDist * 0.5;
for (let i = 1; i < moves.length; i++) {
const dx = moves[i].x - moves[i - 1].x;
const dy = moves[i].y - moves[i - 1].y;
const jump = Math.sqrt(dx * dx + dy * dy);
if (jump > maxJump) throw new Error(`Jump too large at step ${i}: ${jump.toFixed(1)}`);
}
});
await test('humanMove not a straight line', async () => {
const cfg = resolveConfig('default');
let maxDev = 0;
for (let trial = 0; trial < 5; trial++) {
const moves = [];
const fakeRaw = {
move: async (x, y) => moves.push({ x, y }),
down: async () => {},
up: async () => {},
wheel: async () => {},
};
await humanMove(fakeRaw, 0, 0, 500, 0, cfg);
const dev = Math.max(...moves.map(m => Math.abs(m.y)));
if (dev > maxDev) maxDev = dev;
}
if (maxDev < 0.5) throw new Error(`Curve too straight, max y deviation: ${maxDev.toFixed(2)}`);
});
await test('clickTarget within bounding box', async () => {
const cfg = resolveConfig('default');
const box = { x: 100, y: 200, width: 150, height: 40 };
for (let i = 0; i < 50; i++) {
const t = clickTarget(box, false, cfg);
if (t.x < 100 || t.x > 250) throw new Error(`x out of box: ${t.x}`);
if (t.y < 200 || t.y > 240) throw new Error(`y out of box: ${t.y}`);
}
});
// =========================================================================
// 3. Fill clearing (with real browser)
// =========================================================================
console.log('\n' + '='.repeat(60));
console.log(' FILL CLEARING (browser)');
console.log('='.repeat(60));
await test('fill() clears existing text', async () => {
const browser = await launch({ headless: true, humanize: true });
const page = await browser.newPage();
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
await delay(1000);
await page.locator('#searchInput').type('initial text');
await delay(500);
const before = await page.locator('#searchInput').inputValue();
if (before !== 'initial text') throw new Error(`Initial type failed: '${before}'`);
await page.locator('#searchInput').fill('replaced text');
await delay(500);
const after = await page.locator('#searchInput').inputValue();
if (after !== 'replaced text') throw new Error(`Fill did not replace: '${after}'`);
if (after.includes('initial')) throw new Error('Old text still present');
await browser.close();
});
await test('fill() timing is humanized (>1s)', async () => {
const browser = await launch({ headless: true, humanize: true });
const page = await browser.newPage();
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
await delay(1000);
const t0 = Date.now();
await page.locator('#searchInput').fill('Human speed test');
const elapsed = Date.now() - t0;
if (elapsed < 1000) throw new Error(`fill() too fast: ${elapsed} ms`);
await browser.close();
});
await test('clear() empties field', async () => {
const browser = await launch({ headless: true, humanize: true });
const page = await browser.newPage();
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
await delay(1000);
await page.locator('#searchInput').fill('some text');
await delay(500);
await page.locator('#searchInput').clear();
await delay(500);
const val = await page.locator('#searchInput').inputValue();
if (val !== '') throw new Error(`clear() did not empty: '${val}'`);
await browser.close();
});
// =========================================================================
// 4. Bot detection form — deviceandbrowserinfo.com
// =========================================================================
console.log('\n' + '='.repeat(60));
console.log(' BOT DETECTION FORM (deviceandbrowserinfo.com)');
console.log('='.repeat(60));
await test('bot detection form — behavioral checks pass', async () => {
const browser = await launch({ headless: false, humanize: true, proxy: PROXY });
const page = await browser.newPage();
await page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions', { waitUntil: 'domcontentloaded' });
await delay(3000);
await page.locator('#email').click();
await delay(300);
await page.locator('#email').fill('test@example.com');
await delay(500);
await page.locator('#password').click();
await delay(300);
await page.locator('#password').fill('SecurePass!123');
await delay(500);
await page.locator('button[type="submit"]').click();
await delay(5000);
const body = await page.locator('body').textContent();
const superHuman = body.includes('"superHumanSpeed": true');
const suspicious = body.includes('"suspiciousClientSideBehavior": true');
const cdpMouse = body.includes('"hasCDPMouseLeak": true');
console.log(` superHumanSpeed: ${superHuman}`);
console.log(` suspiciousClientSideBehavior: ${suspicious}`);
console.log(` hasCDPMouseLeak: ${cdpMouse}`);
if (superHuman) throw new Error('superHumanSpeed detected');
if (suspicious) throw new Error('suspiciousClientSideBehavior detected');
if (body.includes('"isAutomatedWithCDP": true')) {
console.log(' [INFO] isAutomatedWithCDP=true — stealth issue, not humanize');
}
await browser.close();
});
await test('bot detection form timing (>3s)', async () => {
const browser = await launch({ headless: true, humanize: true, proxy: PROXY });
const page = await browser.newPage();
await page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions', { waitUntil: 'domcontentloaded' });
await delay(2000);
const t0 = Date.now();
await page.locator('#email').fill('test@example.com');
await page.locator('#password').fill('MyPassword!99');
await page.locator('button[type="submit"]').click();
const elapsed = Date.now() - t0;
await delay(3000);
console.log(` Form fill + submit took: ${elapsed} ms`);
if (elapsed < 3000) throw new Error(`Form filled too fast: ${elapsed} ms`);
await browser.close();
});
// =========================================================================
// 5. Patching integrity
// =========================================================================
console.log('\n' + '='.repeat(60));
console.log(' PATCHING INTEGRITY');
console.log('='.repeat(60));
await test('page has _original after launch', async () => {
const browser = await launch({ headless: true, humanize: true });
const page = await browser.newPage();
if (!page._original) throw new Error('page._original missing');
if (!page._humanCfg) throw new Error('page._humanCfg missing');
if (!page._humanCursor) throw new Error('page._humanCursor missing');
await browser.close();
});
await test('page.click is humanized', async () => {
const browser = await launch({ headless: true, humanize: true });
const page = await browser.newPage();
const clickStr = page.click.toString();
if (!clickStr.includes('ensureCursorInit') && !clickStr.includes('humanClickFn') && !clickStr.includes('scrollToElement')) {
throw new Error('page.click does not appear humanized');
}
await browser.close();
});
await test('non-humanized page works normally', async () => {
const browser = await launch({ headless: true, humanize: false });
const page = await browser.newPage();
if (page._original) throw new Error('Non-humanized page should not have _original');
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
await delay(1000);
const t0 = Date.now();
await page.locator('#searchInput').fill('test');
const elapsed = Date.now() - t0;
if (elapsed > 500) throw new Error(`Non-humanized fill too slow: ${elapsed} ms`);
await browser.close();
});
// =========================================================================
// 6. Focus check — press skips click when focused
// =========================================================================
console.log('\n' + '='.repeat(60));
console.log(' FOCUS CHECK (press / pressSequentially)');
console.log('='.repeat(60));
await test('press skips click when element already focused', async () => {
const browser = await launch({ headless: true, humanize: true });
const page = await browser.newPage();
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
await delay(1000);
// Click input first to focus it
await page.locator('#searchInput').click();
await delay(300);
// Record mouse moves before pressing Enter
const movesBefore = [];
const origMove = page._humanOriginals.mouseMove;
let moveCount = 0;
page._humanOriginals.mouseMove = async (x, y, opts) => {
moveCount++;
return origMove(x, y, opts);
};
// Press Enter — element is already focused, should NOT trigger mouse move
const movesAtStart = moveCount;
await page.locator('#searchInput').press('a');
const movesUsed = moveCount - movesAtStart;
// Restore
page._humanOriginals.mouseMove = origMove;
// If focus check works, should be 0 moves (just keyboard press)
if (movesUsed > 0) {
console.log(` [INFO] press() triggered ${movesUsed} mouse moves on focused element`);
}
// Lenient: allow some moves but not a full Bézier path (>10 would indicate a click)
if (movesUsed > 10) {
throw new Error(`press() moved mouse ${movesUsed} times on already-focused element — focus check broken`);
}
await browser.close();
});
// =========================================================================
// 7. check/uncheck idle
// =========================================================================
console.log('\n' + '='.repeat(60));
console.log(' CHECK/UNCHECK IDLE');
console.log('='.repeat(60));
await test('check() respects idle_between_actions config', async () => {
const cfg = resolveConfig('default', { idle_between_actions: true, idle_between_duration: [50, 100] });
if (!cfg.idle_between_actions) throw new Error('idle_between_actions should be true');
if (!cfg.idle_between_duration || cfg.idle_between_duration[0] !== 50) {
throw new Error('idle_between_duration not set');
}
// Verify config is carried through to page
const browser = await launch({ headless: true, humanize: true, humanize_config: { idle_between_actions: true } });
const page = await browser.newPage();
if (!page._humanCfg) throw new Error('page._humanCfg missing');
await browser.close();
});
// =========================================================================
// 8. Frame patching completeness
// =========================================================================
console.log('\n' + '='.repeat(60));
console.log(' FRAME PATCHING COMPLETENESS');
console.log('='.repeat(60));
await test('frame has all methods patched', async () => {
const browser = await launch({ headless: true, humanize: true });
const page = await browser.newPage();
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
await delay(1000);
const mainFrame = page.mainFrame();
const expected = ['click', 'dblclick', 'hover', 'type', 'fill',
'check', 'uncheck', 'selectOption', 'press',
'clear', 'dragAndDrop'];
const missing = [];
for (const method of expected) {
if (typeof mainFrame[method] !== 'function') {
missing.push(method);
}
}
if (missing.length > 0) {
throw new Error(`Frame missing patched methods: ${missing.join(', ')}`);
}
// Verify they are patched (not original Playwright bindings)
if (!mainFrame._humanPatched) {
throw new Error('mainFrame._humanPatched flag not set');
}
await browser.close();
});
// =========================================================================
// 9. drag_to safety — page._original check
// =========================================================================
console.log('\n' + '='.repeat(60));
console.log(' DRAG_TO SAFETY');
console.log('='.repeat(60));
await test('page._humanCfg is accessible', async () => {
const browser = await launch({ headless: true, humanize: true });
const page = await browser.newPage();
if (!page._humanCfg) throw new Error('page._humanCfg not set');
if (!page._original) throw new Error('page._original not set');
if (typeof page._original.mouseDown !== 'function') throw new Error('mouseDown not preserved');
if (typeof page._original.mouseUp !== 'function') throw new Error('mouseUp not preserved');
await browser.close();
});
// =========================================================================
// 10. patchBrowser.newPage uses original context
// =========================================================================
console.log('\n' + '='.repeat(60));
console.log(' PATCH BROWSER — newPage context');
console.log('='.repeat(60));
await test('browser.newPage returns patched page', async () => {
const browser = await launch({ headless: true, humanize: true });
const page = await browser.newPage();
if (!page._original) throw new Error('page from browser.newPage() not patched');
if (!page._humanCfg) throw new Error('page._humanCfg missing from browser.newPage()');
await browser.close();
});
// =========================================================================
// SUMMARY
// =========================================================================
console.log('\n' + '='.repeat(70));
console.log(' TEST SUMMARY');
console.log('='.repeat(70));
const passed = results.filter(r => r.status === 'PASS').length;
const failed = results.filter(r => r.status === 'FAIL').length;
for (const r of results) {
const icon = r.status === 'PASS' ? 'OK' : 'XX';
console.log(` [${icon}] ${r.name}`);
}
console.log(`\n ${passed}/${results.length} passed, ${failed} failed`);
if (failed === 0) console.log(' *** ALL JS TESTS PASSED ***');
else console.log(` *** ${failed} TESTS FAILED ***`);
console.log('='.repeat(70));
process.exit(failed === 0 ? 0 : 1);
+575
View File
@@ -0,0 +1,575 @@
"""
Unit + integration tests for the humanize layer.
Fast unit tests (config, Bézier math, mocks) are proper test_ functions
that pytest discovers automatically.
Browser-dependent tests are marked @pytest.mark.slow and skipped in CI
unless explicitly requested (pytest -m slow).
Can also run directly: python tests/test_humanize_unit.py
"""
import math
import time
import sys
import pytest
# =========================================================================
# Helper: ensure Locator class is patched before mock tests
# =========================================================================
def _ensure_locator_patched():
import cloakbrowser.human as h
h._locator_sync_patched = False
h._patch_locator_class_sync()
# =========================================================================
# Helper: fake RawMouse for Bézier tests
# =========================================================================
class _FakeRawMouse:
def __init__(self):
self.moves = []
def move(self, x, y, **kw):
self.moves.append((x, y))
def down(self, **kw):
pass
def up(self, **kw):
pass
def wheel(self, dx, dy):
pass
# =========================================================================
# 1. Config resolution
# =========================================================================
class TestConfigResolution:
def test_default_config_resolves(self):
from cloakbrowser.human.config import resolve_config, HumanConfig
cfg = resolve_config("default", None)
assert isinstance(cfg, HumanConfig)
assert cfg.mouse_min_steps > 0
assert cfg.mouse_max_steps > cfg.mouse_min_steps
assert len(cfg.initial_cursor_x) == 2
assert len(cfg.initial_cursor_y) == 2
assert cfg.typing_delay > 0
def test_careful_config_resolves(self):
from cloakbrowser.human.config import resolve_config
cfg = resolve_config("careful", None)
default_cfg = resolve_config("default", None)
assert cfg.mouse_min_steps > 0
assert cfg.typing_delay >= default_cfg.typing_delay
def test_custom_override(self):
from cloakbrowser.human.config import resolve_config
cfg = resolve_config("default", {"mouse_min_steps": 100, "mouse_max_steps": 200})
assert cfg.mouse_min_steps == 100
assert cfg.mouse_max_steps == 200
def test_invalid_preset_raises(self):
from cloakbrowser.human.config import resolve_config
with pytest.raises(ValueError, match="Unknown humanize preset"):
resolve_config("nonexistent", None)
def test_rand_within_bounds(self):
from cloakbrowser.human.config import rand, rand_range
for _ in range(200):
v = rand(10, 20)
assert 10 <= v <= 20
for _ in range(200):
v = rand_range([5, 15])
assert 5 <= v <= 15
def test_sleep_ms_timing(self):
from cloakbrowser.human.config import sleep_ms
t0 = time.time()
sleep_ms(50)
elapsed = (time.time() - t0) * 1000
assert elapsed >= 40
assert elapsed < 200
# =========================================================================
# 2. Bézier math
# =========================================================================
class TestBezierMath:
def test_generates_multiple_points(self):
from cloakbrowser.human.mouse import human_move
from cloakbrowser.human.config import resolve_config
cfg = resolve_config("default", None)
raw = _FakeRawMouse()
human_move(raw, 0, 0, 500, 300, cfg)
assert len(raw.moves) >= 10
last_x, last_y = raw.moves[-1]
assert abs(last_x - 500) < 10
assert abs(last_y - 300) < 10
def test_smoothness_no_large_jumps(self):
from cloakbrowser.human.mouse import human_move
from cloakbrowser.human.config import resolve_config
cfg = resolve_config("default", None)
raw = _FakeRawMouse()
human_move(raw, 0, 0, 400, 400, cfg)
total_dist = math.sqrt(400**2 + 400**2)
max_jump = total_dist * 0.5
for i in range(1, len(raw.moves)):
dx = raw.moves[i][0] - raw.moves[i-1][0]
dy = raw.moves[i][1] - raw.moves[i-1][1]
assert math.sqrt(dx*dx + dy*dy) < max_jump
def test_short_distance(self):
from cloakbrowser.human.mouse import human_move
from cloakbrowser.human.config import resolve_config
cfg = resolve_config("default", None)
raw = _FakeRawMouse()
human_move(raw, 100, 100, 103, 102, cfg)
assert len(raw.moves) >= 1
def test_not_straight_line(self):
from cloakbrowser.human.mouse import human_move
from cloakbrowser.human.config import resolve_config
cfg = resolve_config("default", None)
max_dev = 0
for _ in range(5):
raw = _FakeRawMouse()
human_move(raw, 0, 0, 500, 0, cfg)
dev = max(abs(y) for _, y in raw.moves)
if dev > max_dev:
max_dev = dev
assert max_dev > 0.5
def test_click_target_within_box(self):
from cloakbrowser.human.mouse import click_target
from cloakbrowser.human.config import resolve_config
cfg = resolve_config("default", None)
box = {"x": 100, "y": 200, "width": 150, "height": 40}
for _ in range(50):
t = click_target(box, False, cfg)
assert 100 <= t.x <= 250
assert 200 <= t.y <= 240
def test_click_target_input_mode(self):
from cloakbrowser.human.mouse import click_target
from cloakbrowser.human.config import resolve_config
cfg = resolve_config("default", None)
box = {"x": 50, "y": 50, "width": 200, "height": 30}
for _ in range(20):
t = click_target(box, True, cfg)
assert 50 <= t.x <= 250
assert 50 <= t.y <= 80
# =========================================================================
# 3. Async compatibility
# =========================================================================
class TestAsyncCompat:
def test_async_modules_import(self):
from cloakbrowser.human.mouse_async import AsyncRawMouse, async_human_move
from cloakbrowser.human.keyboard_async import AsyncRawKeyboard, async_human_type
from cloakbrowser.human.scroll_async import async_scroll_to_element
from cloakbrowser.human import patch_page_async, patch_browser_async, patch_context_async
assert callable(async_human_move)
assert callable(async_human_type)
assert callable(async_scroll_to_element)
def test_async_locator_patch(self):
import cloakbrowser.human as h
h._locator_async_patched = False
h._patch_locator_class_async()
assert h._locator_async_patched
from playwright.async_api._generated import Locator as AsyncLocator
assert 'humanized' in AsyncLocator.fill.__name__
def test_async_sleep_is_coroutine(self):
from cloakbrowser.human.config import async_sleep_ms
import asyncio
assert asyncio.iscoroutinefunction(async_sleep_ms)
# =========================================================================
# 4. Focus check — press / clear / pressSequentially
# =========================================================================
class TestFocusCheck:
def test_press_skips_click_when_focused(self):
_ensure_locator_patched()
from unittest.mock import MagicMock, patch as mock_patch
page = MagicMock()
page._original = MagicMock()
page._human_cfg = MagicMock()
page._human_cfg.idle_between_actions = False
with mock_patch("cloakbrowser.human._is_selector_focused", return_value=True):
from playwright.sync_api._generated import Locator
loc = MagicMock()
loc.page = page
loc._impl_obj = MagicMock()
loc._impl_obj._selector = "#test"
Locator.press(loc, "Enter")
page.click.assert_not_called()
def test_press_clicks_when_not_focused(self):
_ensure_locator_patched()
from unittest.mock import MagicMock, patch as mock_patch
page = MagicMock()
page._original = MagicMock()
page._human_cfg = MagicMock()
page._human_cfg.idle_between_actions = False
with mock_patch("cloakbrowser.human._is_selector_focused", return_value=False):
from playwright.sync_api._generated import Locator
loc = MagicMock()
loc.page = page
loc._impl_obj = MagicMock()
loc._impl_obj._selector = "#test"
Locator.press(loc, "Enter")
page.click.assert_called_with("#test")
# =========================================================================
# 5. check/uncheck idle
# =========================================================================
class TestCheckUncheckIdle:
def test_check_calls_idle_when_enabled(self):
_ensure_locator_patched()
from unittest.mock import MagicMock, patch as mock_patch
from cloakbrowser.human.config import resolve_config
cfg = resolve_config("default", {"idle_between_actions": True, "idle_between_duration": [50, 100]})
page = MagicMock()
page._original = MagicMock()
page._original.mouse_move = MagicMock()
page._human_cfg = cfg
idle_called = {"n": 0}
def fake_idle(*a, **kw):
idle_called["n"] += 1
from playwright.sync_api._generated import Locator
loc = MagicMock()
loc.page = page
loc._impl_obj = MagicMock()
loc._impl_obj._selector = "#checkbox"
loc.is_checked = MagicMock(return_value=False)
with mock_patch("cloakbrowser.human.human_idle", fake_idle):
Locator.check(loc)
assert idle_called["n"] >= 1
def test_uncheck_calls_idle_when_enabled(self):
_ensure_locator_patched()
from unittest.mock import MagicMock, patch as mock_patch
from cloakbrowser.human.config import resolve_config
cfg = resolve_config("default", {"idle_between_actions": True, "idle_between_duration": [50, 100]})
page = MagicMock()
page._original = MagicMock()
page._original.mouse_move = MagicMock()
page._human_cfg = cfg
idle_called = {"n": 0}
def fake_idle(*a, **kw):
idle_called["n"] += 1
from playwright.sync_api._generated import Locator
loc = MagicMock()
loc.page = page
loc._impl_obj = MagicMock()
loc._impl_obj._selector = "#checkbox"
loc.is_checked = MagicMock(return_value=True)
with mock_patch("cloakbrowser.human.human_idle", fake_idle):
Locator.uncheck(loc)
assert idle_called["n"] >= 1
# =========================================================================
# 6. Frame patching completeness
# =========================================================================
class TestFramePatching:
def test_all_11_methods_patched(self):
from cloakbrowser.human import _patch_single_frame_sync, _CursorState
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", None)
cursor = _CursorState()
page = MagicMock()
page._original = MagicMock()
frame = MagicMock()
frame._human_patched = False
_patch_single_frame_sync(frame, page, cfg, cursor, MagicMock(), MagicMock(), page._original)
expected = ['click', 'dblclick', 'hover', 'type', 'fill',
'check', 'uncheck', 'select_option', 'press',
'clear', 'drag_and_drop']
for method in expected:
fn = getattr(frame, method)
assert not isinstance(fn, MagicMock), f"frame.{method} was not patched"
# =========================================================================
# 7. drag_to safety
# =========================================================================
class TestDragToSafety:
def test_handles_missing_original(self):
_ensure_locator_patched()
from playwright.sync_api._generated import Locator
from unittest.mock import MagicMock
page = MagicMock()
page._original = None
source_loc = MagicMock()
source_loc.page = page
source_loc._impl_obj = MagicMock()
source_loc._impl_obj._selector = "#src"
source_loc.bounding_box = MagicMock(return_value={"x": 10, "y": 10, "width": 50, "height": 50})
target_loc = MagicMock()
target_loc.page = page
target_loc._impl_obj = MagicMock()
target_loc._impl_obj._selector = "#tgt"
target_loc.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 50, "height": 50})
try:
Locator.drag_to(source_loc, target_loc)
except AttributeError:
pytest.fail("drag_to crashed without page._original")
# =========================================================================
# 8. Page config persistence
# =========================================================================
class TestPageConfigPersistence:
def test_resolve_config_has_all_fields(self):
from cloakbrowser.human.config import resolve_config
cfg = resolve_config("default")
required = ["mouse_min_steps", "mouse_max_steps", "typing_delay",
"initial_cursor_x", "initial_cursor_y", "idle_between_actions",
"idle_between_duration", "field_switch_delay",
"mistype_chance", "mistype_delay_notice", "mistype_delay_correct"]
for field in required:
assert hasattr(cfg, field), f"Config missing field: {field}"
# =========================================================================
# 9. Mistype config
# =========================================================================
class TestMistypeConfig:
def test_default_mistype_chance(self):
from cloakbrowser.human.config import resolve_config
cfg = resolve_config("default")
assert 0 < cfg.mistype_chance < 1
assert len(cfg.mistype_delay_notice) == 2
assert len(cfg.mistype_delay_correct) == 2
def test_careful_mistype_higher(self):
from cloakbrowser.human.config import resolve_config
default = resolve_config("default")
careful = resolve_config("careful")
assert careful.mistype_chance >= default.mistype_chance
# =========================================================================
# 10. Select-all platform detection
# =========================================================================
class TestSelectAllPlatform:
def test_select_all_constant_exists(self):
from cloakbrowser.human import _SELECT_ALL
assert _SELECT_ALL in ("Meta+a", "Control+a")
def test_select_all_matches_platform(self):
import sys
from cloakbrowser.human import _SELECT_ALL
if sys.platform == "darwin":
assert _SELECT_ALL == "Meta+a"
else:
assert _SELECT_ALL == "Control+a"
# =========================================================================
# SLOW TESTS — require browser (skipped in CI unless pytest -m slow)
# =========================================================================
@pytest.mark.slow
class TestBrowserFill:
def test_fill_clears_existing(self):
from cloakbrowser import launch
browser = launch(headless=True, humanize=True)
page = browser.new_page()
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
time.sleep(1)
page.locator('#searchInput').type('initial text')
time.sleep(0.5)
page.locator('#searchInput').fill('replaced text')
time.sleep(0.5)
val = page.locator('#searchInput').input_value()
assert val == 'replaced text'
assert 'initial' not in val
browser.close()
def test_fill_timing_humanized(self):
from cloakbrowser import launch
browser = launch(headless=True, humanize=True)
page = browser.new_page()
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
time.sleep(1)
t0 = time.time()
page.locator('#searchInput').fill('Human speed test')
elapsed_ms = int((time.time() - t0) * 1000)
assert elapsed_ms > 1000
browser.close()
def test_clear_empties_field(self):
from cloakbrowser import launch
browser = launch(headless=True, humanize=True)
page = browser.new_page()
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
time.sleep(1)
page.locator('#searchInput').fill('some text')
time.sleep(0.5)
page.locator('#searchInput').clear()
time.sleep(0.5)
val = page.locator('#searchInput').input_value()
assert val == ''
browser.close()
@pytest.mark.slow
class TestBrowserPatching:
def test_page_has_original(self):
from cloakbrowser import launch
browser = launch(headless=True, humanize=True)
page = browser.new_page()
assert hasattr(page, '_original')
assert hasattr(page, '_human_cfg')
browser.close()
def test_locator_methods_patched(self):
from cloakbrowser import launch
browser = launch(headless=True, humanize=True)
page = browser.new_page()
from playwright.sync_api._generated import Locator
methods = ['fill', 'click', 'type', 'dblclick', 'hover', 'check', 'uncheck',
'set_checked', 'select_option', 'press', 'press_sequentially',
'tap', 'drag_to', 'clear']
for method in methods:
fn = getattr(Locator, method)
assert 'humanized' in fn.__name__, f"{method} not patched"
browser.close()
def test_non_humanized_page_normal(self):
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
assert not hasattr(page, '_original')
browser.close()
def test_page_human_cfg_persists(self):
from cloakbrowser import launch
browser = launch(headless=True, humanize=True)
page = browser.new_page()
assert page._human_cfg is not None
assert hasattr(page._human_cfg, 'idle_between_actions')
assert hasattr(page._human_cfg, 'mistype_chance')
browser.close()
@pytest.mark.slow
class TestBrowserBotDetection:
PROXY = ''
def test_behavioral_checks_pass(self):
from cloakbrowser import launch
browser = launch(headless=False, humanize=True, proxy=self.PROXY, geoip=True)
page = browser.new_page()
page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions',
wait_until='domcontentloaded')
time.sleep(3)
page.locator('#email').click()
time.sleep(0.3)
page.locator('#email').fill('test@example.com')
time.sleep(0.5)
page.locator('#password').click()
time.sleep(0.3)
page.locator('#password').fill('SecurePass!123')
time.sleep(0.5)
page.locator('button[type="submit"]').click()
time.sleep(5)
body = page.locator('body').text_content()
assert '"superHumanSpeed": true' not in body
assert '"suspiciousClientSideBehavior": true' not in body
browser.close()
def test_form_timing(self):
from cloakbrowser import launch
browser = launch(headless=True, humanize=True, proxy=self.PROXY, geoip=True)
page = browser.new_page()
page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions',
wait_until='domcontentloaded')
time.sleep(2)
t0 = time.time()
page.locator('#email').fill('test@example.com')
page.locator('#password').fill('MyPassword!99')
page.locator('button[type="submit"]').click()
elapsed_ms = int((time.time() - t0) * 1000)
time.sleep(3)
assert elapsed_ms > 3000
browser.close()
@pytest.mark.slow
class TestAsyncEndToEnd:
def test_async_launch_click_fill(self):
"""launch_async(humanize=True) — async page.click and page.fill work end-to-end."""
import asyncio
from cloakbrowser import launch_async
async def _run():
browser = await launch_async(headless=True, humanize=True)
page = await browser.new_page()
assert hasattr(page, '_original'), "async page not patched"
assert hasattr(page, '_human_cfg'), "async page missing _human_cfg"
await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
await asyncio.sleep(1)
t0 = time.time()
await page.locator('#searchInput').fill('async test')
elapsed_ms = int((time.time() - t0) * 1000)
assert elapsed_ms > 500, f"async fill too fast: {elapsed_ms}ms"
val = await page.locator('#searchInput').input_value()
assert val == 'async test', f"async fill wrong value: {val}"
await browser.close()
asyncio.run(_run())
# =========================================================================
# Direct runner (backwards compat)
# =========================================================================
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v", "--tb=short", "-x"]))
+24 -28
View File
@@ -1,6 +1,5 @@
"""Unit tests for launch_context() — context kwargs, viewport defaults, close cleanup."""
import warnings
from unittest.mock import MagicMock, call, patch
import pytest
@@ -66,7 +65,7 @@ def test_user_agent(mock_launch, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_locale_forwarded(mock_launch, _mock_bin):
"""locale flows to both launch() binary args AND new_context()."""
"""locale flows to launch() for --lang binary flag, NOT to new_context() CDP."""
browser, context = _make_mock_browser()
mock_launch.return_value = browser
@@ -75,18 +74,18 @@ def test_locale_forwarded(mock_launch, _mock_bin):
# Locale in launch() call (for --lang binary flag)
assert mock_launch.call_args[1]["locale"] == "de-DE"
# Locale in new_context() call
# NOT in new_context() — would trigger detectable CDP emulation
ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["locale"] == "de-DE"
assert "locale" not in ctx_kwargs[1]
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_timezone_via_context_not_binary(mock_launch, _mock_bin):
"""timezone passed to new_context(timezone_id=...) but NOT to launch(timezone=...).
def test_timezone_via_binary_not_cdp(mock_launch, _mock_bin):
"""timezone passed to launch() for binary flag, NOT to new_context() CDP.
This is intentional: the --fingerprint-timezone binary flag only applies to the
default context and would conflict with Playwright's timezone_id on new contexts.
--fingerprint-timezone is process-wide (reads CommandLine in renderer),
so it applies to ALL contexts, not just the default one.
"""
browser, context = _make_mock_browser()
mock_launch.return_value = browser
@@ -94,11 +93,11 @@ def test_timezone_via_context_not_binary(mock_launch, _mock_bin):
from cloakbrowser.browser import launch_context
launch_context(timezone="America/New_York")
# timezone=None in launch() — binary flag skipped
assert mock_launch.call_args[1]["timezone"] is None
# timezone_id in new_context()
# timezone in launch() — binary flag set
assert mock_launch.call_args[1]["timezone"] == "America/New_York"
# NOT in new_context() — no CDP emulation
ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["timezone_id"] == "America/New_York"
assert "timezone_id" not in ctx_kwargs[1]
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@@ -119,40 +118,37 @@ def test_color_scheme(mock_launch, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_geoip_resolution(mock_launch, _mock_bin, _mock_geoip):
"""geoip fills timezone+locale, both flow to correct places."""
"""geoip fills timezone+locale, both flow to binary args only."""
browser, context = _make_mock_browser()
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
launch_context(proxy="http://proxy:8080", geoip=True)
# Locale goes to launch() for binary flag
# Both go to launch() for binary flags
assert mock_launch.call_args[1]["locale"] == "de-DE"
# Timezone goes to context, not binary
assert mock_launch.call_args[1]["timezone"] is None
assert mock_launch.call_args[1]["timezone"] == "Europe/Berlin"
# Neither in context — no CDP emulation
ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["timezone_id"] == "Europe/Berlin"
assert ctx_kwargs[1]["locale"] == "de-DE"
assert "timezone_id" not in ctx_kwargs[1]
assert "locale" not in ctx_kwargs[1]
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_timezone_id_deprecation(mock_launch, _mock_bin):
"""timezone_id kwarg triggers FutureWarning, value migrated to timezone."""
def test_timezone_id_alias(mock_launch, _mock_bin):
"""timezone_id kwarg accepted as alias for timezone."""
browser, context = _make_mock_browser()
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
launch_context(timezone_id="Europe/Paris")
launch_context(timezone_id="Europe/Paris")
assert len(w) == 1
assert issubclass(w[0].category, FutureWarning)
assert "timezone_id" in str(w[0].message)
# Migrated value flows to context
# Resolved value flows to launch() for binary flag
assert mock_launch.call_args[1]["timezone"] == "Europe/Paris"
# NOT in context — no CDP emulation
ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["timezone_id"] == "Europe/Paris"
assert "timezone_id" not in ctx_kwargs[1]
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
+22 -25
View File
@@ -3,7 +3,6 @@
All tests mock playwright to avoid needing a binary.
"""
import warnings
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -87,7 +86,7 @@ def test_persistent_context_user_agent(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
def test_persistent_context_locale_and_timezone(_mock_bin):
"""Both timezone and locale flow to context kwargs and binary args."""
"""Timezone and locale flow to binary args only, NOT to CDP context kwargs."""
pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
@@ -95,12 +94,12 @@ def test_persistent_context_locale_and_timezone(_mock_bin):
launch_persistent_context("/tmp/profile", timezone="Asia/Tokyo", locale="ja-JP")
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
# Context kwargs
assert call_kwargs["timezone_id"] == "Asia/Tokyo"
assert call_kwargs["locale"] == "ja-JP"
# Binary args
# Binary args (native, undetectable)
assert "--fingerprint-timezone=Asia/Tokyo" in call_kwargs["args"]
assert "--lang=ja-JP" in call_kwargs["args"]
# NOT in context kwargs (would trigger detectable CDP emulation)
assert "timezone_id" not in call_kwargs
assert "locale" not in call_kwargs
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@@ -120,7 +119,7 @@ def test_persistent_context_color_scheme(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE"))
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
def test_persistent_context_geoip(_mock_bin, _mock_geoip):
"""geoip fills missing tz/locale."""
"""geoip fills missing tz/locale — flows to binary args, not CDP context."""
pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
@@ -128,25 +127,26 @@ def test_persistent_context_geoip(_mock_bin, _mock_geoip):
launch_persistent_context("/tmp/profile", proxy="http://proxy:8080", geoip=True)
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["timezone_id"] == "Europe/Berlin"
assert call_kwargs["locale"] == "de-DE"
# Binary args
assert "--fingerprint-timezone=Europe/Berlin" in call_kwargs["args"]
assert "--lang=de-DE" in call_kwargs["args"]
# NOT in context kwargs
assert "timezone_id" not in call_kwargs
assert "locale" not in call_kwargs
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
def test_persistent_context_timezone_id_deprecation(_mock_bin):
"""Old timezone_id kwarg migrated with warning."""
def test_persistent_context_timezone_id_alias(_mock_bin):
"""timezone_id kwarg accepted as alias for timezone."""
pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
launch_persistent_context("/tmp/profile", timezone_id="Europe/Paris")
launch_persistent_context("/tmp/profile", timezone_id="Europe/Paris")
assert len(w) == 1
assert issubclass(w[0].category, FutureWarning)
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["timezone_id"] == "Europe/Paris"
assert "--fingerprint-timezone=Europe/Paris" in call_kwargs["args"]
assert "timezone_id" not in call_kwargs
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@@ -246,17 +246,14 @@ async def test_persistent_context_async_close_stops_pw(_mock_geoip, _mock_bin):
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
async def test_persistent_context_async_timezone_id_deprecation(_mock_bin):
"""Deprecated timezone_id kwarg migrated with warning in async path."""
async def test_persistent_context_async_timezone_id_alias(_mock_bin):
"""timezone_id kwarg accepted as alias in async path."""
pw_cm, pw, context = _make_mock_async_pw_and_context()
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context_async
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
await launch_persistent_context_async("/tmp/profile", timezone_id="Europe/Paris")
await launch_persistent_context_async("/tmp/profile", timezone_id="Europe/Paris")
assert len(w) == 1
assert issubclass(w[0].category, FutureWarning)
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["timezone_id"] == "Europe/Paris"
assert "--fingerprint-timezone=Europe/Paris" in call_kwargs["args"]
assert "timezone_id" not in call_kwargs
+53
View File
@@ -98,3 +98,56 @@ class TestMaybeResolveGeoip:
tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None)
assert tz == "Europe/Berlin"
assert locale == "ja-JP"
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
def test_geoip_normalizes_bare_proxy_with_creds(self, mock_geo):
# "user:pass@host:port" must be normalized to http:// before geoip lookup.
tz, locale = _maybe_resolve_geoip(True, "user:pass@proxy:8080", None, None)
mock_geo.assert_called_once_with("http://user:pass@proxy:8080")
assert tz == "America/New_York"
assert locale == "en-US"
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
def test_geoip_normalizes_schemeless_proxy_no_creds(self, mock_geo):
# "host:port" (no @ and no scheme) must also be normalized.
tz, locale = _maybe_resolve_geoip(True, "proxy:8080", None, None)
mock_geo.assert_called_once_with("http://proxy:8080")
assert tz == "America/New_York"
class TestBareProxyFormat:
"""_parse_proxy_url must handle bare 'user:pass@host:port' strings (no scheme)."""
def test_bare_with_credentials(self):
r = _parse_proxy_url("user:pass@proxy:8080")
assert r["username"] == "user"
assert r["password"] == "pass"
assert r["server"] == "http://proxy:8080"
def test_bare_credentials_not_in_server(self):
r = _parse_proxy_url("user:pass@proxy1.example.com:5610")
assert "user" not in r["server"]
assert "pass" not in r["server"]
def test_bare_username_only(self):
r = _parse_proxy_url("user@proxy:8080")
assert r["username"] == "user"
assert "password" not in r
assert r["server"] == "http://proxy:8080"
def test_bare_no_port(self):
r = _parse_proxy_url("user:pass@proxy.example.com")
assert r["username"] == "user"
assert r["password"] == "pass"
assert r["server"] == "http://proxy.example.com"
def test_bare_no_credentials_passthrough(self):
# "host:port" without @ — no scheme, no creds — pass through unchanged
r = _parse_proxy_url("proxy:8080")
assert r == {"server": "proxy:8080"}
def test_build_proxy_kwargs_bare(self):
r = _build_proxy_kwargs("user:pass@proxy:8080")
assert r["proxy"]["username"] == "user"
assert r["proxy"]["password"] == "pass"
assert "user" not in r["proxy"]["server"]