Compare commits

...
Author SHA1 Message Date
CloakHQ d63ddd4d43 feat(cloakserve): add connection tracking, configurable data dir, better status endpoint
- Move `import websockets` to top-level (guaranteed by [serve] extra)
- Add --data-dir flag with smart default (Docker → /tmp/cloakserve, bare metal → ~/.cloakbrowser/cloakserve)
- Store launch params (tz/locale/proxy) on ChromeProcess for conflict logging
- Enhance GET / to return per-process detail (pid, port, seed, connections, config)
- Add connection refcounting in WS handlers for status visibility
- Add first-launch-wins note to README
- Add tests for data-dir, Docker detection, and connection tracking
2026-04-05 22:41:33 +02:00
CloakHQandDurafen 03a0ad732f feat: rewrite cloakserve as CDP multiplexer with per-connection fingerprint seeds
Spawns a separate Chrome process per unique fingerprint seed, all behind
a single port (9222). Clients specify seeds and fingerprint params via
query string on the CDP URL:

  connect_over_cdp("http://host:9222?fingerprint=12345&timezone=Asia/Tokyo")

Supports all --fingerprint-* flags as query params, geoip=true for
auto timezone/locale from proxy IP, and proxy= for per-process proxies.

- Rewrite bin/cloakserve from 57-line wrapper to aiohttp CDP multiplexer
- Add ChromePool with per-seed process management and port allocation
- Bidirectional WebSocket proxy for CDP traffic
- URL rewriting for /json/version, /json/list, and WS paths
- Rename _build_args -> build_args, _maybe_resolve_geoip -> maybe_resolve_geoip
- Add aiohttp + websockets to serve optional deps
- Dockerfile installs .[serve] extras
- Add 20 unit tests for cloakserve (param parsing, CLI args, URL rewriting)
2026-04-05 22:30:18 +02:00
CloakHQ c58b691f1c chore(deps): bump actions/checkout, docker/setup-qemu, docker/setup-buildx, docker/login-action; group Dependabot PRs 2026-04-05 22:26:14 +02:00
CloakHQ 1b91a33e51 chore: update integration examples and gitignore
- browser_use: migrate to BrowserSession + bundled ChatOpenAI API
- crawl4ai: add browser_mode="cdp" param
- gitignore: add captures/
2026-04-02 01:32:08 +02:00
CloakHQ 1bfd5ca036 docs: update patch count to 42, add --fingerprint-noise flag, bump version refs 2026-03-30 20:46:16 +02:00
CloakHQ f46f8e9364 release: v0.3.19 — upgrade Linux x64 binary to 145.0.7632.159.8 (42 patches) 2026-03-30 19:17:41 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
592b3d5661 chore(deps): bump docker/build-push-action from 6.19.2 to 7.0.0 (#90)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.19.2 to 7.0.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/10e90e3645eae34f1e60eeb005ba3a3d33f178e8...d08e5c354a6adb9ed34480a06d141179aa583294)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-29 22:54:19 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a0a8210e35 chore(deps): bump actions/setup-python from 5.6.0 to 6.2.0 (#89)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5.6.0 to 6.2.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/a26af69be951a213d495a4c3e4e4022e16d87065...a309ff8b426b58ec0e2a45f0f869d46889d02405)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-29 22:54:17 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
468964ff30 chore(deps): bump actions/setup-node from 4.4.0 to 6.3.0 (#88)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.4.0 to 6.3.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/49933ea5288caeca8642d1e84afbd3f7d6820020...53b83947a5a98c8d113130e565377fae1a50d02f)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 6.3.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-29 22:54:15 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c1b93e634b chore(deps): bump sigstore/cosign-installer from 3.9.1 to 4.1.1 (#87)
Bumps [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) from 3.9.1 to 4.1.1.
- [Release notes](https://github.com/sigstore/cosign-installer/releases)
- [Commits](https://github.com/sigstore/cosign-installer/compare/398d4b0eeef1380460a10c8013a76f728fb906ac...cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003)

---
updated-dependencies:
- dependency-name: sigstore/cosign-installer
  dependency-version: 4.1.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-29 22:54:12 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5ccb4a32a5 chore(deps): bump actions/attest-build-provenance from 2.4.0 to 4.1.0 (#86)
Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 2.4.0 to 4.1.0.
- [Release notes](https://github.com/actions/attest-build-provenance/releases)
- [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md)
- [Commits](https://github.com/actions/attest-build-provenance/compare/e8998f949152b193b063cb0ec769d69d929409be...a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32)

---
updated-dependencies:
- dependency-name: actions/attest-build-provenance
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-29 22:54:10 +02:00
CloakHQ 49d80d3b57 security: pin GitHub Actions to commit SHAs, add Dependabot
Pin all 22 action references across ci.yml, publish.yml, and
attest-release.yml to immutable commit SHAs. Mutable tags can be
force-pushed by attackers (cf. TeamPCP supply chain campaign).

Add Dependabot for github-actions to get weekly PRs when pinned
actions have new versions.
2026-03-27 21:20:08 +01:00
CloakHQ 2813b3dc4c docs: warn that ElementHandle bypasses humanize patches 2026-03-18 07:51:07 +01:00
dgtlmoonandGitHub 6550f3ad6c fix: ensure pw.stop() runs even if browser.close() raises or is cancelled (#60) 2026-03-15 17:51:03 +01:00
CloakHQ 132cafe13c release: v0.3.18 — fix welcome banner polluting stdout (fixes #59) 2026-03-15 17:19:44 +01:00
CloakHQ 6c94b9e985 feat: add GitHub issue template for bug reports 2026-03-15 07:11:33 +01:00
CloakHQ fdc1ae0484 fix: add --ignore-gpu-blocklist to cloakserve for Docker WebGL support
cloakserve bypasses the wrapper and launches Chrome directly, missing
the GPU blocklist fix from 1380c86. Fixes #58.
2026-03-15 06:02:47 +01:00
CloakHQ 2ded0c1866 docs: add Crawlee integration example 2026-03-15 05:42:23 +01:00
CloakHQ f91700c4a4 release: v0.3.17 — Windows x64 binary upgrade to 145.0.7632.159.7
- Bump wrapper version to 0.3.17 (Python + JS)
- Update PLATFORM_CHROMIUM_VERSIONS: windows-x64 109.2 → 159.7
- Update patch counts in platform tables (Linux 33, Windows 33)
- Add Linux arm64 to JS README platform table
- Update CHANGELOG with all changes since v0.3.16
2026-03-15 02:42:13 +01:00
CloakHQ 1380c86847 fix: auto-inject --ignore-gpu-blocklist for headed mode and Windows
Headed mode (all platforms): Chromium's GPU blocklist disables WebGL on
software GPUs in Docker/VNC/Xvfb. Flag lets SwiftShader serve WebGL.
Harmless on real GPUs. Headless unaffected. Ref #56.

Windows (all modes): GPU blocklist also blocks WebGPU for the Microsoft
Basic Render Driver. Dawn's adapter_blocklist bypass alone isn't enough.
2026-03-15 02:23:48 +01:00
CloakHQ 83e3b30117 feat: add 8 framework integration examples + README integrations section
Add examples/integrations/ with tested examples for browser-use, Crawl4AI,
Scrapling, LangChain, Selenium, undetected-chromedriver, and agent-browser.
Add js/examples/stagehand.ts for Stagehand (TypeScript).

README: new "Framework Integrations" subsection with two integration
patterns (direct binary launch vs CDP connect) and table linking all 8 examples.
2026-03-14 20:56:25 +01:00
CloakHQ 5649620545 release: v0.3.16 — Linux ARM64 binary, multi-arch Docker, donate link
- Add linux-arm64 to PLATFORM_CHROMIUM_VERSIONS (Python + JS)
- Multi-arch Docker build (linux/amd64 + linux/arm64) via QEMU in CI
- Add ko-fi donate link to welcome banner (Python + JS)
- Version bump to 0.3.16
2026-03-14 00:42:06 +01:00
CloakHQ d2a42fc86b release: v0.3.15 — upgrade Linux binary to .159.7, StorageBuckets normalization
Binary:
- Upgrade Linux x64 build to Chromium 145.0.7632.159.7 (33 C++ patches)
- StorageBuckets API quota normalization — closes last storage-based incognito detection vector

Wrapper:
- Fix non-ASCII character support in humanized typing (Cyrillic, CJK, emoji)
- Document storage quota tradeoff for persistent contexts
- Add Ko-fi funding link
2026-03-13 18:59:18 +01:00
lilos 1af25d67bc fix: support non-ASCII characters (Cyrillic, CJK, emoji) in humanized typing 2026-03-13 00:34:01 +01:00
CloakHQ 1bef989404 test: add download fallback tests for primary → GitHub failover
Verify that HTTP errors (429, 503, etc.) from cloakbrowser.dev
correctly trigger GitHub Releases fallback for both binary and
checksum downloads. Also test that custom CLOAKBROWSER_DOWNLOAD_URL
disables fallback, and both-sources-fail returns gracefully.
2026-03-12 19:47:49 +01:00
CloakHQ 0aa4ea56bd docs: add Browser Profile Manager section to README 2026-03-12 01:58:26 +01:00
CloakHQ c0ba21faa1 release: v0.3.14 — upgrade Linux binary to .159.6, add binary management CLI
Binary:
- Upgrade Linux build to 145.0.7632.159.6 (32 patches)
- Fix persistent context fingerprint consistency
- Storage quota normalization for persistent context profiles
- Fix window dimension calculation for non-incognito contexts

Wrapper:
- Add CLI for binary management with visible download progress (closes #43)
- Python: python -m cloakbrowser install|info|update|clear-cache
- JavaScript: npx cloakbrowser install|info|update|clear-cache
2026-03-11 23:44:41 +01:00
CloakHQ b501d8f158 chore: gitignore browser profile manager directory 2026-03-11 18:13:19 +01:00
CloakHQ 6007a6e511 feat: add CLI for binary management (Python + JavaScript)
Adds install, info, update, and clear-cache subcommands with visible
download progress. Python: `python -m cloakbrowser install`. JavaScript:
`npx cloakbrowser install`. Useful for Dockerfiles where silent
first-use downloads are hard to debug. Closes #43.
2026-03-11 04:33:37 +01:00
CloakHQ 96c55352e0 ci: remove deployment environments from publish workflow to hide actor identity 2026-03-11 00:22:57 +01:00
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
72 changed files with 8291 additions and 441 deletions
+1
View File
@@ -0,0 +1 @@
ko_fi: cloakhq
+29
View File
@@ -0,0 +1,29 @@
---
name: Bug Report
about: Report a bug or detection issue
labels: bug
---
Description: <!-- What happened? What did you expect? -->
CloakBrowser version: <!-- pip show cloakbrowser / npm list cloakbrowser -->
Wrapper: <!-- Python or JavaScript -->
Environment: <!-- OS, Docker y/n, base image, architecture -->
Launch options:
Tested with a different IP or proxy? <!-- Yes (same result) / Yes (works with different IP) / No -->
Works outside Docker / on host machine? <!-- Yes / No / Not using Docker -->
Steps to reproduce:
Error output / screenshots:
Dockerfile (if applicable):
Additional notes:
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: true
+10
View File
@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
actions:
patterns:
- "*"
+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@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-path: |
cloakbrowser-*.tar.gz
cloakbrowser-*.zip
+4 -4
View File
@@ -10,8 +10,8 @@ jobs:
python:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Install dependencies
@@ -22,8 +22,8 @@ jobs:
javascript:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 20
- name: Install and build
+136
View File
@@ -0,0 +1,136 @@
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
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@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
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
permissions:
id-token: write # OIDC trusted publishing — no PYPI_TOKEN needed
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Build
run: |
pip install build
python -m build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # 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
permissions:
id-token: write # OIDC trusted publishing + provenance — no NPM_TOKEN needed
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
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
permissions:
id-token: write # Cosign keyless signing + attestations
contents: read
attestations: write
packages: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- 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-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PAT }}
- name: Build and push
id: build
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
cloakhq/cloakbrowser:${{ env.VERSION }}
cloakhq/cloakbrowser:latest
provenance: true
sbom: true
- uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
- name: Sign image
run: cosign sign --yes cloakhq/cloakbrowser@${{ steps.build.outputs.digest }}
- name: Attest build provenance
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
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`
+5
View File
@@ -56,9 +56,14 @@ test-infra/
# Website (deployed separately)
site/
# Browser profile manager (deployed separately)
manager/
# Release scripts
publish.sh
deploy.sh
.env
debug
publish-docker.sh
captures
20[0-9][0-9]-[0-9][0-9]-[0-9][0-9]-*.txt
+80
View File
@@ -6,6 +6,86 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
---
## [0.3.19] — 2026-03-30
- **[binary]** Upgrade Linux x64 build to 145.0.7632.159.8 — 42 source-level C++ patches (up from 33)
- **[binary]** 9 new fingerprint patches covering additional browser APIs and cross-platform consistency
- **[binary]** New `--fingerprint-noise` flag — disable noise injection while keeping deterministic fingerprint seed active
- **[binary]** Improved fingerprint noise reliability and determinism across all patched APIs
- **[binary]** Expanded platform-aware fingerprint spoofing for more realistic cross-platform profiles
- **[binary]** Font rendering and detection accuracy improvements for Windows profiles
- **[binary]** Removed experimental patches that caused compatibility issues with certain anti-bot systems
- **[binary]** Docker/VNC environment compatibility improvements
- **[wrapper]** Fix Playwright cleanup — `pw.stop()` now runs even if `browser.close()` raises or is cancelled (fixes #60, thanks [@dgtlmoon](https://github.com/dgtlmoon))
- **[meta]** Pin GitHub Actions to commit SHAs, add Dependabot for automated dependency updates
## [0.3.18] — 2026-03-15
- **[wrapper]** Fix welcome banner printing to stdout — now writes to stderr so it won't corrupt JSON output in programmatic usage (fixes #59)
- **[wrapper]** Fix `cloakserve` Docker WebGL by adding `--ignore-gpu-blocklist` flag
- **[docs]** Add Crawlee integration example
- **[meta]** Add GitHub issue template for bug reports
## [0.3.17] — 2026-03-15
- **[binary]** Windows x64 build upgraded to 145.0.7632.159.7 — 33 source-level C++ patches, matching Linux
- **[wrapper]** Auto-inject GPU blocklist bypass for headed mode and Windows — fixes WebGL/WebGPU on software GPUs in Docker/VNC (fixes #56)
- **[wrapper]** Add 8 framework integration examples (Scrapy, Crawlee, BrowserBase, etc.) and README integrations section
## [0.3.16] — 2026-03-14
- **[binary]** Linux arm64 build available — Raspberry Pi, AWS Graviton, Oracle Ampere now supported
- **[wrapper]** Add donate link to first-launch welcome banner
## [0.3.15] — 2026-03-13
- **[binary]** Upgrade Linux build to 145.0.7632.159.7 — 33 source-level C++ patches
- **[binary]** StorageBuckets API quota normalization — closes the last storage-based incognito detection vector
- **[wrapper]** Fix non-ASCII character support in humanized typing — Cyrillic, CJK, and emoji now type correctly (thanks [@evelaa123](https://github.com/evelaa123))
## [0.3.14] — 2026-03-12
- **[binary]** Upgrade Linux build to 145.0.7632.159.6 — fix persistent context detection by FingerprintJS
- **[binary]** Storage quota normalization for persistent context profiles
- **[binary]** Fix outerHeight calculation for non-incognito contexts
- **[wrapper]** Add CLI for binary management — `python -m cloakbrowser install` / `npx cloakbrowser install` with visible download progress (closes #43)
## [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
+2 -2
View File
@@ -10,7 +10,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0 libgtk-3-0 libpangocairo-1.0-0 libcairo-gobject2 \
libgdk-pixbuf-2.0-0 libxss1 libxtst6 fonts-liberation \
xvfb xdotool \
curl ca-certificates socat \
curl ca-certificates \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
@@ -20,7 +20,7 @@ WORKDIR /app
# Python wrapper
COPY pyproject.toml README.md LICENSE BINARY-LICENSE.md CHANGELOG.md ./
COPY cloakbrowser/ cloakbrowser/
RUN pip install --no-cache-dir .
RUN pip install --no-cache-dir ".[serve]"
# JS wrapper
COPY js/ js/
+268 -50
View File
@@ -11,6 +11,11 @@
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/stars/cloakhq/cloakbrowser" alt="Stars"></a>
<a href="https://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pepy/dt/cloakbrowser?label=pypi&logo=pypi&logoColor=white" alt="PyPI Downloads"></a>
<a href="https://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/dt/cloakbrowser?label=npm&logo=npm&logoColor=white" alt="npm Downloads"></a>
<a href="https://hub.docker.com/r/cloakhq/cloakbrowser"><img src="https://img.shields.io/docker/pulls/cloakhq/cloakbrowser?label=docker&logo=docker&logoColor=white" alt="Docker Pulls"></a>
</p>
<p align="center">
<a href="https://ko-fi.com/cloakhq"><img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="Support on Ko-fi"></a>
</p>
<br>
@@ -35,7 +40,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
- **42 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 +114,29 @@ 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)
## Browser Profile Manager
- **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)
Self-hosted alternative to Multilogin, GoLogin, and AdsPower. Create browser profiles with unique fingerprints, proxies, and persistent sessions. Launch and interact with them in your browser via noVNC.
```bash
docker run -p 8080:8080 -v cloakprofiles:/data cloakhq/cloakbrowser-manager
```
Open [http://localhost:8080](http://localhost:8080). Create a profile. Click **Launch**. Done.
**[CloakBrowser Manager](https://github.com/CloakHQ/CloakBrowser-Manager)** — free, open source (MIT)
---
## Latest: v0.3.19 (Chromium 145.0.7632.159.8)
- **`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. 4 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
- **42 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 +147,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, Scrapling, Stagehand, LangChain, Selenium, and more. See [integrations](#framework-integrations).
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 +195,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 +221,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 42 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 +249,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 +258,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"])
```
@@ -275,7 +306,7 @@ context.close()
### `launch_persistent_context()`
Same as `launch_context()`, but with a persistent user profile. Cookies, localStorage, and cache persist across sessions. Also avoids incognito detection by services like BrowserScan.
Same as `launch_context()`, but with a persistent user profile. Cookies, localStorage, and cache persist across sessions.
Use this when you need to:
- **Stay logged in** across runs (cookies/sessions survive restarts)
@@ -300,6 +331,28 @@ Supports all the same options as `launch_context()`: `proxy`, `user_agent`, `vie
Async version: `launch_persistent_context_async()`.
**Storage quota and detection tradeoff:** By default, the binary normalizes storage quota to pass FingerprintJS, which blocks persistent contexts that report non-incognito quota values. This means detection services that penalize incognito mode (like BrowserScan's `notPrivate` check, -10 points) will still flag it. If your target site penalizes incognito but doesn't use FingerprintJS, set a higher quota to appear as a regular profile:
```python
ctx = launch_persistent_context("./my-profile", args=["--fingerprint-storage-quota=5000"])
```
| Quota setting | FingerprintJS | BrowserScan `notPrivate` |
|---|---|---|
| Default (auto, ~500MB) | PASS | -10 (flagged as incognito) |
| `--fingerprint-storage-quota=5000` | May trigger detection | PASS (appears non-incognito) |
### CLI
Pre-download the binary or check installation status from the command line:
```bash
python -m cloakbrowser install # Download binary with progress output
python -m cloakbrowser info # Show version, path, platform
python -m cloakbrowser update # Check for and download newer binary
python -m cloakbrowser clear-cache # Remove cached binaries
```
### Utility Functions
```python
@@ -307,7 +360,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 +388,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 +440,71 @@ 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.
> **Note:** Always use `page.click(selector)`, `page.type(selector, text)`, `page.hover(selector)`, or `page.locator(selector).*` — these go through the full humanize pipeline. Avoid `page.query_selector()` — `ElementHandle` objects bypass all patches, so mouse movement teleports, keyboard events fire without timing, and scroll has no human curve.
> Contributed by [@evelaa123](https://github.com/evelaa123) — full Playwright API coverage.
## Configuration
| Env Variable | Default | Description |
@@ -450,8 +569,11 @@ 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-storage-quota` | Override storage quota in MB — affects `storage.estimate()`, `storageBuckets`, and legacy webkit APIs. Auto-normalized when `--fingerprint` is set |
| `--fingerprint-taskbar-height` | Override taskbar height (binary defaults: Win=48, Mac=95, Linux=0) |
| `--fingerprint-fonts-dir` | Path to cross-platform font directory |
| `--fingerprint-noise=false` | Disable noise injection (canvas, WebGL, audio, client rects) while keeping the deterministic fingerprint seed active |
| `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements |
> **Note:** All stealth tests were verified with the default fingerprint config above. Changing these flags may affect detection results — test your configuration before using in production.
@@ -491,14 +613,44 @@ browser = launch(args=[
- [`basic-puppeteer.ts`](js/examples/basic-puppeteer.ts) — Puppeteer launch and load
- [`stealth-test.ts`](js/examples/stealth-test.ts) — Run against 6 detection sites
### Framework Integrations
CloakBrowser works with any framework that uses Playwright or Chromium:
```python
# Option 1: Framework launches our binary directly (Selenium, Stagehand, UC)
from cloakbrowser.download import ensure_binary
from cloakbrowser.config import get_default_stealth_args
binary_path = ensure_binary() # auto-downloads if needed
stealth_args = get_default_stealth_args() # all fingerprint flags
# Option 2: CloakBrowser launches first, framework connects via CDP (browser-use, Crawl4AI, Scrapling)
from cloakbrowser import launch_async
browser = await launch_async(args=["--remote-debugging-port=9242"])
# Connect your framework to http://127.0.0.1:9242 — all stealth flags are set
```
| Framework | Stars | Language | Example |
|-----------|-------|----------|---------|
| [browser-use](https://github.com/browser-use/browser-use) | 70K | Python | [`browser_use_example.py`](examples/integrations/browser_use_example.py) |
| [Crawl4AI](https://github.com/unclecode/crawl4ai) | 58K | Python | [`crawl4ai_example.py`](examples/integrations/crawl4ai_example.py) |
| [Crawlee](https://github.com/apify/crawlee-python) | 8.6K | Python | [`crawlee_example.py`](examples/integrations/crawlee_example.py) |
| [Scrapling](https://github.com/D4Vinci/Scrapling) | 21K | Python | [`scrapling_example.py`](examples/integrations/scrapling_example.py) |
| [Stagehand](https://github.com/browserbase/stagehand) | 21K | TypeScript | [`stagehand.ts`](js/examples/stagehand.ts) |
| [LangChain](https://github.com/langchain-ai/langchain) | 100K+ | Python | [`langchain_loader.py`](examples/integrations/langchain_loader.py) |
| [Selenium](https://github.com/SeleniumHQ/selenium) | — | Python | [`selenium_example.py`](examples/integrations/selenium_example.py) |
| [undetected-chromedriver](https://github.com/ultrafunkamsterdam/undetected-chromedriver) | 12K | Python | [`undetected_chromedriver.py`](examples/integrations/undetected_chromedriver.py) |
| [agent-browser](https://github.com/nichochar/agent-browser) | — | Shell | [`agent_browser.sh`](examples/integrations/agent_browser.sh) |
## Platforms
| Platform | Chromium | Patches | Status |
|---|---|---|---|
| Linux x86_64 | 145 | 26 | ✅ Latest |
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest |
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest |
| Windows x86_64 | 145 | 26 | ✅ Latest |
| Linux x86_64 | 145 | 42 | ✅ Latest |
| Linux arm64 (RPi, Graviton) | 145 | 33 | ✅ |
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ |
| macOS x86_64 (Intel) | 145 | 26 | ✅ |
| Windows x86_64 | 145 | 33 | ✅ |
The wrapper auto-downloads the correct binary for your platform.
@@ -602,7 +754,28 @@ services:
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`).
**Per-connection fingerprint seeds** — run multiple browser identities from a single container. Each unique seed spawns a separate Chrome process with its own fingerprint:
```python
# Each seed gets unique canvas noise, client rects, and other browser signals
b1 = pw.chromium.connect_over_cdp("http://localhost:9222?fingerprint=11111")
b2 = pw.chromium.connect_over_cdp("http://localhost:9222?fingerprint=22222")
# Full identity control via query params
b3 = pw.chromium.connect_over_cdp(
"http://localhost:9222?fingerprint=33333"
"&timezone=Asia/Tokyo&locale=ja-JP&platform=macos"
"&hardware-concurrency=4&device-memory=8"
)
# Auto-detect timezone/locale from proxy exit IP
b4 = pw.chromium.connect_over_cdp(
"http://localhost:9222?fingerprint=44444"
"&proxy=http://proxy:8080&geoip=true"
)
```
Supported query params: `fingerprint`, `timezone`, `locale`, `platform`, `platform-version`, `brand`, `brand-version`, `gpu-vendor`, `gpu-renderer`, `hardware-concurrency`, `device-memory`, `screen-width`, `screen-height`, `proxy`, `geoip`. Same seed reuses the same process (first connection's params win). No seed = shared default process (backward compatible). Check active processes at `GET /` (returns JSON with PIDs, ports, and connection counts).
**Persistent profiles** — mount a volume to keep cookies and sessions across container restarts:
@@ -628,6 +801,15 @@ COPY your_script.py /app/
CMD ["python", "your_script.py"]
```
**Building your own image from pip** — use `python -m cloakbrowser install` to download the binary during build with visible progress:
```dockerfile
FROM python:3.12-slim
RUN pip install cloakbrowser && python -m cloakbrowser install
COPY your_script.py /app/
CMD ["python", "/app/your_script.py"]
```
**Building from source** — a [`Dockerfile`](Dockerfile) is also included if you prefer to build your own image:
```bash
@@ -640,7 +822,9 @@ CloakBrowser works identically local, in Docker, and on VPS. No environment-spec
## Troubleshooting
**Still getting blocked on aggressive sites (DataDome, Turnstile)?**
---
### Still getting blocked on aggressive sites (DataDome, Turnstile)?
Some sites detect headless mode even with our C++ patches. Run in **headed mode** with a virtual display:
@@ -665,7 +849,9 @@ browser.close()
This runs a real headed browser rendered on a virtual display — no physical monitor needed. Combined with a residential proxy, this passes even the most aggressive detection services. Datacenter IPs are often flagged by IP reputation regardless of browser fingerprint — a residential proxy makes the difference.
**Sites challenge fresh sessions but work after first visit**
---
### Sites challenge fresh sessions but work after first visit
Some sites challenge first-time visitors with no cookies over HTTP/2. This affects all Chromium browsers, not just CloakBrowser. Use a persistent profile to warm up cookies once, then reuse across sessions:
@@ -699,7 +885,10 @@ ctx = await launchPersistentContext({ userDataDir: './profile' });
For stateless/ephemeral use cases, `launch(args=["--disable-http2"])` forces HTTP/1.1 which bypasses the check. Only use this flag for sites that require it — most work fine with HTTP/2.
**Something not working? Make sure you're on the latest version**
---
### Something not working? Make sure you're on the latest version
Older versions may use outdated stealth args or download an older binary:
```bash
pip install -U cloakbrowser # Python
@@ -707,64 +896,68 @@ 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**
---
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:
### Site detects incognito / private browsing mode
By default, `launch()` opens an incognito context. Some sites penalize this. Use `launch_persistent_context()` to get a real profile with cookie persistence:
```python
from cloakbrowser import launch_persistent_context
ctx = launch_persistent_context("./my-profile", headless=False)
page = ctx.new_page()
```
```javascript
import { launchPersistentContext } from 'cloakbrowser';
If the site still flags incognito, raise the storage quota to appear as a regular browsing session. See the [storage quota tradeoff](#launch_persistent_context) for details on how this affects different detection services.
const ctx = await launchPersistentContext({
userDataDir: './my-profile',
headless: false,
});
```
---
This also gives you cookie and localStorage persistence across sessions.
**reCAPTCHA v3 scores are low (0.10.3)**
### reCAPTCHA v3 scores are low (0.10.3)
Avoid `page.wait_for_timeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
@@ -786,7 +979,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
@@ -816,9 +1009,9 @@ A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
| Feature | Status |
|---------|--------|
| Linux x64 — Chromium 145 (26 patches) | ✅ Released |
| Linux x64 — Chromium 145 (42 patches) | ✅ Released |
| macOS arm64/x64 — Chromium 145 (26 patches) | ✅ Released |
| Windows x64 — Chromium 145 (26 patches) | ✅ Released |
| Windows x64 — Chromium 145 (33 patches) | ✅ Released |
| JavaScript/Puppeteer + Playwright support | ✅ Released |
| Fingerprint rotation per session | ✅ Released |
| Built-in proxy rotation | 📋 Planned |
@@ -830,8 +1023,28 @@ A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
- 🐛 **Bug reports & feature requests** — [GitHub Issues](https://github.com/CloakHQ/CloakBrowser/issues)
- 📦 **PyPI** — [pypi.org/project/cloakbrowser](https://pypi.org/project/cloakbrowser/)
- 📦 **npm** — [npmjs.com/package/cloakbrowser](https://www.npmjs.com/package/cloakbrowser)
- ☕ **Support** — [ko-fi.com/cloakhq](https://ko-fi.com/cloakhq)
- 📧 **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.8
# Verify GitHub binary attestation (Sigstore)
gh attestation verify cloakbrowser-linux-x64.tar.gz --repo CloakHQ/cloakbrowser
# Verify Docker image signature (Cosign/Sigstore)
cosign verify \
--certificate-identity-regexp "https://github.com/CloakHQ/CloakBrowser/" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
cloakhq/cloakbrowser:latest
```
## License
- **Wrapper code** (this repository) — MIT. See [LICENSE](https://github.com/CloakHQ/CloakBrowser/blob/main/LICENSE).
@@ -840,3 +1053,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
+603 -43
View File
@@ -1,33 +1,54 @@
#!/usr/bin/env python3
"""Launch stealth Chromium as a CDP server for remote connections.
"""CDP multiplexer — per-connection fingerprint seeds for stealth Chromium.
Spawns a separate Chrome process per unique fingerprint seed, routing CDP
connections through a single port. Each seed gets its own browser identity.
Usage:
cloakserve # headless on port 9222
cloakserve --headless=false # headed (uses Xvfb in Docker)
cloakserve --proxy-server=host:port # with proxy
cloakserve # default, backward compat
cloakserve --port=9222 # custom port
Connect from host:
playwright.chromium.connect_over_cdp("http://localhost:9222")
Client:
browser = pw.chromium.connect_over_cdp("http://host:9222?fingerprint=12345")
browser = pw.chromium.connect_over_cdp(
"http://host:9222?fingerprint=12345&timezone=America/New_York&locale=en-US"
)
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import signal
import random
import shutil
import socket
import subprocess
import sys
import time
from dataclasses import dataclass
from urllib.parse import parse_qs
from cloakbrowser.config import get_default_stealth_args
from pathlib import Path
import aiohttp
import websockets
from aiohttp import web
from cloakbrowser.browser import build_args, maybe_resolve_geoip
from cloakbrowser.download import ensure_binary
EXPOSE_PORT = 9222 # External, 0.0.0.0 (user connects here)
CHROME_PORT = 9223 # Internal, 127.0.0.1 (Chrome binds here)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("cloakserve")
binary = ensure_binary()
chrome_args = [
binary,
f"--remote-debugging-port={CHROME_PORT}",
# "--remote-debugging-address=0.0.0.0", # TODO: enable after binary patch 032
# Sane defaults for running Chrome directly (outside Playwright)
# Args for running Chrome directly (outside Playwright).
# Playwright normally adds its own version of these.
BASE_CHROME_ARGS = [
"--no-first-run",
"--no-default-browser-check",
"--disable-dev-shm-usage",
@@ -35,35 +56,574 @@ chrome_args = [
"--disable-popup-blocking",
"--disable-background-networking",
"--metrics-recording-only",
] + get_default_stealth_args() + sys.argv[1:]
"--ignore-gpu-blocklist",
]
# Launch Chrome
chrome = subprocess.Popen(chrome_args)
# Wait for Chrome to start listening
time.sleep(2)
# Forward 0.0.0.0:9222 -> 127.0.0.1:9223
# Chrome hardcodes 127.0.0.1 for security; socat exposes it to Docker network.
# TODO: Remove socat after patching binary to support --remote-debugging-address
socat = subprocess.Popen([
"socat",
f"TCP-LISTEN:{EXPOSE_PORT},fork,reuseaddr,bind=0.0.0.0",
f"TCP:127.0.0.1:{CHROME_PORT}",
])
print(f"CloakBrowser CDP server ready on port {EXPOSE_PORT}", flush=True)
BASE_CDP_PORT = 5100
def cleanup(sig, frame):
chrome.terminate()
socat.terminate()
sys.exit(0)
# ---------------------------------------------------------------------------
# ChromeProcess — one running Chrome instance
# ---------------------------------------------------------------------------
@dataclass
class ChromeProcess:
seed: str
process: subprocess.Popen
cdp_port: int
user_data_dir: str
timezone: str | None = None
locale: str | None = None
proxy: str | None = None
signal.signal(signal.SIGTERM, cleanup)
signal.signal(signal.SIGINT, cleanup)
# ---------------------------------------------------------------------------
# ChromePool — manages multiple Chrome processes keyed by seed
# ---------------------------------------------------------------------------
# Wait for Chrome to exit, then clean up socat
chrome.wait()
socat.terminate()
class ChromePool:
def __init__(
self,
binary: str,
global_args: list[str],
headless: bool,
data_dir: str = "/tmp/cloakserve",
):
self._binary = binary
self._global_args = global_args
self._headless = headless
self._data_dir = data_dir
self._processes: dict[str, ChromeProcess] = {}
self._default: ChromeProcess | None = None
self._locks: dict[str, asyncio.Lock] = {}
self._next_port = BASE_CDP_PORT
# Connection refcounting for status reporting
self._connections: dict[str, int] = {}
def _get_lock(self, seed: str) -> asyncio.Lock:
if seed not in self._locks:
self._locks[seed] = asyncio.Lock()
return self._locks[seed]
def _allocate_port(self) -> int:
"""Find a free port starting from _next_port."""
for _ in range(100):
port = self._next_port
self._next_port += 1
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", port))
return port
except OSError:
continue
raise RuntimeError("No free ports available for Chrome CDP")
def connect(self, seed_key: str) -> None:
"""Increment connection refcount for a seed."""
self._connections[seed_key] = self._connections.get(seed_key, 0) + 1
def disconnect(self, seed_key: str) -> None:
"""Decrement connection refcount for a seed."""
count = self._connections.get(seed_key, 0) - 1
if count <= 0:
self._connections.pop(seed_key, None)
else:
self._connections[seed_key] = count
async def get_or_launch(
self,
seed: str | None,
extra_args: list[str] | None = None,
timezone: str | None = None,
locale: str | None = None,
proxy: str | None = None,
geoip: bool = False,
) -> ChromeProcess:
"""Get existing or launch new Chrome process for a seed."""
# No seed = default shared process
if seed is None:
seed_key = "__default__"
actual_seed = str(random.randint(10000, 99999))
else:
seed_key = seed
actual_seed = seed
lock = self._get_lock(seed_key)
async with lock:
# Check if already running (including default fast-path)
if seed_key in self._processes:
proc = self._processes[seed_key]
if proc.process.poll() is None:
if any([extra_args, timezone, locale, proxy, geoip]):
logger.warning(
"Seed %s already running (port %d, tz=%s, locale=%s, proxy=%s) — "
"ignoring new params (first-launch wins)",
seed_key, proc.cdp_port,
proc.timezone, proc.locale, proc.proxy,
)
return proc
# Dead — clean up
await self._cleanup_process(seed_key)
# Resolve geoip if requested
if geoip and proxy:
timezone, locale = maybe_resolve_geoip(True, proxy, timezone, locale)
# Build Chrome args via shared logic
fp_extra = [f"--fingerprint={actual_seed}"]
if extra_args:
fp_extra.extend(extra_args)
if proxy:
fp_extra.append(f"--proxy-server={proxy}")
chrome_args = build_args(
stealth_args=True,
extra_args=fp_extra,
timezone=timezone,
locale=locale,
headless=self._headless,
)
# Allocate port and user data dir
port = self._allocate_port()
user_data_dir = os.path.join(self._data_dir, seed_key)
os.makedirs(user_data_dir, exist_ok=True)
full_args = (
[self._binary]
+ BASE_CHROME_ARGS
+ chrome_args
+ self._global_args
+ [
f"--remote-debugging-port={port}",
"--remote-debugging-address=127.0.0.1",
f"--user-data-dir={user_data_dir}",
]
)
logger.info("Launching Chrome (seed=%s, port=%d)", actual_seed, port)
process = subprocess.Popen(
full_args,
stdout=subprocess.DEVNULL,
)
# Wait for CDP to be ready
if not await self._wait_for_cdp(port):
process.kill()
await asyncio.to_thread(process.wait, timeout=5)
await asyncio.to_thread(shutil.rmtree, user_data_dir, True)
raise web.HTTPBadGateway(
text=json.dumps({"error": "Chrome failed to start"}),
content_type="application/json",
)
cp = ChromeProcess(
seed=actual_seed,
process=process,
cdp_port=port,
user_data_dir=user_data_dir,
timezone=timezone,
locale=locale,
proxy=proxy,
)
self._processes[seed_key] = cp
if seed is None:
self._default = cp
logger.info("Chrome ready (seed=%s, port=%d, pid=%d)", actual_seed, port, process.pid)
return cp
async def _cleanup_process(self, key: str) -> None:
"""Terminate a Chrome process and clean up."""
proc = self._processes.pop(key, None)
if not proc:
return
if proc.process.poll() is None:
proc.process.terminate()
try:
await asyncio.to_thread(proc.process.wait, timeout=5)
except subprocess.TimeoutExpired:
proc.process.kill()
# Clean up user data dir (can be slow for large profiles)
await asyncio.to_thread(shutil.rmtree, proc.user_data_dir, True)
if self._default is proc:
self._default = None
self._locks.pop(key, None)
self._connections.pop(key, None)
async def shutdown(self) -> None:
"""Terminate all Chrome processes."""
for key in list(self._processes.keys()):
await self._cleanup_process(key)
logger.info("All Chrome processes terminated")
@staticmethod
async def _wait_for_cdp(port: int, timeout: float = 10.0) -> bool:
"""Poll Chrome's /json/version until ready."""
deadline = time.monotonic() + timeout
delay = 0.1
session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=1)
)
try:
while time.monotonic() < deadline:
try:
async with session.get(
f"http://127.0.0.1:{port}/json/version"
) as resp:
if resp.status == 200:
return True
except Exception:
pass
await asyncio.sleep(delay)
delay = min(delay * 2, 1.0)
return False
finally:
await session.close()
# ---------------------------------------------------------------------------
# Query param parsing
# ---------------------------------------------------------------------------
# Params that need special handling (not simple --fingerprint-{name}= mapping)
SPECIAL_PARAMS = {"fingerprint", "proxy", "geoip", "locale", "timezone"}
def parse_connection_params(query_string: str) -> dict:
"""Parse query params into connection config."""
qs = parse_qs(query_string, keep_blank_values=False)
result: dict = {
"seed": None,
"timezone": None,
"locale": None,
"proxy": None,
"geoip": False,
"extra_args": [],
}
for key, values in qs.items():
val = values[0]
if key == "fingerprint":
result["seed"] = val
elif key == "timezone":
result["timezone"] = val
elif key == "locale":
result["locale"] = val
elif key == "proxy":
result["proxy"] = val
elif key == "geoip":
result["geoip"] = val.lower() in ("true", "1", "yes")
elif key not in SPECIAL_PARAMS:
# Generic fingerprint param: map to --fingerprint-{key}={val}
result["extra_args"].append(f"--fingerprint-{key}={val}")
return result
# ---------------------------------------------------------------------------
# HTTP handlers
# ---------------------------------------------------------------------------
def _ws_scheme(request: web.Request) -> str:
"""Return 'wss' if client connected via HTTPS (e.g. TLS-terminating proxy), else 'ws'."""
proto = request.headers.get("X-Forwarded-Proto", request.scheme)
return "wss" if proto == "https" else "ws"
async def handle_root(request: web.Request) -> web.Response:
"""Health check / process status."""
pool: ChromePool = request.app["pool"]
processes = {}
for key, proc in pool._processes.items():
if proc.process.poll() is None:
processes[key] = {
"pid": proc.process.pid,
"port": proc.cdp_port,
"seed": proc.seed,
"connections": pool._connections.get(key, 0),
"timezone": proc.timezone,
"locale": proc.locale,
"proxy": proc.proxy,
}
return web.json_response({
"status": "ok",
"active": len(processes),
"processes": processes,
})
async def handle_json_version(request: web.Request) -> web.Response:
"""Proxy /json/version with optional per-seed routing."""
pool: ChromePool = request.app["pool"]
params = parse_connection_params(request.query_string)
cp = await pool.get_or_launch(
seed=params["seed"],
extra_args=params["extra_args"] or None,
timezone=params["timezone"],
locale=params["locale"],
proxy=params["proxy"],
geoip=params["geoip"],
)
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"http://127.0.0.1:{cp.cdp_port}/json/version",
timeout=aiohttp.ClientTimeout(total=5),
) as resp:
data = await resp.json()
except Exception as exc:
logger.error("Failed to reach Chrome CDP (port %d): %s", cp.cdp_port, exc)
return web.json_response({"error": "CDP endpoint unreachable"}, status=502)
# Rewrite webSocketDebuggerUrl to route through our multiplexer
host = request.headers.get("Host", f"localhost:{request.app['port']}")
seed_key = params["seed"]
if seed_key:
ws_path = f"fingerprint/{seed_key}/devtools/browser"
else:
ws_path = "devtools/browser"
# Extract the browser GUID from Chrome's original URL
orig_ws = data.get("webSocketDebuggerUrl", "")
guid = orig_ws.rsplit("/", 1)[-1] if "/devtools/" in orig_ws else ""
scheme = _ws_scheme(request)
data["webSocketDebuggerUrl"] = f"{scheme}://{host}/{ws_path}/{guid}"
return web.json_response(data)
async def handle_json_list(request: web.Request) -> web.Response:
"""Proxy /json/list with per-seed routing. Rewrites all entries."""
pool: ChromePool = request.app["pool"]
params = parse_connection_params(request.query_string)
cp = await pool.get_or_launch(
seed=params["seed"],
extra_args=params["extra_args"] or None,
timezone=params["timezone"],
locale=params["locale"],
proxy=params["proxy"],
geoip=params["geoip"],
)
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"http://127.0.0.1:{cp.cdp_port}/json/list",
timeout=aiohttp.ClientTimeout(total=5),
) as resp:
data = await resp.json()
except Exception as exc:
logger.error("Failed to reach Chrome CDP (port %d): %s", cp.cdp_port, exc)
return web.json_response({"error": "CDP endpoint unreachable"}, status=502)
host = request.headers.get("Host", f"localhost:{request.app['port']}")
scheme = _ws_scheme(request)
seed_key = params["seed"]
for entry in data:
if "webSocketDebuggerUrl" in entry:
ws_tail = entry["webSocketDebuggerUrl"].split("/devtools/")[-1]
if seed_key:
entry["webSocketDebuggerUrl"] = (
f"{scheme}://{host}/fingerprint/{seed_key}/devtools/{ws_tail}"
)
else:
entry["webSocketDebuggerUrl"] = f"{scheme}://{host}/devtools/{ws_tail}"
return web.json_response(data)
# ---------------------------------------------------------------------------
# WebSocket proxy
# ---------------------------------------------------------------------------
async def proxy_cdp_websocket(
client_ws: web.WebSocketResponse,
target_url: str,
label: str,
) -> None:
"""Bidirectional WebSocket proxy between client and Chrome CDP."""
try:
async with websockets.connect(
target_url, max_size=None, ping_interval=None, ping_timeout=None,
) as cdp_ws:
logger.info("%s: connected to %s", label, target_url)
async def client_to_cdp():
try:
async for msg in client_ws:
if msg.type == aiohttp.WSMsgType.TEXT:
await cdp_ws.send(msg.data)
elif msg.type == aiohttp.WSMsgType.BINARY:
await cdp_ws.send(msg.data)
elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSED):
break
except Exception as exc:
logger.debug("%s [c->cdp]: %s", label, exc)
async def cdp_to_client():
try:
async for msg in cdp_ws:
if isinstance(msg, str):
await client_ws.send_str(msg)
else:
await client_ws.send_bytes(msg)
except Exception as exc:
logger.debug("%s [cdp->c]: %s", label, exc)
c2d = asyncio.create_task(client_to_cdp(), name="c2d")
d2c = asyncio.create_task(cdp_to_client(), name="d2c")
done, pending = await asyncio.wait(
[c2d, d2c], return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
logger.info("%s: disconnected", label)
except Exception as exc:
logger.error("%s error: %s", label, exc)
async def handle_ws_default(request: web.Request) -> web.WebSocketResponse:
"""WebSocket proxy for default (no-seed) Chrome: /devtools/{type}/{guid}"""
pool: ChromePool = request.app["pool"]
path = request.match_info.get("path", "")
cp = await pool.get_or_launch(seed=None)
ws = web.WebSocketResponse()
await ws.prepare(request)
pool.connect("__default__")
try:
target_url = f"ws://127.0.0.1:{cp.cdp_port}/devtools/{path}"
await proxy_cdp_websocket(ws, target_url, f"CDP default [{path}]")
finally:
pool.disconnect("__default__")
return ws
async def handle_ws_seed(request: web.Request) -> web.WebSocketResponse:
"""WebSocket proxy for seed-specific Chrome: /fingerprint/{seed}/devtools/{type}/{guid}"""
pool: ChromePool = request.app["pool"]
seed = request.match_info["seed"]
path = request.match_info.get("path", "")
cp = await pool.get_or_launch(seed=seed)
ws = web.WebSocketResponse()
await ws.prepare(request)
pool.connect(seed)
try:
target_url = f"ws://127.0.0.1:{cp.cdp_port}/devtools/{path}"
await proxy_cdp_websocket(ws, target_url, f"CDP seed={seed} [{path}]")
finally:
pool.disconnect(seed)
return ws
async def on_shutdown(app: web.Application) -> None:
await app["pool"].shutdown()
# ---------------------------------------------------------------------------
# CLI arg parsing
# ---------------------------------------------------------------------------
def _default_data_dir() -> str:
"""Smart default: Docker → /tmp/cloakserve, bare metal → ~/.cloakbrowser/cloakserve."""
if os.path.exists("/.dockerenv"):
return "/tmp/cloakserve"
return str(Path.home() / ".cloakbrowser" / "cloakserve")
def parse_cli_args(argv: list[str]) -> tuple[dict, list[str]]:
"""Parse cloakserve-specific args, return (config, passthrough_args)."""
config: dict = {
"port": 9222,
"headless": True,
"data_dir": None,
}
passthrough = []
# Flags consumed by cloakserve (not passed to Chrome)
consumed_prefixes = (
"--port=",
"--data-dir=",
"--remote-debugging-port=",
"--remote-debugging-address=",
)
for arg in argv:
if arg.startswith("--port="):
config["port"] = int(arg.split("=", 1)[1])
elif arg.startswith("--data-dir="):
config["data_dir"] = arg.split("=", 1)[1]
elif arg == "--headless=false" or arg == "--headless=False":
config["headless"] = False
passthrough.append(arg)
elif arg.startswith(consumed_prefixes):
pass # Strip these silently
else:
passthrough.append(arg)
if config["data_dir"] is None:
config["data_dir"] = _default_data_dir()
return config, passthrough
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
binary = ensure_binary()
config, global_args = parse_cli_args(sys.argv[1:])
pool = ChromePool(
binary=binary,
global_args=global_args,
headless=config["headless"],
data_dir=config["data_dir"],
)
app = web.Application()
app["pool"] = pool
app["port"] = config["port"]
# Routes
app.router.add_get("/", handle_root)
app.router.add_get("/json/version", handle_json_version)
app.router.add_get("/json/version/", handle_json_version)
app.router.add_get("/json/list", handle_json_list)
app.router.add_get("/json/list/", handle_json_list)
app.router.add_get("/json", handle_json_list)
app.router.add_get("/json/", handle_json_list)
# WebSocket routes — seed-specific (must be before default to match first)
app.router.add_get("/fingerprint/{seed}/devtools/{path:.+}", handle_ws_seed)
# WebSocket routes — default (no seed)
app.router.add_get("/devtools/{path:.+}", handle_ws_default)
app.on_shutdown.append(on_shutdown)
port = config["port"]
logger.info("CloakBrowser CDP multiplexer starting on port %d", port)
logger.info(
"Connect: playwright.chromium.connect_over_cdp("
"\"http://localhost:%d?fingerprint=<seed>\")",
port,
)
web.run_app(app, host="0.0.0.0", port=port, print=None)
if __name__ == "__main__":
main()
+18 -1
View File
@@ -11,11 +11,23 @@ Usage:
browser.close()
"""
from .browser import launch, launch_async, launch_context, launch_persistent_context, launch_persistent_context_async, ProxySettings
from .browser import launch, launch_async, launch_context, launch_persistent_context, launch_persistent_context_async, ProxySettings, build_args, maybe_resolve_geoip
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",
@@ -28,6 +40,11 @@ __all__ = [
"check_for_update",
"CHROMIUM_VERSION",
"get_default_stealth_args",
"build_args",
"maybe_resolve_geoip",
"ProxySettings",
"HumanConfig",
"resolve_human_config",
"__version__",
]
+111
View File
@@ -0,0 +1,111 @@
"""CLI for cloakbrowser — download and manage the stealth Chromium binary.
Usage:
python -m cloakbrowser install # Download binary (with progress)
python -m cloakbrowser info # Show binary version, path, platform
python -m cloakbrowser update # Check for and download newer binary
python -m cloakbrowser clear-cache # Remove cached binaries
"""
from __future__ import annotations
import argparse
import logging
import sys
def _setup_logging() -> None:
"""Route cloakbrowser logger to stderr with clean output."""
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
stream=sys.stderr,
force=True,
)
# Suppress noisy HTTP request logs from httpx
logging.getLogger("httpx").setLevel(logging.WARNING)
def cmd_install(args: argparse.Namespace) -> None:
from .download import ensure_binary
path = ensure_binary()
print(path)
def cmd_info(args: argparse.Namespace) -> None:
from .config import get_local_binary_override
from .download import binary_info
info = binary_info()
override = get_local_binary_override()
print(f"Version: {info['version']}")
print(f"Platform: {info['platform']}")
print(f"Binary: {info['binary_path']}")
print(f"Installed: {info['installed']}")
print(f"Cache: {info['cache_dir']}")
if override:
print(f"Override: {override} (CLOAKBROWSER_BINARY_PATH)")
def cmd_update(args: argparse.Namespace) -> None:
from .download import check_for_update
logger = logging.getLogger("cloakbrowser")
logger.info("Checking for updates...")
new_version = check_for_update()
if new_version:
print(f"Updated to Chromium {new_version}")
else:
print("Already up to date.")
def cmd_clear_cache(args: argparse.Namespace) -> None:
from .config import get_cache_dir
from .download import clear_cache
if not get_cache_dir().exists():
print("No cache to clear.")
return
clear_cache()
print("Cache cleared.")
def main() -> None:
parser = argparse.ArgumentParser(
prog="cloakbrowser",
description="Manage the CloakBrowser stealth Chromium binary.",
)
sub = parser.add_subparsers(dest="command")
sub.add_parser("install", help="Download the Chromium binary")
sub.add_parser("info", help="Show binary version, path, and platform")
sub.add_parser("update", help="Check for and download a newer binary")
sub.add_parser("clear-cache", help="Remove all cached binaries")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(2)
_setup_logging()
commands = {
"install": cmd_install,
"info": cmd_info,
"update": cmd_update,
"clear-cache": cmd_clear_cache,
}
try:
commands[args.command](args)
except KeyboardInterrupt:
sys.exit(130)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.3.9"
__version__ = "0.3.19"
+147 -59
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:
@@ -97,8 +101,8 @@ def launch(
sync_playwright = _import_sync_playwright(_resolve_backend(backend))
binary_path = ensure_binary()
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale)
timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args))
@@ -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,
)
@@ -116,15 +120,24 @@ def launch(
_original_close = browser.close
def _close_with_cleanup() -> None:
_original_close()
pw.stop()
try:
_original_close()
finally:
pw.stop()
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 +146,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 +162,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:
@@ -167,8 +186,8 @@ async def launch_async(
async_playwright = _import_async_playwright(_resolve_backend(backend))
binary_path = ensure_binary()
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale)
timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args))
@@ -177,7 +196,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,
)
@@ -186,11 +205,20 @@ async def launch_async(
_original_close = browser.close
async def _close_with_cleanup() -> None:
await _original_close()
await pw.stop()
try:
await _original_close()
finally:
await pw.stop()
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 +235,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 +263,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,11 +281,11 @@ 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)
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale)
timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
logger.debug(
"Launching persistent stealth Chromium (headless=%s, user_data_dir=%s)",
@@ -259,14 +293,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 +309,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,
)
@@ -286,11 +318,20 @@ def launch_persistent_context(
_original_close = context.close
def _close_with_cleanup() -> None:
_original_close()
pw.stop()
try:
_original_close()
finally:
pw.stop()
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 +348,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 +373,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,11 +396,11 @@ 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)
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale)
timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
logger.debug(
"Launching persistent stealth Chromium async (headless=%s, user_data_dir=%s)",
@@ -361,14 +408,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 +424,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,
)
@@ -388,11 +433,20 @@ async def launch_persistent_context_async(
_original_close = context.close
async def _close_with_cleanup() -> None:
await _original_close()
await pw.stop()
try:
await _original_close()
finally:
await pw.stop()
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 +462,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 +485,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
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.
# resolved values flow to binary flags
timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
# --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)
@@ -466,11 +522,20 @@ def launch_context(
_original_ctx_close = context.close
def _close_context_with_cleanup() -> None:
_original_ctx_close()
browser.close()
try:
_original_ctx_close()
finally:
browser.close()
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,7 +585,12 @@ def _import_async_playwright(backend: str):
# ---------------------------------------------------------------------------
def _maybe_resolve_geoip(
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,
timezone: str | None,
@@ -535,6 +605,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
@@ -543,11 +614,12 @@ def _maybe_resolve_geoip(
return timezone, locale
def _build_args(
def build_args(
stealth_args: bool,
extra_args: list[str] | None,
timezone: str | None = None,
locale: str | None = None,
headless: bool = True,
) -> list[str]:
"""Combine stealth args with user-provided args and locale flags.
@@ -560,6 +632,16 @@ def _build_args(
for arg in get_default_stealth_args():
seen[arg.split("=", 1)[0]] = arg
# GPU blocklist bypass:
# - Headed mode (all platforms): Chromium blocks WebGL on software GPUs
# in Docker/Xvfb. Flag lets SwiftShader serve WebGL. See issue #56.
# - Windows (all modes): Chromium's GPU blocklist blocks WebGPU for the
# Microsoft Basic Render Driver. Dawn's adapter_blocklist bypass alone
# isn't enough — need this flag too. Linux doesn't need it.
import platform as _platform
if not headless or _platform.system() == "Windows":
seen["--ignore-gpu-blocklist"] = "--ignore-gpu-blocklist"
if extra_args:
for arg in extra_args:
key = arg.split("=", 1)[0]
@@ -575,11 +657,11 @@ def _build_args(
logger.debug("Arg override: %s -> %s", seen[key], flag)
seen[key] = flag
if locale:
key = "--lang"
flag = f"{key}={locale}"
if key in seen:
logger.debug("Arg override: %s -> %s", seen[key], flag)
seen[key] = flag
for key in ("--lang", "--fingerprint-locale"):
flag = f"{key}={locale}"
if key in seen:
logger.debug("Arg override: %s -> %s", seen[key], flag)
seen[key] = flag
return list(seen.values())
@@ -588,12 +670,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 ""
+14 -5
View File
@@ -15,15 +15,24 @@ 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.8"
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
"linux-x64": "145.0.7632.159",
"linux-x64": "145.0.7632.159.8",
"linux-arm64": "145.0.7632.159.7",
"darwin-arm64": "145.0.7632.109.2",
"darwin-x64": "145.0.7632.109.2",
"windows-x64": "145.0.7632.109.2",
"windows-x64": "145.0.7632.159.7",
}
# ---------------------------------------------------------------------------
# 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 +65,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)",
]
+9 -7
View File
@@ -12,6 +12,7 @@ import os
import platform
import stat
import subprocess
import sys
import tarfile
import tempfile
import threading
@@ -55,13 +56,14 @@ def _show_welcome() -> None:
marker = get_cache_dir() / ".welcome_shown"
if marker.exists():
return
print()
print(" CloakBrowser — stealth Chromium for automation")
print(" https://github.com/CloakHQ/CloakBrowser")
print()
print(" Issues? https://github.com/CloakHQ/CloakBrowser/issues")
print(" Star us if CloakBrowser helps your project!")
print()
sys.stderr.write("\n")
sys.stderr.write(" CloakBrowser — stealth Chromium for automation\n")
sys.stderr.write(" https://github.com/CloakHQ/CloakBrowser\n")
sys.stderr.write("\n")
sys.stderr.write(" Issues? https://github.com/CloakHQ/CloakBrowser/issues\n")
sys.stderr.write(" Donate? https://ko-fi.com/cloakhq\n")
sys.stderr.write(" Star us if CloakBrowser helps your project!\n")
sys.stderr.write("\n")
try:
marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text("")
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)
+112
View File
@@ -0,0 +1,112 @@
"""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):
# Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
if not ch.isascii():
sleep_ms(rand_range(cfg.key_hold))
raw.insert_text(ch)
if i < len(text) - 1:
_inter_char_delay(cfg)
continue
# Mistype chance — only for ASCII alphanumeric
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))
+93
View File
@@ -0,0 +1,93 @@
"""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):
# Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
if not ch.isascii():
await async_sleep_ms(rand_range(cfg.key_hold))
await raw.insert_text(ch)
if i < len(text) - 1:
await _inter_char_delay(cfg)
continue
# Mistype chance — only for ASCII alphanumeric
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
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# agent-browser + CloakBrowser: AI browser agent with stealth fingerprints.
#
# agent-browser is a Node.js CLI for browser automation with session management.
# CloakBrowser provides the stealth Chromium binary.
#
# Requires: npm install -g agent-browser
# pip install cloakbrowser (to auto-download the binary)
#
# Note: agent-browser launches Chrome itself via env vars — it can't connect
# to an existing browser via CDP. So we pass the binary path and stealth args directly.
# Get CloakBrowser binary path (auto-downloads if needed)
BINARY_PATH=$(python3 -c "from cloakbrowser.download import ensure_binary; print(ensure_binary())")
# Get stealth args from our wrapper (comma-separated for agent-browser)
STEALTH_ARGS=$(python3 -c "from cloakbrowser.config import get_default_stealth_args; print(','.join(get_default_stealth_args()))")
# Point agent-browser at CloakBrowser
export AGENT_BROWSER_EXECUTABLE_PATH="$BINARY_PATH"
export AGENT_BROWSER_ARGS="$STEALTH_ARGS"
# Open a page
agent-browser --session stealth-test open "https://example.com"
# Get page title
agent-browser --session stealth-test eval "document.title"
# Check stealth
agent-browser --session stealth-test eval "JSON.stringify({webdriver: navigator.webdriver, plugins: navigator.plugins.length, platform: navigator.platform})"
@@ -0,0 +1,41 @@
"""browser-use + CloakBrowser: AI agent with stealth fingerprints.
browser-use handles AI agent logic, CloakBrowser handles bot detection.
Your agent can now browse sites behind Cloudflare, reCAPTCHA, DataDome.
Requires: pip install browser-use cloakbrowser
Set OPENAI_API_KEY (or swap for another LLM provider).
"""
import asyncio
from browser_use import Agent, BrowserSession, ChatOpenAI
from cloakbrowser import launch_async
async def main():
# Step 1: Launch CloakBrowser (handles binary, stealth args, fingerprints)
cb_browser = await launch_async(
headless=True,
args=["--remote-debugging-port=9242", "--remote-debugging-address=127.0.0.1"],
)
# Step 2: Connect browser-use to the stealth browser via CDP
session = BrowserSession(cdp_url="http://127.0.0.1:9242")
# Step 3: Run your AI agent — it browses through CloakBrowser
agent = Agent(
task="Go to https://www.google.com and search for 'browser automation'",
llm=ChatOpenAI(model="gpt-4o-mini"),
browser_session=session,
)
result = await agent.run()
print(result)
await cb_browser.close()
if __name__ == "__main__":
asyncio.run(main())
+39
View File
@@ -0,0 +1,39 @@
"""Crawl4AI + CloakBrowser: LLM-ready web crawling with stealth fingerprints.
Crawl4AI handles extraction and markdown conversion,
CloakBrowser handles bot detection.
Requires: pip install crawl4ai cloakbrowser
"""
import asyncio
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig
from cloakbrowser import launch_async
async def main():
# Step 1: Launch CloakBrowser with remote debugging
cb_browser = await launch_async(
headless=True,
args=["--remote-debugging-port=9243", "--remote-debugging-address=127.0.0.1"],
)
# Step 2: Connect Crawl4AI to the stealth browser via CDP
browser_config = BrowserConfig(browser_mode="cdp", cdp_url="http://127.0.0.1:9243")
run_config = CrawlerRunConfig()
async with AsyncWebCrawler(config=browser_config) as crawler:
result = await crawler.arun(
"https://example.com",
config=run_config,
)
print(f"Extracted {len(result.markdown)} chars of markdown")
print(result.markdown[:500])
await cb_browser.close()
if __name__ == "__main__":
asyncio.run(main())
+72
View File
@@ -0,0 +1,72 @@
"""Crawlee + CloakBrowser: stealth web crawling with PlaywrightCrawler.
Uses a custom BrowserPlugin to swap Crawlee's default Chromium
for CloakBrowser's patched binary with source-level fingerprint patches.
Requires: pip install cloakbrowser "crawlee[playwright]"
"""
import asyncio
from cloakbrowser.config import IGNORE_DEFAULT_ARGS, get_default_stealth_args
from cloakbrowser.download import ensure_binary
from typing_extensions import override
from crawlee.browsers import (
BrowserPool,
PlaywrightBrowserController,
PlaywrightBrowserPlugin,
)
from crawlee.crawlers import PlaywrightCrawler, PlaywrightCrawlingContext
class CloakBrowserPlugin(PlaywrightBrowserPlugin):
"""Browser plugin that uses CloakBrowser's patched Chromium,
but otherwise keeps the functionality of PlaywrightBrowserPlugin.
"""
@override
async def new_browser(self) -> PlaywrightBrowserController:
if not self._playwright:
raise RuntimeError('Playwright browser plugin is not initialized.')
binary_path = ensure_binary()
stealth_args = get_default_stealth_args()
# Merge CloakBrowser stealth args with any user-provided launch options.
launch_options = dict(self._browser_launch_options)
launch_options.pop('executable_path', None)
launch_options.pop('chromium_sandbox', None)
existing_args = list(launch_options.pop('args', []))
launch_options['args'] = [*existing_args, *stealth_args]
return PlaywrightBrowserController(
browser=await self._playwright.chromium.launch(
executable_path=binary_path,
ignore_default_args=IGNORE_DEFAULT_ARGS,
**launch_options,
),
max_open_pages_per_browser=1,
# CloakBrowser handles fingerprints at the binary level.
header_generator=None,
)
async def main() -> None:
crawler = PlaywrightCrawler(
max_requests_per_crawl=10,
browser_pool=BrowserPool(plugins=[CloakBrowserPlugin()]),
)
@crawler.router.default_handler
async def request_handler(context: PlaywrightCrawlingContext) -> None:
context.log.info(f'Processing {context.request.url} ...')
title = await context.page.title()
await context.push_data({'url': context.request.url, 'title': title})
await context.enqueue_links()
await crawler.run(['https://example.com'])
if __name__ == '__main__':
asyncio.run(main())
+51
View File
@@ -0,0 +1,51 @@
"""LangChain + CloakBrowser: load web pages behind bot detection into LangChain Documents.
LangChain's PlaywrightURLLoader hardcodes chromium.launch() with no way to pass
a custom binary. This example uses CloakBrowser directly as a stealth document loader
that produces LangChain Document objects.
Requires: pip install langchain-core cloakbrowser
"""
import asyncio
from langchain_core.documents import Document
from cloakbrowser import launch_async
async def load_urls_stealth(urls: list[str], **launch_kwargs) -> list[Document]:
"""Load URLs using CloakBrowser stealth browser, return LangChain Documents."""
browser = await launch_async(headless=True, **launch_kwargs)
page = await browser.new_page()
docs = []
for url in urls:
await page.goto(url, wait_until="domcontentloaded")
text = await page.evaluate("document.body.innerText")
title = await page.title()
docs.append(Document(
page_content=text,
metadata={"source": url, "title": title},
))
await browser.close()
return docs
async def main():
urls = [
"https://example.com",
"https://httpbin.org/html",
]
docs = await load_urls_stealth(urls)
for doc in docs:
print(f"--- {doc.metadata['title']} ({doc.metadata['source']}) ---")
print(doc.page_content[:300])
print()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,42 @@
"""Scrapling + CloakBrowser: adaptive web scraping with stealth fingerprints.
Scrapling handles parsing and element tracking,
CloakBrowser handles bot detection.
Requires: pip install scrapling[all] cloakbrowser
"""
import asyncio
import json
from urllib.request import urlopen
from scrapling.fetchers import StealthyFetcher
from cloakbrowser import launch_async
async def main():
# Launch CloakBrowser with remote debugging
cb_browser = await launch_async(
headless=True,
args=["--remote-debugging-port=9245", "--remote-debugging-address=127.0.0.1"],
)
# Get the WebSocket URL from Chrome (Scrapling requires ws:// scheme)
info = json.loads(urlopen("http://127.0.0.1:9245/json/version").read())
ws_url = info["webSocketDebuggerUrl"]
# Connect Scrapling to the stealth browser via CDP
page = await StealthyFetcher.async_fetch(
"https://example.com",
cdp_url=ws_url,
)
print(f"Title: {page.css('title::text').get()}")
print(f"Text: {page.css('p::text').getall()}")
await cb_browser.close()
if __name__ == "__main__":
asyncio.run(main())
+41
View File
@@ -0,0 +1,41 @@
"""Selenium + CloakBrowser: use stealth Chromium with Selenium WebDriver.
CloakBrowser provides the binary and stealth args.
Selenium drives it via ChromeDriver.
Requires: pip install selenium cloakbrowser
Note: ChromeDriver version must match Chromium 145.
pip install chromedriver-autoinstaller or download manually.
"""
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from cloakbrowser.config import get_default_stealth_args
from cloakbrowser.download import ensure_binary
binary_path = ensure_binary()
stealth_args = get_default_stealth_args()
options = Options()
options.binary_location = binary_path
options.add_argument("--headless")
for arg in stealth_args:
options.add_argument(arg)
driver = webdriver.Chrome(options=options)
driver.get("https://example.com")
print(f"Selenium + CloakBrowser: {driver.title}")
# Verify stealth
result = driver.execute_script("""
return {
webdriver: navigator.webdriver,
plugins: navigator.plugins.length,
platform: navigator.platform,
}
""")
print(f"Stealth checks: {result}")
driver.quit()
@@ -0,0 +1,40 @@
"""undetected-chromedriver + CloakBrowser: double stealth layer.
undetected-chromedriver patches ChromeDriver detection signals,
CloakBrowser patches the browser fingerprints at the C++ level.
Requires: pip install undetected-chromedriver cloakbrowser
"""
import undetected_chromedriver as uc
from cloakbrowser.config import get_chromium_version, get_default_stealth_args
from cloakbrowser.download import ensure_binary
binary_path = ensure_binary()
stealth_args = get_default_stealth_args()
chromium_major = int(get_chromium_version().split(".")[0])
options = uc.ChromeOptions()
options.binary_location = binary_path
options.add_argument("--headless")
for arg in stealth_args:
options.add_argument(arg)
driver = uc.Chrome(options=options, version_main=chromium_major)
driver.get("https://example.com")
print(f"undetected-chromedriver + CloakBrowser: {driver.title}")
# Verify stealth
result = driver.execute_script("""
return {
webdriver: navigator.webdriver,
plugins: navigator.plugins.length,
platform: navigator.platform,
hardwareConcurrency: navigator.hardwareConcurrency,
}
""")
print(f"Stealth checks: {result}")
driver.quit()
+20 -8
View File
@@ -11,12 +11,12 @@
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
- **42 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
- **Free and open source** — no subscriptions, no usage limits
- **Works with any framework** — also tested with Selenium, undetected-chromedriver, browser-use, Crawl4AI, and agent-browser
- **Works with any framework** — tested with browser-use, Crawl4AI, Scrapling, Stagehand ([example](examples/stagehand.ts)), LangChain, Selenium, and more
## Install
@@ -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 },
@@ -133,6 +133,17 @@ const browser = await launch({ proxy: 'http://proxy:8080', geoip: true, timezone
> **Note:** For rotating residential proxies, the DNS-resolved IP may differ from the exit IP. Pass explicit `timezone`/`locale` in those cases.
### CLI
Pre-download the binary or check installation status from the command line:
```bash
npx cloakbrowser install # Download binary with progress output
npx cloakbrowser info # Show version, path, platform
npx cloakbrowser update # Check for and download newer binary
npx cloakbrowser clear-cache # Remove cached binaries
```
### Utilities
```javascript
@@ -192,10 +203,11 @@ const page = await browser.newPage();
| Platform | Chromium | Patches | Status |
|---|---|---|---|
| Linux x86_64 | 145 | 26 | ✅ Latest |
| Linux x86_64 | 145 | 33 | ✅ Latest |
| Linux arm64 (RPi, Graviton) | 145 | 33 | ✅ Latest |
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest |
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest |
| Windows x86_64 | 145 | 26 | ✅ Latest |
| Windows x86_64 | 145 | 33 | ✅ Latest |
## Requirements
@@ -247,13 +259,13 @@ 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
# Windows
set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-145.0.7632.109.2\chrome.exe
set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-145.0.7632.159.7\chrome.exe
```
## Links
+36
View File
@@ -0,0 +1,36 @@
/**
* Stagehand + CloakBrowser: AI browser automation with stealth fingerprints.
*
* Stagehand handles AI-powered navigation and actions,
* CloakBrowser handles bot detection.
*
* Requires: npm install @browserbasehq/stagehand cloakbrowser
* Set OPENAI_API_KEY for the AI model.
*
* Usage:
* CLOAKBROWSER_BINARY_PATH=/path/to/chrome npx tsx examples/stagehand.ts
*/
import { Stagehand } from "@browserbasehq/stagehand";
import { ensureBinary } from "../src/download.js";
import { getDefaultStealthArgs } from "../src/config.js";
const binaryPath = await ensureBinary();
const stealthArgs = getDefaultStealthArgs();
const stagehand = new Stagehand({
env: "LOCAL",
localBrowserLaunchOptions: {
executablePath: binaryPath,
args: stealthArgs,
headless: true,
},
});
await stagehand.init();
const page = stagehand.context.pages()[0];
await page.goto("https://example.com");
console.log(`Stagehand + CloakBrowser: ${await page.title()}`);
await stagehand.close();
+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"
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cloakbrowser",
"version": "0.3.9",
"version": "0.3.19",
"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",
@@ -15,6 +15,9 @@
"import": "./dist/puppeteer.js"
}
},
"bin": {
"cloakbrowser": "./dist/cli.js"
},
"files": [
"dist"
],
+15 -5
View File
@@ -20,6 +20,15 @@ export function buildArgs(options: LaunchOptions): string[] {
seen.set(arg.split("=")[0], arg);
}
}
// GPU blocklist bypass:
// - Headed mode (all platforms): Chromium blocks WebGL on software GPUs
// in Docker/Xvfb. Flag lets SwiftShader serve WebGL. See issue #56.
// - Windows (all modes): Chromium's GPU blocklist blocks WebGPU for the
// Microsoft Basic Render Driver. Dawn's adapter_blocklist bypass alone
// isn't enough. Linux doesn't need it.
if (options.headless === false || process.platform === "win32") {
seen.set("--ignore-gpu-blocklist", "--ignore-gpu-blocklist");
}
if (options.args) {
for (const arg of options.args) {
const key = arg.split("=")[0];
@@ -38,12 +47,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()];
}
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env node
/**
* CLI for cloakbrowser — download and manage the stealth Chromium binary.
*
* Usage:
* npx cloakbrowser install # Download binary (with progress)
* npx cloakbrowser info # Show binary version, path, platform
* npx cloakbrowser update # Check for and download newer binary
* npx cloakbrowser clear-cache # Remove cached binaries
*/
import { ensureBinary, binaryInfo, checkForUpdate, clearCache } from "./download.js";
import { getLocalBinaryOverride, getCacheDir } from "./config.js";
import fs from "node:fs";
const USAGE = `Usage: cloakbrowser <command>
Commands:
install Download the Chromium binary
info Show binary version, path, and platform
update Check for and download a newer binary
clear-cache Remove all cached binaries`;
async function cmdInstall(): Promise<void> {
const binaryPath = await ensureBinary();
console.log(binaryPath);
}
function cmdInfo(): void {
const info = binaryInfo();
const override = getLocalBinaryOverride();
console.log(`Version: ${info.version}`);
console.log(`Platform: ${info.platform}`);
console.log(`Binary: ${info.binaryPath}`);
console.log(`Installed: ${info.installed}`);
console.log(`Cache: ${info.cacheDir}`);
if (override) {
console.log(`Override: ${override} (CLOAKBROWSER_BINARY_PATH)`);
}
}
async function cmdUpdate(): Promise<void> {
console.error("Checking for updates...");
const newVersion = await checkForUpdate();
if (newVersion) {
console.log(`Updated to Chromium ${newVersion}`);
} else {
console.log("Already up to date.");
}
}
function cmdClearCache(): void {
const cacheDir = getCacheDir();
if (!fs.existsSync(cacheDir)) {
console.log("No cache to clear.");
return;
}
clearCache();
console.log("Cache cleared.");
}
async function main(): Promise<void> {
const command = process.argv[2];
if (!command || command === "--help" || command === "-h") {
console.log(USAGE);
process.exit(command ? 0 : 2);
}
try {
switch (command) {
case "install":
await cmdInstall();
break;
case "info":
cmdInfo();
break;
case "update":
await cmdUpdate();
break;
case "clear-cache":
cmdClearCache();
break;
default:
console.error(`Unknown command: ${command}\n`);
console.log(USAGE);
process.exit(2);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: ${message}`);
process.exit(1);
}
}
main();
+14 -5
View File
@@ -27,13 +27,14 @@ 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.8";
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
"linux-x64": "145.0.7632.159",
"linux-x64": "145.0.7632.159.8",
"linux-arm64": "145.0.7632.159.7",
"darwin-arm64": "145.0.7632.109.2",
"darwin-x64": "145.0.7632.109.2",
"windows-x64": "145.0.7632.109.2",
"windows-x64": "145.0.7632.159.7",
};
// ---------------------------------------------------------------------------
@@ -188,6 +189,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 +231,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)",
];
}
+10 -8
View File
@@ -146,13 +146,14 @@ export async function checkForUpdate(): Promise<string | null> {
function showWelcome(): void {
const marker = path.join(getCacheDir(), ".welcome_shown");
if (fs.existsSync(marker)) return;
console.log();
console.log(" CloakBrowser — stealth Chromium for automation");
console.log(" https://github.com/CloakHQ/CloakBrowser");
console.log();
console.log(" Issues? https://github.com/CloakHQ/CloakBrowser/issues");
console.log(" Star us if CloakBrowser helps your project!");
console.log();
console.error();
console.error(" CloakBrowser — stealth Chromium for automation");
console.error(" https://github.com/CloakHQ/CloakBrowser");
console.error();
console.error(" Issues? https://github.com/CloakHQ/CloakBrowser/issues");
console.error(" Donate? https://ko-fi.com/cloakhq");
console.error(" Star us if CloakBrowser helps your project!");
console.error();
try {
fs.mkdirSync(getCacheDir(), { recursive: true });
fs.writeFileSync(marker, "");
@@ -227,7 +228,8 @@ async function verifyDownloadChecksum(filePath: string, version?: string): Promi
await verifyChecksum(filePath, expected);
}
async function fetchChecksums(version?: string): Promise<Map<string, string> | null> {
/** @internal Exported for testing only. */
export async function fetchChecksums(version?: string): Promise<Map<string, string> | null> {
const v = version || getChromiumVersion();
const hasCustomUrl = !!process.env.CLOAKBROWSER_DOWNLOAD_URL;
+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 };
+128
View File
@@ -0,0 +1,128 @@
/**
* 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 isAscii(ch: string): boolean {
const code = ch.codePointAt(0);
return code !== undefined && code < 128;
}
function getNearbyKey(ch: string): string {
const lower = ch.toLowerCase();
if (lower in NEARBY_KEYS) {
const neighbors = NEARBY_KEYS[lower];
const wrong = neighbors[Math.floor(Math.random() * neighbors.length)];
return ch === ch.toUpperCase() && ch !== ch.toLowerCase() ? wrong.toUpperCase() : wrong;
}
return ch;
}
export async function humanType(
page: Page,
raw: RawKeyboard,
text: string,
cfg: HumanConfig,
): Promise<void> {
const chars = [...text]; // Handle emoji surrogate pairs correctly
for (let i = 0; i < chars.length; i++) {
const ch = chars[i];
// Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
if (!isAscii(ch)) {
await sleep(randRange(cfg.key_hold));
await raw.insertText(ch);
if (i < chars.length - 1) {
await interCharDelay(cfg);
}
continue;
}
// Mistype chance — only for ASCII alphanumeric
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 < chars.length - 1) {
await interCharDelay(cfg);
}
}
}
async function typeNormalChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
await raw.down(ch);
await sleep(randRange(cfg.key_hold));
await raw.up(ch);
}
async function typeShiftedChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay));
await raw.down(ch);
await sleep(randRange(cfg.key_hold));
await raw.up(ch);
await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift');
}
async function typeShiftSymbol(page: Page, raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay));
await raw.insertText(ch);
await page.evaluate((key: string) => {
const el = document.activeElement;
if (el) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
}
}, ch);
await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift');
}
function isUpperCase(ch: string): boolean {
return ch.length === 1 && ch >= 'A' && ch <= 'Z';
}
async function interCharDelay(cfg: HumanConfig): Promise<void> {
if (Math.random() < cfg.typing_pause_chance) {
await sleep(randRange(cfg.typing_pause_range));
} else {
const delay = cfg.typing_delay + (Math.random() - 0.5) * 2 * cfg.typing_delay_spread;
await sleep(Math.max(10, delay));
}
}
+193
View File
@@ -0,0 +1,193 @@
/**
* cloakbrowser-human — Human-like mouse movement and clicking.
*/
import { HumanConfig, rand, randRange, randIntRange, sleep } from './config.js';
// ---------------------------------------------------------------------------
// Raw interface — original Playwright methods, bypassing the wrapper
// ---------------------------------------------------------------------------
export interface RawMouse {
move: (x: number, y: number) => Promise<void>;
down: (options?: any) => Promise<void>;
up: (options?: any) => Promise<void>;
wheel: (deltaX: number, deltaY: number) => Promise<void>;
}
export interface RawKeyboard {
down: (key: string) => Promise<void>;
up: (key: string) => Promise<void>;
type: (text: string) => Promise<void>;
insertText: (text: string) => Promise<void>;
}
// ---------------------------------------------------------------------------
// Easing
// ---------------------------------------------------------------------------
function easeInOut(t: number): number {
return t < 0.5
? 4 * t * t * t
: 1 - Math.pow(-2 * t + 2, 3) / 2;
}
// ---------------------------------------------------------------------------
// Bezier
// ---------------------------------------------------------------------------
interface Point {
x: number;
y: number;
}
function bezier(p0: Point, p1: Point, p2: Point, p3: Point, t: number): Point {
const u = 1 - t;
const uu = u * u;
const uuu = uu * u;
const tt = t * t;
const ttt = tt * t;
return {
x: uuu * p0.x + 3 * uu * t * p1.x + 3 * u * tt * p2.x + ttt * p3.x,
y: uuu * p0.y + 3 * uu * t * p1.y + 3 * u * tt * p2.y + ttt * p3.y,
};
}
function randomControlPoints(start: Point, end: Point): [Point, Point] {
const dx = end.x - start.x;
const dy = end.y - start.y;
const dist = Math.hypot(dx, dy);
const px = -dy / (dist || 1);
const py = dx / (dist || 1);
const bias1 = rand(-0.3, 0.3) * dist;
const bias2 = rand(-0.3, 0.3) * dist;
return [
{ x: start.x + dx * 0.25 + px * bias1, y: start.y + dy * 0.25 + py * bias1 },
{ x: start.x + dx * 0.75 + px * bias2, y: start.y + dy * 0.75 + py * bias2 },
];
}
// ---------------------------------------------------------------------------
// Human mouse movement
// ---------------------------------------------------------------------------
export async function humanMove(
raw: RawMouse,
startX: number,
startY: number,
endX: number,
endY: number,
cfg: HumanConfig,
): Promise<void> {
const dist = Math.hypot(endX - startX, endY - startY);
if (dist < 1) return;
const steps = Math.max(
cfg.mouse_min_steps,
Math.min(cfg.mouse_max_steps, Math.round(dist / cfg.mouse_steps_divisor)),
);
const start: Point = { x: startX, y: startY };
const end: Point = { x: endX, y: endY };
const [cp1, cp2] = randomControlPoints(start, end);
let burstCounter = 0;
const burstSize = randIntRange(cfg.mouse_burst_size);
for (let i = 0; i <= steps; i++) {
const progress = i / steps;
const easedT = easeInOut(progress);
const pt = bezier(start, cp1, cp2, end, easedT);
const wobbleAmp = Math.sin(Math.PI * progress) * cfg.mouse_wobble_max;
const wx = pt.x + (Math.random() - 0.5) * 2 * wobbleAmp;
const wy = pt.y + (Math.random() - 0.5) * 2 * wobbleAmp;
await raw.move(Math.round(wx), Math.round(wy));
burstCounter++;
if (burstCounter >= burstSize && i < steps) {
await sleep(randRange(cfg.mouse_burst_pause));
burstCounter = 0;
}
}
if (Math.random() < cfg.mouse_overshoot_chance) {
const overshootDist = randRange(cfg.mouse_overshoot_px);
const angle = Math.atan2(endY - startY, endX - startX);
const ovX = Math.round(endX + Math.cos(angle) * overshootDist);
const ovY = Math.round(endY + Math.sin(angle) * overshootDist);
await raw.move(ovX, ovY);
await sleep(rand(30, 70));
const corrX = Math.round(endX + (Math.random() - 0.5) * 4);
const corrY = Math.round(endY + (Math.random() - 0.5) * 4);
await raw.move(corrX, corrY);
}
}
// ---------------------------------------------------------------------------
// Human click
// ---------------------------------------------------------------------------
export function clickTarget(
box: { x: number; y: number; width: number; height: number },
isInput: boolean,
cfg: HumanConfig,
): Point {
if (isInput) {
const xFrac = randRange(cfg.click_input_x_range);
const yFrac = rand(0.30, 0.70);
return {
x: Math.round(box.x + box.width * xFrac),
y: Math.round(box.y + box.height * yFrac),
};
}
const xFrac = rand(0.35, 0.65);
const yFrac = rand(0.35, 0.65);
return {
x: Math.round(box.x + box.width * xFrac),
y: Math.round(box.y + box.height * yFrac),
};
}
export async function humanClick(
raw: RawMouse,
isInput: boolean,
cfg: HumanConfig,
): Promise<void> {
const aimDelay = isInput
? randRange(cfg.click_aim_delay_input)
: randRange(cfg.click_aim_delay_button);
await sleep(aimDelay);
const holdTime = isInput
? randRange(cfg.click_hold_input)
: randRange(cfg.click_hold_button);
await raw.down();
await sleep(holdTime);
await raw.up();
}
// ---------------------------------------------------------------------------
// Human idle / drift
// ---------------------------------------------------------------------------
export async function humanIdle(
raw: RawMouse,
seconds: number,
cx: number,
cy: number,
cfg: HumanConfig,
): Promise<void> {
const endTime = Date.now() + seconds * 1000;
let x = cx;
let y = cy;
while (Date.now() < endTime) {
const dx = (Math.random() - 0.5) * 2 * cfg.idle_drift_px;
const dy = (Math.random() - 0.5) * 2 * cfg.idle_drift_px;
x += dx;
y += dy;
await raw.move(Math.round(x), Math.round(y));
await sleep(randRange(cfg.idle_pause_range));
}
}
+150
View File
@@ -0,0 +1,150 @@
/**
* cloakbrowser-human — Human-like scrolling via mouse wheel events.
*/
import type { Page } from 'playwright-core';
import { HumanConfig, rand, randRange, randIntRange, sleep } from './config.js';
import { RawMouse, humanMove } from './mouse.js';
interface ElementBounds {
x: number;
y: number;
width: number;
height: number;
}
function isInViewport(
bounds: ElementBounds,
viewportHeight: number,
cfg: HumanConfig,
): boolean {
const topEdge = bounds.y;
const bottomEdge = bounds.y + bounds.height;
const zoneTop = viewportHeight * cfg.scroll_target_zone[0];
const zoneBottom = viewportHeight * cfg.scroll_target_zone[1];
return topEdge >= zoneTop && bottomEdge <= zoneBottom;
}
async function smoothWheel(raw: RawMouse, delta: number, cfg: HumanConfig): Promise<void> {
const absD = Math.abs(delta);
const sign = delta > 0 ? 1 : -1;
let sent = 0;
while (sent < absD) {
const stepSize = rand(20, 40);
const chunk = Math.min(stepSize, absD - sent);
await raw.wheel(0, Math.round(chunk) * sign);
sent += chunk;
await sleep(rand(8, 20));
}
}
export async function scrollToElement(
page: Page,
raw: RawMouse,
selector: string,
cursorX: number,
cursorY: number,
cfg: HumanConfig,
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
const viewport = page.viewportSize();
if (!viewport) throw new Error('Viewport size not available');
let box = await getElementBox(page, selector);
if (!box) {
await sleep(200);
box = await getElementBox(page, selector);
if (!box) throw new Error(`Element not found: ${selector}`);
}
if (isInViewport(box, viewport.height, cfg)) {
return { box, cursorX, cursorY };
}
// Move cursor into scroll area
const scrollAreaX = Math.round(viewport.width * rand(0.3, 0.7));
const scrollAreaY = Math.round(viewport.height * rand(0.3, 0.7));
await humanMove(raw, cursorX, cursorY, scrollAreaX, scrollAreaY, cfg);
cursorX = scrollAreaX;
cursorY = scrollAreaY;
await sleep(randRange(cfg.scroll_pre_move_delay));
// Calculate scroll distance
const targetY = viewport.height * rand(cfg.scroll_target_zone[0], cfg.scroll_target_zone[1]);
const elementCenter = box.y + box.height / 2;
const distanceToScroll = elementCenter - targetY;
const direction = distanceToScroll > 0 ? 1 : -1;
const absDistance = Math.abs(distanceToScroll);
const avgDelta = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2;
const totalClicks = Math.max(3, Math.ceil(absDistance / avgDelta));
const accelSteps = randIntRange(cfg.scroll_accel_steps);
const decelSteps = randIntRange(cfg.scroll_decel_steps);
let scrolled = 0;
// Scroll loop: accelerate → cruise → decelerate
for (let i = 0; i < totalClicks; i++) {
let delta: number;
let pause: number;
if (i < accelSteps) {
delta = rand(80, 100);
pause = randRange(cfg.scroll_pause_slow);
} else if (i >= totalClicks - decelSteps) {
delta = rand(60, 90);
pause = randRange(cfg.scroll_pause_slow);
} else {
delta = randRange(cfg.scroll_delta_base);
pause = randRange(cfg.scroll_pause_fast);
}
delta *= 1 + (Math.random() - 0.5) * 2 * cfg.scroll_delta_variance;
delta = Math.round(delta) * direction;
await smoothWheel(raw, delta, cfg);
scrolled += Math.abs(delta);
await sleep(pause);
// Check visibility every 3 steps
if (i % 3 === 2 || i === totalClicks - 1) {
box = await getElementBox(page, selector);
if (box && isInViewport(box, viewport.height, cfg)) {
break;
}
}
if (scrolled >= absDistance * 1.1) break;
}
// Optional overshoot + correction
if (Math.random() < cfg.scroll_overshoot_chance) {
const overshootPx = Math.round(randRange(cfg.scroll_overshoot_px)) * direction;
await smoothWheel(raw, overshootPx, cfg);
await sleep(randRange(cfg.scroll_settle_delay));
const corrections = randIntRange([1, 2]);
for (let c = 0; c < corrections; c++) {
const corrDelta = Math.round(rand(40, 80)) * -direction;
await smoothWheel(raw, corrDelta, cfg);
await sleep(rand(100, 250));
}
}
// Settle
await sleep(randRange(cfg.scroll_settle_delay));
box = await getElementBox(page, selector);
if (!box) throw new Error(`Element lost after scrolling: ${selector}`);
return { box, cursorX, cursorY };
}
async function getElementBox(page: Page, selector: string): Promise<ElementBounds | null> {
const el = page.locator(selector).first();
try {
const box = await el.boundingBox({ timeout: 2000 });
return box;
} catch {
return null;
}
}
+47 -32
View File
@@ -5,15 +5,15 @@
import type { Browser, BrowserContext } from "playwright-core";
import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js";
import { DEFAULT_VIEWPORT } from "./config.js";
import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js";
import { maybeResolveGeoip } from "./geoip.js";
/** @internal Migrate deprecated timezoneId → timezone, warn once. Exported for testing. */
export function migrateTimezoneId<T extends { timezone?: string; timezoneId?: string }>(options: T): T {
/** @internal Accept both timezone and timezoneId — either works, no warning. Exported for testing. */
export function resolveTimezone<T extends { timezone?: string; timezoneId?: string }>(options: T): T {
if (options.timezoneId != null) {
console.warn("[cloakbrowser] timezoneId is deprecated, use timezone instead");
const merged = { ...options, timezone: options.timezone ?? options.timezoneId };
delete (merged as any).timezoneId;
return merged;
@@ -45,13 +45,24 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
executablePath: binaryPath,
headless: options.headless ?? true,
args,
ignoreDefaultArgs: ["--enable-automation"],
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
...(options.proxy
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
: {}),
...options.launchOptions,
});
// Human-like behavioral patching
if (options.humanize) {
const { patchBrowser } = await import('./human/index.js');
const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig(
(options.humanPreset as any) ?? 'default',
options.humanConfig as any,
);
patchBrowser(browser, cfg);
}
return browser;
}
@@ -74,21 +85,19 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
export async function launchContext(
options: LaunchContextOptions = {}
): Promise<BrowserContext> {
options = migrateTimezoneId(options);
options = resolveTimezone(options);
// Resolve geoip BEFORE launch() to avoid double-resolution
const resolved = await maybeResolveGeoip(options);
// Skip --fingerprint-timezone binary flag: it only applies to the default
// context and interferes with Playwright's timezoneId on new contexts.
// Timezone is set via browser.newContext(timezoneId: ...) below instead.
const browser = await launch({ ...options, ...resolved, geoip: false, timezone: undefined });
// --fingerprint-timezone is process-wide (reads CommandLine in renderer),
// so it applies to ALL contexts, not just the default one.
// locale and timezone are set via binary flags only — no CDP emulation.
const browser = await launch({ ...options, ...resolved, geoip: false });
let context: BrowserContext;
try {
context = await browser.newContext({
...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport ?? DEFAULT_VIEWPORT,
...(resolved.locale ? { locale: resolved.locale } : {}),
...(resolved.timezone ? { timezoneId: resolved.timezone } : {}),
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
});
} catch (err) {
@@ -103,6 +112,17 @@ export async function launchContext(
await browser.close();
};
// Human-like behavioral patching
if (options.humanize) {
const { patchContext } = await import('./human/index.js');
const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig(
(options.humanPreset as any) ?? 'default',
options.humanConfig as any,
);
patchContext(context, cfg);
}
return context;
}
@@ -130,29 +150,40 @@ export async function launchContext(
export async function launchPersistentContext(
options: LaunchPersistentContextOptions
): Promise<BrowserContext> {
options = migrateTimezoneId(options);
options = resolveTimezone(options);
const { chromium } = await import("playwright-core");
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const resolved = await maybeResolveGeoip(options);
const args = buildArgs({ ...options, ...resolved });
// locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
// — NOT via Playwright context kwargs which use detectable CDP emulation.
const context = await chromium.launchPersistentContext(options.userDataDir, {
executablePath: binaryPath,
headless: options.headless ?? true,
args,
ignoreDefaultArgs: ["--enable-automation"],
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
...(options.proxy
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
: {}),
...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport ?? DEFAULT_VIEWPORT,
...(resolved.locale ? { locale: resolved.locale } : {}),
...(resolved.timezone ? { timezoneId: resolved.timezone } : {}),
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
...options.launchOptions,
});
// Human-like behavioral patching
if (options.humanize) {
const { patchContext } = await import('./human/index.js');
const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig(
(options.humanPreset as any) ?? 'default',
options.humanConfig as any,
);
patchContext(context, cfg);
}
return context;
}
@@ -160,21 +191,5 @@ export async function launchPersistentContext(
// Internal
// ---------------------------------------------------------------------------
async function maybeResolveGeoip(
options: LaunchOptions
): Promise<{ timezone?: string; locale?: string }> {
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
const { resolveProxyGeo } = await import("./geoip.js");
const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
return {
timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined,
};
}
/** @internal Exposed for unit tests only. */
export { buildArgs as _buildArgsForTest } from "./args.js";
+14 -2
View File
@@ -8,16 +8,28 @@ export interface ParsedProxy {
password?: string;
}
/**
* Prepend http:// to schemeless proxy URLs so parsers can extract hostname.
* Used by geoip resolution which only needs a valid hostname, not auth fields.
*/
export function ensureProxyScheme(proxyUrl: string): string {
return proxyUrl.includes("://") ? proxyUrl : `http://${proxyUrl}`;
}
/**
* Parse a proxy URL, extracting credentials into separate fields.
*
* Handles: "http://user:pass@host:port" -> { server: "http://host:port", username: "user", password: "pass" }
* Also handles: no credentials, URL-encoded special chars, socks5://, missing port.
* Also handles: no credentials, URL-encoded special chars, socks5://, missing port,
* and bare proxy strings without a scheme (e.g. "user:pass@host:port" -> treated as http).
*/
export function parseProxyUrl(proxy: string): ParsedProxy {
let url: URL;
// Bare format: "user:pass@host:port" — new URL() throws without a scheme.
const normalized =
proxy.includes("@") && !proxy.includes("://") ? `http://${proxy}` : proxy;
try {
url = new URL(proxy);
url = new URL(normalized);
} catch {
// Not a parseable URL (e.g. bare "host:port") — pass through as-is
return { server: proxy };
+3 -17
View File
@@ -5,9 +5,11 @@
import type { Browser } from "puppeteer-core";
import type { LaunchOptions } from "./types.js";
import { IGNORE_DEFAULT_ARGS } from "./config.js";
import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js";
import { maybeResolveGeoip } from "./geoip.js";
/**
* Launch stealth Chromium browser via Puppeteer.
@@ -61,7 +63,7 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
executablePath: binaryPath,
headless: options.headless ?? true,
args,
ignoreDefaultArgs: ["--enable-automation"],
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
...options.launchOptions,
});
@@ -83,19 +85,3 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
// Internal
// ---------------------------------------------------------------------------
async function maybeResolveGeoip(
options: LaunchOptions
): Promise<{ timezone?: string; locale?: string }> {
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
const { resolveProxyGeo } = await import("./geoip.js");
const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
return {
timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined,
};
}
+7 -1
View File
@@ -24,6 +24,12 @@ export interface LaunchOptions {
geoip?: boolean;
/** Raw options passed directly to playwright/puppeteer launch(). */
launchOptions?: Record<string, unknown>;
/** Enable human-like mouse, keyboard, and scroll behavior. */
humanize?: boolean;
/** Human behavior preset: 'default' or 'careful'. */
humanPreset?: 'default' | 'careful';
/** Override individual human behavior parameters. */
humanConfig?: Record<string, unknown>;
}
export interface LaunchContextOptions extends LaunchOptions {
@@ -33,7 +39,7 @@ export interface LaunchContextOptions extends LaunchOptions {
viewport?: { width: number; height: number };
/** Browser locale, e.g. "en-US". */
locale?: string;
/** @deprecated Use `timezone` (inherited from LaunchOptions) instead. */
/** IANA timezone — alias for `timezone`. Either works. */
timezoneId?: string;
/** Color scheme preference — 'light', 'dark', or 'no-preference'. */
colorScheme?: "light" | "dark" | "no-preference";
+17 -10
View File
@@ -9,7 +9,7 @@ import {
getDownloadUrl,
getFallbackDownloadUrl,
} from "../src/config.js";
import { _buildArgsForTest, migrateTimezoneId } from "../src/playwright.js";
import { _buildArgsForTest, resolveTimezone } from "../src/playwright.js";
describe("config", () => {
it("CHROMIUM_VERSION matches expected format", () => {
@@ -97,21 +97,24 @@ describe("buildArgs timezone/locale", () => {
expect(args).toContain("--fingerprint-timezone=America/New_York");
});
it("injects --lang when locale is set", () => {
it("injects --lang and --fingerprint-locale when locale is set", () => {
const args = _buildArgsForTest({ locale: "en-US" });
expect(args).toContain("--lang=en-US");
expect(args).toContain("--fingerprint-locale=en-US");
});
it("injects both when both are set", () => {
const args = _buildArgsForTest({ timezone: "Europe/Berlin", locale: "de-DE" });
expect(args).toContain("--fingerprint-timezone=Europe/Berlin");
expect(args).toContain("--lang=de-DE");
expect(args).toContain("--fingerprint-locale=de-DE");
});
it("injects timezone/locale even when stealthArgs=false", () => {
const args = _buildArgsForTest({ stealthArgs: false, timezone: "America/New_York", locale: "en-US" });
expect(args).toContain("--fingerprint-timezone=America/New_York");
expect(args).toContain("--lang=en-US");
expect(args).toContain("--fingerprint-locale=en-US");
expect(args.some(a => a.startsWith("--fingerprint="))).toBe(false);
});
@@ -119,6 +122,7 @@ describe("buildArgs timezone/locale", () => {
const args = _buildArgsForTest({});
expect(args.some(a => a.startsWith("--fingerprint-timezone="))).toBe(false);
expect(args.some(a => a.startsWith("--lang="))).toBe(false);
expect(args.some(a => a.startsWith("--fingerprint-locale="))).toBe(false);
});
});
@@ -147,14 +151,17 @@ describe("buildArgs deduplication", () => {
expect(tzArgs[0]).toBe("--fingerprint-timezone=America/New_York");
});
it("locale param overrides user --lang arg", () => {
it("locale param overrides user --lang and --fingerprint-locale args", () => {
const args = _buildArgsForTest({
args: ["--lang=de-DE"],
args: ["--lang=de-DE", "--fingerprint-locale=de-DE"],
locale: "en-US",
});
const langArgs = args.filter(a => a.startsWith("--lang="));
expect(langArgs).toHaveLength(1);
expect(langArgs[0]).toBe("--lang=en-US");
const localeArgs = args.filter(a => a.startsWith("--fingerprint-locale="));
expect(localeArgs).toHaveLength(1);
expect(localeArgs[0]).toBe("--fingerprint-locale=en-US");
});
it("no duplicate flag keys in output", () => {
@@ -175,29 +182,29 @@ describe("buildArgs deduplication", () => {
});
});
describe("migrateTimezoneId deprecation", () => {
it("migrates timezoneId to timezone", () => {
const result = migrateTimezoneId({ timezoneId: "Europe/Paris" });
describe("resolveTimezone alias", () => {
it("resolves timezoneId to timezone", () => {
const result = resolveTimezone({ timezoneId: "Europe/Paris" });
expect(result.timezone).toBe("Europe/Paris");
expect(result).not.toHaveProperty("timezoneId");
});
it("preserves explicit timezone over timezoneId", () => {
const result = migrateTimezoneId({ timezone: "UTC", timezoneId: "Europe/Paris" });
const result = resolveTimezone({ timezone: "UTC", timezoneId: "Europe/Paris" });
expect(result.timezone).toBe("UTC");
expect(result).not.toHaveProperty("timezoneId");
});
it("returns options unchanged when no timezoneId", () => {
const opts = { timezone: "UTC" };
const result = migrateTimezoneId(opts);
const result = resolveTimezone(opts);
expect(result).toBe(opts); // same reference, no copy
expect(result.timezone).toBe("UTC");
});
it("returns options unchanged when neither is set", () => {
const opts = {};
const result = migrateTimezoneId(opts);
const result = resolveTimezone(opts);
expect(result).toBe(opts);
});
});
+11
View File
@@ -25,6 +25,17 @@ describe("resolveProxyIp", () => {
it("returns null for empty string", async () => {
expect(await resolveProxyIp("")).toBeNull();
});
it("returns null for schemeless proxy (shows why normalization is needed)", async () => {
// no scheme — new URL() gives empty hostname for both bare formats
expect(await resolveProxyIp("user:pass@10.50.96.5:8888")).toBeNull();
expect(await resolveProxyIp("10.50.96.5:8888")).toBeNull();
});
it("extracts IP after normalization (http:// prepended by maybeResolveGeoip)", async () => {
expect(await resolveProxyIp("http://user:pass@10.50.96.5:8888")).toBe("10.50.96.5");
expect(await resolveProxyIp("http://10.50.96.5:8888")).toBe("10.50.96.5");
});
});
describe("COUNTRY_LOCALE_MAP", () => {
+627
View File
@@ -0,0 +1,627 @@
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;
}
// =========================================================================
// humanType non-ASCII
// =========================================================================
describe("humanType non-ASCII", () => {
function makeRawKeyboardMock() {
const downKeys: string[] = [];
const insertedChars: string[] = [];
const raw = {
down: vi.fn(async (k: string) => { downKeys.push(k); }),
up: vi.fn(async () => {}),
type: vi.fn(async () => {}),
insertText: vi.fn(async (t: string) => { insertedChars.push(t); }),
};
return { raw, downKeys, insertedChars };
}
it("types Cyrillic via insertText, not down", async () => {
const { humanType } = await import("../src/human/keyboard.js");
const cfg = resolveConfig("default", { mistype_chance: 0 });
const { raw, downKeys, insertedChars } = makeRawKeyboardMock();
await humanType({} as any, raw, "Привет", cfg);
expect(insertedChars.join("")).toBe("Привет");
for (const k of downKeys) {
expect(k.charCodeAt(0)).toBeLessThan(128);
}
});
it("types mixed ASCII + Cyrillic correctly", async () => {
const { humanType } = await import("../src/human/keyboard.js");
const cfg = resolveConfig("default", { mistype_chance: 0 });
const { raw, downKeys, insertedChars } = makeRawKeyboardMock();
await humanType({} as any, raw, "Hi Мир", cfg);
expect(downKeys).toContain("H");
expect(downKeys).toContain("i");
expect(insertedChars.join("")).toContain("М");
expect(insertedChars.join("")).toContain("и");
expect(insertedChars.join("")).toContain("р");
});
it("types CJK via insertText", async () => {
const { humanType } = await import("../src/human/keyboard.js");
const cfg = resolveConfig("default", { mistype_chance: 0 });
const { raw, insertedChars } = makeRawKeyboardMock();
await humanType({} as any, raw, "你好", cfg);
expect(insertedChars.join("")).toBe("你好");
});
it("types emoji via insertText", async () => {
const { humanType } = await import("../src/human/keyboard.js");
const cfg = resolveConfig("default", { mistype_chance: 0 });
const { raw, insertedChars } = makeRawKeyboardMock();
await humanType({} as any, raw, "Hi 👋", cfg);
expect(insertedChars.join("")).toContain("👋");
});
it("mistype only triggers for ASCII, not Cyrillic", async () => {
const { humanType } = await import("../src/human/keyboard.js");
const cfg = resolveConfig("default", { mistype_chance: 1.0 });
const { raw, downKeys } = makeRawKeyboardMock();
await humanType({} as any, raw, "AБ", cfg);
expect(downKeys).toContain("Backspace");
});
});
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", () => {
+49
View File
@@ -14,6 +14,7 @@ import {
checkWrapperUpdate,
clearCache,
ensureBinary,
fetchChecksums,
getLatestChromiumVersion,
parseChecksums,
resetWrapperUpdateChecked,
@@ -269,6 +270,54 @@ describe("parseChecksums", () => {
});
});
describe("download fallback", () => {
afterEach(() => {
vi.restoreAllMocks();
delete process.env.CLOAKBROWSER_DOWNLOAD_URL;
});
it("checksum fetch falls back to GitHub on primary 429", async () => {
const HASH =
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
const checksumText = `${HASH} cloakbrowser-${getPlatformTag()}.tar.gz`;
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: (input as Request).url;
if (url.includes("cloakbrowser.dev")) {
return {
ok: false,
status: 429,
statusText: "Too Many Requests",
} as Response;
}
// GitHub fallback
return { ok: true, text: async () => checksumText } as Response;
});
const result = await fetchChecksums();
expect(result).not.toBeNull();
expect(
result!.has(`cloakbrowser-${getPlatformTag()}.tar.gz`)
).toBe(true);
});
it("checksum fetch returns null when both sources fail", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
status: 429,
statusText: "Too Many Requests",
} as Response);
const result = await fetchChecksums();
expect(result).toBeNull();
});
});
describe("effective version", () => {
it("returns platform version when no marker exists", () => {
// Default behavior — no marker file in test environment
+5
View File
@@ -56,6 +56,11 @@ dependencies = [
[project.optional-dependencies]
geoip = ["geoip2>=4.0"]
patchright = ["patchright>=1.40"]
serve = ["aiohttp>=3.9", "websockets>=12.0"]
dev = ["pytest>=7.0", "pytest-asyncio>=0.23"]
[project.scripts]
cloakbrowser = "cloakbrowser.__main__:main"
[project.urls]
Homepage = "https://github.com/CloakHQ/CloakBrowser"
+38 -44
View File
@@ -1,97 +1,88 @@
"""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():
"""--fingerprint-timezone flag should appear when timezone is set."""
args = _build_args(stealth_args=True, extra_args=None, timezone="America/New_York")
args = build_args(stealth_args=True, extra_args=None, timezone="America/New_York")
assert "--fingerprint-timezone=America/New_York" in args
def test_locale_injected():
"""--lang flag should appear when locale is set."""
args = _build_args(stealth_args=True, extra_args=None, locale="en-US")
"""--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():
"""Both flags should appear when both are set."""
args = _build_args(stealth_args=True, extra_args=None, timezone="Europe/Berlin", locale="de-DE")
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():
"""--fingerprint-timezone should be injected even when stealth_args=False."""
args = _build_args(stealth_args=False, extra_args=None, timezone="America/New_York", locale="en-US")
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."""
args = _build_args(stealth_args=True, extra_args=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():
"""Extra args should still be included alongside timezone/locale."""
args = _build_args(stealth_args=True, extra_args=["--disable-gpu"], timezone="Asia/Tokyo", locale="ja-JP")
args = build_args(stealth_args=True, extra_args=["--disable-gpu"], timezone="Asia/Tokyo", locale="ja-JP")
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 ---
@@ -99,7 +90,7 @@ def test_migrate_both_none():
def test_user_fingerprint_overrides_default():
"""User --fingerprint should override the random default seed."""
args = _build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
args = build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
fingerprint_args = [a for a in args if a.startswith("--fingerprint=")]
assert len(fingerprint_args) == 1
assert fingerprint_args[0] == "--fingerprint=99887"
@@ -107,7 +98,7 @@ def test_user_fingerprint_overrides_default():
def test_user_platform_overrides_default():
"""User --fingerprint-platform should override the default."""
args = _build_args(stealth_args=True, extra_args=["--fingerprint-platform=linux"])
args = build_args(stealth_args=True, extra_args=["--fingerprint-platform=linux"])
platform_args = [a for a in args if a.startswith("--fingerprint-platform=")]
assert len(platform_args) == 1
assert platform_args[0] == "--fingerprint-platform=linux"
@@ -115,7 +106,7 @@ def test_user_platform_overrides_default():
def test_timezone_param_overrides_user_arg():
"""Dedicated timezone param should override user arg."""
args = _build_args(
args = build_args(
stealth_args=True,
extra_args=["--fingerprint-timezone=Europe/London"],
timezone="America/New_York",
@@ -126,20 +117,23 @@ def test_timezone_param_overrides_user_arg():
def test_locale_param_overrides_user_arg():
"""Dedicated locale param should override user --lang arg."""
args = _build_args(
"""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():
"""No flag key should appear more than once in the output."""
args = _build_args(
args = build_args(
stealth_args=True,
extra_args=["--fingerprint=99887", "--fingerprint-timezone=UTC", "--lang=fr-FR"],
timezone="Europe/Berlin",
@@ -151,7 +145,7 @@ def test_no_duplicate_flags():
def test_non_value_flags_preserved():
"""Flags without = should be preserved without dedup issues."""
args = _build_args(stealth_args=True, extra_args=["--disable-gpu", "--no-zygote"])
args = build_args(stealth_args=True, extra_args=["--disable-gpu", "--no-zygote"])
assert "--disable-gpu" in args
assert "--no-zygote" in args
assert "--no-sandbox" in args
@@ -162,5 +156,5 @@ def test_override_logs_debug(caplog):
import logging
with caplog.at_level(logging.DEBUG, logger="cloakbrowser"):
_build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
assert any("--fingerprint=" in r.message and "99887" in r.message for r in caplog.records)
+244
View File
@@ -0,0 +1,244 @@
"""Unit tests for cloakserve — parse_connection_params, parse_cli_args, URL rewriting, connection tracking."""
import importlib.machinery
import importlib.util
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
aiohttp = pytest.importorskip("aiohttp", reason="cloakserve requires aiohttp (install with .[serve])")
# Load cloakserve as a module from bin/ (no .py extension).
_bin_path = str(Path(__file__).resolve().parents[1] / "bin" / "cloakserve")
_loader = importlib.machinery.SourceFileLoader("cloakserve", _bin_path)
_spec = importlib.util.spec_from_file_location("cloakserve", _bin_path, loader=_loader)
_mod = importlib.util.module_from_spec(_spec)
sys.modules["cloakserve"] = _mod
_loader.exec_module(_mod)
parse_connection_params = _mod.parse_connection_params
parse_cli_args = _mod.parse_cli_args
ChromePool = _mod.ChromePool
_default_data_dir = _mod._default_data_dir
# ---------------------------------------------------------------------------
# parse_connection_params
# ---------------------------------------------------------------------------
class TestParseConnectionParams:
def test_empty_query(self):
result = parse_connection_params("")
assert result["seed"] is None
assert result["extra_args"] == []
def test_fingerprint_seed(self):
result = parse_connection_params("fingerprint=12345")
assert result["seed"] == "12345"
def test_timezone_and_locale(self):
result = parse_connection_params("fingerprint=1&timezone=Asia/Tokyo&locale=ja-JP")
assert result["timezone"] == "Asia/Tokyo"
assert result["locale"] == "ja-JP"
def test_proxy(self):
result = parse_connection_params("proxy=http://proxy:8080")
assert result["proxy"] == "http://proxy:8080"
def test_geoip_true_variants(self):
for val in ("true", "1", "yes", "True", "YES"):
result = parse_connection_params(f"geoip={val}")
assert result["geoip"] is True, f"geoip={val} should be True"
def test_geoip_false(self):
for val in ("false", "0", "no", "anything"):
result = parse_connection_params(f"geoip={val}")
assert result["geoip"] is False, f"geoip={val} should be False"
def test_generic_fingerprint_params(self):
qs = "fingerprint=1&platform=windows&hardware-concurrency=8&gpu-vendor=NVIDIA"
result = parse_connection_params(qs)
assert "--fingerprint-platform=windows" in result["extra_args"]
assert "--fingerprint-hardware-concurrency=8" in result["extra_args"]
assert "--fingerprint-gpu-vendor=NVIDIA" in result["extra_args"]
def test_special_params_not_in_extra_args(self):
qs = "fingerprint=1&timezone=UTC&locale=en-US&proxy=http://x:1&geoip=true"
result = parse_connection_params(qs)
assert result["extra_args"] == []
def test_multiple_values_takes_first(self):
result = parse_connection_params("fingerprint=111&fingerprint=222")
assert result["seed"] == "111"
# ---------------------------------------------------------------------------
# parse_cli_args
# ---------------------------------------------------------------------------
class TestParseCliArgs:
def test_defaults(self):
config, passthrough = parse_cli_args([])
assert config["port"] == 9222
assert config["headless"] is True
assert config["data_dir"] is not None
assert passthrough == []
def test_custom_port(self):
config, _ = parse_cli_args(["--port=8080"])
assert config["port"] == 8080
def test_headless_false(self):
config, passthrough = parse_cli_args(["--headless=false"])
assert config["headless"] is False
# headless flag still passed through to Chrome
assert "--headless=false" in passthrough
def test_strips_remote_debugging_flags(self):
args = ["--remote-debugging-port=9999", "--remote-debugging-address=0.0.0.0", "--no-sandbox"]
config, passthrough = parse_cli_args(args)
assert passthrough == ["--no-sandbox"]
def test_passthrough_args(self):
args = ["--no-sandbox", "--disable-gpu", "--fingerprint=999"]
_, passthrough = parse_cli_args(args)
assert passthrough == args
def test_port_not_in_passthrough(self):
_, passthrough = parse_cli_args(["--port=9222", "--no-sandbox"])
assert "--port=9222" not in passthrough
assert "--no-sandbox" in passthrough
def test_custom_data_dir(self):
config, passthrough = parse_cli_args(["--data-dir=/custom/path", "--no-sandbox"])
assert config["data_dir"] == "/custom/path"
assert "--data-dir=/custom/path" not in passthrough
def test_data_dir_not_in_passthrough(self):
_, passthrough = parse_cli_args(["--data-dir=/tmp/test"])
assert not any(a.startswith("--data-dir=") for a in passthrough)
@patch("os.path.exists", return_value=True)
def test_default_data_dir_docker(self, _mock):
assert _default_data_dir() == "/tmp/cloakserve"
@patch("os.path.exists", return_value=False)
def test_default_data_dir_bare_metal(self, _mock):
result = _default_data_dir()
assert result.endswith(".cloakbrowser/cloakserve")
# ---------------------------------------------------------------------------
# URL rewriting logic (pure string manipulation, extracted from handlers)
# ---------------------------------------------------------------------------
class TestURLRewriting:
"""Test the URL rewriting logic used by /json/version and /json/list."""
def _rewrite_version(self, orig_ws: str, host: str, seed: str | None, scheme: str = "ws") -> str:
"""Replicate the URL rewrite logic from handle_json_version."""
if seed:
ws_path = f"fingerprint/{seed}/devtools/browser"
else:
ws_path = "devtools/browser"
guid = orig_ws.rsplit("/", 1)[-1] if "/devtools/" in orig_ws else ""
return f"{scheme}://{host}/{ws_path}/{guid}"
def _rewrite_list_entry(self, orig_ws: str, host: str, seed: str | None, scheme: str = "ws") -> str:
"""Replicate the URL rewrite logic from handle_json_list."""
ws_tail = orig_ws.split("/devtools/")[-1]
if seed:
return f"{scheme}://{host}/fingerprint/{seed}/devtools/{ws_tail}"
else:
return f"{scheme}://{host}/devtools/{ws_tail}"
def test_version_rewrite_with_seed(self):
orig = "ws://127.0.0.1:5100/devtools/browser/abc-123"
result = self._rewrite_version(orig, "container:9222", "12345")
assert result == "ws://container:9222/fingerprint/12345/devtools/browser/abc-123"
def test_version_rewrite_no_seed(self):
orig = "ws://127.0.0.1:5100/devtools/browser/abc-123"
result = self._rewrite_version(orig, "container:9222", None)
assert result == "ws://container:9222/devtools/browser/abc-123"
def test_list_rewrite_page_with_seed(self):
orig = "ws://127.0.0.1:5100/devtools/page/DEF-456"
result = self._rewrite_list_entry(orig, "host:9222", "99")
assert result == "ws://host:9222/fingerprint/99/devtools/page/DEF-456"
def test_list_rewrite_page_no_seed(self):
orig = "ws://127.0.0.1:5100/devtools/page/DEF-456"
result = self._rewrite_list_entry(orig, "host:9222", None)
assert result == "ws://host:9222/devtools/page/DEF-456"
def test_list_rewrite_browser(self):
orig = "ws://127.0.0.1:5100/devtools/browser/XYZ"
result = self._rewrite_list_entry(orig, "host:9222", "seed1")
assert result == "ws://host:9222/fingerprint/seed1/devtools/browser/XYZ"
def test_wss_scheme_version(self):
orig = "ws://127.0.0.1:5100/devtools/browser/abc-123"
result = self._rewrite_version(orig, "host:443", "seed1", scheme="wss")
assert result == "wss://host:443/fingerprint/seed1/devtools/browser/abc-123"
def test_wss_scheme_list(self):
orig = "ws://127.0.0.1:5100/devtools/page/DEF-456"
result = self._rewrite_list_entry(orig, "host:443", "seed1", scheme="wss")
assert result == "wss://host:443/fingerprint/seed1/devtools/page/DEF-456"
# ---------------------------------------------------------------------------
# Connection refcounting
# ---------------------------------------------------------------------------
class TestConnectionTracking:
"""Test ChromePool.connect() / disconnect() without real Chrome."""
def _make_pool(self):
return ChromePool(
binary="/fake/chrome",
global_args=[],
headless=True,
data_dir="/tmp/test-cloakserve",
)
def test_connect_increments(self):
pool = self._make_pool()
pool.connect("seed1")
assert pool._connections["seed1"] == 1
pool.connect("seed1")
assert pool._connections["seed1"] == 2
def test_disconnect_decrements(self):
pool = self._make_pool()
pool.connect("seed1")
pool.connect("seed1")
pool.disconnect("seed1")
assert pool._connections["seed1"] == 1
def test_disconnect_to_zero_removes_key(self):
pool = self._make_pool()
pool.connect("seed1")
pool.disconnect("seed1")
assert "seed1" not in pool._connections
def test_disconnect_below_zero_safe(self):
pool = self._make_pool()
pool.disconnect("nonexistent")
assert "nonexistent" not in pool._connections
def test_multiple_seeds_independent(self):
pool = self._make_pool()
pool.connect("a")
pool.connect("b")
pool.connect("a")
pool.disconnect("a")
assert pool._connections["a"] == 1
assert pool._connections["b"] == 1
+8 -8
View File
@@ -4,7 +4,7 @@ from unittest.mock import patch
import pytest
from cloakbrowser.browser import _maybe_resolve_geoip
from cloakbrowser.browser import maybe_resolve_geoip
from cloakbrowser.geoip import (
COUNTRY_LOCALE_MAP,
_is_private_ip,
@@ -92,25 +92,25 @@ def test_resolve_geo_returns_none_when_db_missing():
# ---------------------------------------------------------------------------
# _maybe_resolve_geoip (browser.py helper)
# maybe_resolve_geoip (browser.py helper)
# ---------------------------------------------------------------------------
def test_maybe_resolve_skips_when_geoip_false():
tz, loc = _maybe_resolve_geoip(False, "http://proxy:8080", None, None)
tz, loc = maybe_resolve_geoip(False, "http://proxy:8080", None, None)
assert tz is None
assert loc is None
def test_maybe_resolve_skips_when_no_proxy():
tz, loc = _maybe_resolve_geoip(True, None, None, None)
tz, loc = maybe_resolve_geoip(True, None, None, None)
assert tz is None
assert loc is None
def test_maybe_resolve_skips_when_both_explicit():
"""Explicit values should not trigger geoip resolution."""
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", "de-DE")
tz, loc = maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", "de-DE")
assert tz == "Europe/Berlin"
assert loc == "de-DE"
@@ -118,7 +118,7 @@ def test_maybe_resolve_skips_when_both_explicit():
def test_maybe_resolve_fills_missing_timezone():
"""When only locale is explicit, geoip should fill timezone."""
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")):
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", None, "fr-FR")
tz, loc = maybe_resolve_geoip(True, "http://proxy:8080", None, "fr-FR")
assert tz == "America/New_York"
assert loc == "fr-FR" # Explicit wins
@@ -126,7 +126,7 @@ def test_maybe_resolve_fills_missing_timezone():
def test_maybe_resolve_fills_missing_locale():
"""When only timezone is explicit, geoip should fill locale."""
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")):
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", "Asia/Tokyo", None)
tz, loc = maybe_resolve_geoip(True, "http://proxy:8080", "Asia/Tokyo", None)
assert tz == "Asia/Tokyo" # Explicit wins
assert loc == "en-US"
@@ -134,7 +134,7 @@ def test_maybe_resolve_fills_missing_locale():
def test_maybe_resolve_fills_both():
"""When neither is set, geoip should fill both."""
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Europe/Berlin", "de-DE")):
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", None, None)
tz, loc = maybe_resolve_geoip(True, "http://proxy:8080", None, None)
assert tz == "Europe/Berlin"
assert loc == "de-DE"
+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);
+696
View File
@@ -0,0 +1,696 @@
"""
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"
# =========================================================================
# 11. Non-ASCII keyboard input
# =========================================================================
class TestNonAsciiKeyboard:
def test_cyrillic_uses_insert_text(self):
from cloakbrowser.human.keyboard import human_type
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", {"mistype_chance": 0})
page = MagicMock()
raw = MagicMock()
down_keys = []
inserted = []
raw.down = MagicMock(side_effect=lambda k: down_keys.append(k))
raw.up = MagicMock()
raw.insert_text = MagicMock(side_effect=lambda t: inserted.append(t))
human_type(page, raw, "Привет", cfg)
assert "".join(inserted) == "Привет"
for k in down_keys:
assert ord(k[0]) < 128 or k in ("Shift", "Backspace")
def test_mixed_ascii_cyrillic(self):
from cloakbrowser.human.keyboard import human_type
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", {"mistype_chance": 0})
page = MagicMock()
raw = MagicMock()
down_keys = []
inserted = []
raw.down = MagicMock(side_effect=lambda k: down_keys.append(k))
raw.up = MagicMock()
raw.insert_text = MagicMock(side_effect=lambda t: inserted.append(t))
human_type(page, raw, "Hi Мир", cfg)
assert "H" in down_keys
assert "i" in down_keys
assert "М" in "".join(inserted)
def test_cjk_uses_insert_text(self):
from cloakbrowser.human.keyboard import human_type
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", {"mistype_chance": 0})
page = MagicMock()
raw = MagicMock()
inserted = []
raw.down = MagicMock()
raw.up = MagicMock()
raw.insert_text = MagicMock(side_effect=lambda t: inserted.append(t))
human_type(page, raw, "你好", cfg)
assert "".join(inserted) == "你好"
def test_mistype_only_ascii(self):
from cloakbrowser.human.keyboard import human_type
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", {"mistype_chance": 1.0})
page = MagicMock()
raw = MagicMock()
down_keys = []
raw.down = MagicMock(side_effect=lambda k: down_keys.append(k))
raw.up = MagicMock()
raw.insert_text = MagicMock()
human_type(page, raw, "", cfg)
assert "Backspace" in down_keys
def test_no_error_on_cyrillic(self):
from cloakbrowser.human.keyboard import human_type
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", {"mistype_chance": 0})
page = MagicMock()
raw = MagicMock()
raw.down = MagicMock()
raw.up = MagicMock()
raw.insert_text = MagicMock()
# Should not raise
human_type(page, raw, "Тест кириллицы", cfg)
class TestNonAsciiKeyboardAsync:
@pytest.mark.asyncio
async def test_async_cyrillic_uses_insert_text(self):
from cloakbrowser.human.keyboard_async import async_human_type
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock, AsyncMock
cfg = resolve_config("default", {"mistype_chance": 0})
page = MagicMock()
raw = MagicMock()
inserted = []
raw.down = AsyncMock()
raw.up = AsyncMock()
raw.insert_text = AsyncMock(side_effect=lambda t: inserted.append(t))
await async_human_type(page, raw, "Привет", cfg)
assert "".join(inserted) == "Привет"
# =========================================================================
# 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"]))
+25 -29
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")
@@ -115,44 +114,41 @@ def test_color_scheme(mock_launch, _mock_bin):
assert ctx_kwargs[1]["color_scheme"] == "dark"
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE"))
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE"))
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_geoip_resolution(mock_launch, _mock_bin, _mock_geoip):
"""geoip fills timezone+locale, both flow to correct places."""
"""geoip fills timezone+locale, both flow to binary args only."""
browser, context = _make_mock_browser()
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
launch_context(proxy="http://proxy:8080", geoip=True)
# Locale goes to launch() for binary flag
# Both go to launch() for binary flags
assert mock_launch.call_args[1]["locale"] == "de-DE"
# Timezone goes to context, not binary
assert mock_launch.call_args[1]["timezone"] is None
assert mock_launch.call_args[1]["timezone"] == "Europe/Berlin"
# Neither in context — no CDP emulation
ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["timezone_id"] == "Europe/Berlin"
assert ctx_kwargs[1]["locale"] == "de-DE"
assert "timezone_id" not in ctx_kwargs[1]
assert "locale" not in ctx_kwargs[1]
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_timezone_id_deprecation(mock_launch, _mock_bin):
"""timezone_id kwarg triggers FutureWarning, value migrated to timezone."""
def test_timezone_id_alias(mock_launch, _mock_bin):
"""timezone_id kwarg accepted as alias for timezone."""
browser, context = _make_mock_browser()
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
launch_context(timezone_id="Europe/Paris")
launch_context(timezone_id="Europe/Paris")
assert len(w) == 1
assert issubclass(w[0].category, FutureWarning)
assert "timezone_id" in str(w[0].message)
# Migrated value flows to context
# Resolved value flows to launch() for binary flag
assert mock_launch.call_args[1]["timezone"] == "Europe/Paris"
# NOT in context — no CDP emulation
ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["timezone_id"] == "Europe/Paris"
assert "timezone_id" not in ctx_kwargs[1]
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
+33 -36
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
@@ -27,7 +26,7 @@ def _make_mock_pw_and_context():
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_args_built(_mock_geoip, _mock_bin):
"""Stealth args + extra args combined correctly."""
pw_cm, pw, context = _make_mock_pw_and_context()
@@ -43,7 +42,7 @@ def test_persistent_context_args_built(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_default_viewport(_mock_geoip, _mock_bin):
"""DEFAULT_VIEWPORT applied when no viewport given."""
pw_cm, pw, context = _make_mock_pw_and_context()
@@ -57,7 +56,7 @@ def test_persistent_context_default_viewport(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin):
"""Custom viewport overrides DEFAULT_VIEWPORT."""
pw_cm, pw, context = _make_mock_pw_and_context()
@@ -72,7 +71,7 @@ def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_user_agent(_mock_geoip, _mock_bin):
"""user_agent forwarded to launch_persistent_context()."""
pw_cm, pw, context = _make_mock_pw_and_context()
@@ -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,16 +94,16 @@ 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")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_color_scheme(_mock_geoip, _mock_bin):
"""color_scheme forwarded correctly."""
pw_cm, pw, context = _make_mock_pw_and_context()
@@ -117,10 +116,10 @@ def test_persistent_context_color_scheme(_mock_geoip, _mock_bin):
assert call_kwargs["color_scheme"] == "dark"
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE"))
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE"))
@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,29 +127,30 @@ def test_persistent_context_geoip(_mock_bin, _mock_geoip):
launch_persistent_context("/tmp/profile", proxy="http://proxy:8080", geoip=True)
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["timezone_id"] == "Europe/Berlin"
assert call_kwargs["locale"] == "de-DE"
# Binary args
assert "--fingerprint-timezone=Europe/Berlin" in call_kwargs["args"]
assert "--lang=de-DE" in call_kwargs["args"]
# NOT in context kwargs
assert "timezone_id" not in call_kwargs
assert "locale" not in call_kwargs
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
def test_persistent_context_timezone_id_deprecation(_mock_bin):
"""Old timezone_id kwarg migrated with warning."""
def test_persistent_context_timezone_id_alias(_mock_bin):
"""timezone_id kwarg accepted as alias for timezone."""
pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
launch_persistent_context("/tmp/profile", timezone_id="Europe/Paris")
launch_persistent_context("/tmp/profile", timezone_id="Europe/Paris")
assert len(w) == 1
assert issubclass(w[0].category, FutureWarning)
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["timezone_id"] == "Europe/Paris"
assert "--fingerprint-timezone=Europe/Paris" in call_kwargs["args"]
assert "timezone_id" not in call_kwargs
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin):
"""context.close() also calls pw.stop()."""
pw_cm, pw, context = _make_mock_pw_and_context()
@@ -166,7 +166,7 @@ def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_proxy_string(_mock_geoip, _mock_bin):
"""Proxy string parsed and passed."""
pw_cm, pw, context = _make_mock_pw_and_context()
@@ -182,7 +182,7 @@ def test_persistent_context_proxy_string(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_proxy_dict(_mock_geoip, _mock_bin):
"""Proxy dict passed through."""
pw_cm, pw, context = _make_mock_pw_and_context()
@@ -213,7 +213,7 @@ def _make_mock_async_pw_and_context():
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
async def test_persistent_context_async_args_built(_mock_geoip, _mock_bin):
"""Async launch builds args correctly."""
pw_cm, pw, context = _make_mock_async_pw_and_context()
@@ -229,7 +229,7 @@ async def test_persistent_context_async_args_built(_mock_geoip, _mock_bin):
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
async def test_persistent_context_async_close_stops_pw(_mock_geoip, _mock_bin):
"""await context.close() calls await pw.stop()."""
pw_cm, pw, context = _make_mock_async_pw_and_context()
@@ -246,17 +246,14 @@ async def test_persistent_context_async_close_stops_pw(_mock_geoip, _mock_bin):
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
async def test_persistent_context_async_timezone_id_deprecation(_mock_bin):
"""Deprecated timezone_id kwarg migrated with warning in async path."""
async def test_persistent_context_async_timezone_id_alias(_mock_bin):
"""timezone_id kwarg accepted as alias in async path."""
pw_cm, pw, context = _make_mock_async_pw_and_context()
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context_async
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
await launch_persistent_context_async("/tmp/profile", timezone_id="Europe/Paris")
await launch_persistent_context_async("/tmp/profile", timezone_id="Europe/Paris")
assert len(w) == 1
assert issubclass(w[0].category, FutureWarning)
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["timezone_id"] == "Europe/Paris"
assert "--fingerprint-timezone=Europe/Paris" in call_kwargs["args"]
assert "timezone_id" not in call_kwargs
+59 -6
View File
@@ -2,7 +2,7 @@
from unittest.mock import patch
from cloakbrowser.browser import _build_proxy_kwargs, _maybe_resolve_geoip, _parse_proxy_url
from cloakbrowser.browser import _build_proxy_kwargs, maybe_resolve_geoip, _parse_proxy_url
class TestParseProxyUrl:
@@ -70,7 +70,7 @@ class TestBuildProxyKwargs:
class TestMaybeResolveGeoip:
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
def test_geoip_with_string_proxy(self, mock_geo):
tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", None, None)
tz, locale = maybe_resolve_geoip(True, "http://proxy:8080", None, None)
mock_geo.assert_called_once_with("http://proxy:8080")
assert tz == "America/New_York"
assert locale == "en-US"
@@ -78,23 +78,76 @@ class TestMaybeResolveGeoip:
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Europe/London", "en-GB"))
def test_geoip_with_dict_proxy_extracts_server(self, mock_geo):
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"}
tz, locale = _maybe_resolve_geoip(True, proxy_dict, None, None)
tz, locale = maybe_resolve_geoip(True, proxy_dict, None, None)
mock_geo.assert_called_once_with("http://proxy:8080")
assert tz == "Europe/London"
assert locale == "en-GB"
def test_geoip_disabled_skips_resolution(self):
tz, locale = _maybe_resolve_geoip(False, "http://proxy:8080", None, None)
tz, locale = maybe_resolve_geoip(False, "http://proxy:8080", None, None)
assert tz is None
assert locale is None
def test_geoip_no_proxy_skips_resolution(self):
tz, locale = _maybe_resolve_geoip(True, None, None, None)
tz, locale = maybe_resolve_geoip(True, None, None, None)
assert tz is None
assert locale is None
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Asia/Tokyo", "ja-JP"))
def test_geoip_preserves_explicit_timezone(self, mock_geo):
tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None)
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"]
+74
View File
@@ -20,6 +20,8 @@ from cloakbrowser.config import (
)
from cloakbrowser.download import (
_check_wrapper_update,
_download_and_extract,
_fetch_checksums,
_get_latest_chromium_version,
_parse_checksums,
_should_check_for_update,
@@ -474,3 +476,75 @@ class TestWriteVersionMarker:
marker = tmp_path / f"latest_version_{get_platform_tag()}"
assert marker.exists()
assert marker.read_text() == "999.0.0.0"
class TestDownloadFallback:
"""Verify primary server (cloakbrowser.dev) → GitHub Releases fallback on HTTP errors."""
def test_binary_download_falls_back_on_http_error(self, tmp_path):
"""HTTP error from primary triggers GitHub Releases fallback for binary download."""
with patch.dict(os.environ, {
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
"CLOAKBROWSER_DOWNLOAD_URL": "",
"CLOAKBROWSER_SKIP_CHECKSUM": "true",
}):
urls_called = []
def mock_download_file(url, dest):
urls_called.append(url)
if "cloakbrowser.dev" in url:
raise Exception("HTTP 429 Too Many Requests")
# GitHub fallback succeeds
dest.write_bytes(b"fake")
with patch("cloakbrowser.download._download_file", side_effect=mock_download_file), \
patch("cloakbrowser.download._extract_archive"), \
patch("cloakbrowser.download._show_welcome"):
_download_and_extract()
assert len(urls_called) == 2
assert "cloakbrowser.dev" in urls_called[0]
assert "github.com" in urls_called[1]
def test_binary_download_no_fallback_with_custom_url(self, tmp_path):
"""Custom CLOAKBROWSER_DOWNLOAD_URL disables GitHub fallback — error propagates."""
with patch.dict(os.environ, {
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
"CLOAKBROWSER_DOWNLOAD_URL": "https://my-mirror.com/releases",
"CLOAKBROWSER_SKIP_CHECKSUM": "true",
}):
with patch("cloakbrowser.download._download_file", side_effect=Exception("503")):
with pytest.raises(Exception, match="503"):
_download_and_extract()
def test_checksum_fetch_falls_back_on_http_error(self):
"""HTTP error from primary checksum URL triggers GitHub fallback."""
valid_checksums = (
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
" cloakbrowser-linux-x64.tar.gz\n"
)
def mock_get(url, **kwargs):
resp = MagicMock()
if "cloakbrowser.dev" in url:
resp.raise_for_status.side_effect = Exception("HTTP 429")
return resp
# GitHub URL succeeds
resp.text = valid_checksums
resp.raise_for_status = MagicMock()
return resp
with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}):
with patch("cloakbrowser.download.httpx.get", side_effect=mock_get):
result = _fetch_checksums()
assert result is not None
assert "cloakbrowser-linux-x64.tar.gz" in result
def test_checksum_fetch_returns_none_when_both_fail(self):
"""Both primary and GitHub checksum URLs fail → returns None (skip verification)."""
with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}):
with patch("cloakbrowser.download.httpx.get", side_effect=Exception("network error")):
result = _fetch_checksums()
assert result is None