Compare 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
CloakHQ c8e09656aa release: v0.3.10 — Linux build 145.0.7632.159.2
Binary: fix detection regression (#16), fix fingerprint consistency in offline audio rendering.
Wrapper: bump version to 0.3.10, update Linux binary version to 145.0.7632.159.2.
2026-03-07 02:28:53 +01:00
CloakHQ 724d49f65b test: add wrapper regression tests for issues #9, #27
docs: add Docker Compose with healthcheck, persistent profiles
via volume mount, and resource usage numbers to README
2026-03-06 07:37:19 +01:00
CloakHQ ed79560e5f feat: add cloakserve CDP server mode for Docker
Add bin/cloakserve — launches stealth Chromium with remote debugging
enabled so users can connect via connect_over_cdp() from the host.
Uses socat to forward 0.0.0.0:9222 to Chrome's localhost-only CDP port.

Usage: docker run -d -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser cloakserve

Tested: all stealth checks pass via CDP, bot.sannysoft.com 54/54,
reCAPTCHA 0.9, zero detection regressions.
2026-03-06 05:20:40 +01:00
CloakHQ 829e4b881f ci: add unit test workflow for Python and JavaScript
Runs pytest and vitest on every push/PR to main.
Only unit tests — no binary required.
2026-03-06 05:13:07 +01:00
51 changed files with 6401 additions and 299 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
+34
View File
@@ -0,0 +1,34 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
python:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -e ".[dev]" pytest pytest-asyncio
- name: Run tests
run: pytest tests/ -v -m "not slow"
javascript:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install and build
run: cd js && npm install && npm run build
- name: Typecheck
run: cd js && npm run typecheck
- name: Run tests
run: cd js && npm test
+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`
+36
View File
@@ -6,6 +6,42 @@ 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
- **[binary]** Fix detection regression caused by unnecessary browser flag (fixes #16)
- **[binary]** Fix fingerprint consistency in offline audio rendering
- **[wrapper]** Add `cloakserve` CDP server mode for Docker — exposes Chrome DevTools Protocol on `0.0.0.0:9222` for external tool integration
- **[wrapper]** Add wrapper regression tests: page.goto timing with stealth init (#9), add_init_script compatibility with proxy auth (#27)
## [0.3.9] — 2026-03-05
- **[binary]** Upgrade Chromium base to 145.0.7632.159 (Linux x64). macOS and Windows remain on 145.0.7632.109.2
+4 -1
View File
@@ -36,7 +36,10 @@ RUN python -c "from cloakbrowser import ensure_binary; ensure_binary()" \
# CLI shortcuts
COPY bin/cloaktest /usr/local/bin/cloaktest
RUN chmod +x /usr/local/bin/cloaktest
COPY bin/cloakserve /usr/local/bin/cloakserve
RUN chmod +x /usr/local/bin/cloaktest /usr/local/bin/cloakserve
EXPOSE 9222
# Xvfb entrypoint for headed mode support
COPY bin/docker-entrypoint.sh /entrypoint.sh
+247 -37
View File
@@ -9,8 +9,9 @@
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/last-commit/cloakhq/cloakbrowser" alt="Last Commit"></a>
<br>
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/stars/cloakhq/cloakbrowser" alt="Stars"></a>
<a href="https://pepy.tech/projects/cloakbrowser"><img src="https://img.shields.io/pepy/dt/cloakbrowser?label=pypi&logo=pypi&logoColor=white" alt="PyPI Downloads"></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.8 (Chromium 145.0.7632.159)
## 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"])
```
@@ -307,7 +320,7 @@ from cloakbrowser import binary_info, clear_cache, ensure_binary
# Check binary installation status
print(binary_info())
# {'version': '145.0.7632.159', 'platform': 'linux-x64', 'installed': True, ...}
# {'version': '145.0.7632.159.2', 'platform': 'linux-x64', 'installed': True, ...}
# Force re-download
clear_cache()
@@ -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 |
@@ -506,13 +584,18 @@ The wrapper auto-downloads the correct binary for your platform.
## Docker
Pre-built image on Docker Hub — no install, no setup:
Pre-built image on Docker Hub — no install, no setup.
### Quick test
```bash
# Run the stealth test suite
docker run --rm cloakhq/cloakbrowser cloaktest
```
# Run your own script
### Run a script
```bash
# Inline script
docker run --rm cloakhq/cloakbrowser python -c "
from cloakbrowser import launch
browser = launch()
@@ -522,6 +605,9 @@ print(page.title())
browser.close()
"
# Mount your own script
docker run --rm -v ./my_script.py:/app/my_script.py cloakhq/cloakbrowser python my_script.py
# With a proxy
docker run --rm cloakhq/cloakbrowser python -c "
from cloakbrowser import launch
@@ -533,7 +619,86 @@ browser.close()
"
```
To extend with your own script:
### CDP server mode
Start a persistent stealth browser and connect to it remotely via Chrome DevTools Protocol:
```bash
docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser cloakserve
```
Then connect from your host machine:
```python
from playwright.sync_api import sync_playwright
pw = sync_playwright().start()
browser = pw.chromium.connect_over_cdp("http://localhost:9222")
page = browser.new_page()
page.goto("https://example.com")
print(page.title())
browser.close()
```
Pass extra flags to the browser:
```bash
# With proxy
docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser \
cloakserve --proxy-server=http://proxy:8080
# Headed mode (renders to Xvfb inside container)
docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser \
cloakserve --headless=false
```
Stop the server:
```bash
docker stop cloak && docker rm cloak
```
> **Security:** CDP gives full control over the browser (execute JS, read pages, access files).
> The examples bind to `127.0.0.1` so only your machine can connect. Never expose port 9222
> to the public internet without additional authentication.
### Docker Compose
```yaml
services:
cloakbrowser:
image: cloakhq/cloakbrowser
command: cloakserve
restart: unless-stopped
ports:
- "127.0.0.1:9222:9222"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9222/json/version"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
```
Run multiple instances with different fingerprint seeds on different ports — each gets unique canvas noise, client rects, and other browser signals. Pass `--fingerprint=<seed>` in the command (e.g., `cloakserve --fingerprint=12345`).
**Persistent profiles** — mount a volume to keep cookies and sessions across container restarts:
```bash
docker run --rm -v ./my-profile:/profile cloakhq/cloakbrowser python -c "
from cloakbrowser import launch_persistent_context
ctx = launch_persistent_context('/profile')
page = ctx.new_page()
page.goto('https://example.com')
ctx.close()
"
```
Run again with the same volume — cookies, localStorage, and cache are restored automatically.
**Resource usage:** ~190MB RAM idle, ~280MB with 3 tabs. ~30MB per additional tab.
### Extend with your own image
```dockerfile
FROM cloakhq/cloakbrowser
@@ -553,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:
@@ -578,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:
@@ -612,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
@@ -620,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/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:
@@ -677,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:
@@ -699,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
@@ -745,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).
@@ -753,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
Executable
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Launch stealth Chromium as a CDP server for remote connections.
Usage:
cloakserve # headless on port 9222
cloakserve --headless=false # headed (uses Xvfb in Docker)
cloakserve --proxy-server=host:port # with proxy
Connect from host:
playwright.chromium.connect_over_cdp("http://localhost:9222")
"""
import signal
import subprocess
import sys
import time
from cloakbrowser.config import get_default_stealth_args
from cloakbrowser.download import ensure_binary
PORT = 9222
binary = ensure_binary()
chrome_args = [
binary,
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",
"--disable-dev-shm-usage",
"--disable-extensions",
"--disable-popup-blocking",
"--disable-background-networking",
"--metrics-recording-only",
] + get_default_stealth_args() + sys.argv[1:]
chrome = subprocess.Popen(chrome_args)
time.sleep(2)
print(f"CloakBrowser CDP server ready on port {PORT}", flush=True)
def cleanup(sig, frame):
chrome.terminate()
sys.exit(0)
signal.signal(signal.SIGTERM, cleanup)
signal.signal(signal.SIGINT, cleanup)
chrome.wait()
+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.9"
__version__ = "0.3.13"
+101 -34
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,7 +636,7 @@ def _build_args(
logger.debug("Arg override: %s -> %s", seen[key], flag)
seen[key] = flag
if locale:
key = "--lang"
for key in ("--lang", "--fingerprint-locale"):
flag = f"{key}={locale}"
if key in seen:
logger.debug("Arg override: %s -> %s", seen[key], flag)
@@ -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"
CHROMIUM_VERSION = "145.0.7632.159.5"
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
"linux-x64": "145.0.7632.159",
"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
+4 -4
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 },
@@ -247,7 +247,7 @@ Other tips for maximizing reCAPTCHA scores:
When auto-update downloads a newer binary, the previous version stays in `~/.cloakbrowser/`. Point `CLOAKBROWSER_BINARY_PATH` to the older cached binary:
```bash
# Linux
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.159/chrome
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
+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.9",
"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";
export const CHROMIUM_VERSION = "145.0.7632.159.5";
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
"linux-x64": "145.0.7632.159",
"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 -11
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", () => {
@@ -29,7 +29,6 @@ describe("config", () => {
expect(args.some((a) => a.includes("hardware-concurrency"))).toBe(false);
} else {
expect(args).toContain("--fingerprint-platform=windows");
expect(args).toContain("--fingerprint-hardware-concurrency=8");
}
// Should have a random fingerprint seed
@@ -98,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);
});
@@ -120,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);
});
});
@@ -148,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", () => {
@@ -176,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"]))
+23 -27
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")
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")
+20 -23
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")
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")
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"]
+67
View File
@@ -217,3 +217,70 @@ class TestBotDetectionSites:
score = results["score"]
assert score is not None, "Could not extract reCAPTCHA score"
assert score >= 0.7, f"reCAPTCHA score too low: {score}"
class TestIssueRegressions:
"""Regression tests for specific GitHub issues.
Uses the shared browser fixture to avoid "Sync API inside asyncio loop"
errors when pytest-asyncio is active.
"""
@pytest.mark.slow
def test_immediate_goto_works(self, browser):
"""Issue #9: page.goto() immediately after launch must not fail.
User reported reCAPTCHA fails if goto is called too quickly after
launch. This test verifies that immediate navigation works without
needing an artificial delay.
"""
page = browser.new_page()
# No delay — goto immediately
page.goto("https://example.com", timeout=30000)
title = page.title()
page.close()
assert "Example Domain" in title, f"Immediate goto failed, title={title}"
@pytest.mark.slow
def test_add_init_script_without_proxy(self, browser):
"""Issue #27: add_init_script must work (baseline without proxy).
The bug is proxy + add_init_script, but we first verify init_script
alone works so we have a baseline.
"""
page = browser.new_page()
page.add_init_script("window.__cloaktest = 42;")
page.goto("https://example.com", timeout=30000)
val = page.evaluate("window.__cloaktest")
page.close()
assert val == 42, f"add_init_script failed, got {val}"
@pytest.mark.slow
def test_add_init_script_with_proxy(self, browser):
"""Issue #27: add_init_script + proxy must not cause ERR_TUNNEL_CONNECTION_FAILED.
Patchright bug: add_init_script breaks proxy auth. This test guards
against regression if/when the upstream fix lands. Uses context-level
proxy to avoid launching a separate browser (event loop conflict).
"""
proxy = os.environ.get("CLOAKBROWSER_TEST_PROXY")
if not proxy:
pytest.skip("CLOAKBROWSER_TEST_PROXY not set")
ctx = browser.new_context(proxy={"server": proxy})
page = ctx.new_page()
page.add_init_script("window.__cloaktest = 99;")
try:
page.goto("https://httpbin.org/ip", timeout=30000)
body = page.evaluate("document.body.innerText")
val = page.evaluate("window.__cloaktest")
assert val == 99, f"init_script value wrong: {val}"
assert "origin" in body, f"Page didn't load through proxy: {body[:100]}"
except Exception as e:
err = str(e)
if "ERR_TUNNEL_CONNECTION_FAILED" in err:
pytest.xfail("Known patchright bug: add_init_script + proxy auth (issue #27)")
raise
finally:
page.close()
ctx.close()