Compare commits

...
Author SHA1 Message Date
CloakHQ 4459f66593 release: v0.3.25 — Chromium 146.0.7680.177.3, launch_context_async, contextOptions 2026-04-16 22:24:36 +02:00
CloakHQ ce8b92ba4f feat: add launch_context_async() + JS contextOptions escape hatch (#141)
Python: add async counterpart to launch_context(). Forwards all kwargs to
browser.new_context() — enables storage_state, permissions, extra_http_headers,
etc. without needing a persistent profile folder.

JS: launchContext() and launchPersistentContext() silently dropped unknown
options. New contextOptions field in LaunchContextOptions is spread into
newContext() to forward arbitrary Playwright context options (e.g.
storageState, permissions, geolocation).
2026-04-16 21:30:40 +02:00
CloakHQ 4e1027847e fix: bump CHROMIUM_VERSION display constant to .2 (#157) 2026-04-15 18:20:01 +02:00
EternalandGitHub f164c1c874 fix(types): type humanConfig properly (#151) 2026-04-12 22:58:48 +02:00
lilosandGitHub f5e242a160 Update CHANGELOG for version 0.3.24 (#139)
Wrong username :(
2026-04-11 01:03:29 +02:00
CloakHQ 935beef980 docs: add recommended anti-bot config and SOCKS5 tips to troubleshooting
Based on recurring GitHub issue patterns (#117, #78, #130, #131).
2026-04-10 23:47:42 +02:00
CloakHQ c6d3469e4c release: v0.3.24 — SOCKS5 proxy support, arm64 146 upgrade, ElementHandle humanize 2026-04-10 22:42:29 +02:00
CloakHQ cb0b87873e feat: native SOCKS5 proxy support in proxy= parameter
Route SOCKS5/SOCKS5h proxies via --proxy-server Chrome arg instead of
Playwright's proxy dict (which rejects SOCKS5 with credentials).
Handles string URLs, Playwright dicts, IPv6, bypass lists.

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

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

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

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

Co-authored-by: kitiho <51785099+kitiho@users.noreply.github.com>
2026-04-07 05:14:01 +02:00
kitihoandGitHub 8eb2e4b905 fix: allow null viewport to disable viewport emulation (#107)
fix: allow null viewport to disable viewport emulation
2026-04-07 05:11:44 +02:00
CloakHQ 216a7d6a6a fix(examples): enable geoip in stealth test to fix FingerprintJS detection 2026-04-06 02:38:26 +02:00
CloakHQ 02359f69c8 ci: remove npm self-upgrade step — Node 22 ships with compatible npm 2026-04-06 02:08:44 +02:00
CloakHQ a0c7704c4b release: v0.3.20 — 48 patches, WebRTC IP spoofing, proxy signal removal 2026-04-06 01:54:35 +02:00
lilos ccda93669e feat(humanize): implement CDP Isolated Worlds and trusted keyboard events (fixes #110) 2026-04-06 01:43:46 +02:00
CloakHQ eb4efef329 feat: add --fingerprint-webrtc-ip flag with auto-resolve support
Two ways to spoof WebRTC ICE candidate IPs:

1. --fingerprint-webrtc-ip=auto in args: resolves proxy exit IP via
   HTTP call through the proxy (ipify.org). No extra deps needed.

2. geoip=True: auto-injects the flag for free (exit IP already
   resolved during timezone/locale lookup, zero extra network cost).

Explicit IP (--fingerprint-webrtc-ip=1.2.3.4) also supported.
User-provided values always take precedence.

Python + JS wrappers, README docs, tests.
2026-04-06 01:16:10 +02:00
CloakHQ 25d34dcea3 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
CloakHQ c9e4f58353 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
CloakHQ 724d49f65b test: add wrapper regression tests for issues #9, #27
docs: add Docker Compose with healthcheck, persistent profiles
via volume mount, and resource usage numbers to README
2026-03-06 07:37:19 +01:00
CloakHQ ed79560e5f feat: add cloakserve CDP server mode for Docker
Add bin/cloakserve — launches stealth Chromium with remote debugging
enabled so users can connect via connect_over_cdp() from the host.
Uses socat to forward 0.0.0.0:9222 to Chrome's localhost-only CDP port.

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

Tested: all stealth checks pass via CDP, bot.sannysoft.com 54/54,
reCAPTCHA 0.9, zero detection regressions.
2026-03-06 05:20:40 +01:00
CloakHQ 829e4b881f ci: add unit test workflow for Python and JavaScript
Runs pytest and vitest on every push/PR to main.
Only unit tests — no binary required.
2026-03-06 05:13:07 +01:00
Cloak-HQ 3880d30d0f fix: deduplicate CLI flags when user args overlap with stealth defaults
Bump version to 0.3.9. Extract shared buildArgs into js/src/args.ts (DRY),
guard console.debug behind DEBUG=cloakbrowser env var, strengthen caplog assertion.
2026-03-05 18:44:18 +01:00
Cloak-HQ 9c533e4120 feat: upgrade Chromium base to 145.0.7632.159 (Linux x64)
- Bump linux-x64 binary to 145.0.7632.159 (macOS/Windows stay at 145.0.7632.109.2)
- Wrapper version 0.3.8
- Fix rollback path examples to use correct per-platform versions
2026-03-05 18:44:17 +01:00
Cloak-HQ 98c216f07e feat: make patchright optional, default to stock playwright 2026-03-05 18:44:17 +01:00
Cloak-HQandGitHub ee953709b0 Merge pull request #29 from evelaa123/fix/python-download-timeout
fix(python): reduce download connect timeout to 10s, read to 60s for …
2026-03-05 12:08:04 +01:00
lilos a45fdc4d7e fix(python): reduce download connect timeout to 10s, read to 60s for faster fallback 2026-03-05 13:48:05 +03:00
Cloak-HQ e411f24cf3 feat: first-launch welcome message for all install methods
Show welcome banner once per install (Python, JS, Docker).
Uses marker file in ~/.cloakbrowser/ — resets on cache clear or update.
Replaces logger.info() calls that were invisible by default.

Bump to v0.3.8.
2026-03-05 07:49:08 +01:00
Cloak-HQ 0a99a1458a docs: update troubleshooting — persistent profiles for sites that challenge fresh sessions 2026-03-05 07:25:49 +01:00
Cloak-HQ f76dbdb044 feat: Docker Hub image, cloaktest CLI, and example UX improvements
Add cloakhq/cloakbrowser Docker Hub image with Node.js, JS wrapper,
Xvfb headed mode, and cloaktest shortcut. Add launch feedback and IP
display to all examples. Update README Docker section for Docker Hub.
2026-03-05 05:09:29 +01:00
Cloak-HQ 976f5ae534 docs: streamline READMEs for launch — remove repetition, reorder for conversion
- Hero: remove emojis, cut weak bullets, add auto-updating/free+OSS
- Latest: rename to v0.3.5 (Chromium 145), swap weaker items for CDP/audit/persistent
- Why: remove unverified AI agent claims, cut redundant lines
- Test Results: 30/30 → tested against 30+ detection sites
- Comparison: move up after proof images, Camoufox "Unstable"
- Fingerprint flags: collapse into <details> block
- Platforms: move up before Docker
- Headed Mode: merge into Troubleshooting
- Roadmap: move down after FAQ
- FAQ legal: rewrite to "do not condone illegal use"
- Add rollback instructions via CLOAKBROWSER_BINARY_PATH
- Examples: update descriptions, remove persistent-context.ts
- js/README.md: sync hero, platforms, test table, reCAPTCHA tips
2026-03-05 03:54:10 +01:00
Cloak-HQ 0719f750ef test: add comprehensive unit tests for all public APIs
Python (75 new tests):
- launch_context(): viewport, timezone bypass, geoip, close cleanup, error cleanup
- launch_persistent_context(): sync + async, args, proxy, close/pw.stop()
- config: binary paths, archive names, cache dir, stealth args profiles
- extract: tar/zip with path traversal protection, .app bundle preservation
- ensure_binary(), clear_cache(), check_for_update(), version markers
- geoip: private IP detection

JavaScript (26 new tests):
- puppeteer wrapper: stealth args, proxy string/dict, auth monkey-patch
- launchContext/launchPersistentContext: viewport, timezone, proxy, close
- ensureBinary, clearCache, checkForUpdate, archive helpers

Total: 169 Python + 88 JS tests (was 59 + 47)
2026-03-05 03:02:46 +01:00
Cloak-HQ 05fa1a052a refactor: unify timezone parameter naming across Python and JS wrappers
- Rename timezone_id → timezone in launch_context(), launch_persistent_context(),
  and launch_persistent_context_async() (Python)
- Extract _migrate_timezone_id() helper for deprecation compat (DRY)
- Always pop timezone_id from kwargs to prevent override via context_kwargs.update()
- Use FutureWarning (visible by default) instead of DeprecationWarning
- JS: deprecate timezoneId on LaunchContextOptions with runtime shim
- Extract migrateTimezoneId<T>() shared helper in playwright.ts (DRY)
- Bump version to 0.3.7 in _version.py and package.json
- Add 4 Python + 4 JS unit tests for deprecation compat behavior
2026-03-05 02:50:55 +01:00
Cloak-HQ 25acff23b7 docs: strengthen binary license — liability cap, cloud/CI use, acceptable use
Add Cloud/Container/Integration Use section clarifying internal Docker/CI
is permitted, dependency listing is not redistribution, OEM/SaaS requires
separate license. Add Limitation of Liability ($100 cap). Add prohibited
use cases (banking, credential stuffing, fraud). Clarify that flags,
extensions, and custom profiles are permitted configuration. Update README
and js/README with prohibition language and license link.
2026-03-04 22:40:25 +01:00
Cloak-HQ bd22e51bc2 feat: support proxy dict with bypass field (#24)
The `proxy` parameter now accepts a Playwright proxy dict
({server, bypass, username, password}) in addition to URL strings.
Dict proxies are passed directly to Playwright, enabling bypass
lists and other advanced proxy options.

- Add ProxySettings TypedDict for Python type safety
- Extract server URL from dict proxies for geoip resolution
- Handle dict proxy args/auth in Puppeteer wrapper
- Strip inline credentials from dict proxy server URL in Puppeteer
- Fix JS launchContext() double-setting timezone (binary flag + context)
- Remove unnecessary non-null assertions in TS geoip helpers
- Use nullish coalescing for password fallbacks
- Add unit tests for geoip with dict proxy input
2026-03-04 21:16:35 +01:00
CloakHQ ca5cce2222 release: v0.3.5 — persistent context, Windows zip fix, community PRs
- Add launch_persistent_context() Python + JS with examples
- Document persistent context API in both READMEs
- Bump version to 0.3.5
- Credit @evelaa123 and @yahooguntu in CHANGELOG
2026-03-04 19:23:16 +01:00
Cloak-HQandGitHub de54e67f74 Merge pull request #22 from evelaa123/feat/launch-persistent-context
feat: add launchPersistentContext() to avoid incognito detection
2026-03-04 19:10:23 +01:00
Cloak-HQandGitHub ef066fa091 Merge pull request #23 from evelaa123/fix/windows-zip-extraction
fix(windows): zip extraction fails when primary download server is down
2026-03-04 18:34:25 +01:00
lilos 5237065385 fix(windows): destroy fileStream on failed download to prevent zip lock 2026-03-04 13:38:09 +03:00
lilos 8e83b8c399 fix: LaunchPersistentContextOptions extends LaunchContextOptions 2026-03-04 12:31:33 +03:00
lilos c44b04a953 feat: add launch_persistent_context + async variant (Python), fix import os at module level 2026-03-04 12:19:56 +03:00
lilos 51c3f464a5 feat: add launchPersistentContext() to avoid incognito detection 2026-03-04 12:00:49 +03:00
CloakHQ ed0ecf0e48 docs: add macOS fingerprint profile troubleshooting note 2026-03-04 03:51:05 +01:00
CloakHQ 46049a15d3 feat: Windows .zip download support, binary v145.0.7632.109.2
- Add get_archive_ext() / get_archive_name() for platform-aware archive format (.zip on Windows, .tar.gz elsewhere)
- Add _extract_zip() / extractZip() with path traversal protection
- Python: zipfile module extraction
- JS: PowerShell Expand-Archive on Windows, system unzip on others
- Bump all platform versions to 145.0.7632.109.2 (4 platforms: linux-x64, darwin-arm64, darwin-x64, windows-x64)
- Update checksum lookup, temp file naming, and auto-update asset matching to use archive helpers
2026-03-04 01:23:07 +01:00
CloakHQ 11b3bcb701 release: v0.3.4 — 26 patches, auto-spoof, timezone fix, README refresh 2026-03-04 01:11:50 +01:00
CloakHQ 28de7bb147 refactor: simplify stealth args — rely on binary auto-generation (v14+)
Binary v14+ auto-generates hardware concurrency, device memory, screen
dimensions, and window size from the fingerprint seed. Remove these
explicit flags from Python/JS wrapper defaults and update README:

- Remove 5 flags from get_default_stealth_args() in both wrappers
- Move hardware-concurrency, device-memory, screen-width, screen-height
  to the Additional Flags table with auto-generated defaults documented
- Update code examples to use --fingerprint instead of --window-size
- Simplify fingerprint defaults table to show only wrapper-set flags
2026-03-03 21:27:01 +01:00
CloakHQ 0c64a32122 release: v0.3.3 — Windows x64, macOS v145, auto-spoof docs
Bump wrapper to 0.3.3. Update README fingerprint section to
document auto-spoof behavior (zero-config stealth). Improve
reCAPTCHA test with wait_for_selector instead of blind sleep.
2026-03-03 20:05:16 +01:00
CloakHQ f9887943c0 feat: add Windows x64 support, update macOS to v145 2026-03-03 08:57:07 +01:00
CloakHQ 55418add96 feat: macOS v145 wrapper prep — GPU flags, version bump, README update
- Add explicit Mac GPU flags (Apple M3 Metal renderer) to stealth args
- Bump macOS platform versions to 145.0.7632.109
- Update README fingerprint table to reflect actual Mac GPU defaults
- Add warning about binary requiring explicit flags without wrapper
- Fix stealth_test.py wait_until for reCAPTCHA page
2026-03-03 08:57:07 +01:00
96 changed files with 21616 additions and 597 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
+34
View File
@@ -0,0 +1,34 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
python:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -e ".[dev]" pytest pytest-asyncio
- name: Run tests
run: pytest tests/ -v -m "not slow"
javascript:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 20
- name: Install and build
run: cd js && npm install && npm run build
- name: Typecheck
run: cd js && npm run typecheck
- name: Run tests
run: cd js && npm test
+134
View File
@@ -0,0 +1,134 @@
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: 24 # npm 11.11.0 native — no upgrade needed (Node 22.22.2 has broken npm)
registry-url: 'https://registry.npmjs.org'
- 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`
+11
View File
@@ -56,8 +56,19 @@ 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
# Beads / Dolt files (added by bd init)
.dolt/
*.db
.beads-credential-key
+22 -3
View File
@@ -26,13 +26,21 @@ You may NOT:
4. **Modify** the Binary or create derivative works based on it
5. **Remove or alter** any copyright notices, license files, or attribution included with the Binary
Listing CloakBrowser as a dependency in your project (e.g., in `requirements.txt`, `package.json`, or documentation) is not redistribution — end users download the Binary directly from official CloakHQ channels.
Normal use of the Binary with command-line flags, browser extensions, managed policies, custom profiles, or user data directories does not constitute modification or creation of derivative works.
Internal caching or mirroring (including via artifact repositories such as Artifactory or Nexus) of unmodified Binaries that were originally obtained from official CloakHQ distribution channels is permitted solely for internal operational purposes within your organization. This permission does not allow public redistribution or distribution to third parties.
## Cloud, Container & Integration Use
**Internal use** — You may store and run the unmodified Binary within internal infrastructure, including Docker images, VM templates, CI runners, container registries, and artifact repositories (e.g., Artifactory, Nexus), solely for your organization's internal operational purposes.
**Dependency listing** — Listing CloakBrowser as a dependency in your project or third-party framework (e.g., in `requirements.txt`, `package.json`, or documentation) is not redistribution, as end users download the Binary directly from official CloakHQ channels. No commercial license is required for this.
**Using CloakBrowser for your own business is free** — no license beyond this one is needed, regardless of company size or revenue.
**OEM/SaaS license required** — Bundling, embedding, or pre-installing the Binary into a product, hosted service, or cloud artifact distributed to third parties requires a separate OEM license. This includes running the Binary on your infrastructure to serve third-party customers (e.g., browser-as-a-service). Contact cloakhq@pm.me for OEM/SaaS licensing.
## Official Distribution
The Binary must originally be obtained from official CloakHQ distribution channels, including GitHub Releases (github.com/CloakHQ/CloakBrowser) and cloakbrowser.dev. Internal organizational mirrors permitted under the Restrictions section are not considered unauthorized sources.
The Binary must originally be obtained from official CloakHQ distribution channels, including GitHub Releases (github.com/CloakHQ/CloakBrowser) and cloakbrowser.dev. Internal organizational mirrors permitted under the Cloud, Container & Integration Use section are not considered unauthorized sources.
## Trademark Notice
@@ -46,6 +54,13 @@ Attribution is appreciated but not required. If you'd like to credit CloakBrowse
You are solely responsible for how you use the Binary. You agree NOT to use the Binary for any activity that violates applicable laws or regulations in your jurisdiction. CloakHQ does not endorse, encourage, or support any illegal use.
Without limiting the above, the following uses are expressly prohibited:
- Unauthorized access to financial, banking, healthcare, or government authentication systems
- Credential stuffing, brute-force login attempts, or automated account creation
- Circumventing authentication on systems you do not own or have authorization to test
- Any activity that constitutes fraud, identity theft, or unauthorized data collection
## Indemnification
You agree to indemnify and hold harmless CloakHQ and its contributors from any claims, damages, losses, liabilities, and expenses (including reasonable legal fees) arising from your unlawful use of the Binary or your violation of this license.
@@ -54,6 +69,10 @@ You agree to indemnify and hold harmless CloakHQ and its contributors from any c
THE BINARY IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE BINARY OR THE USE OR OTHER DEALINGS IN THE BINARY.
## Limitation of Liability
IN NO EVENT SHALL CLOAKHQ OR ITS CONTRIBUTORS BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, INCLUDING BUT NOT LIMITED TO LOSS OF PROFITS, DATA, BUSINESS OPPORTUNITIES, OR GOODWILL, ARISING OUT OF OR IN CONNECTION WITH THE USE OF THE BINARY, REGARDLESS OF THE THEORY OF LIABILITY. CLOAKHQ'S TOTAL AGGREGATE LIABILITY SHALL NOT EXCEED ONE HUNDRED US DOLLARS (US $100).
## Data Collection
CloakHQ does not intentionally include telemetry, analytics, or tracking mechanisms in the Binary. The Binary is built on ungoogled-chromium, which removes Google-specific services and telemetry. Any network activity may result from normal browser operation, Chromium subsystems, user configuration, extensions, or the web pages and services you access, and not from any telemetry or analytics service operated by CloakHQ.
+183
View File
@@ -6,6 +6,189 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
---
## [Unreleased]
## [0.3.25] — 2026-04-16
- **[wrapper]** Python: add `launch_context_async()` — async counterpart to `launch_context()`. Returns a BrowserContext with all kwargs forwarded to `browser.new_context()`, enabling `storage_state`, `permissions`, `extra_http_headers`, etc. without a persistent profile folder. Closes #141.
- **[wrapper]** JS: `launchContext()` and `launchPersistentContext()` silently dropped unknown options (including `storageState`). New `contextOptions` escape hatch forwards arbitrary options to Playwright's `newContext()`.
- **[wrapper]** Fix `humanConfig` TypeScript typing (#151).
- **[binary]** New build 146.0.7680.177.3 for Linux x64 + arm64 — 57 source-level fingerprint patches (up from 49): WebAuthn capabilities, AAC audio encoder, and window position spoofing; WebGL and canvas format consistency fixes; SOCKS5 warm connection pool auth fix for credentialed proxies.
- **[docs]** Add recommended anti-bot config and SOCKS5 tips to troubleshooting.
## [0.3.24] — 2026-04-10
- **[wrapper]** Native SOCKS5 proxy support — pass `proxy="socks5://user:pass@host:port"` directly. Credentials handled natively by Chrome. Works across all launch functions, Python + JS.
- **[wrapper]** Add Playwright ElementHandle humanize support — `element_handle.click()`, `.fill()`, `.type()` now use human-like behavior when `humanize=True` (thanks [@evelaa123](https://github.com/evelaa123), #133)
- **[binary]** Upgrade Linux arm64 to Chromium 146.0.7680.177.2 (49 patches) — now matches Linux x64
- **[binary]** New build 146.0.7680.177.2 for both Linux platforms: native SOCKS5 proxy with UDP ASSOCIATE (QUIC/HTTP3 over SOCKS5)
- **[docs]** Clarify humanize requires wrapper import over CDP (#126)
## [0.3.23] — 2026-04-09
- **[wrapper]** Add full Puppeteer humanize support — human-like mouse, keyboard, and scroll behavior for `puppeteer-core` users (thanks [@evelaa123](https://github.com/evelaa123), #129)
- **[wrapper]** Fix Playwright humanize gaps — `pressSequentially`, `tap`, `clear` on pages and frames now use human-like behavior (#129)
- **[wrapper]** Expose humanize module for CDP-connected browsers — `import from 'cloakbrowser/human'` for manual patching of external Playwright instances (#126)
- **[docker]** Fix `cloakserve` locale/timezone mismatch — CLI args now route through `build_args()` so the companion `--lang` flag is added automatically (#130)
- **[meta]** Use Node 24 in CI publish workflow to work around broken npm in Node 22.22.2
## [0.3.22] — 2026-04-09
- **[binary]** Upgrade Linux x64 build to Chromium 146.0.7680.177.1 — 49 source-level C++ patches (up from 48), rebased from 145.0.7632.x
## [0.3.21] — 2026-04-07
- **[wrapper]** Remove dead `--disable-blink-features=AutomationControlled` flag -- binary patch 009 already handles `navigator.webdriver` at source level
- **[wrapper]** Remove hardcoded GPU vendor/renderer flags -- binary auto-generates diverse, realistic GPU profiles from the fingerprint seed. Each seed gets a unique GPU instead of every user sharing the same one
- **[wrapper]** Allow `viewport=None` to disable viewport emulation in both Python and JS wrappers (thanks [@kitiho](https://github.com/kitiho), #107)
- **[wrapper]** Enable `geoip=True` in stealth test example to fix FingerprintJS detection
- **[meta]** Remove npm self-upgrade step in CI -- Node 22 ships with compatible npm
- **[docker]** Install `geoip2` in Docker image for GeoIP auto-detection support
## [0.3.20] — 2026-04-06
- **[binary]** Upgrade Linux x64 build to 145.0.7632.159.9 — 48 source-level C++ patches (up from 42)
- **[binary]** 6 new patches: WebRTC IP spoofing, proxy signal removal, network timing normalization, WebGL accuracy improvements
- **[binary]** New `--fingerprint-webrtc-ip` flag — spoof WebRTC ICE candidate IPs to match your proxy exit IP
- **[binary]** Proxy detection signals eliminated — timing, headers, and network metadata normalized when proxy is active
- **[binary]** WebGL rendering accuracy improvements for headed mode
- **[wrapper]** Auto-inject `--fingerprint-webrtc-ip` when `geoip=True` — uses resolved exit IP from GeoIP lookup
- **[wrapper]** Rewrite `cloakserve` as CDP multiplexer with per-connection fingerprint seeds and connection tracking
- **[wrapper]** Humanize keyboard improvements — better behavioral stealth for typing interactions (thanks [@evelaa123](https://github.com/evelaa123))
- **[meta]** Bump GitHub Actions dependencies
## [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
- **[binary]** WebGPU adapter spoofing for headless/Docker, timezone multi-context fix, stealth audit phase 2 (6 detection vector fixes), font auto-hide for cross-platform fingerprints
- **[wrapper]** Default Playwright backend switched from `patchright` to stock `playwright`. Patchright broke proxy auth and `add_init_script` (#27) and is redundant since the binary handles stealth at C++ level. Opt in with `launch(backend="patchright")` or `CLOAKBROWSER_BACKEND=patchright` env var. Install: `pip install cloakbrowser[patchright]`
- **[wrapper]** Deduplicate CLI flags when user args overlap with stealth defaults — user values win cleanly instead of passing both to Chromium
- **[wrapper]** Extract shared `buildArgs` into `js/src/args.ts` (JS DRY fix), guard debug logging behind `DEBUG=cloakbrowser` env var
## [0.3.7] — 2026-03-05
- **[wrapper]** Unify timezone parameter: rename `timezone_id` to `timezone` in `launch_context()`, `launch_persistent_context()`, and `launch_persistent_context_async()` (Python). Old `timezone_id` still works with a deprecation warning. JS: deprecate `timezoneId` on `LaunchContextOptions` — use `timezone` (inherited from `LaunchOptions`)
- **[wrapper]** Docker Hub image (`cloakhq/cloakbrowser`) — pre-built with Python + JS wrappers, Xvfb for headed mode, and `cloaktest` CLI shortcut. One-liner: `docker run --rm cloakhq/cloakbrowser cloaktest`
- **[wrapper]** Add "Launching stealth browser..." feedback to all examples for better UX in Docker/CI
- **[wrapper]** Comprehensive unit tests: 169 Python + 88 JS (up from 59 + 47)
- **[docs]** Streamline READMEs for launch — reorder for conversion, collapse fingerprint flags, update Docker section
## [0.3.6] — 2026-03-04
- **[wrapper]** `proxy` parameter now accepts a Playwright proxy dict (`{server, bypass, username, password}`) in addition to URL strings — enables bypass lists and separate auth fields (PR #24). **TS note:** type changed from `string` to `string | object` — code that assumed `proxy` is always a string may need a `typeof` narrowing check
## [0.3.5] — 2026-03-04
- **[wrapper]** Add `launch_persistent_context()` and `launch_persistent_context_async()` (Python) — persistent browser profiles with cookie/localStorage persistence across sessions, avoids incognito detection (thanks [@evelaa123](https://github.com/evelaa123), [@yahooguntu](https://github.com/yahooguntu) — PRs #22, #17)
- **[wrapper]** Add `launchPersistentContext()` (JS/TS) — same feature for JavaScript with full type support
- **[wrapper]** Fix Windows zip extraction failure when primary download server is down — file handle leak caused `ERROR_SHARING_VIOLATION` on fallback download (thanks [@evelaa123](https://github.com/evelaa123) — PR #23)
## [0.3.4] — 2026-03-04
Binary v14: auto-spoof restored with seed, wrapper simplified to match.
- **[binary]** Restore full auto-spoof when `--fingerprint=seed` is set — all randomized properties now derive from the seed consistently
- **[binary]** Auto-inject random fingerprint seed at startup if none provided. Binary is stealthy with zero flags
- **[binary]** 26 source-level C++ patches (up from 25)
- **[wrapper]** Simplify default stealth args — remove flags the binary now auto-generates. Wrapper still sets platform profile on Linux and `--no-sandbox`
- **[wrapper]** Fix timezone in `launch_context()` — use Playwright's per-context timezone instead of binary flag, fixing mismatch when creating new browser contexts with geoip
- **[wrapper]** Clarify README platform detection behavior
## [0.3.3] — 2026-03-03
All platforms now run Chromium 145 v2 with 25 patches. Windows x64 added.
- **[binary]** Auto-spoof by default — binary is stealthy with zero flags. Random fingerprint seed auto-generated at startup, no wrapper or configuration required
- **[binary]** Platform-aware auto-detection — GPU, screen dimensions, and User-Agent automatically match the real OS (macOS, Linux, Windows) without explicit flags
- **[binary]** Expanded GPU model database for realistic per-session diversity
- **[binary]** First macOS v145 builds (arm64 + x64) — 25 patches, up from 16 on v142
- **[binary]** First Windows x64 v145 build — 25 patches
- **[wrapper]** Add Windows x64 platform support — auto-download, binary path resolution, and platform detection
- **[wrapper]** Upgrade macOS (arm64 + x64) from Chromium 142 to 145 — all platforms now ship the same 25-patch build
- **[wrapper]** Add explicit Mac GPU flags (`Apple M3 Metal` renderer) to default stealth args for consistent WebGL fingerprints
- **[wrapper]** Improve reCAPTCHA stealth test — wait for score element instead of blind sleep
- **[wrapper]** JS: add `win32-x64` platform mapping, Windows binary path (`chrome.exe`)
## [0.3.1] — 2026-03-03
- **[wrapper]** Auto-check for wrapper updates on startup (PyPI/npm). Notifies users when a newer wrapper version is available. Runs once per process, respects `CLOAKBROWSER_AUTO_UPDATE=false`.
+31 -4
View File
@@ -1,6 +1,6 @@
FROM python:3.12-slim
# Chromium system deps (matches fingerprint-chromium 142+ requirements)
# Chromium system deps + Node.js
RUN apt-get update && apt-get install -y --no-install-recommends \
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 \
libdbus-1-3 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 \
@@ -9,16 +9,43 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libxcb1 libxext6 libxshmfence1 \
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 \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml README.md LICENSE ./
# Python wrapper
COPY pyproject.toml README.md LICENSE BINARY-LICENSE.md CHANGELOG.md ./
COPY cloakbrowser/ cloakbrowser/
RUN pip install --no-cache-dir ".[serve,geoip]"
RUN pip install --no-cache-dir .
# JS wrapper
COPY js/ js/
RUN cd js && npm install && npm run build
# Examples
COPY examples/ examples/
# Pre-download stealth Chromium binary during build (not at runtime)
RUN python -c "from cloakbrowser import ensure_binary; ensure_binary()"
# Remove welcome marker so users see it on first container run
RUN python -c "from cloakbrowser import ensure_binary; ensure_binary()" \
&& rm -f ~/.cloakbrowser/.welcome_shown
# CLI shortcuts
COPY bin/cloaktest /usr/local/bin/cloaktest
COPY bin/cloakserve /usr/local/bin/cloakserve
RUN chmod +x /usr/local/bin/cloaktest /usr/local/bin/cloakserve
EXPOSE 9222
# Xvfb entrypoint for headed mode support
COPY bin/docker-entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENV DISPLAY=:99
ENTRYPOINT ["/entrypoint.sh"]
CMD ["python"]
+652 -125
View File
File diff suppressed because it is too large Load Diff
Executable
+669
View File
@@ -0,0 +1,669 @@
#!/usr/bin/env python3
"""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 # default, backward compat
cloakserve --port=9222 # custom port
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 random
import shutil
import socket
import subprocess
import sys
import time
from dataclasses import dataclass
from urllib.parse import parse_qs
from pathlib import Path
import aiohttp
import websockets
from aiohttp import web
from cloakbrowser.browser import build_args, maybe_resolve_geoip, _resolve_webrtc_args
from cloakbrowser.download import ensure_binary
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("cloakserve")
# 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",
"--disable-extensions",
"--disable-popup-blocking",
"--disable-background-networking",
"--metrics-recording-only",
"--ignore-gpu-blocklist",
]
BASE_CDP_PORT = 5100
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# ChromePool — manages multiple Chrome processes keyed by seed
# ---------------------------------------------------------------------------
class ChromePool:
def __init__(
self,
binary: str,
global_args: list[str],
headless: bool,
data_dir: str = "/tmp/cloakserve",
default_seed: str | None = None,
default_locale: str | None = None,
default_timezone: str | None = None,
):
self._binary = binary
self._global_args = global_args
self._headless = headless
self._data_dir = data_dir
self._default_seed = default_seed
self._default_locale = default_locale
self._default_timezone = default_timezone
self._processes: dict[str, ChromeProcess] = {}
self._default: ChromeProcess | None = None
self._locks: dict[str, asyncio.Lock] = {}
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."""
# Apply CLI defaults when query params don't provide values
if seed is None and self._default_seed:
seed = self._default_seed
if locale is None:
locale = self._default_locale
if timezone is None:
timezone = self._default_timezone
# No seed = default shared process
if seed is None:
seed_key = "__default__"
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
exit_ip = None
if geoip and proxy:
timezone, locale, exit_ip = 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}")
# WebRTC IP spoofing: resolve auto, inject geoip exit IP
fp_extra = _resolve_webrtc_args(fp_extra, proxy)
if exit_ip and not any(a.startswith("--fingerprint-webrtc-ip") for a in (fp_extra or [])):
fp_extra = list(fp_extra or [])
fp_extra.append(f"--fingerprint-webrtc-ip={exit_ip}")
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).
--fingerprint, --fingerprint-locale, and --fingerprint-timezone are
extracted into config defaults so they route through build_args()
(e.g. locale needs both --lang and --fingerprint-locale).
Query-string params override these defaults per-connection.
"""
config: dict = {
"port": 9222,
"headless": True,
"data_dir": None,
"default_seed": None,
"default_locale": None,
"default_timezone": None,
}
passthrough = []
# Flags consumed by cloakserve (not passed to Chrome)
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
# Route through build_args() so companion flags are set correctly
elif arg.startswith("--fingerprint-locale="):
config["default_locale"] = arg.split("=", 1)[1]
elif arg.startswith("--fingerprint-timezone="):
config["default_timezone"] = arg.split("=", 1)[1]
elif arg.startswith("--fingerprint="):
config["default_seed"] = arg.split("=", 1)[1]
else:
passthrough.append(arg)
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"],
default_seed=config["default_seed"],
default_locale=config["default_locale"],
default_timezone=config["default_timezone"],
)
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()
Executable
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
# Run CloakBrowser stealth test suite
exec python -u /app/examples/stealth_test.py --no-screenshots "$@"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# Start Xvfb for headed mode (Turnstile, CAPTCHAs), then run user command
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
sleep 1
exec "$@"
+22 -1
View File
@@ -11,20 +11,41 @@ Usage:
browser.close()
"""
from .browser import launch, launch_async, launch_context
from .browser import launch, launch_async, launch_context, launch_context_async, 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",
"launch_context",
"launch_context_async",
"launch_persistent_context",
"launch_persistent_context_async",
"ensure_binary",
"clear_cache",
"binary_info",
"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.1"
__version__ = "0.3.25"
+751 -63
View File
@@ -15,30 +15,64 @@ Usage:
from __future__ import annotations
import logging
from typing import Any, Literal
from urllib.parse import unquote, urlparse, urlunparse
import os
from typing import Any, Literal, TypedDict
from urllib.parse import quote, 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
from .human.config import HumanConfigOverrides, HumanPreset
logger = logging.getLogger("cloakbrowser")
# Sentinel to distinguish "viewport not provided" from "viewport=None" (disable emulation)
_VIEWPORT_UNSET = object()
def _resolve_timezone(timezone: str | None, kwargs: dict[str, Any]) -> str | None:
"""Accept both timezone and timezone_id — either works, no warning."""
if "timezone_id" in kwargs:
if timezone is None:
timezone = kwargs.pop("timezone_id")
else:
kwargs.pop("timezone_id")
return timezone
class _ProxySettingsRequired(TypedDict):
server: str
class ProxySettings(_ProxySettingsRequired, total=False):
"""Playwright-compatible proxy configuration."""
bypass: str
username: str
password: str
def launch(
headless: bool = True,
proxy: str | None = None,
proxy: str | ProxySettings | None = None,
args: list[str] | None = None,
stealth_args: bool = True,
timezone: str | None = None,
locale: str | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
**kwargs: Any,
) -> Any:
"""Launch stealth Chromium browser. Returns a Playwright Browser object.
Args:
headless: Run in headless mode (default True).
proxy: Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080').
proxy: Proxy URL string or Playwright proxy dict.
String: 'http://user:pass@proxy:8080' (credentials auto-extracted).
Dict: {"server": "http://proxy:8080", "bypass": ".google.com", ...}
— passed directly to Playwright.
args: Additional Chromium CLI arguments to pass.
stealth_args: Include default stealth fingerprint args (default True).
Set to False if you want to pass your own --fingerprint flags.
@@ -48,6 +82,13 @@ def launch(
Requires ``pip install cloakbrowser[geoip]``. Downloads ~70 MB
GeoLite2-City database on first use. Explicit timezone/locale
always override geoip results.
backend: Playwright backend — 'playwright' (default) or 'patchright'.
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 mapping to override preset values.
**kwargs: Passed directly to playwright.chromium.launch().
Returns:
@@ -61,11 +102,16 @@ def launch(
>>> print(page.title())
>>> browser.close()
"""
from patchright.sync_api import sync_playwright
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, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
args = _resolve_webrtc_args(args, proxy)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args))
@@ -74,8 +120,8 @@ def launch(
executable_path=binary_path,
headless=headless,
args=chrome_args,
ignore_default_args=["--enable-automation"],
**_build_proxy_kwargs(proxy),
ignore_default_args=IGNORE_DEFAULT_ARGS,
**proxy_kwargs,
**kwargs,
)
@@ -83,34 +129,51 @@ 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 | None = None,
proxy: str | ProxySettings | None = None,
args: list[str] | None = None,
stealth_args: bool = True,
timezone: str | None = None,
locale: str | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
**kwargs: Any,
) -> Any:
"""Async version of launch(). Returns a Playwright Browser object.
Args:
headless: Run in headless mode (default True).
proxy: Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080').
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
args: Additional Chromium CLI arguments to pass.
stealth_args: Include default stealth fingerprint args (default True).
timezone: IANA timezone (e.g. 'America/New_York'). Sets --fingerprint-timezone binary flag.
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 mapping to override preset values.
**kwargs: Passed directly to playwright.chromium.launch().
Returns:
@@ -129,11 +192,16 @@ async def launch_async(
>>>
>>> asyncio.run(main())
"""
from patchright.async_api import async_playwright
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, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
args = _resolve_webrtc_args(args, proxy)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args))
@@ -142,8 +210,8 @@ async def launch_async(
executable_path=binary_path,
headless=headless,
args=chrome_args,
ignore_default_args=["--enable-automation"],
**_build_proxy_kwargs(proxy),
ignore_default_args=IGNORE_DEFAULT_ARGS,
**proxy_kwargs,
**kwargs,
)
@@ -151,25 +219,288 @@ 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
def launch_context(
def launch_persistent_context(
user_data_dir: str | os.PathLike,
headless: bool = True,
proxy: str | None = None,
proxy: str | ProxySettings | None = None,
args: list[str] | None = None,
stealth_args: bool = True,
user_agent: str | None = None,
viewport: dict | None = None,
viewport: dict | None = _VIEWPORT_UNSET,
locale: str | None = None,
timezone_id: str | None = None,
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
**kwargs: Any,
) -> Any:
"""Launch stealth browser with a persistent profile and return a BrowserContext.
This persists cookies, localStorage, cache, and other browser state across
sessions by storing them in ``user_data_dir``. Also avoids incognito detection
by services like BrowserScan (-10% penalty).
Args:
user_data_dir: Path to the directory where browser profile data is stored.
Created automatically if it doesn't exist. Reuse the same path across
sessions to restore cookies, localStorage, cached credentials, etc.
headless: Run in headless mode (default True).
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
args: Additional Chromium CLI arguments.
stealth_args: Include default stealth fingerprint args (default True).
user_agent: Custom user agent string.
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
Pass None to disable viewport emulation (use OS window size).
locale: Browser locale, e.g. "en-US".
timezone: IANA timezone (e.g. 'America/New_York').
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
Default: None (uses Chromium default, which is 'light').
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 mapping to override preset values.
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
Returns:
Playwright BrowserContext object backed by a persistent profile.
Call ``.close()`` when done — this also stops the Playwright instance.
Example:
>>> from cloakbrowser import launch_persistent_context
>>> ctx = launch_persistent_context("./my-profile", headless=False)
>>> page = ctx.new_page()
>>> page.goto("https://protected-site.com")
>>> ctx.close() # Profile is saved; re-use path next run to restore state.
"""
sync_playwright = _import_sync_playwright(_resolve_backend(backend))
timezone = _resolve_timezone(timezone, kwargs)
binary_path = ensure_binary()
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
args = _resolve_webrtc_args(args, proxy)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
logger.debug(
"Launching persistent stealth Chromium (headless=%s, user_data_dir=%s)",
headless,
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
if viewport is _VIEWPORT_UNSET:
context_kwargs["viewport"] = DEFAULT_VIEWPORT
elif viewport is None:
context_kwargs["no_viewport"] = True
else:
context_kwargs["viewport"] = viewport
if color_scheme:
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs)
pw = sync_playwright().start()
context = pw.chromium.launch_persistent_context(
user_data_dir=os.fspath(user_data_dir),
executable_path=binary_path,
headless=headless,
args=chrome_args,
ignore_default_args=IGNORE_DEFAULT_ARGS,
**proxy_kwargs,
**context_kwargs,
)
# Patch close() to also stop the Playwright instance
_original_close = context.close
def _close_with_cleanup() -> None:
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
async def launch_persistent_context_async(
user_data_dir: str | os.PathLike,
headless: bool = True,
proxy: str | ProxySettings | None = None,
args: list[str] | None = None,
stealth_args: bool = True,
user_agent: str | None = None,
viewport: dict | None = _VIEWPORT_UNSET,
locale: str | None = None,
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
**kwargs: Any,
) -> Any:
"""Async version of launch_persistent_context().
Launch stealth browser with a persistent profile and return a BrowserContext.
This persists cookies, localStorage, cache, and other browser state across
sessions by storing them in ``user_data_dir``.
Args:
user_data_dir: Path to the directory where browser profile data is stored.
Created automatically if it doesn't exist.
headless: Run in headless mode (default True).
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
args: Additional Chromium CLI arguments.
stealth_args: Include default stealth fingerprint args (default True).
user_agent: Custom user agent string.
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
Pass None to disable viewport emulation (use OS window size).
locale: Browser locale, e.g. "en-US".
timezone: IANA timezone (e.g. 'America/New_York').
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
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 mapping to override preset values.
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
Returns:
Playwright BrowserContext object backed by a persistent profile (async API).
Call ``await .close()`` when done.
Example:
>>> import asyncio
>>> from cloakbrowser import launch_persistent_context_async
>>>
>>> async def main():
... ctx = await launch_persistent_context_async("./my-profile", headless=False)
... page = await ctx.new_page()
... await page.goto("https://protected-site.com")
... await ctx.close()
>>>
>>> asyncio.run(main())
"""
async_playwright = _import_async_playwright(_resolve_backend(backend))
timezone = _resolve_timezone(timezone, kwargs)
binary_path = ensure_binary()
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
args = _resolve_webrtc_args(args, proxy)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
logger.debug(
"Launching persistent stealth Chromium async (headless=%s, user_data_dir=%s)",
headless,
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
if viewport is _VIEWPORT_UNSET:
context_kwargs["viewport"] = DEFAULT_VIEWPORT
elif viewport is None:
context_kwargs["no_viewport"] = True
else:
context_kwargs["viewport"] = viewport
if color_scheme:
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs)
pw = await async_playwright().start()
context = await pw.chromium.launch_persistent_context(
user_data_dir=os.fspath(user_data_dir),
executable_path=binary_path,
headless=headless,
args=chrome_args,
ignore_default_args=IGNORE_DEFAULT_ARGS,
**proxy_kwargs,
**context_kwargs,
)
# Patch close() to also stop the Playwright instance
_original_close = context.close
async def _close_with_cleanup() -> None:
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
def launch_context(
headless: bool = True,
proxy: str | ProxySettings | None = None,
args: list[str] | None = None,
stealth_args: bool = True,
user_agent: str | None = None,
viewport: dict | None = _VIEWPORT_UNSET,
locale: str | None = None,
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
**kwargs: Any,
) -> Any:
"""Launch stealth browser and return a BrowserContext with common options pre-set.
@@ -179,36 +510,50 @@ def launch_context(
Args:
headless: Run in headless mode (default True).
proxy: Proxy server URL.
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
args: Additional Chromium CLI arguments.
stealth_args: Include default stealth fingerprint args (default True).
user_agent: Custom user agent string.
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
Pass None to disable viewport emulation (use OS window size).
locale: Browser locale, e.g. "en-US".
timezone_id: Timezone, e.g. "America/New_York".
timezone: IANA timezone (e.g. 'America/New_York').
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
Default: None (uses Chromium default, which is 'light').
Note: 'no-preference' doesn't work in Patchright (falls back to '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 mapping to override preset values.
**kwargs: Passed to browser.new_context().
Returns:
Playwright BrowserContext object.
"""
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_id, locale = _maybe_resolve_geoip(geoip, proxy, timezone_id, locale)
# resolved values flow to binary flags
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
# Inject geoip exit IP for WebRTC spoofing (free — no extra HTTP call)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
# --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=timezone_id, locale=locale)
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_id:
context_kwargs["timezone_id"] = timezone_id
if viewport is _VIEWPORT_UNSET:
context_kwargs["viewport"] = DEFAULT_VIEWPORT
elif viewport is None:
context_kwargs["no_viewport"] = True
else:
context_kwargs["viewport"] = viewport
if color_scheme:
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs)
@@ -223,69 +568,379 @@ 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
async def launch_context_async(
headless: bool = True,
proxy: str | ProxySettings | None = None,
args: list[str] | None = None,
stealth_args: bool = True,
user_agent: str | None = None,
viewport: dict | None = _VIEWPORT_UNSET,
locale: str | None = None,
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
**kwargs: Any,
) -> Any:
"""Async version of launch_context().
Launch stealth browser and return a BrowserContext with common options pre-set.
All extra kwargs are forwarded to ``browser.new_context()`` — use this for
``storage_state``, ``permissions``, ``extra_http_headers``, etc. without needing
a persistent profile folder.
Args:
headless: Run in headless mode (default True).
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
args: Additional Chromium CLI arguments.
stealth_args: Include default stealth fingerprint args (default True).
user_agent: Custom user agent string.
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
Pass None to disable viewport emulation (use OS window size).
locale: Browser locale, e.g. "en-US".
timezone: IANA timezone (e.g. 'America/New_York').
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
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 mapping to override preset values.
**kwargs: Passed to browser.new_context() — e.g. storage_state, permissions.
Returns:
Playwright BrowserContext object (async API).
Call ``await .close()`` when done — this also closes the underlying browser.
Example:
>>> import asyncio
>>> from cloakbrowser import launch_context_async
>>>
>>> async def main():
... # Load saved session (cookies, localStorage)
... ctx = await launch_context_async(
... headless=True,
... storage_state="state.json",
... )
... page = await ctx.new_page()
... await page.goto("https://example.com")
... # Save state back
... await ctx.storage_state(path="state.json")
... await ctx.close()
>>>
>>> asyncio.run(main())
"""
timezone = _resolve_timezone(timezone, kwargs)
# Resolve geoip BEFORE launch_async() to avoid double-resolution and ensure
# resolved values flow to binary flags
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
# --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 = await launch_async(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
timezone=timezone, locale=locale, backend=backend)
context_kwargs: dict[str, Any] = {}
if user_agent:
context_kwargs["user_agent"] = user_agent
if viewport is _VIEWPORT_UNSET:
context_kwargs["viewport"] = DEFAULT_VIEWPORT
elif viewport is None:
context_kwargs["no_viewport"] = True
else:
context_kwargs["viewport"] = viewport
if color_scheme:
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs)
# Catch BaseException (not just Exception) so that asyncio.CancelledError
# triggers browser cleanup — otherwise the underlying Chromium process
# leaks when the awaiting task is cancelled.
try:
context = await browser.new_context(**context_kwargs)
except BaseException:
try:
await browser.close()
except BaseException:
pass
raise
# Patch close() to also close the browser (and its Playwright instance)
_original_ctx_close = context.close
async def _close_context_with_cleanup() -> None:
try:
await _original_ctx_close()
finally:
await browser.close()
context.close = _close_context_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
# ---------------------------------------------------------------------------
# Backend resolution
# ---------------------------------------------------------------------------
def _resolve_backend(backend: str | None) -> str:
"""Resolve backend: param > env var > default ('playwright')."""
b = backend or os.environ.get("CLOAKBROWSER_BACKEND", "playwright")
if b not in ("playwright", "patchright"):
raise ValueError(f"Unknown backend '{b}'. Use 'playwright' or 'patchright'.")
return b
def _import_sync_playwright(backend: str):
"""Import sync_playwright from the resolved backend."""
if backend == "patchright":
try:
from patchright.sync_api import sync_playwright
except ModuleNotFoundError:
raise ModuleNotFoundError(
"patchright is not installed. Install it with: pip install cloakbrowser[patchright]"
) from None
return sync_playwright
from playwright.sync_api import sync_playwright
return sync_playwright
def _import_async_playwright(backend: str):
"""Import async_playwright from the resolved backend."""
if backend == "patchright":
try:
from patchright.async_api import async_playwright
except ModuleNotFoundError:
raise ModuleNotFoundError(
"patchright is not installed. Install it with: pip install cloakbrowser[patchright]"
) from None
return async_playwright
from playwright.async_api import async_playwright
return async_playwright
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
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 _reconstruct_socks_url(proxy: ProxySettings) -> str:
"""Reconstruct a SOCKS5 URL with inline credentials from a Playwright proxy dict."""
server = proxy.get("server", "")
username = proxy.get("username", "")
password = proxy.get("password", "")
if not username:
return server
parsed = urlparse(server)
creds = quote(username, safe="")
if password:
creds += f":{quote(password, safe='')}"
host = parsed.hostname or ""
if ":" in host: # IPv6 literal — re-add brackets
host = f"[{host}]"
netloc = f"{creds}@{host}"
if parsed.port:
netloc += f":{parsed.port}"
return urlunparse((parsed.scheme, netloc, parsed.path, "", "", ""))
def _extract_proxy_url(proxy: str | ProxySettings | None) -> str | None:
"""Extract and normalize proxy URL string from proxy param.
For SOCKS5 dicts with separate username/password fields, reconstructs
the full URL with inline credentials so SOCKS5 auth works.
"""
if proxy is None:
return None
if isinstance(proxy, dict):
server = proxy.get("server", "")
if not server:
return None
if _is_socks_proxy(proxy):
return _reconstruct_socks_url(proxy)
return _ensure_proxy_scheme(server)
return _ensure_proxy_scheme(proxy)
def maybe_resolve_geoip(
geoip: bool,
proxy: str | None,
proxy: str | ProxySettings | None,
timezone: str | None,
locale: str | None,
) -> tuple[str | None, str | None]:
"""Auto-fill timezone/locale from proxy IP when geoip is enabled."""
if not geoip or not proxy or (timezone is not None and locale is not None):
return timezone, locale
) -> tuple[str | None, str | None, str | None]:
"""Auto-fill timezone/locale from proxy IP when geoip is enabled.
from .geoip import resolve_proxy_geo
Returns ``(timezone, locale, exit_ip)``. *exit_ip* is a free bonus
from the geoip lookup (no extra HTTP call) — used for WebRTC spoofing.
"""
if not geoip or not proxy:
return timezone, locale, None
geo_tz, geo_locale = resolve_proxy_geo(proxy)
from .geoip import resolve_proxy_geo_with_ip
proxy_url = _extract_proxy_url(proxy)
if not proxy_url:
return timezone, locale, None
# When both tz/locale are explicit, still resolve exit IP for WebRTC
if timezone is not None and locale is not None:
from .geoip import _resolve_exit_ip
exit_ip = _resolve_exit_ip(proxy_url)
return timezone, locale, exit_ip
geo_tz, geo_locale, exit_ip = resolve_proxy_geo_with_ip(proxy_url)
if timezone is None:
timezone = geo_tz
if locale is None:
locale = geo_locale
return timezone, locale
return timezone, locale, exit_ip
def _build_args(
def _resolve_webrtc_args(
args: list[str] | None,
proxy: str | ProxySettings | None,
) -> list[str] | None:
"""Replace --fingerprint-webrtc-ip=auto with the resolved proxy exit IP.
Returns args unchanged if no ``auto`` value is present.
"""
if not args:
return args
idx = None
for i, a in enumerate(args):
if a == "--fingerprint-webrtc-ip=auto":
idx = i
break
if idx is None:
return args
proxy_url = _extract_proxy_url(proxy)
if not proxy_url:
logger.warning("--fingerprint-webrtc-ip=auto requires a proxy; removing flag")
args = list(args)
del args[idx]
return args
try:
from .geoip import _resolve_exit_ip
exit_ip = _resolve_exit_ip(proxy_url)
except Exception:
logger.warning("Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto")
args = list(args)
del args[idx]
return args
if exit_ip:
args = list(args)
args[idx] = f"--fingerprint-webrtc-ip={exit_ip}"
else:
logger.warning("Could not resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto")
args = list(args)
del args[idx]
return args
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."""
result = []
"""Combine stealth args with user-provided args and locale flags.
Deduplicates by flag key (everything before '=').
Priority: stealth defaults < user args < dedicated params (timezone/locale).
"""
seen: dict[str, str] = {}
if stealth_args:
result.extend(get_default_stealth_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:
result.extend(extra_args)
for arg in extra_args:
key = arg.split("=", 1)[0]
if key in seen:
logger.debug("Arg override: %s -> %s", seen[key], arg)
seen[key] = arg
# Timezone/locale flags are independent of stealth_args — always inject when set
if timezone:
result.append(f"--fingerprint-timezone={timezone}")
key = "--fingerprint-timezone"
flag = f"{key}={timezone}"
if key in seen:
logger.debug("Arg override: %s -> %s", seen[key], flag)
seen[key] = flag
if locale:
result.append(f"--lang={locale}")
return result
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())
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 ""
@@ -302,8 +957,41 @@ def _parse_proxy_url(proxy: str) -> dict[str, Any]:
return result
def _build_proxy_kwargs(proxy: str | None) -> dict[str, Any]:
"""Build proxy kwargs for Playwright launch."""
def _is_socks_proxy(proxy: str | ProxySettings | None) -> bool:
"""Check if the proxy uses SOCKS5 protocol."""
if proxy is None:
return {}
return {"proxy": _parse_proxy_url(proxy)}
return False
url = proxy.get("server", "") if isinstance(proxy, dict) else proxy
return url.lower().startswith(("socks5://", "socks5h://"))
def _resolve_proxy_config(
proxy: str | ProxySettings | None,
) -> tuple[dict[str, Any], list[str]]:
"""Resolve proxy into Playwright kwargs and Chrome args.
Playwright rejects SOCKS5 proxies with credentials in its proxy dict,
so SOCKS5 is passed via --proxy-server Chrome arg instead.
Returns:
(proxy_kwargs, extra_chrome_args) — one or both will be empty.
"""
if proxy is None:
return {}, []
if _is_socks_proxy(proxy):
# SOCKS5: bypass Playwright, pass directly to Chrome via --proxy-server.
# Chrome handles SOCKS5 auth natively from the URL.
if isinstance(proxy, dict):
url = _reconstruct_socks_url(proxy)
extra_args = [f"--proxy-server={url}"]
if proxy.get("bypass"):
extra_args.append(f"--proxy-bypass-list={proxy['bypass']}")
return {}, extra_args
# String URL — pass as-is (Chrome handles user:pass@ in the URL)
return {}, [f"--proxy-server={proxy}"]
# HTTP/HTTPS: use Playwright's proxy dict as before
if isinstance(proxy, dict):
return {"proxy": proxy}, []
return {"proxy": _parse_proxy_url(proxy)}, []
+39 -28
View File
@@ -11,19 +11,28 @@ from ._version import __version__
# ---------------------------------------------------------------------------
# Chromium version shipped with this release.
# Different platforms may ship different versions (e.g. Linux gets v145 first,
# macOS stays on v142 until Mac builds are ready).
# Different platforms may ship different versions during transition periods.
# 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.109"
CHROMIUM_VERSION = "146.0.7680.177.3"
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
"linux-x64": "145.0.7632.109",
"darwin-arm64": "142.0.7444.175",
"darwin-x64": "142.0.7444.175",
"linux-x64": "146.0.7680.177.3",
"linux-arm64": "146.0.7680.177.3",
"darwin-arm64": "145.0.7632.109.2",
"darwin-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.
@@ -39,27 +48,17 @@ def get_default_stealth_args() -> list[str]:
base = [
"--no-sandbox",
"--disable-blink-features=AutomationControlled",
f"--fingerprint={seed}",
]
if system == "Darwin":
# Tell the fingerprint patches we're on macOS so GPU/UA match natively
return base + [
"--fingerprint-platform=macos",
]
return base + ["--fingerprint-platform=macos"]
# Linux: spoof as Windows
return base + [
"--fingerprint-platform=windows",
"--fingerprint-hardware-concurrency=8",
"--fingerprint-device-memory=8",
"--fingerprint-gpu-vendor=NVIDIA Corporation",
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
"--fingerprint-screen-width=1920",
"--fingerprint-screen-height=1080",
"--window-size=1920,1080",
]
# Linux/Windows: Windows fingerprint profile
# Hardware concurrency, device memory, screen, window size, and GPU are
# auto-generated by the binary from the seed (v14+).
return base + ["--fingerprint-platform=windows"]
# ---------------------------------------------------------------------------
@@ -77,6 +76,8 @@ SUPPORTED_PLATFORMS: dict[tuple[str, str], str] = {
("Linux", "aarch64"): "linux-arm64",
("Darwin", "arm64"): "darwin-arm64",
("Darwin", "x86_64"): "darwin-x64",
("Windows", "AMD64"): "windows-x64",
("Windows", "x86_64"): "windows-x64",
}
# Platforms with pre-built binaries available for download (derived from version map).
@@ -130,6 +131,8 @@ def get_binary_path(version: str | None = None) -> Path:
if platform.system() == "Darwin":
# macOS: Chromium.app bundle
return binary_dir / "Chromium.app" / "Contents" / "MacOS" / "Chromium"
elif platform.system() == "Windows":
return binary_dir / "chrome.exe"
else:
# Linux: flat binary
return binary_dir / "chrome"
@@ -148,9 +151,8 @@ def check_platform_available() -> None:
available = ", ".join(sorted(AVAILABLE_PLATFORMS))
import sys
sys.exit(
f"\n\033[1mCloakBrowser\033[0m — Pre-built binaries are currently only available for: {available}.\n"
f"Windows builds are coming soon.\n\n"
f"To use CloakBrowser now, run in Docker (see README) or set CLOAKBROWSER_BINARY_PATH."
f"\n\033[1mCloakBrowser\033[0m — Pre-built binaries are currently only available for: {available}.\n\n"
f"To use CloakBrowser now, set CLOAKBROWSER_BINARY_PATH to a local Chromium binary."
)
@@ -202,18 +204,27 @@ GITHUB_DOWNLOAD_BASE_URL = (
)
def get_archive_ext() -> str:
"""Return the archive extension for the current platform (.zip for Windows, .tar.gz otherwise)."""
return ".zip" if platform.system() == "Windows" else ".tar.gz"
def get_archive_name(tag: str | None = None) -> str:
"""Return the archive filename for a platform tag (e.g. 'cloakbrowser-linux-x64.tar.gz')."""
t = tag or get_platform_tag()
return f"cloakbrowser-{t}{get_archive_ext()}"
def get_download_url(version: str | None = None) -> str:
"""Return the full download URL for the current platform's binary archive."""
v = version or get_chromium_version()
tag = get_platform_tag()
return f"{DOWNLOAD_BASE_URL}/chromium-v{v}/cloakbrowser-{tag}.tar.gz"
return f"{DOWNLOAD_BASE_URL}/chromium-v{v}/{get_archive_name()}"
def get_fallback_download_url(version: str | None = None) -> str:
"""Return the GitHub Releases fallback URL for the binary archive."""
v = version or get_chromium_version()
tag = get_platform_tag()
return f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}/cloakbrowser-{tag}.tar.gz"
return f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}/{get_archive_name()}"
# ---------------------------------------------------------------------------
+70 -28
View File
@@ -12,6 +12,7 @@ import os
import platform
import stat
import subprocess
import sys
import tarfile
import tempfile
import threading
@@ -28,6 +29,8 @@ from .config import (
GITHUB_DOWNLOAD_BASE_URL,
_version_newer,
check_platform_available,
get_archive_ext,
get_archive_name,
get_binary_dir,
get_binary_path,
get_cache_dir,
@@ -42,12 +45,32 @@ from .config import (
logger = logging.getLogger("cloakbrowser")
# Timeout for download (large binary, allow 10 min)
DOWNLOAD_TIMEOUT = 600.0
DOWNLOAD_TIMEOUT = httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=10.0)
# Auto-update check interval (1 hour)
UPDATE_CHECK_INTERVAL = 3600
def _show_welcome() -> None:
"""Show welcome message on first launch. Uses a marker file to show only once."""
marker = get_cache_dir() / ".welcome_shown"
if marker.exists():
return
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("")
except OSError:
pass
def ensure_binary() -> str:
"""Ensure the stealth Chromium binary is available. Download if needed.
@@ -75,6 +98,7 @@ def ensure_binary() -> str:
if binary_path.exists() and _is_executable(binary_path):
logger.debug("Binary found in cache: %s (version %s)", binary_path, effective)
_show_welcome()
_maybe_trigger_update_check()
return str(binary_path)
@@ -123,7 +147,7 @@ def _download_and_extract(version: str | None = None) -> None:
binary_dir.parent.mkdir(parents=True, exist_ok=True)
# Download to temp file first (atomic — no partial downloads in cache)
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
with tempfile.NamedTemporaryFile(suffix=get_archive_ext(), delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
@@ -144,9 +168,7 @@ def _download_and_extract(version: str | None = None) -> None:
_verify_download_checksum(tmp_path, version)
_extract_archive(tmp_path, binary_dir, binary_path)
logger.info("Visit https://cloakbrowser.dev for docs and release notifications.")
logger.info("Issues? https://github.com/CloakHQ/CloakBrowser/issues")
logger.info("Star us if CloakBrowser helps: https://github.com/CloakHQ/CloakBrowser")
_show_welcome()
finally:
# Clean up temp file
tmp_path.unlink(missing_ok=True)
@@ -155,7 +177,7 @@ def _download_and_extract(version: str | None = None) -> None:
def _verify_download_checksum(file_path: Path, version: str | None = None) -> None:
"""Fetch SHA256SUMS and verify the downloaded file. Warn if unavailable, fail on mismatch."""
checksums = _fetch_checksums(version)
tarball_name = f"cloakbrowser-{get_platform_tag()}.tar.gz"
tarball_name = get_archive_name()
if checksums is None:
logger.warning("SHA256SUMS not available for this release — skipping checksum verification")
@@ -256,7 +278,7 @@ def _download_file(url: str, dest: Path) -> None:
def _extract_archive(
archive_path: Path, dest_dir: Path, binary_path: Path | None = None
) -> None:
"""Extract tar.gz archive to destination directory."""
"""Extract tar.gz or zip archive to destination directory."""
logger.info("Extracting to %s", dest_dir)
# Clean existing dir if partial download existed
@@ -266,26 +288,12 @@ def _extract_archive(
dest_dir.mkdir(parents=True, exist_ok=True)
with tarfile.open(archive_path, "r:gz") as tar:
# Security: prevent path traversal
safe_members = []
for member in tar.getmembers():
# Allow symlinks — macOS .app bundles require them (Framework layout)
if member.issym() or member.islnk():
link_target = member.linkname
# Reject symlinks that escape the dest dir
if os.path.isabs(link_target) or ".." in link_target.split("/"):
logger.warning("Skipping suspicious symlink: %s -> %s", member.name, link_target)
continue
else:
member_path = (dest_dir / member.name).resolve()
if not str(member_path).startswith(str(dest_dir.resolve())):
raise RuntimeError(f"Archive contains path traversal: {member.name}")
safe_members.append(member)
if str(archive_path).endswith(".zip"):
_extract_zip(archive_path, dest_dir)
else:
_extract_tar(archive_path, dest_dir)
tar.extractall(dest_dir, members=safe_members)
# If tar extracted into a single subdirectory, flatten it
# If extracted into a single subdirectory, flatten it
# (e.g. fingerprint-chromium-142-custom-v2/chrome → chrome)
# But never flatten .app bundles — macOS needs the bundle structure intact
_flatten_single_subdir(dest_dir)
@@ -303,6 +311,38 @@ def _extract_archive(
logger.info("Binary ready: %s", bp)
def _extract_tar(archive_path: Path, dest_dir: Path) -> None:
"""Extract tar.gz archive with path traversal protection."""
with tarfile.open(archive_path, "r:gz") as tar:
safe_members = []
for member in tar.getmembers():
# Allow symlinks — macOS .app bundles require them (Framework layout)
if member.issym() or member.islnk():
link_target = member.linkname
if os.path.isabs(link_target) or ".." in link_target.split("/"):
logger.warning("Skipping suspicious symlink: %s -> %s", member.name, link_target)
continue
else:
member_path = (dest_dir / member.name).resolve()
if not str(member_path).startswith(str(dest_dir.resolve())):
raise RuntimeError(f"Archive contains path traversal: {member.name}")
safe_members.append(member)
tar.extractall(dest_dir, members=safe_members)
def _extract_zip(archive_path: Path, dest_dir: Path) -> None:
"""Extract zip archive with path traversal protection."""
import zipfile
with zipfile.ZipFile(archive_path, "r") as zf:
for info in zf.infolist():
member_path = (dest_dir / info.filename).resolve()
if not str(member_path).startswith(str(dest_dir.resolve())):
raise RuntimeError(f"Archive contains path traversal: {info.filename}")
zf.extractall(dest_dir)
def _flatten_single_subdir(dest_dir: Path) -> None:
"""If extraction created a single subdirectory, move its contents up.
@@ -330,7 +370,9 @@ def _is_executable(path: Path) -> bool:
def _make_executable(path: Path) -> None:
"""Make a file executable (chmod +x)."""
"""Make a file executable (chmod +x). Skipped on Windows (no-op / AV lock risk)."""
if platform.system() == "Windows":
return
current = path.stat().st_mode
path.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
@@ -433,7 +475,7 @@ def _get_latest_chromium_version() -> str | None:
GITHUB_API_URL, params={"per_page": 10}, timeout=10.0
)
resp.raise_for_status()
platform_tarball = f"cloakbrowser-{get_platform_tag()}.tar.gz"
platform_tarball = get_archive_name()
for release in resp.json():
tag = release.get("tag_name", "")
if tag.startswith("chromium-v") and not release.get("draft"):
+24 -7
View File
@@ -53,6 +53,18 @@ def resolve_proxy_geo(proxy_url: str) -> tuple[str | None, str | None]:
Returns ``(timezone, locale)`` — either or both may be ``None`` on
failure (missing dep, DB download error, lookup miss). Never raises.
"""
tz, locale, _ip = resolve_proxy_geo_with_ip(proxy_url)
return tz, locale
def resolve_proxy_geo_with_ip(
proxy_url: str,
) -> tuple[str | None, str | None, str | None]:
"""Resolve timezone, locale, and exit IP from a proxy.
Returns ``(timezone, locale, exit_ip)``. The exit IP is a free bonus
from the lookup — reused for WebRTC spoofing without an extra HTTP call.
"""
try:
import geoip2.database # noqa: F811
except ImportError:
@@ -63,14 +75,14 @@ def resolve_proxy_geo(proxy_url: str) -> tuple[str | None, str | None]:
db_path = _ensure_geoip_db()
if db_path is None:
return None, None
return None, None, None
# Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
ip = _resolve_exit_ip(proxy_url)
if ip is None:
ip = _resolve_proxy_ip(proxy_url)
if ip is None:
return None, None
return None, None, None
try:
with geoip2.database.Reader(str(db_path)) as reader:
@@ -82,10 +94,10 @@ def resolve_proxy_geo(proxy_url: str) -> tuple[str | None, str | None]:
"GeoIP: %s → tz=%s, country=%s, locale=%s",
ip, timezone, country, locale,
)
return timezone, locale
return timezone, locale, ip
except Exception as exc:
logger.debug("GeoIP lookup failed for %s: %s", ip, exc)
return None, None
logger.warning("GeoIP lookup failed for %s: %s", ip, exc)
return None, None, ip
# ---------------------------------------------------------------------------
@@ -120,7 +132,7 @@ def _resolve_proxy_ip(proxy_url: str) -> str | None:
return ip
return None
except Exception as exc:
logger.debug("Failed to resolve proxy hostname: %s", exc)
logger.warning("Failed to resolve proxy hostname: %s", exc)
return None
@@ -153,9 +165,14 @@ def _resolve_exit_ip(proxy_url: str) -> str | None:
ipaddress.ip_address(ip)
logger.debug("Exit IP via %s: %s", url, ip)
return ip
except httpx.UnsupportedProtocol:
logger.warning(
"SOCKS5 proxy requires socksio: pip install cloakbrowser[geoip]"
)
return None
except Exception:
continue
logger.debug("Failed to discover exit IP through proxy")
logger.warning("Failed to discover exit IP through proxy")
return None
File diff suppressed because it is too large Load Diff
+238
View File
@@ -0,0 +1,238 @@
"""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, TypedDict
# ---------------------------------------------------------------------------
# Type alias
# ---------------------------------------------------------------------------
Range = Tuple[float, float]
HumanPreset = Literal["default", "careful"]
class HumanConfigOverrides(TypedDict, total=False):
typing_delay: float
typing_delay_spread: float
typing_pause_chance: float
typing_pause_range: Range
shift_down_delay: Range
shift_up_delay: Range
key_hold: Range
field_switch_delay: Range
mistype_chance: float
mistype_delay_notice: Range
mistype_delay_correct: Range
mouse_steps_divisor: float
mouse_min_steps: int
mouse_max_steps: int
mouse_wobble_max: float
mouse_overshoot_chance: float
mouse_overshoot_px: Range
mouse_burst_size: Range
mouse_burst_pause: Range
click_aim_delay_input: Range
click_aim_delay_button: Range
click_hold_input: Range
click_hold_button: Range
click_input_x_range: Range
idle_drift_px: float
idle_pause_range: Range
scroll_delta_base: Range
scroll_delta_variance: float
scroll_pause_fast: Range
scroll_pause_slow: Range
scroll_accel_steps: Range
scroll_decel_steps: Range
scroll_overshoot_chance: float
scroll_overshoot_px: Range
scroll_settle_delay: Range
scroll_target_zone: Range
scroll_pre_move_delay: Range
initial_cursor_x: Range
initial_cursor_y: Range
idle_between_actions: bool
idle_between_duration: Range
# ---------------------------------------------------------------------------
# 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: HumanConfigOverrides | None = None,
) -> HumanConfig:
"""Resolve a preset name + optional overrides into a full HumanConfig.
Args:
preset: 'default' or 'careful'.
overrides: Typed mapping of HumanConfig 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)
+189
View File
@@ -0,0 +1,189 @@
"""cloakbrowser-human — Human-like keyboard input.
Stealth-aware: when a CDP session is provided, shift symbols are typed
via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace).
Falls back to page.evaluate when no CDP session is available.
"""
from __future__ import annotations
import random
from typing import Any, Optional, 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',
}
# CDP key code for each shift symbol's physical key.
_SHIFT_SYMBOL_CODES: dict[str, str] = {
'!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4',
'%': 'Digit5', '^': 'Digit6', '&': 'Digit7', '*': 'Digit8',
'(': 'Digit9', ')': 'Digit0', '_': 'Minus', '+': 'Equal',
'{': 'BracketLeft', '}': 'BracketRight', '|': 'Backslash',
':': 'Semicolon', '"': 'Quote', '<': 'Comma', '>': 'Period',
'?': 'Slash', '~': 'Backquote',
}
# Windows virtual key codes for Input.dispatchKeyEvent.
_SHIFT_SYMBOL_KEYCODES: dict[str, int] = {
'!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
'^': 54, '&': 55, '*': 56, '(': 57, ')': 48,
'_': 189, '+': 187, '{': 219, '}': 221, '|': 220,
':': 186, '"': 222, '<': 188, '>': 190, '?': 191,
'~': 192,
}
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,
cdp_session: Any = None,
) -> None:
"""Type text with human-like per-character timing.
Args:
cdp_session: If provided, shift symbols use CDP Input.dispatchKeyEvent
producing isTrusted=true events with no evaluate stack trace.
If None, falls back to page.evaluate (detectable).
"""
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, cdp_session)
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,
cdp_session: Any = None,
) -> None:
"""Type a shift symbol character.
Stealth path (cdp_session provided):
Uses CDP Input.dispatchKeyEvent → isTrusted=true, clean stack.
Fallback path (no cdp_session):
Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent.
Detectable via isTrusted=false and evaluate stack frame.
"""
if cdp_session is not None:
# --- Stealth path: CDP Input.dispatchKeyEvent ---
code = _SHIFT_SYMBOL_CODES.get(ch, '')
key_code = _SHIFT_SYMBOL_KEYCODES.get(ch, 0)
raw.down("Shift")
sleep_ms(rand_range(cfg.shift_down_delay))
cdp_session.send("Input.dispatchKeyEvent", {
"type": "keyDown",
"modifiers": 8, # Shift modifier flag
"key": ch,
"code": code,
"windowsVirtualKeyCode": key_code,
"text": ch,
"unmodifiedText": ch,
})
sleep_ms(rand_range(cfg.key_hold))
cdp_session.send("Input.dispatchKeyEvent", {
"type": "keyUp",
"modifiers": 8,
"key": ch,
"code": code,
"windowsVirtualKeyCode": key_code,
})
sleep_ms(rand_range(cfg.shift_up_delay))
raw.up("Shift")
else:
# --- Fallback path: page.evaluate (detectable) ---
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))
+150
View File
@@ -0,0 +1,150 @@
"""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``.
Stealth-aware: when a CDP session is provided, shift symbols are typed
via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace).
"""
from __future__ import annotations
import random
from typing import Any, Optional, Protocol
from .config import HumanConfig, rand, rand_range, async_sleep_ms
from .keyboard import SHIFT_SYMBOLS, NEARBY_KEYS, _get_nearby_key
from .keyboard import _SHIFT_SYMBOL_CODES, _SHIFT_SYMBOL_KEYCODES
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,
cdp_session: Any = None,
) -> None:
"""Type text with human-like per-character timing (async).
Args:
cdp_session: If provided, shift symbols use CDP Input.dispatchKeyEvent
producing isTrusted=true events with no evaluate stack trace.
If None, falls back to page.evaluate (detectable).
"""
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, cdp_session)
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,
cdp_session: Any = None,
) -> None:
"""Type a shift symbol character (async).
Stealth path (cdp_session provided):
Uses CDP Input.dispatchKeyEvent → isTrusted=true, clean stack.
Fallback path (no cdp_session):
Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent.
Detectable via isTrusted=false and evaluate stack frame.
"""
if cdp_session is not None:
# --- Stealth path: CDP Input.dispatchKeyEvent ---
code = _SHIFT_SYMBOL_CODES.get(ch, '')
key_code = _SHIFT_SYMBOL_KEYCODES.get(ch, 0)
await raw.down("Shift")
await async_sleep_ms(rand_range(cfg.shift_down_delay))
await cdp_session.send("Input.dispatchKeyEvent", {
"type": "keyDown",
"modifiers": 8, # Shift modifier flag
"key": ch,
"code": code,
"windowsVirtualKeyCode": key_code,
"text": ch,
"unmodifiedText": ch,
})
await async_sleep_ms(rand_range(cfg.key_hold))
await cdp_session.send("Input.dispatchKeyEvent", {
"type": "keyUp",
"modifiers": 8,
"key": ch,
"code": code,
"windowsVirtualKeyCode": key_code,
})
await async_sleep_ms(rand_range(cfg.shift_up_delay))
await raw.up("Shift")
else:
# --- Fallback path: page.evaluate (detectable) ---
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
+1
View File
@@ -2,6 +2,7 @@
from cloakbrowser import launch
print("Launching stealth browser...", flush=True)
browser = launch(headless=False)
page = browser.new_page()
+1
View File
@@ -185,6 +185,7 @@ def main():
print(f"Proxy: {PROXY or 'none'}")
print()
print("Launching stealth browser...", flush=True)
context = launch_context(
headless=HEADLESS,
proxy=PROXY,
+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()
+31
View File
@@ -0,0 +1,31 @@
"""Persistent context example: cookies and localStorage survive across sessions."""
from cloakbrowser import launch_persistent_context
PROFILE_DIR = "./my-profile"
# Session 1 — set some state
print("=== Session 1: Setting state ===")
print("Launching stealth browser...", flush=True)
ctx = launch_persistent_context(PROFILE_DIR, headless=False)
page = ctx.new_page()
page.goto("https://example.com")
page.evaluate("document.cookie = 'session=abc123; path=/; max-age=3600'")
page.evaluate("localStorage.setItem('user', 'returning')")
print(f"Cookie: {page.evaluate('document.cookie')}")
ls_val = page.evaluate("localStorage.getItem('user')")
print(f"localStorage: {ls_val}")
ctx.close()
# Session 2 — state is restored
print("\n=== Session 2: Verifying persistence ===")
print("Launching stealth browser...", flush=True)
ctx = launch_persistent_context(PROFILE_DIR, headless=False)
page = ctx.new_page()
page.goto("https://example.com")
print(f"Cookie: {page.evaluate('document.cookie')}")
ls_val = page.evaluate("localStorage.getItem('user')")
print(f"localStorage: {ls_val}")
ctx.close()
print("\nDone!")
+1
View File
@@ -9,6 +9,7 @@ import time
from cloakbrowser import launch
print("Launching stealth browser...", flush=True)
browser = launch(headless=True)
page = browser.new_page()
+82 -30
View File
@@ -53,21 +53,27 @@ def test_bot_sannysoft(page):
def test_bot_incolumitas(page):
"""bot.incolumitas.com — comprehensive 30+ check bot detection."""
page.goto("https://bot.incolumitas.com", wait_until="networkidle", timeout=30000)
time.sleep(12) # needs time to run all detection tests
# Site outputs JSON blocks in page text, not HTML tables
results = page.evaluate("""() => {
const text = document.body.innerText;
const okMatches = text.match(/"\\w+":\\s*"OK"/g) || [];
const failMatches = text.match(/"\\w+":\\s*"FAIL"/g) || [];
const failedTests = failMatches.map(m => m.match(/"(\\w+)"/)[1]);
return {
passed: okMatches.length,
failed: failMatches.length,
failedTests,
total: okMatches.length + failMatches.length
};
}""")
# Poll until test count stabilizes (site runs tests progressively)
last_total = 0
for _ in range(15):
time.sleep(2)
results = page.evaluate("""() => {
const text = document.body.innerText;
const okMatches = text.match(/"\\w+":\\s*"OK"/g) || [];
const failMatches = text.match(/"\\w+":\\s*"FAIL"/g) || [];
const failedTests = failMatches.map(m => m.match(/"(\\w+)"/)[1]);
return {
passed: okMatches.length,
failed: failMatches.length,
failedTests,
total: okMatches.length + failMatches.length
};
}""")
if results["total"] >= 30 and results["total"] == last_total:
break
last_total = results["total"]
return results
@@ -143,22 +149,21 @@ def test_recaptcha(page):
"""recaptcha-demo.appspot.com — Google's official reCAPTCHA v3 score."""
page.goto(
"https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php",
wait_until="networkidle",
wait_until="domcontentloaded",
timeout=30000,
)
# Page auto-submits via grecaptcha.execute() — wait for backend response
time.sleep(8)
# Wait for score to appear (polls up to 30s)
for _ in range(15):
time.sleep(2)
score = page.evaluate("""() => {
const text = document.body.innerText;
const match = text.match(/"score":\\s*(\\d+\\.\\d+)/);
return match ? parseFloat(match[1]) : null;
}""")
if score is not None:
break
results = page.evaluate("""() => {
const text = document.body.innerText;
// Score appears in JSON response block: "score": 0.9
const scoreMatch = text.match(/"score":\\s*(\\d+\\.\\d+)/);
return {
score: scoreMatch ? parseFloat(scoreMatch[1]) : null,
pageText: text.substring(0, 500)
};
}""")
return results
return {"score": score}
TESTS = [
@@ -175,8 +180,11 @@ TESTS = [
"url": "https://bot.incolumitas.com",
"runner": test_bot_incolumitas,
"verdict": lambda r: f"{r['passed']}/{r['total']} passed"
+ (f" (FAILED: {', '.join(r.get('failedTests', []))})" if r.get("failed", 0) > 0 else " — ALL GREEN"),
"pass": lambda r: r.get("failed", 0) <= 1, # fpscanner.WEBDRIVER false positive expected (all builds)
+ (" — ALL GREEN" if r.get("failed", 0) == 0
else f" (FAILED: {', '.join(r.get('failedTests', []))} — known false positives)"
if set(r.get("failedTests", [])) <= {"WEBDRIVER", "connectionRTT"}
else f" (FAILED: {', '.join(r.get('failedTests', []))})"),
"pass": lambda r: set(r.get("failedTests", [])) <= {"WEBDRIVER", "connectionRTT"}, # known false positives
},
{
"name": "BrowserScan",
@@ -218,10 +226,54 @@ def main():
print(f"Screenshots: {'on' if SCREENSHOTS else 'off'}")
print(f"Proxy: {PROXY or 'none'}")
print()
print("Launching stealth browser...", flush=True)
browser = launch(headless=not HEADED, proxy=PROXY)
browser = launch(headless=not HEADED, proxy=PROXY, geoip=True)
page = browser.new_page()
# Show browser fingerprint details
try:
import re
info = page.evaluate("""async () => {
const ua = navigator.userAgent;
let fullVersion = null;
try {
const data = await navigator.userAgentData.getHighEntropyValues(['fullVersionList', 'platform', 'platformVersion']);
const chrome = data.fullVersionList.find(b => b.brand === 'Chromium' || b.brand === 'Google Chrome');
fullVersion = chrome ? chrome.version : null;
} catch {}
const gl = document.createElement('canvas').getContext('webgl');
const dbg = gl ? gl.getExtension('WEBGL_debug_renderer_info') : null;
return {
ua,
fullVersion,
platform: navigator.platform,
cores: navigator.hardwareConcurrency,
gpu: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : 'N/A',
gpuVendor: dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : 'N/A',
screen: screen.width + 'x' + screen.height,
languages: navigator.languages.join(', '),
};
}""")
# Condensed UA
ua_short = re.sub(r'^Mozilla/5\.0 \(', '', info["ua"])
ua_short = re.sub(r'\) AppleWebKit/[\d.]+ \(KHTML, like Gecko\) ', ' | ', ua_short)
print(f"UA: {ua_short}", flush=True)
print(f"Platform: {info['platform']} | Cores: {info['cores']} | Screen: {info['screen']}", flush=True)
print(f"GPU: {info['gpuVendor']}{info['gpu']}", flush=True)
except Exception:
print("Chrome: could not detect", flush=True)
# Show IP address
try:
page.goto("https://httpbin.org/ip", timeout=10000)
ip = page.evaluate("JSON.parse(document.body.innerText).origin")
print(f"IP: {ip}", flush=True)
except Exception:
print("IP: could not detect", flush=True)
print(f"Running {len(TESTS)} tests (this takes ~2 minutes)...\n", flush=True)
results_summary = []
for test in TESTS:
+87 -21
View File
@@ -9,13 +9,14 @@
**Stealth Chromium that passes every bot detection test.**
Drop-in Playwright/Puppeteer replacement. Same API — just swap the import. Scores **0.9 on reCAPTCHA v3**, passes **Cloudflare Turnstile**, and clears **30/30** stealth detection tests.
Drop-in Playwright/Puppeteer replacement. Same API, same code — just swap the import. **3 lines of code, 30 seconds to unblock.**
- 🔒 **25 source-level C++ patches**not JS injection, not config flags
- 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified
- ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 30/30 tests
- 🔄 **Drop-in replacement**works with both Playwright and Puppeteer
- 📦 **`npm install cloakbrowser`** — binary auto-downloads, zero config
- **48 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, WebRTC, network timing, 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** — tested with browser-use, Crawl4AI, Scrapling, Stagehand ([example](examples/stagehand.ts)), LangChain, Selenium, and more
## Install
@@ -60,22 +61,30 @@ await browser.close();
### Options
```javascript
import { launch, launchContext } from 'cloakbrowser';
import { launch, launchContext, launchPersistentContext } from 'cloakbrowser';
// With proxy
// With proxy (HTTP or SOCKS5)
const browser = await launch({
proxy: 'http://user:pass@proxy:8080',
});
const browser = await launch({
proxy: 'socks5://user:pass@proxy:1080',
});
// With proxy object (bypass, separate auth fields)
const browser = await launch({
proxy: { server: 'http://proxy:8080', bypass: '.google.com', username: 'user', password: 'pass' },
});
// Headed mode (visible browser window)
const browser = await launch({ headless: false });
// Extra Chrome args
const browser = await launch({
args: ['--window-size=1920,1080'],
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',
@@ -87,13 +96,23 @@ 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 },
locale: 'en-US',
timezoneId: 'America/New_York',
timezone: 'America/New_York',
});
// Persistent profile — stay logged in, bypass incognito detection, load extensions
const ctx = await launchPersistentContext({
userDataDir: './chrome-profile',
headless: false,
proxy: 'http://user:pass@proxy:8080',
});
const page = ctx.pages()[0] || await ctx.newPage();
await page.goto('https://example.com');
await ctx.close(); // profile saved — reuse same path to restore state
```
### Auto Timezone/Locale from Proxy IP
@@ -117,6 +136,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
@@ -146,6 +176,9 @@ if (newVersion) console.log(`Updated to ${newVersion}`);
| **BrowserScan** | DETECTED | **NORMAL** (4/4) |
| **bot.incolumitas.com** | 13 fails | **1 fail** |
| `navigator.webdriver` | `true` | **`false`** |
| CDP detection | Detected | **Not detected** |
| TLS fingerprint | Mismatch | **Identical to Chrome** |
| | | **Tested against 30+ detection sites** |
## Configuration
@@ -171,22 +204,36 @@ const page = await browser.newPage();
## Platforms
| Platform | Status |
|---|---|
| Linux x86_64 | ✅ Available |
| macOS arm64 (Apple Silicon) | ✅ Available |
| macOS x86_64 (Intel) | ✅ Available |
| Windows | Planned |
**On Windows?** You can still use CloakBrowser via Docker or with your own Chromium binary by setting `CLOAKBROWSER_BINARY_PATH=/path/to/chrome`.
| Platform | Chromium | Patches | Status |
|---|---|---|---|
| Linux x86_64 | 145 | 48 | ✅ Latest |
| Linux arm64 (RPi, Graviton) | 145 | 48 | ✅ Latest |
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest |
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest |
| Windows x86_64 | 145 | 48 | ✅ Latest |
## Requirements
- Node.js >= 18
- Node.js >= 20
- One of: `playwright-core` >= 1.40 or `puppeteer-core` >= 21
## Troubleshooting
**Site detects incognito / private browsing mode**
By default, `launch()` opens an incognito context. Some sites (like BrowserScan) detect this. Use `launchPersistentContext()` instead — it runs with a real user profile:
```javascript
import { launchPersistentContext } from 'cloakbrowser';
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)**
Avoid `page.waitForTimeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
@@ -205,8 +252,25 @@ Other tips for maximizing reCAPTCHA scores:
- **Spend 15+ seconds on the page** before triggering reCAPTCHA — short visits score lower
- **Space out requests** — back-to-back `grecaptcha.execute()` calls from the same session get penalized. Wait 30+ seconds between pages with reCAPTCHA
- **Use a fixed fingerprint seed** (`--fingerprint=12345`) for consistent device identity across sessions
- **Use `page.type()` instead of `page.fill()`** for form filling — `fill()` sets values directly without keyboard events, which reCAPTCHA's behavioral analysis flags. `type()` with a delay simulates real keystrokes:
```javascript
await page.type('#email', 'user@example.com', { delay: 50 });
```
- **Minimize `page.evaluate()` calls** before the reCAPTCHA check fires — each one sends CDP traffic
**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:
```bash
# Linux
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.159.2/chrome
# macOS
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.109.2/Chromium.app/Contents/MacOS/Chromium
# Windows
set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-145.0.7632.159.7\chrome.exe
```
## Links
- 🌐 [Website](https://cloakbrowser.dev)
@@ -219,3 +283,5 @@ Other tips for maximizing reCAPTCHA scores:
- **Wrapper code** (this repository) — MIT. See [LICENSE](https://github.com/CloakHQ/CloakBrowser/blob/main/LICENSE).
- **CloakBrowser binary** (compiled Chromium) — free to use, no redistribution. See [BINARY-LICENSE.md](https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md).
Use against financial, banking, healthcare, or government authentication systems without authorization is expressly prohibited.
+40
View File
@@ -0,0 +1,40 @@
/**
* Persistent context example: cookies and localStorage survive across sessions.
*
* Usage:
* CLOAKBROWSER_BINARY_PATH=/path/to/chrome npx tsx examples/persistent-context.ts
*/
import { launchPersistentContext } from "../src/index.js";
const PROFILE_DIR = "./my-profile";
// Session 1 — set some state
console.log("=== Session 1: Setting state ===");
let ctx = await launchPersistentContext({
userDataDir: PROFILE_DIR,
headless: false,
});
let page = ctx.pages()[0] || (await ctx.newPage());
await page.goto("https://example.com");
await page.evaluate(() => {
document.cookie = "session=abc123; path=/; max-age=3600";
localStorage.setItem("user", "returning");
});
console.log(`Cookie: ${await page.evaluate(() => document.cookie)}`);
console.log(`localStorage: ${await page.evaluate(() => localStorage.getItem("user"))}`);
await ctx.close();
// Session 2 — state is restored
console.log("\n=== Session 2: Verifying persistence ===");
ctx = await launchPersistentContext({
userDataDir: PROFILE_DIR,
headless: false,
});
page = ctx.pages()[0] || (await ctx.newPage());
await page.goto("https://example.com");
console.log(`Cookie: ${await page.evaluate(() => document.cookie)}`);
console.log(`localStorage: ${await page.evaluate(() => localStorage.getItem("user"))}`);
await ctx.close();
console.log("\nDone!");
+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();
+57 -9
View File
@@ -1,31 +1,36 @@
{
"name": "cloakbrowser",
"version": "0.2.0",
"version": "0.3.23",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cloakbrowser",
"version": "0.2.0",
"version": "0.3.23",
"license": "MIT",
"dependencies": {
"tar": "^7.0.0"
},
"bin": {
"cloakbrowser": "dist/cli.js"
},
"devDependencies": {
"@types/node": "^20.10.0",
"mmdb-lib": "^3.0.2",
"playwright-core": "^1.40.0",
"puppeteer-core": "^21.0.0",
"socks-proxy-agent": "^10.0.0",
"typescript": "^5.3.0",
"vitest": "^1.0.0"
},
"engines": {
"node": ">=18.0.0"
"node": ">=20.0.0"
},
"peerDependencies": {
"mmdb-lib": ">=2.0.0",
"playwright-core": ">=1.40.0",
"puppeteer-core": ">=21.0.0"
"puppeteer-core": ">=21.0.0",
"socks-proxy-agent": ">=8.0.0"
},
"peerDependenciesMeta": {
"mmdb-lib": {
@@ -36,6 +41,9 @@
},
"puppeteer-core": {
"optional": true
},
"socks-proxy-agent": {
"optional": true
}
}
},
@@ -2003,6 +2011,21 @@
"node": ">= 14"
}
},
"node_modules/pac-proxy-agent/node_modules/socks-proxy-agent": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "^4.3.4",
"socks": "^2.8.3"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/pac-resolver": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz",
@@ -2164,6 +2187,21 @@
"node": ">= 14"
}
},
"node_modules/proxy-agent/node_modules/socks-proxy-agent": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "^4.3.4",
"socks": "^2.8.3"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -2332,18 +2370,28 @@
}
},
"node_modules/socks-proxy-agent": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-10.0.0.tgz",
"integrity": "sha512-pyp2YR3mNxAMu0mGLtzs4g7O3uT4/9sQOLAKcViAkaS9fJWkud7nmaf6ZREFqQEi24IPkBcjfHjXhPTUWjo3uA==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"agent-base": "9.0.0",
"debug": "^4.3.4",
"socks": "^2.8.3"
},
"engines": {
"node": ">= 14"
"node": ">= 20"
}
},
"node_modules/socks-proxy-agent/node_modules/agent-base": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
"integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
}
},
"node_modules/source-map": {
+15 -3
View File
@@ -1,6 +1,6 @@
{
"name": "cloakbrowser",
"version": "0.3.1",
"version": "0.3.25",
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
"type": "module",
"main": "dist/index.js",
@@ -13,8 +13,15 @@
"./puppeteer": {
"types": "./dist/puppeteer.d.ts",
"import": "./dist/puppeteer.js"
},
"./human": {
"types": "./dist/human/index.d.ts",
"import": "./dist/human/index.js"
}
},
"bin": {
"cloakbrowser": "./dist/cli.js"
},
"files": [
"dist"
],
@@ -48,12 +55,13 @@
},
"homepage": "https://github.com/CloakHQ/cloakbrowser#javascript--nodejs",
"engines": {
"node": ">=18.0.0"
"node": ">=20.0.0"
},
"peerDependencies": {
"mmdb-lib": ">=2.0.0",
"playwright-core": ">=1.40.0",
"puppeteer-core": ">=21.0.0"
"puppeteer-core": ">=21.0.0",
"socks-proxy-agent": ">=10.0.0"
},
"peerDependenciesMeta": {
"playwright-core": {
@@ -64,6 +72,9 @@
},
"mmdb-lib": {
"optional": true
},
"socks-proxy-agent": {
"optional": true
}
},
"dependencies": {
@@ -72,6 +83,7 @@
"devDependencies": {
"@types/node": "^20.10.0",
"mmdb-lib": "^3.0.2",
"socks-proxy-agent": "^10.0.0",
"playwright-core": "^1.40.0",
"puppeteer-core": "^21.0.0",
"typescript": "^5.3.0",
+59
View File
@@ -0,0 +1,59 @@
/**
* Shared argument builder for Playwright and Puppeteer wrappers.
*/
import type { LaunchOptions } from "./types.js";
import { getDefaultStealthArgs } from "./config.js";
const DEBUG = /\bcloakbrowser\b/.test(process.env.DEBUG ?? "");
/**
* Build deduplicated Chromium CLI args from stealth defaults + user overrides.
*
* Priority: stealth defaults < user args < dedicated params (timezone/locale).
*/
export function buildArgs(options: LaunchOptions): string[] {
const seen = new Map<string, string>();
if (options.stealthArgs !== false) {
for (const arg of getDefaultStealthArgs()) {
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];
if (seen.has(key)) {
if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(key)} -> ${arg}`);
}
seen.set(key, arg);
}
}
if (options.timezone) {
const key = "--fingerprint-timezone";
const flag = `${key}=${options.timezone}`;
if (seen.has(key)) {
if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(key)} -> ${flag}`);
}
seen.set(key, flag);
}
if (options.locale) {
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);
}
}
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();
+36 -26
View File
@@ -23,17 +23,18 @@ export { WRAPPER_VERSION };
// ---------------------------------------------------------------------------
// Chromium version shipped with this release.
// Different platforms may ship different versions (e.g. Linux gets v145 first,
// macOS stays on v142 until Mac builds are ready).
// Different platforms may ship different versions during transition periods.
// 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.109";
export const CHROMIUM_VERSION = "146.0.7680.177.3";
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
"linux-x64": "145.0.7632.109",
"darwin-arm64": "142.0.7444.175",
"darwin-x64": "142.0.7444.175",
"linux-x64": "146.0.7680.177.3",
"linux-arm64": "146.0.7680.177.3",
"darwin-arm64": "145.0.7632.109.2",
"darwin-x64": "145.0.7632.109.2",
"windows-x64": "145.0.7632.159.7",
};
// ---------------------------------------------------------------------------
@@ -44,6 +45,7 @@ const SUPPORTED_PLATFORMS: Record<string, string> = {
"linux-arm64": "linux-arm64",
"darwin-arm64": "darwin-arm64",
"darwin-x64": "darwin-x64",
"win32-x64": "windows-x64",
};
// Platforms with pre-built binaries available for download (derived from version map).
@@ -64,6 +66,7 @@ export function getPlatformTag(): string {
else if (platform === "linux" && arch === "arm64") key = "linux-arm64";
else if (platform === "darwin" && arch === "arm64") key = "darwin-arm64";
else if (platform === "darwin" && arch === "x64") key = "darwin-x64";
else if (platform === "win32" && arch === "x64") key = "win32-x64";
else {
const supported = Object.values(SUPPORTED_PLATFORMS).join(", ");
throw new Error(
@@ -92,6 +95,9 @@ export function getBinaryPath(version?: string): string {
if (process.platform === "darwin") {
return path.join(binaryDir, "Chromium.app", "Contents", "MacOS", "Chromium");
}
if (process.platform === "win32") {
return path.join(binaryDir, "chrome.exe");
}
return path.join(binaryDir, "chrome");
}
@@ -102,9 +108,8 @@ export function checkPlatformAvailable(): void {
if (!AVAILABLE_PLATFORMS.has(tag)) {
const available = [...AVAILABLE_PLATFORMS].sort().join(", ");
throw new Error(
`CloakBrowser — Pre-built binaries are currently only available for: ${available}.\n` +
`Windows builds are coming soon.\n\n` +
`To use CloakBrowser now, run in Docker (see README) or set CLOAKBROWSER_BINARY_PATH.`
`CloakBrowser — Pre-built binaries are currently only available for: ${available}.\n\n` +
`To use CloakBrowser now, set CLOAKBROWSER_BINARY_PATH to a local Chromium binary.`
);
}
}
@@ -122,16 +127,22 @@ export const GITHUB_API_URL =
export const GITHUB_DOWNLOAD_BASE_URL =
"https://github.com/CloakHQ/cloakbrowser/releases/download";
export function getArchiveExt(): string {
return process.platform === "win32" ? ".zip" : ".tar.gz";
}
export function getArchiveName(tag?: string): string {
return `cloakbrowser-${tag || getPlatformTag()}${getArchiveExt()}`;
}
export function getDownloadUrl(version?: string): string {
const v = version || getChromiumVersion();
const tag = getPlatformTag();
return `${DOWNLOAD_BASE_URL}/chromium-v${v}/cloakbrowser-${tag}.tar.gz`;
return `${DOWNLOAD_BASE_URL}/chromium-v${v}/${getArchiveName()}`;
}
export function getFallbackDownloadUrl(version?: string): string {
const v = version || getChromiumVersion();
const tag = getPlatformTag();
return `${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/cloakbrowser-${tag}.tar.gz`;
return `${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/${getArchiveName()}`;
}
export function getEffectiveVersion(): string {
@@ -178,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
// ---------------------------------------------------------------------------
@@ -192,7 +211,6 @@ export function getDefaultStealthArgs(): string[] {
const base = [
"--no-sandbox",
"--disable-blink-features=AutomationControlled",
`--fingerprint=${seed}`,
];
@@ -201,16 +219,8 @@ export function getDefaultStealthArgs(): string[] {
return [...base, "--fingerprint-platform=macos"];
}
// Linux: spoof as Windows
return [
...base,
"--fingerprint-platform=windows",
"--fingerprint-hardware-concurrency=8",
"--fingerprint-device-memory=8",
"--fingerprint-gpu-vendor=NVIDIA Corporation",
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
"--fingerprint-screen-width=1920",
"--fingerprint-screen-height=1080",
"--window-size=1920,1080",
];
// Linux/Windows: spoof as Windows desktop
// Hardware concurrency, device memory, screen, window size, and GPU are
// auto-generated by the binary from the seed (v14+).
return [...base, "--fingerprint-platform=windows"];
}
+96 -40
View File
@@ -19,6 +19,8 @@ import {
GITHUB_DOWNLOAD_BASE_URL,
WRAPPER_VERSION,
checkPlatformAvailable,
getArchiveExt,
getArchiveName,
getBinaryDir,
getBinaryPath,
getCacheDir,
@@ -63,6 +65,7 @@ export async function ensureBinary(): Promise<string> {
const binaryPath = getBinaryPath(effective);
if (fs.existsSync(binaryPath) && isExecutable(binaryPath)) {
showWelcome();
maybeTriggerUpdateCheck();
return binaryPath;
}
@@ -87,8 +90,8 @@ export async function ensureBinary(): Promise<string> {
if (!fs.existsSync(downloadedPath)) {
throw new Error(
`Download completed but binary not found at expected path: ${downloadedPath}. ` +
`This may indicate a packaging issue. Please report at ` +
`https://github.com/CloakHQ/cloakbrowser/issues`
`This may indicate a packaging issue. Please report at ` +
`https://github.com/CloakHQ/cloakbrowser/issues`
);
}
@@ -136,6 +139,29 @@ export async function checkForUpdate(): Promise<string | null> {
return latest;
}
// ---------------------------------------------------------------------------
// Welcome message (shown once per install)
// ---------------------------------------------------------------------------
function showWelcome(): void {
const marker = path.join(getCacheDir(), ".welcome_shown");
if (fs.existsSync(marker)) return;
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, "");
} catch {
// Non-fatal
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
@@ -152,7 +178,7 @@ async function downloadAndExtract(version?: string): Promise<void> {
// Download to temp file (atomic — no partial downloads in cache)
const tmpPath = path.join(
path.dirname(binaryDir),
`_download_${Date.now()}.tar.gz`
`_download_${Date.now()}${getArchiveExt()}`
);
try {
@@ -175,15 +201,7 @@ async function downloadAndExtract(version?: string): Promise<void> {
}
await extractArchive(tmpPath, binaryDir, binaryPath);
console.log(
`[cloakbrowser] Visit https://cloakbrowser.dev for docs and release notifications.`
);
console.log(
`[cloakbrowser] Issues? https://github.com/CloakHQ/CloakBrowser/issues`
);
console.log(
`[cloakbrowser] Star us if CloakBrowser helps: https://github.com/CloakHQ/CloakBrowser`
);
showWelcome();
} finally {
// Clean up temp file
if (fs.existsSync(tmpPath)) {
@@ -194,7 +212,7 @@ async function downloadAndExtract(version?: string): Promise<void> {
async function verifyDownloadChecksum(filePath: string, version?: string): Promise<void> {
const checksums = await fetchChecksums(version);
const tarballName = `cloakbrowser-${getPlatformTag()}.tar.gz`;
const tarballName = getArchiveName();
if (!checksums) {
console.warn("[cloakbrowser] SHA256SUMS not available for this release — skipping checksum verification");
@@ -210,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;
@@ -274,6 +293,9 @@ async function downloadFile(url: string, dest: string): Promise<void> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
// Create file stream early so we can ensure cleanup on error
const fileStream = createWriteStream(dest);
try {
const response = await fetch(url, {
signal: controller.signal,
@@ -292,7 +314,6 @@ async function downloadFile(url: string, dest: string): Promise<void> {
let downloaded = 0;
let lastLoggedPct = -1;
const fileStream = createWriteStream(dest);
const reader = response.body.getReader();
// Stream chunks to file with progress logging
@@ -316,19 +337,32 @@ async function downloadFile(url: string, dest: string): Promise<void> {
}
}
// Wait for file stream to finish
// Wait for file stream to fully close (not just finish)
await new Promise<void>((resolve, reject) => {
fileStream.end(() => resolve());
fileStream.end();
fileStream.on("close", () => resolve());
fileStream.on("error", reject);
});
const sizeMB = Math.floor(fs.statSync(dest).size / (1024 * 1024));
console.log(`[cloakbrowser] Download complete: ${sizeMB} MB`);
} catch (err) {
// Ensure file stream is destroyed on error to release the handle
if (!fileStream.destroyed) {
await new Promise<void>((resolve) => {
fileStream.destroy();
fileStream.on("close", () => resolve());
// Safety timeout in case close never fires
setTimeout(resolve, 2000);
});
}
throw err;
} finally {
clearTimeout(timeout);
}
}
async function extractArchive(
archivePath: string,
destDir: string,
@@ -342,30 +376,18 @@ async function extractArchive(
}
fs.mkdirSync(destDir, { recursive: true });
// Extract with tar — the 'tar' package handles symlink/traversal safety
await tarExtract({
file: archivePath,
cwd: destDir,
// Security: strip leading path components and reject absolute paths
strip: 0,
filter: (entryPath: string) => {
// Reject absolute paths and path traversal
if (path.isAbsolute(entryPath) || entryPath.includes("..")) {
console.warn(
`[cloakbrowser] Skipping suspicious archive entry: ${entryPath}`
);
return false;
}
return true;
},
});
if (archivePath.endsWith(".zip")) {
await extractZip(archivePath, destDir);
} else {
await extractTar(archivePath, destDir);
}
// Flatten single subdirectory if needed
flattenSingleSubdir(destDir);
// Make binary executable
// Make binary executable (skip on Windows — no-op / AV lock risk)
const bp = binaryPath || getBinaryPath();
if (fs.existsSync(bp)) {
if (process.platform !== "win32" && fs.existsSync(bp)) {
fs.chmodSync(bp, 0o755);
}
@@ -379,6 +401,40 @@ async function extractArchive(
}
}
async function extractTar(archivePath: string, destDir: string): Promise<void> {
await tarExtract({
file: archivePath,
cwd: destDir,
strip: 0,
filter: (entryPath: string) => {
if (path.isAbsolute(entryPath) || entryPath.includes("..")) {
console.warn(
`[cloakbrowser] Skipping suspicious archive entry: ${entryPath}`
);
return false;
}
return true;
},
});
}
async function extractZip(archivePath: string, destDir: string): Promise<void> {
// Brief delay to ensure OS fully releases file handles (Windows)
await new Promise(resolve => setTimeout(resolve, 500));
if (process.platform === "win32") {
// PowerShell 5.1's Expand-Archive uses .NET FileStream which can conflict
// with recently-closed Node.js file handles. Use ZipFile API directly.
execFileSync("powershell", [
"-NoProfile", "-Command",
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
`[System.IO.Compression.ZipFile]::ExtractToDirectory('${archivePath}', '${destDir}')`,
], { timeout: 120_000 });
} else {
execFileSync("unzip", ["-o", archivePath, "-d", destDir], { timeout: 120_000 });
}
}
/**
* If extraction created a single subdirectory, move its contents up.
* Many tarballs wrap files in a top-level directory.
@@ -452,7 +508,7 @@ export async function getLatestChromiumVersion(): Promise<string | null> {
draft: boolean;
assets: Array<{ name: string }>;
}>;
const platformTarball = `cloakbrowser-${getPlatformTag()}.tar.gz`;
const platformTarball = getArchiveName();
for (const release of releases) {
if (release.tag_name.startsWith("chromium-v") && !release.draft) {
const assetNames = new Set(
@@ -500,7 +556,7 @@ export async function checkWrapperUpdate(): Promise<void> {
if (data.version && versionNewer(data.version, WRAPPER_VERSION)) {
console.warn(
`[cloakbrowser] Update available: ${WRAPPER_VERSION}${data.version}. ` +
`Run: npm install cloakbrowser@latest`
`Run: npm install cloakbrowser@latest`
);
}
} catch {
@@ -547,10 +603,10 @@ async function checkAndDownloadUpdate(): Promise<void> {
function maybeTriggerUpdateCheck(): void {
// Wrapper update: once per process, not rate-limited
if (!wrapperUpdateChecked) {
checkWrapperUpdate().catch(() => {});
checkWrapperUpdate().catch(() => { });
}
// Binary update: rate-limited to once per hour
if (!shouldCheckForUpdate()) return;
checkAndDownloadUpdate().catch(() => {});
checkAndDownloadUpdate().catch(() => { });
}
+126 -7
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, isSocksProxy, reconstructSocksUrl, type ProxyDict } from "./proxy.js";
// P3TERX mirror of MaxMind GeoLite2-City — no license key needed
const GEOIP_DB_URL =
@@ -42,6 +44,7 @@ export const COUNTRY_LOCALE_MAP: Record<string, string> = {
export interface GeoResult {
timezone: string | null;
locale: string | null;
exitIp: string | null;
}
/**
@@ -63,12 +66,12 @@ export async function resolveProxyGeo(
}
const dbPath = await ensureGeoipDb();
if (!dbPath) return { timezone: null, locale: null };
if (!dbPath) return { timezone: null, locale: null, exitIp: null };
// Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
let ip = await resolveExitIp(proxyUrl);
if (!ip) ip = await resolveProxyIp(proxyUrl);
if (!ip) return { timezone: null, locale: null };
if (!ip) return { timezone: null, locale: null, exitIp: null };
try {
const buf = fs.readFileSync(dbPath);
@@ -78,9 +81,9 @@ export async function resolveProxyGeo(
const countryCode: string | null = result?.country?.iso_code ?? null;
const locale =
countryCode ? (COUNTRY_LOCALE_MAP[countryCode] ?? null) : null;
return { timezone, locale };
return { timezone, locale, exitIp: ip };
} catch {
return { timezone: null, locale: null };
return { timezone: null, locale: null, exitIp: ip };
}
}
@@ -126,9 +129,44 @@ const IP_ECHO_URLS = [
];
async function resolveExitIp(proxyUrl: string): Promise<string | null> {
// Node.js fetch doesn't support proxy natively — use a CONNECT tunnel via http
// For simplicity, use a direct HTTP request to a plain-text IP echo service
// through the proxy using Node's http module
const isSocks = isSocksProxy(proxyUrl);
// SOCKS5: tunnel through the SOCKS5 proxy via socks-proxy-agent
if (isSocks) {
let SocksProxyAgent: typeof import("socks-proxy-agent").SocksProxyAgent;
try {
({ SocksProxyAgent } = await import("socks-proxy-agent"));
} catch {
console.warn("[cloakbrowser] socks-proxy-agent not installed — cannot resolve exit IP through SOCKS5 proxy. Install it: npm install socks-proxy-agent");
return null;
}
const { default: https } = await import("node:https");
const agent = new SocksProxyAgent(proxyUrl);
for (const echoUrl of IP_ECHO_URLS) {
try {
const ip = await new Promise<string | null>((resolve) => {
const req = https.request(echoUrl, { agent, timeout: 10_000 }, (res) => {
let data = "";
res.on("data", (chunk: Buffer) => (data += chunk.toString()));
res.on("end", () => {
const ip = data.trim();
resolve(net.isIP(ip) ? ip : null);
});
});
req.on("error", () => resolve(null));
req.on("timeout", () => { req.destroy(); resolve(null); });
req.end();
});
if (ip) return ip;
} catch {
continue;
}
}
return null;
}
// HTTP/HTTPS: use a CONNECT tunnel via http
try {
const { default: http } = await import("node:http");
const { default: https } = await import("node:https");
@@ -260,3 +298,84 @@ function maybeTriggerUpdate(dbPath: string): void {
// Fire-and-forget background update
downloadGeoipDb(dbPath).catch(() => {});
}
/**
* Extract a usable proxy URL from LaunchOptions.proxy.
* For SOCKS5 dicts with separate credentials, reconstructs the full URL
* with inline credentials so SOCKS5 auth works.
*/
function extractProxyUrl(proxy: string | ProxyDict | undefined): string | null {
if (!proxy) return null;
if (typeof proxy === "string") return ensureProxyScheme(proxy);
const p = proxy as ProxyDict;
if (!p.server) return null;
if (p.username && isSocksProxy(p)) {
return reconstructSocksUrl(p);
}
return ensureProxyScheme(p.server);
}
/**
* Auto-fill timezone/locale from proxy IP when geoip is enabled.
* Also returns exitIp as a free bonus (reused for WebRTC spoofing).
*/
export async function maybeResolveGeoip(
options: LaunchOptions
): Promise<{ timezone?: string; locale?: string; exitIp?: string }> {
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
const proxyUrl = extractProxyUrl(options.proxy);
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
// When both tz/locale are explicit, still resolve exit IP for WebRTC
if (options.timezone && options.locale) {
const exitIp = await resolveExitIp(proxyUrl) ?? undefined;
return { timezone: options.timezone, locale: options.locale, exitIp };
}
const { timezone: geoTz, locale: geoLocale, exitIp: geoExitIp } = await resolveProxyGeo(proxyUrl);
const exitIp = geoExitIp ?? undefined;
return {
timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined,
exitIp,
};
}
/**
* Replace --fingerprint-webrtc-ip=auto with the resolved proxy exit IP.
* Returns args unchanged if no ``auto`` value is present.
*/
export async function resolveWebrtcArgs(
options: LaunchOptions
): Promise<string[] | undefined> {
const args = options.args;
if (!args) return args;
const idx = args.findIndex(a => a === "--fingerprint-webrtc-ip=auto");
if (idx === -1) return args;
const proxyUrl = extractProxyUrl(options.proxy);
if (!proxyUrl) {
console.warn("[cloakbrowser] --fingerprint-webrtc-ip=auto requires a proxy; removing flag");
const result = [...args];
result.splice(idx, 1);
return result;
}
try {
const ip = await resolveExitIp(proxyUrl);
const result = [...args];
if (ip) {
result[idx] = `--fingerprint-webrtc-ip=${ip}`;
} else {
console.warn("[cloakbrowser] Could not resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto");
result.splice(idx, 1);
}
return result;
} catch {
console.warn("[cloakbrowser] Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto");
const result = [...args];
result.splice(idx, 1);
return result;
}
}
+913
View File
@@ -0,0 +1,913 @@
/**
* Human-like behavioral layer for cloakbrowser Puppeteer edition.
*
* Mirrors Playwright humanize architecture, adapted for Puppeteer API.
*
* Patches ALL native Puppeteer interaction surfaces:
*
* PAGE-LEVEL:
* click (with clickCount support for dblclick), hover, type,
* select, focus, tap, goto
*
* MOUSE:
* move, click (with clickCount support for dblclick), wheel,
* dragAndDrop
*
* KEYBOARD:
* type, down, up, press, sendCharacter
*
* FRAME-LEVEL:
* click, hover, type, select, focus, tap
* + $, $$, waitForSelector (return patched ElementHandles)
*
* ELEMENTHANDLE-LEVEL (Puppeteer-specific, no Playwright equivalent):
* click (with clickCount), hover, type, press, tap, select,
* focus, drop, dragAndDrop
* + $, $$, waitForSelector (nested elements are also patched)
*
* BROWSER-LEVEL:
* newPage, createBrowserContext / createIncognitoBrowserContext,
* targetcreated event
*
* Stealth-aware:
* - isInputElement / isSelectorFocused use CDP Isolated Worlds
* - Shift symbol typing uses CDP Input.dispatchKeyEvent (isTrusted=true)
* - ElementHandle isInput check uses CDP DOM.describeNode (no JS execution)
* - Falls back to page.evaluate only when CDP session is unavailable
*
* Puppeteer-specific adaptations:
* - page.createCDPSession() instead of context.newCDPSession(page)
* - page.viewport() instead of page.viewportSize()
* - page.$(selector) instead of page.locator(selector)
* - keyboard.sendCharacter() mapped via RawKeyboard.insertText
* - mouse.wheel({deltaX, deltaY}) object form adapted to (dx, dy)
* - page.select() instead of page.selectOption()
* - ElementHandle prototype patching (Puppeteer-only)
* - No page.dblclick() Puppeteer uses click({clickCount:2})
*/
import type { Browser, Page, Frame, CDPSession, ElementHandle, BrowserContext } from 'puppeteer-core';
import type { HumanConfig } from '../human/config.js';
import { resolveConfig, rand, randRange, sleep } from '../human/config.js';
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from '../human/mouse.js';
import { humanType } from './keyboard.js';
import { scrollToElement, smoothWheel } from './scroll.js';
export type { HumanConfig } from '../human/config.js';
export { resolveConfig } from '../human/config.js';
export { humanMove, humanClick, clickTarget, humanIdle } from '../human/mouse.js';
export { humanType } from './keyboard.js';
export { scrollToElement } from './scroll.js';
// ============================================================================
// CDP Isolated World — stealth DOM evaluation (Puppeteer version)
// ============================================================================
class StealthEval {
private cdp: CDPSession | null = null;
private contextId: number | null = null;
private page: Page;
constructor(page: Page) {
this.page = page;
}
private async ensureCdp(): Promise<CDPSession> {
if (!this.cdp) {
this.cdp = await this.page.createCDPSession();
}
return this.cdp;
}
private async createWorld(): Promise<number> {
const cdp = await this.ensureCdp();
const tree = await cdp.send('Page.getFrameTree');
const frameId = (tree as any).frameTree.frame.id;
const result = await cdp.send('Page.createIsolatedWorld', {
frameId,
worldName: '',
grantUniveralAccess: true,
});
const ctxId = (result as any).executionContextId;
this.contextId = ctxId;
return ctxId;
}
async evaluate(expression: string): Promise<any> {
if (this.contextId === null) {
await this.createWorld();
}
for (let attempt = 0; attempt < 2; attempt++) {
try {
const cdp = await this.ensureCdp();
const result = await cdp.send('Runtime.evaluate', {
expression,
contextId: this.contextId!,
returnByValue: true,
});
if ((result as any).exceptionDetails) {
if (attempt === 0) {
await this.createWorld();
continue;
}
return undefined;
}
return (result as any).result?.value;
} catch {
if (attempt === 0) {
this.contextId = null;
try { await this.createWorld(); } catch { return undefined; }
continue;
}
return undefined;
}
}
return undefined;
}
invalidate(): void {
this.contextId = null;
}
async getCdpSession(): Promise<CDPSession> {
return this.ensureCdp();
}
}
// ============================================================================
// Cursor state
// ============================================================================
class CursorState {
x = 0;
y = 0;
initialized = false;
}
// ============================================================================
// Stealth DOM queries
// ============================================================================
async function isInputElement(
stealth: StealthEval | null,
page: Page,
selector: string,
): Promise<boolean> {
if (stealth) {
try {
const escaped = JSON.stringify(selector);
const result = await stealth.evaluate(`
(() => {
const el = document.querySelector(${escaped});
if (!el) return false;
const tag = el.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea'
|| el.getAttribute('contenteditable') === 'true';
})()
`);
return !!result;
} catch { /* fallthrough */ }
}
return page.evaluate((sel: string) => {
const el = document.querySelector(sel);
if (!el) return false;
const tag = el.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea'
|| el.getAttribute('contenteditable') === 'true';
}, selector).catch(() => false);
}
async function isSelectorFocused(
stealth: StealthEval | null,
page: Page,
selector: string,
): Promise<boolean> {
if (stealth) {
try {
const escaped = JSON.stringify(selector);
const result = await stealth.evaluate(`
(() => {
const el = document.querySelector(${escaped});
return el === document.activeElement;
})()
`);
return !!result;
} catch { /* fallthrough */ }
}
return page.evaluate((sel: string) => {
const el = document.querySelector(sel);
return el === document.activeElement;
}, selector).catch(() => false);
}
// ============================================================================
// Stealth ElementHandle input check — uses CDP DOM.describeNode
// instead of el.evaluate() to avoid main-world JS execution.
// ============================================================================
async function isInputElementHandle(
stealth: StealthEval | null,
el: ElementHandle,
): Promise<boolean> {
if (stealth) {
try {
const cdp = await stealth.getCdpSession();
const remoteObject = (el as any).remoteObject?.();
if (remoteObject?.objectId) {
const { node } = await cdp.send('DOM.describeNode', {
objectId: remoteObject.objectId,
}) as any;
const tag = (node?.nodeName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea') return true;
const attrs: string[] = node?.attributes || [];
for (let i = 0; i < attrs.length; i += 2) {
if (attrs[i] === 'contenteditable' && attrs[i + 1] === 'true') {
return true;
}
}
return false;
}
} catch { /* fallthrough to el.evaluate */ }
}
return el.evaluate((node: any) => {
const tag = node.tagName?.toLowerCase();
return tag === 'input' || tag === 'textarea'
|| node.getAttribute?.('contenteditable') === 'true';
}).catch(() => false);
}
// ============================================================================
// Page-level patching
// ============================================================================
function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
const originals = {
click: page.click.bind(page),
hover: page.hover.bind(page),
type: page.type.bind(page),
select: page.select.bind(page),
focus: page.focus.bind(page),
goto: page.goto.bind(page),
tap: page.tap.bind(page),
mouseMove: page.mouse.move.bind(page.mouse),
mouseClick: page.mouse.click.bind(page.mouse),
mouseDown: page.mouse.down.bind(page.mouse),
mouseUp: page.mouse.up.bind(page.mouse),
mouseWheel: (page.mouse as any).wheel?.bind(page.mouse),
mouseDragAndDrop: (page.mouse as any).dragAndDrop?.bind(page.mouse),
keyboardType: page.keyboard.type.bind(page.keyboard),
keyboardDown: page.keyboard.down.bind(page.keyboard) as (key: string) => Promise<void>,
keyboardUp: page.keyboard.up.bind(page.keyboard) as (key: string) => Promise<void>,
keyboardPress: page.keyboard.press.bind(page.keyboard),
keyboardSendCharacter: page.keyboard.sendCharacter.bind(page.keyboard),
};
(page as any)._original = originals;
(page as any)._humanCfg = cfg;
const stealth = new StealthEval(page);
(page as any)._stealth = stealth;
let cdpSession: CDPSession | null = null;
const ensureCdp = async (): Promise<CDPSession | null> => {
if (!cdpSession) {
try { cdpSession = await stealth.getCdpSession(); } catch {}
}
return cdpSession;
};
const raw: RawMouse = {
move: originals.mouseMove,
down: originals.mouseDown,
up: originals.mouseUp,
wheel: async (deltaX: number, deltaY: number) => {
if (originals.mouseWheel) {
await originals.mouseWheel({ deltaX, deltaY });
}
},
};
const rawKb: RawKeyboard = {
down: originals.keyboardDown,
up: originals.keyboardUp,
type: originals.keyboardType,
insertText: originals.keyboardSendCharacter,
};
async function ensureCursorInit(): Promise<void> {
if (!cursor.initialized) {
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]);
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]);
await originals.mouseMove(cursor.x, cursor.y);
cursor.initialized = true;
}
}
// ==== goto ====
const humanGoto = async (url: string, options?: any) => {
const response = await originals.goto(url, options);
stealth.invalidate();
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
return response;
};
// ==== click (with clickCount support for dblclick) ====
const humanClickFn = async (selector: string, options?: any) => {
await ensureCursorInit();
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
}
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
cursor.x = cursorX;
cursor.y = cursorY;
const isInput = await isInputElement(stealth, page, selector);
const target = clickTarget(box, isInput, cfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
cursor.x = target.x;
cursor.y = target.y;
const clickCount = options?.clickCount ?? options?.count ?? 1;
if (clickCount >= 2) {
await humanClick(raw, isInput, cfg);
await sleep(rand(40, 90));
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
} else {
await humanClick(raw, isInput, cfg);
}
};
// ==== hover ====
const humanHoverFn = async (selector: string, options?: any) => {
await ensureCursorInit();
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
}
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
cursor.x = cursorX;
cursor.y = cursorY;
const target = clickTarget(box, false, cfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
cursor.x = target.x;
cursor.y = target.y;
};
// ==== type ====
const humanTypeFn = async (selector: string, text: string, options?: any) => {
await sleep(randRange(cfg.field_switch_delay));
await humanClickFn(selector);
await sleep(rand(100, 250));
const cdp = await ensureCdp();
await humanType(page, rawKb, text, cfg, cdp);
};
// ==== select ====
const humanSelectFn = async (selector: string, ...values: string[]) => {
await humanHoverFn(selector);
await sleep(rand(100, 300));
return originals.select(selector, ...values);
};
// ==== focus ====
const humanFocusFn = async (selector: string) => {
if (!await isSelectorFocused(stealth, page, selector)) {
await humanClickFn(selector);
}
};
// ==== tap ====
const humanTapFn = async (selector: string, options?: any) => {
await humanClickFn(selector, options);
};
// ============================================================
// Assign page-level patches
// ============================================================
(page as any).goto = humanGoto;
(page as any).click = humanClickFn;
(page as any).hover = humanHoverFn;
(page as any).type = humanTypeFn;
(page as any).select = humanSelectFn;
(page as any).focus = humanFocusFn;
(page as any).tap = humanTapFn;
// ============================================================
// Mouse patches
// ============================================================
page.mouse.move = async (x: number, y: number, options?: any) => {
await ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
cursor.x = x;
cursor.y = y;
};
page.mouse.click = async (x: number, y: number, options?: any) => {
await ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
cursor.x = x;
cursor.y = y;
const clickCount = options?.clickCount ?? options?.count ?? 1;
if (clickCount >= 2) {
await humanClick(raw, false, cfg);
await sleep(rand(40, 90));
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
} else {
await humanClick(raw, false, cfg);
}
};
if (originals.mouseWheel) {
(page.mouse as any).wheel = async (options?: { deltaX?: number; deltaY?: number }) => {
const dx = options?.deltaX ?? 0;
const dy = options?.deltaY ?? 0;
if (Math.abs(dy) > 0) {
await smoothWheel(raw, dy, cfg, 'y');
}
if (Math.abs(dx) > 0) {
await smoothWheel(raw, dx, cfg, 'x');
}
};
}
if (originals.mouseDragAndDrop) {
(page.mouse as any).dragAndDrop = async (
start: { x: number; y: number },
target: { x: number; y: number },
options?: any,
) => {
await ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, start.x, start.y, cfg);
cursor.x = start.x;
cursor.y = start.y;
await sleep(rand(100, 200));
await originals.mouseDown();
await sleep(rand(80, 150));
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
cursor.x = target.x;
cursor.y = target.y;
await sleep(rand(80, 150));
await originals.mouseUp();
};
}
// ============================================================
// Keyboard patches
// ============================================================
page.keyboard.type = async (text: string, options?: any) => {
const cdp = await ensureCdp();
await humanType(page, rawKb, text, cfg, cdp);
};
page.keyboard.press = async (key: any, options?: any) => {
await sleep(rand(20, 60));
await originals.keyboardDown(key as any);
await sleep(randRange(cfg.key_hold));
await originals.keyboardUp(key as any);
};
page.keyboard.down = async (key: any) => {
await sleep(rand(10, 30));
await originals.keyboardDown(key as any);
};
page.keyboard.up = async (key: any) => {
await sleep(rand(10, 30));
await originals.keyboardUp(key as any);
};
// ============================================================
// Store helpers for frame/element patching
// ============================================================
(page as any)._humanCursor = cursor;
(page as any)._humanRaw = raw;
(page as any)._humanRawKb = rawKb;
(page as any)._ensureCursorInit = ensureCursorInit;
// Initialize cursor
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]);
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]);
originals.mouseMove(cursor.x, cursor.y).then(() => {
cursor.initialized = true;
}).catch(() => {});
// Patch frames
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
// Patch ElementHandle selectors
patchElementHandle(page, cfg, cursor, raw, rawKb, originals, stealth);
}
// ============================================================================
// ElementHandle patching — PUPPETEER-SPECIFIC
// ============================================================================
function patchElementHandle(
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: StealthEval,
): void {
const orig$ = page.$.bind(page);
const orig$$ = page.$$.bind(page);
const origWaitForSelector = page.waitForSelector.bind(page);
(page as any).$ = async (selector: string) => {
const el = await orig$(selector);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
(page as any).$$ = async (selector: string) => {
const els = await orig$$(selector);
for (const el of els) {
patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
}
return els;
};
(page as any).waitForSelector = async (selector: string, options?: any) => {
const el = await origWaitForSelector(selector, options);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
}
function patchSingleElementHandle(
el: ElementHandle,
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: StealthEval,
): void {
if ((el as any)._humanPatched) return;
(el as any)._humanPatched = true;
const origElClick = el.click.bind(el);
const origElHover = el.hover.bind(el);
const origElType = el.type.bind(el);
const origElPress = (el as any).press?.bind(el);
const origElTap = (el as any).tap?.bind(el);
const origElFocus = (el as any).focus?.bind(el);
const origElDragAndDrop = (el as any).dragAndDrop?.bind(el);
const origElSelect = (el as any).select?.bind(el);
const origElDrop = (el as any).drop?.bind(el);
// --- Nested selectors ---
const origEl$ = el.$.bind(el);
const origEl$$ = el.$$.bind(el);
const origElWaitForSelector = el.waitForSelector.bind(el);
(el as any).$ = async (selector: string) => {
const child = await origEl$(selector);
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
return child;
};
(el as any).$$ = async (selector: string) => {
const children = await origEl$$(selector);
for (const child of children) {
patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
}
return children;
};
(el as any).waitForSelector = async (selector: string, options?: any) => {
const child = await origElWaitForSelector(selector, options);
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
return child;
};
// --- Helper: get box and move cursor ---
const moveToElement = async () => {
await (page as any)._ensureCursorInit();
const box = await el.boundingBox();
if (!box) return null;
const isInp = await isInputElementHandle(stealth, el);
const target = clickTarget(box, isInp, cfg);
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
}
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
cursor.x = target.x;
cursor.y = target.y;
return { box, isInp };
};
// --- el.click() ---
(el as any).click = async (options?: any) => {
const info = await moveToElement();
if (!info) return origElClick(options);
const clickCount = options?.clickCount ?? options?.count ?? 1;
if (clickCount >= 2) {
await humanClick(raw, info.isInp, cfg);
await sleep(rand(40, 90));
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
} else {
await humanClick(raw, info.isInp, cfg);
}
};
// --- el.hover() ---
(el as any).hover = async () => {
const info = await moveToElement();
if (!info) return origElHover();
};
// --- el.type() ---
(el as any).type = async (text: string, options?: any) => {
const info = await moveToElement();
if (!info) return origElType(text, options);
await humanClick(raw, info.isInp, cfg);
await sleep(rand(100, 250));
const cdp = await stealth.getCdpSession().catch(() => null);
await humanType(page, rawKb, text, cfg, cdp);
};
// --- el.press() ---
if (origElPress) {
(el as any).press = async (key: string, options?: any) => {
await sleep(rand(20, 60));
await originals.keyboardDown(key as any);
await sleep(randRange(cfg.key_hold));
await originals.keyboardUp(key as any);
};
}
// --- el.tap() ---
if (origElTap) {
(el as any).tap = async () => {
const info = await moveToElement();
if (!info) return origElTap();
await humanClick(raw, info.isInp, cfg);
};
}
// --- el.focus() ---
if (origElFocus) {
(el as any).focus = async () => {
const info = await moveToElement();
if (!info) return origElFocus();
await humanClick(raw, info.isInp, cfg);
};
}
// --- el.select() ---
if (origElSelect) {
(el as any).select = async (...values: string[]) => {
const info = await moveToElement();
if (!info) return origElSelect(...values);
await humanClick(raw, false, cfg);
await sleep(rand(100, 300));
return origElSelect(...values);
};
}
// --- el.drop() ---
if (origElDrop) {
(el as any).drop = async (draggable: ElementHandle, options?: any) => {
const srcBox = await draggable.boundingBox();
const tgtBox = await el.boundingBox();
if (srcBox && tgtBox) {
const sx = srcBox.x + srcBox.width / 2;
const sy = srcBox.y + srcBox.height / 2;
const tx = tgtBox.x + tgtBox.width / 2;
const ty = tgtBox.y + tgtBox.height / 2;
await (page as any)._ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, sx, sy, cfg);
cursor.x = sx;
cursor.y = sy;
await sleep(rand(100, 200));
await originals.mouseDown();
await sleep(rand(80, 150));
await humanMove(raw, cursor.x, cursor.y, tx, ty, cfg);
cursor.x = tx;
cursor.y = ty;
await sleep(rand(80, 150));
await originals.mouseUp();
} else {
return origElDrop(draggable, options);
}
};
}
// --- el.dragAndDrop() ---
if (origElDragAndDrop) {
(el as any).dragAndDrop = async (targetEl: ElementHandle, options?: any) => {
const srcBox = await el.boundingBox();
const tgtBox = await targetEl.boundingBox();
if (srcBox && tgtBox) {
const sx = srcBox.x + srcBox.width / 2;
const sy = srcBox.y + srcBox.height / 2;
const tx = tgtBox.x + tgtBox.width / 2;
const ty = tgtBox.y + tgtBox.height / 2;
await (page as any)._ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, sx, sy, cfg);
cursor.x = sx;
cursor.y = sy;
await sleep(rand(100, 200));
await originals.mouseDown();
await sleep(rand(80, 150));
await humanMove(raw, cursor.x, cursor.y, tx, ty, cfg);
cursor.x = tx;
cursor.y = ty;
await sleep(rand(80, 150));
await originals.mouseUp();
} else {
return origElDragAndDrop(targetEl, options);
}
};
}
}
// ============================================================================
// Frame-level patching — native Puppeteer Frame methods only
// Puppeteer Frame has: click, hover, type, select, focus, tap
// ============================================================================
function patchFrames(
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: StealthEval,
): void {
for (const frame of iterFrames(page)) {
patchSingleFrame(frame, page, cfg, cursor, raw, rawKb, originals, stealth);
}
}
function patchSingleFrame(
frame: Frame,
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: StealthEval,
): void {
if ((frame as any)._humanPatched) return;
(frame as any)._humanPatched = true;
const origFrameSelect = frame.select.bind(frame);
(frame as any).click = async (selector: string, options?: any) => {
await (page as any).click(selector, options);
};
(frame as any).hover = async (selector: string, options?: any) => {
await (page as any).hover(selector, options);
};
(frame as any).type = async (selector: string, text: string, options?: any) => {
await (page as any).type(selector, text, options);
};
(frame as any).select = async (selector: string, ...values: string[]) => {
await (page as any).hover(selector);
await sleep(rand(100, 300));
return origFrameSelect(selector, ...values);
};
(frame as any).focus = async (selector: string) => {
await (page as any).focus(selector);
};
(frame as any).tap = async (selector: string, options?: any) => {
await (page as any).click(selector, options);
};
// Patch frame.$() to return patched ElementHandles
const origFrame$ = frame.$.bind(frame);
const origFrame$$ = frame.$$.bind(frame);
const origFrameWaitForSelector = frame.waitForSelector.bind(frame);
(frame as any).$ = async (selector: string) => {
const el = await origFrame$(selector);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
(frame as any).$$ = async (selector: string) => {
const els = await origFrame$$(selector);
for (const el of els) {
patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
}
return els;
};
(frame as any).waitForSelector = async (selector: string, options?: any) => {
const el = await origFrameWaitForSelector(selector, options);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
}
function* iterFrames(page: Page): Generator<Frame> {
try {
const mainFrame = page.mainFrame();
yield mainFrame;
for (const child of mainFrame.childFrames()) {
yield child;
}
} catch {}
}
// ============================================================================
// Browser-level patching
// ============================================================================
export function patchBrowser(browser: Browser, cfg: HumanConfig): void {
browser.pages().then(pages => {
for (const page of pages) {
if (!(page as any)._original) {
patchPage(page, cfg, new CursorState());
}
}
}).catch(() => {});
const origNewPage = browser.newPage.bind(browser);
(browser as any).newPage = async () => {
const page = await origNewPage();
if (!(page as any)._original) {
patchPage(page, cfg, new CursorState());
}
return page;
};
// v21: createIncognitoBrowserContext
// v22+: createBrowserContext (renamed in puppeteer/puppeteer#11834)
for (const methodName of ['createBrowserContext', 'createIncognitoBrowserContext'] as const) {
if (typeof (browser as any)[methodName] === 'function') {
const origCreateContext = (browser as any)[methodName].bind(browser);
(browser as any)[methodName] = async (options?: any) => {
const context: BrowserContext = await origCreateContext(options);
const origCtxNewPage = context.newPage.bind(context);
(context as any).newPage = async () => {
const page = await origCtxNewPage();
if (!(page as any)._original) {
patchPage(page, cfg, new CursorState());
}
return page;
};
return context;
};
}
}
browser.on('targetcreated', async (target: any) => {
try {
if (target.type() === 'page') {
const page = await target.page();
if (page && !(page as any)._original) {
patchPage(page, cfg, new CursorState());
}
}
} catch {}
});
}
export { patchPage };
+187
View File
@@ -0,0 +1,187 @@
/**
* cloakbrowser-human Human-like keyboard input.
* Adapted for Puppeteer API.
*
* Changes from Playwright version:
* - Uses puppeteer-core Page/CDPSession types
* - keyboard.sendCharacter() mapped via RawKeyboard.insertText adapter
* - CDPSession obtained via page.createCDPSession()
*
* Stealth-aware: shift symbols use CDP Input.dispatchKeyEvent (isTrusted=true).
*/
import type { Page, CDPSession } from 'puppeteer-core';
import { RawKeyboard } from '../human/mouse.js';
import type { HumanConfig } from '../human/config.js';
import { rand, randRange, sleep } from '../human/config.js';
const SHIFT_SYMBOLS = new Set([
'@', '#', '!', '$', '%', '^', '&', '*', '(', ')',
'_', '+', '{', '}', '|', ':', '"', '<', '>', '?', '~',
]);
const NEARBY_KEYS: Record<string, string> = {
a: 'sqwz', b: 'vghn', c: 'xdfv', d: 'sfecx', e: 'wrsdf',
f: 'dgrtcv', g: 'fhtyb', h: 'gjybn', i: 'ujko', j: 'hkunm',
k: 'jloi', l: 'kop', m: 'njk', n: 'bhjm', o: 'iklp',
p: 'ol', q: 'wa', r: 'edft', s: 'awedxz', t: 'rfgy',
u: 'yhji', v: 'cfgb', w: 'qase', x: 'zsdc', y: 'tghu',
z: 'asx',
'1': '2q', '2': '13qw', '3': '24we', '4': '35er', '5': '46rt',
'6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p',
};
const SHIFT_SYMBOL_CODES: Record<string, string> = {
'!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4',
'%': 'Digit5', '^': 'Digit6', '&': 'Digit7', '*': 'Digit8',
'(': 'Digit9', ')': 'Digit0', '_': 'Minus', '+': 'Equal',
'{': 'BracketLeft', '}': 'BracketRight', '|': 'Backslash',
':': 'Semicolon', '"': 'Quote', '<': 'Comma', '>': 'Period',
'?': 'Slash', '~': 'Backquote',
};
const SHIFT_SYMBOL_KEYCODES: Record<string, number> = {
'!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
'^': 54, '&': 55, '*': 56, '(': 57, ')': 48,
'_': 189, '+': 187, '{': 219, '}': 221, '|': 220,
':': 186, '"': 222, '<': 188, '>': 190, '?': 191,
'~': 192,
};
function isAscii(ch: string): boolean {
const code = ch.codePointAt(0);
return code !== undefined && code < 128;
}
function getNearbyKey(ch: string): string {
const lower = ch.toLowerCase();
if (lower in NEARBY_KEYS) {
const neighbors = NEARBY_KEYS[lower];
const wrong = neighbors[Math.floor(Math.random() * neighbors.length)];
return ch === ch.toUpperCase() && ch !== ch.toLowerCase() ? wrong.toUpperCase() : wrong;
}
return ch;
}
function isUpperCase(ch: string): boolean {
return ch.length === 1 && ch >= 'A' && ch <= 'Z';
}
export async function humanType(
page: Page,
raw: RawKeyboard,
text: string,
cfg: HumanConfig,
cdpSession?: CDPSession | null,
): Promise<void> {
const chars = [...text];
for (let i = 0; i < chars.length; i++) {
const ch = chars[i];
// Non-ASCII → sendCharacter via insertText adapter
if (!isAscii(ch)) {
await sleep(randRange(cfg.key_hold));
await raw.insertText(ch);
if (i < chars.length - 1) await interCharDelay(cfg);
continue;
}
// Mistype
if (Math.random() < cfg.mistype_chance && /^[a-zA-Z0-9]$/.test(ch)) {
const wrong = getNearbyKey(ch);
await typeNormalChar(raw, wrong, cfg);
await sleep(randRange(cfg.mistype_delay_notice));
await raw.down('Backspace');
await sleep(randRange(cfg.key_hold));
await raw.up('Backspace');
await sleep(randRange(cfg.mistype_delay_correct));
}
if (isUpperCase(ch)) {
await typeShiftedChar(raw, ch, cfg);
} else if (SHIFT_SYMBOLS.has(ch)) {
await typeShiftSymbol(page, raw, ch, cfg, cdpSession);
} else {
await typeNormalChar(raw, ch, cfg);
}
if (i < chars.length - 1) await interCharDelay(cfg);
}
}
async function typeNormalChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
await raw.down(ch);
await sleep(randRange(cfg.key_hold));
await raw.up(ch);
}
async function typeShiftedChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay));
await raw.down(ch);
await sleep(randRange(cfg.key_hold));
await raw.up(ch);
await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift');
}
async function typeShiftSymbol(
page: Page,
raw: RawKeyboard,
ch: string,
cfg: HumanConfig,
cdpSession?: CDPSession | null,
): Promise<void> {
if (cdpSession) {
const code = SHIFT_SYMBOL_CODES[ch] || '';
const keyCode = SHIFT_SYMBOL_KEYCODES[ch] || 0;
await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay));
await cdpSession.send('Input.dispatchKeyEvent', {
type: 'keyDown',
modifiers: 8,
key: ch,
code,
windowsVirtualKeyCode: keyCode,
text: ch,
unmodifiedText: ch,
});
await sleep(randRange(cfg.key_hold));
await cdpSession.send('Input.dispatchKeyEvent', {
type: 'keyUp',
modifiers: 8,
key: ch,
code,
windowsVirtualKeyCode: keyCode,
});
await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift');
} else {
await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay));
await raw.insertText(ch);
await page.evaluate((key: string) => {
const el = document.activeElement;
if (el) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
}
}, ch);
await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift');
}
}
async function interCharDelay(cfg: HumanConfig): Promise<void> {
if (Math.random() < cfg.typing_pause_chance) {
await sleep(randRange(cfg.typing_pause_range));
} else {
const delay = cfg.typing_delay + (Math.random() - 0.5) * 2 * cfg.typing_delay_spread;
await sleep(Math.max(10, delay));
}
}
+166
View File
@@ -0,0 +1,166 @@
/**
* cloakbrowser-human Human-like scrolling via mouse wheel events.
* Adapted for Puppeteer API.
*
* Changes from Playwright version:
* - page.viewport() instead of page.viewportSize()
* - page.$(selector) + el.boundingBox() instead of page.locator().boundingBox()
* - No timeout parameter on boundingBox()
*/
import type { Page } from 'puppeteer-core';
import type { HumanConfig } from '../human/config.js';
import { rand, randRange, randIntRange, sleep } from '../human/config.js';
import { RawMouse, humanMove } from '../human/mouse.js';
interface ElementBounds {
x: number;
y: number;
width: number;
height: number;
}
function isInViewport(
bounds: ElementBounds,
viewportHeight: number,
cfg: HumanConfig,
): boolean {
const topEdge = bounds.y;
const bottomEdge = bounds.y + bounds.height;
const zoneTop = viewportHeight * cfg.scroll_target_zone[0];
const zoneBottom = viewportHeight * cfg.scroll_target_zone[1];
return topEdge >= zoneTop && bottomEdge <= zoneBottom;
}
export async function smoothWheel(
raw: RawMouse,
delta: number,
cfg: HumanConfig,
axis: 'x' | 'y' = 'y',
): Promise<void> {
const absD = Math.abs(delta);
const sign = delta > 0 ? 1 : -1;
let sent = 0;
while (sent < absD) {
const stepSize = rand(20, 40);
const chunk = Math.min(stepSize, absD - sent);
const d = Math.round(chunk) * sign;
if (axis === 'x') {
await raw.wheel(d, 0);
} else {
await raw.wheel(0, d);
}
sent += chunk;
await sleep(rand(8, 20));
}
}
async function getElementBox(page: Page, selector: string): Promise<ElementBounds | null> {
try {
const el = await page.$(selector);
if (!el) return null;
const box = await el.boundingBox();
if (!box) return null;
return { x: box.x, y: box.y, width: box.width, height: box.height };
} catch {
return null;
}
}
export async function scrollToElement(
page: Page,
raw: RawMouse,
selector: string,
cursorX: number,
cursorY: number,
cfg: HumanConfig,
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
const viewport = page.viewport();
if (!viewport) throw new Error('Viewport size not available');
let box = await getElementBox(page, selector);
if (!box) {
await sleep(200);
box = await getElementBox(page, selector);
if (!box) throw new Error(`Element not found: ${selector}`);
}
if (isInViewport(box, viewport.height, cfg)) {
return { box, cursorX, cursorY };
}
// Move cursor into scroll area
const scrollAreaX = Math.round(viewport.width * rand(0.3, 0.7));
const scrollAreaY = Math.round(viewport.height * rand(0.3, 0.7));
await humanMove(raw, cursorX, cursorY, scrollAreaX, scrollAreaY, cfg);
cursorX = scrollAreaX;
cursorY = scrollAreaY;
await sleep(randRange(cfg.scroll_pre_move_delay));
// Calculate scroll distance
const targetY = viewport.height * rand(cfg.scroll_target_zone[0], cfg.scroll_target_zone[1]);
const elementCenter = box.y + box.height / 2;
const distanceToScroll = elementCenter - targetY;
const direction = distanceToScroll > 0 ? 1 : -1;
const absDistance = Math.abs(distanceToScroll);
const avgDelta = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2;
const totalClicks = Math.max(3, Math.ceil(absDistance / avgDelta));
const accelSteps = randIntRange(cfg.scroll_accel_steps);
const decelSteps = randIntRange(cfg.scroll_decel_steps);
let scrolled = 0;
for (let i = 0; i < totalClicks; i++) {
let delta: number;
let pause: number;
if (i < accelSteps) {
delta = rand(80, 100);
pause = randRange(cfg.scroll_pause_slow);
} else if (i >= totalClicks - decelSteps) {
delta = rand(60, 90);
pause = randRange(cfg.scroll_pause_slow);
} else {
delta = randRange(cfg.scroll_delta_base);
pause = randRange(cfg.scroll_pause_fast);
}
delta *= 1 + (Math.random() - 0.5) * 2 * cfg.scroll_delta_variance;
delta = Math.round(delta) * direction;
await smoothWheel(raw, delta, cfg);
scrolled += Math.abs(delta);
await sleep(pause);
if (i % 3 === 2 || i === totalClicks - 1) {
box = await getElementBox(page, selector);
if (box && isInViewport(box, viewport.height, cfg)) {
break;
}
}
if (scrolled >= absDistance * 1.1) break;
}
// Optional overshoot + correction
if (Math.random() < cfg.scroll_overshoot_chance) {
const overshootPx = Math.round(randRange(cfg.scroll_overshoot_px)) * direction;
await smoothWheel(raw, overshootPx, cfg);
await sleep(randRange(cfg.scroll_settle_delay));
const corrections = randIntRange([1, 2]);
for (let c = 0; c < corrections; c++) {
const corrDelta = Math.round(rand(40, 80)) * -direction;
await smoothWheel(raw, corrDelta, cfg);
await sleep(rand(100, 250));
}
}
await sleep(randRange(cfg.scroll_settle_delay));
box = await getElementBox(page, selector);
if (!box) throw new Error(`Element lost after scrolling: ${selector}`);
return { box, cursorX, cursorY };
}
+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));
}
+366
View File
@@ -0,0 +1,366 @@
/**
* ElementHandle humanization for Playwright.
*
* Mirrors Puppeteer's ElementHandle patching architecture.
* Patches page.$(), page.$$(), page.waitForSelector() to return humanized handles,
* and patches all interaction methods on each ElementHandle instance.
*
* Playwright ElementHandle methods patched:
* click, dblclick, hover, type, fill, press, selectOption,
* check, uncheck, setChecked, tap, focus
* + $, $$, waitForSelector (nested elements are also patched)
*
* Stealth-aware:
* - Uses CDP DOM.describeNode when available to check element type
* (no main-world JS execution)
* - Falls back to el.evaluate() only when CDP is unavailable
*/
import type { Page, Frame, ElementHandle, CDPSession } from 'playwright-core';
import type { HumanConfig } from './config.js';
import { rand, randRange, sleep } from './config.js';
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
import { humanType } from './keyboard.js';
// --- Platform-aware select-all shortcut ---
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
// ============================================================================
// Stealth ElementHandle input check — uses CDP DOM.describeNode
// ============================================================================
async function isInputElementHandle(
stealth: any, // StealthEval from index.ts
el: ElementHandle,
): Promise<boolean> {
// Try CDP DOM.describeNode first (no main-world JS execution)
if (stealth) {
try {
const cdp: CDPSession = await stealth.getCdpSession();
// Playwright exposes the JSHandle's internal preview via _objectId or similar
// We need the remote object ID. Try to get it via internal API.
const impl = (el as any)._impl ?? (el as any)._object ?? el;
const guid = (impl as any)._guid;
// Use el.evaluate as a reliable fallback within stealth context
// Playwright doesn't expose remoteObject directly like Puppeteer
} catch { /* fallthrough */ }
}
// Fallback: el.evaluate (works reliably in Playwright)
try {
return await el.evaluate((node: any) => {
const tag = node.tagName?.toLowerCase();
return tag === 'input' || tag === 'textarea'
|| node.getAttribute?.('contenteditable') === 'true';
});
} catch {
return false;
}
}
// ============================================================================
// CursorState type (matches index.ts)
// ============================================================================
interface CursorState {
x: number;
y: number;
initialized: boolean;
}
// ============================================================================
// Patch a single Playwright ElementHandle
// ============================================================================
export function patchSingleElementHandle(
el: ElementHandle,
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: any,
): void {
if ((el as any)._humanPatched) return;
(el as any)._humanPatched = true;
// Save originals
const origElClick = el.click.bind(el);
const origElDblclick = el.dblclick.bind(el);
const origElHover = el.hover.bind(el);
const origElType = el.type.bind(el);
const origElFill = el.fill.bind(el);
const origElPress = el.press.bind(el);
const origElSelectOption = el.selectOption.bind(el);
const origElCheck = el.check.bind(el);
const origElUncheck = el.uncheck.bind(el);
const origElSetChecked = (el as any).setChecked?.bind(el);
const origElTap = el.tap.bind(el);
const origElFocus = el.focus.bind(el);
// Nested selectors
const origEl$ = el.$.bind(el);
const origEl$$ = el.$$.bind(el);
const origElWaitForSelector = el.waitForSelector.bind(el);
// --- Nested elements are also patched ---
(el as any).$ = async (selector: string) => {
const child = await origEl$(selector);
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
return child;
};
(el as any).$$ = async (selector: string) => {
const children = await origEl$$(selector);
for (const child of children) {
patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
}
return children;
};
(el as any).waitForSelector = async (selector: string, options?: any) => {
const child = await origElWaitForSelector(selector, options);
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
return child;
};
// --- Helper: get bounding box and move cursor to element ---
const moveToElement = async () => {
// Ensure cursor is initialized
const ensureCursorInit = (page as any)._ensureCursorInit;
if (ensureCursorInit) await ensureCursorInit();
const box = await el.boundingBox();
if (!box) return null;
const isInp = await isInputElementHandle(stealth, el);
const target = clickTarget(box, isInp, cfg);
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
}
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
cursor.x = target.x;
cursor.y = target.y;
return { box, isInp };
};
// --- el.click() ---
(el as any).click = async (options?: any) => {
const info = await moveToElement();
if (!info) return origElClick(options);
await humanClick(raw, info.isInp, cfg);
};
// --- el.dblclick() ---
(el as any).dblclick = async (options?: any) => {
const info = await moveToElement();
if (!info) return origElDblclick(options);
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
};
// --- el.hover() ---
(el as any).hover = async (options?: any) => {
const info = await moveToElement();
if (!info) return origElHover(options);
// Just move — no click
};
// --- el.type() ---
(el as any).type = async (text: string, options?: any) => {
const info = await moveToElement();
if (!info) return origElType(text, options);
await humanClick(raw, info.isInp, cfg);
await sleep(rand(100, 250));
let cdpSession: CDPSession | null = null;
try { cdpSession = await stealth?.getCdpSession(); } catch {}
await humanType(page, rawKb, text, cfg, cdpSession);
};
// --- el.fill() ---
(el as any).fill = async (value: string, options?: any) => {
const info = await moveToElement();
if (!info) return origElFill(value, options);
await humanClick(raw, info.isInp, cfg);
await sleep(rand(100, 250));
// Clear existing content
await originals.keyboardPress(SELECT_ALL);
await sleep(rand(30, 80));
await originals.keyboardPress('Backspace');
await sleep(rand(50, 150));
let cdpSession: CDPSession | null = null;
try { cdpSession = await stealth?.getCdpSession(); } catch {}
await humanType(page, rawKb, value, cfg, cdpSession);
};
// --- el.press() ---
(el as any).press = async (key: string, options?: any) => {
await sleep(rand(20, 60));
await originals.keyboardDown(key);
await sleep(randRange(cfg.key_hold));
await originals.keyboardUp(key);
};
// --- el.selectOption() ---
(el as any).selectOption = async (values: any, options?: any) => {
const info = await moveToElement();
if (!info) return origElSelectOption(values, options);
await humanClick(raw, false, cfg);
await sleep(rand(100, 300));
return origElSelectOption(values, options);
};
// --- el.check() ---
(el as any).check = async (options?: any) => {
try {
const checked = await el.isChecked();
if (checked) return; // Already checked
} catch {}
const info = await moveToElement();
if (!info) return origElCheck(options);
await humanClick(raw, info.isInp, cfg);
};
// --- el.uncheck() ---
(el as any).uncheck = async (options?: any) => {
try {
const checked = await el.isChecked();
if (!checked) return; // Already unchecked
} catch {}
const info = await moveToElement();
if (!info) return origElUncheck(options);
await humanClick(raw, info.isInp, cfg);
};
// --- el.setChecked() ---
if (origElSetChecked) {
(el as any).setChecked = async (checked: boolean, options?: any) => {
try {
const current = await el.isChecked();
if (current === checked) return;
} catch {}
const info = await moveToElement();
if (!info) return origElSetChecked(checked, options);
await humanClick(raw, info.isInp, cfg);
};
}
// --- el.tap() ---
(el as any).tap = async (options?: any) => {
const info = await moveToElement();
if (!info) return origElTap(options);
await humanClick(raw, info.isInp, cfg);
};
// --- el.focus() ---
// Move cursor humanly but use programmatic focus (no click side-effects).
// Stock Playwright el.focus() never clicks — clicking would trigger onclick,
// submit forms, navigate links, etc.
(el as any).focus = async () => {
await moveToElement(); // human-like Bézier cursor movement
await origElFocus(); // programmatic focus, no click
};
}
// ============================================================================
// Page-level ElementHandle patching
// ============================================================================
export function patchPageElementHandles(
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: any,
): void {
// Patch page.$() — only if the method exists
if (typeof page.$ === 'function') {
const orig$ = page.$.bind(page);
(page as any).$ = async (selector: string) => {
const el = await orig$(selector);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
}
// Patch page.$$()
if (typeof page.$$ === 'function') {
const orig$$ = page.$$.bind(page);
(page as any).$$ = async (selector: string) => {
const els = await orig$$(selector);
for (const el of els) {
patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
}
return els;
};
}
// Patch page.waitForSelector()
if (typeof page.waitForSelector === 'function') {
const origWaitForSelector = page.waitForSelector.bind(page);
(page as any).waitForSelector = async (selector: string, options?: any) => {
const el = await origWaitForSelector(selector, options);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
}
}
// ============================================================================
// Frame-level ElementHandle patching
// ============================================================================
export function patchFrameElementHandles(
frame: Frame,
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: any,
): void {
// Patch frame.$() — only if the method exists
if (typeof frame.$ === 'function') {
const origFrame$ = frame.$.bind(frame);
(frame as any).$ = async (selector: string) => {
const el = await origFrame$(selector);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
}
// Patch frame.$$()
if (typeof frame.$$ === 'function') {
const origFrame$$ = frame.$$.bind(frame);
(frame as any).$$ = async (selector: string) => {
const els = await origFrame$$(selector);
for (const el of els) {
patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
}
return els;
};
}
// Patch frame.waitForSelector()
if (typeof frame.waitForSelector === 'function') {
const origFrameWaitForSelector = frame.waitForSelector.bind(frame);
(frame as any).waitForSelector = async (selector: string, options?: any) => {
const el = await origFrameWaitForSelector(selector, options);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
}
}
+694
View File
@@ -0,0 +1,694 @@
/**
* 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.
*
* Stealth-aware (fixes #110):
* - isInputElement / isSelectorFocused use CDP Isolated Worlds instead of page.evaluate
* - Shift symbol typing uses CDP Input.dispatchKeyEvent for isTrusted=true events
* - Falls back to page.evaluate only when CDP session is unavailable
*
* Patches all interaction methods:
* click, dblclick, hover, type, fill, check, uncheck, selectOption,
* press, pressSequentially, tap, dragTo, clear + Frame-level equivalents.
*
* ELEMENTHANDLE-LEVEL:
* click, dblclick, hover, type, fill, press, selectOption,
* check, uncheck, setChecked, tap, focus
* + $, $$, waitForSelector (nested elements are also patched)
*
* page.$(), page.$$(), page.waitForSelector() and Frame equivalents
* return patched ElementHandles automatically.
*/
import type { Browser, BrowserContext, Page, Frame, CDPSession } from 'playwright-core';
import { HumanConfig, resolveConfig, rand, randRange, sleep } from './config.js';
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
import { humanType } from './keyboard.js';
import { scrollToElement } from './scroll.js';
import { patchPageElementHandles, patchFrameElementHandles, patchSingleElementHandle } from './elementhandle.js';
export { HumanConfig, resolveConfig } from './config.js';
export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
export { humanType } from './keyboard.js';
export { scrollToElement } from './scroll.js';
export { patchSingleElementHandle } from './elementhandle.js';
// --- Platform-aware select-all shortcut (macOS uses Meta, others use Control) ---
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
// ============================================================================
// CDP Isolated World — stealth DOM evaluation
// ============================================================================
/**
* Manages a CDP isolated execution context for DOM reads.
* Produces clean Error.stack traces (no 'eval at evaluate :302:')
* and is invisible to querySelector monkey-patches in the main world.
*
* Context ID is invalidated on navigation and auto-recreated on next call.
*/
class StealthEval {
private cdp: CDPSession | null = null;
private contextId: number | null = null;
private page: Page;
constructor(page: Page) {
this.page = page;
}
private async ensureCdp(): Promise<CDPSession> {
if (!this.cdp) {
this.cdp = await this.page.context().newCDPSession(this.page);
}
return this.cdp;
}
private async createWorld(): Promise<number> {
const cdp = await this.ensureCdp();
const tree = await cdp.send('Page.getFrameTree');
const frameId = tree.frameTree.frame.id;
const result = await cdp.send('Page.createIsolatedWorld', {
frameId,
worldName: '',
grantUniveralAccess: true,
});
const ctxId = result.executionContextId;
this.contextId = ctxId;
return ctxId;
}
/**
* Evaluate a JS expression in the isolated world.
* Auto-recreates the world if the context was invalidated (navigation).
* Returns the result value, or undefined on failure.
*/
async evaluate(expression: string): Promise<any> {
if (this.contextId === null) {
await this.createWorld();
}
for (let attempt = 0; attempt < 2; attempt++) {
try {
const cdp = await this.ensureCdp();
const result = await cdp.send('Runtime.evaluate', {
expression,
contextId: this.contextId!,
returnByValue: true,
});
if (result.exceptionDetails) {
// Context was likely invalidated by navigation
if (attempt === 0) {
await this.createWorld();
continue;
}
return undefined;
}
return result.result?.value;
} catch {
if (attempt === 0) {
this.contextId = null;
try {
await this.createWorld();
} catch {
return undefined;
}
continue;
}
return undefined;
}
}
return undefined;
}
/** Mark context as stale — call after navigation. */
invalidate(): void {
this.contextId = null;
}
/** Get the underlying CDP session (reused for Input.dispatchKeyEvent etc.). */
async getCdpSession(): Promise<CDPSession> {
return this.ensureCdp();
}
}
// ============================================================================
// Cursor state
// ============================================================================
class CursorState {
x = 0;
y = 0;
initialized = false;
}
// ============================================================================
// Stealth DOM queries — isolated world with evaluate fallback
// ============================================================================
/**
* Check if selector matches an input/textarea/contenteditable element.
* Uses CDP Isolated World when available invisible to main world.
*/
async function isInputElement(
stealth: StealthEval | null,
page: Page,
selector: string,
): Promise<boolean> {
if (stealth) {
try {
const escaped = JSON.stringify(selector);
const result = await stealth.evaluate(`
(() => {
const el = document.querySelector(${escaped});
if (!el) return false;
const tag = el.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea'
|| el.getAttribute('contenteditable') === 'true';
})()
`);
return !!result;
} catch {
// Fall through to page.evaluate
}
}
// Fallback: page.evaluate (detectable — should only happen if CDP fails)
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);
}
/**
* Check if the element matching selector is currently focused.
* Uses CDP Isolated World when available invisible to main world.
*/
async function isSelectorFocused(
stealth: StealthEval | null,
page: Page,
selector: string,
): Promise<boolean> {
if (stealth) {
try {
const escaped = JSON.stringify(selector);
const result = await stealth.evaluate(`
(() => {
const el = document.querySelector(${escaped});
return el === document.activeElement;
})()
`);
return !!result;
} catch {
// Fall through to page.evaluate
}
}
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;
// --- Stealth infrastructure ---
const stealth = new StealthEval(page);
(page as any)._stealth = stealth;
// CDP session for shift symbol typing (lazy-initialized, reuses stealth's session)
let cdpSession: CDPSession | null = null;
const ensureCdp = async (): Promise<CDPSession | null> => {
if (!cdpSession) {
try {
cdpSession = await stealth.getCdpSession();
} catch {}
}
return cdpSession;
};
const raw: RawMouse = {
move: originals.mouseMove,
down: originals.mouseDown,
up: originals.mouseUp,
wheel: 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 (invalidate isolated world on navigation) ---
const humanGoto = async (url: string, options?: any) => {
const response = await originals.goto(url, options);
stealth.invalidate();
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
return response;
};
// --- click ---
const humanClickFn = async (selector: string, options?: any) => {
await ensureCursorInit();
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
}
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
cursor.x = cursorX;
cursor.y = cursorY;
const isInput = await isInputElement(stealth, page, selector);
const target = clickTarget(box, isInput, cfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
cursor.x = target.x;
cursor.y = target.y;
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(stealth, page, selector);
const target = clickTarget(box, isInput, cfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
cursor.x = target.x;
cursor.y = target.y;
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));
const cdp = await ensureCdp();
await humanType(page, rawKb, text, cfg, cdp);
};
// --- 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));
const cdp = await ensureCdp();
await humanType(page, rawKb, value, cfg, cdp);
};
// --- clear ---
const humanClearFn = async (selector: string, options?: any) => {
if (!await isSelectorFocused(stealth, 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(stealth, 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(stealth, page, selector)) {
await humanClickFn(selector);
}
await sleep(rand(100, 250));
const cdp = await ensureCdp();
await humanType(page, rawKb, text, cfg, cdp);
};
// --- 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;
(page as any).pressSequentially = humanPressSequentiallyFn;
(page as any).tap = humanTapFn;
(page as any).clear = humanClearFn;
// --- mouse patches ---
page.mouse.move = async (x: number, y: number, options?: any) => {
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) => {
const cdp = await ensureCdp();
await humanType(page, rawKb, text, cfg, cdp);
};
// 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, stealth);
// --- Patch ElementHandle selectors (page.$, page.$$, page.waitForSelector) ---
patchPageElementHandles(page, cfg, cursor, raw, rawKb, originals, stealth);
}
// ============================================================================
// Frame-level patching
// ============================================================================
/**
* Patch Frame methods so Locator-based calls go through humanization.
* All 13 methods patched: click, dblclick, hover, type, fill, check, uncheck,
* selectOption, press, pressSequentially, tap, clear, dragAndDrop.
*/
function patchFrames(
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: StealthEval,
): void {
for (const frame of iterFrames(page)) {
patchSingleFrame(frame, page, cfg, originals, stealth);
// Patch frame-level ElementHandle selectors ($, $$, waitForSelector)
patchFrameElementHandles(frame, page, cfg, cursor, raw, rawKb, originals, stealth);
}
}
function patchSingleFrame(
frame: Frame,
page: Page,
cfg: HumanConfig,
originals: any,
stealth: StealthEval,
): 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).pressSequentially = async (selector: string, text: string, options?: any) => {
await (page as any).pressSequentially(selector, text, options);
};
(frame as any).tap = async (selector: string, options?: any) => {
await (page as any).tap(selector, options);
};
(frame as any).clear = async (selector: string, options?: any) => {
if (!await isSelectorFocused(stealth, page, selector)) {
await (page as any).click(selector);
}
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 };
+214
View File
@@ -0,0 +1,214 @@
/**
* cloakbrowser-human Human-like keyboard input.
*
* Stealth-aware: when a CDPSession is provided, shift symbols are typed
* via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace).
* Falls back to page.evaluate when no CDPSession is available.
*/
import type { Page, CDPSession } 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',
};
/**
* CDP key code for each shift symbol's physical key.
* Used by Input.dispatchKeyEvent to produce isTrusted=true events.
*/
const SHIFT_SYMBOL_CODES: Record<string, string> = {
'!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4',
'%': 'Digit5', '^': 'Digit6', '&': 'Digit7', '*': 'Digit8',
'(': 'Digit9', ')': 'Digit0', '_': 'Minus', '+': 'Equal',
'{': 'BracketLeft', '}': 'BracketRight', '|': 'Backslash',
':': 'Semicolon', '"': 'Quote', '<': 'Comma', '>': 'Period',
'?': 'Slash', '~': 'Backquote',
};
/**
* Windows virtual key codes for shift symbols.
* Input.dispatchKeyEvent uses these to match real keyboard behavior.
*/
const SHIFT_SYMBOL_KEYCODES: Record<string, number> = {
'!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
'^': 54, '&': 55, '*': 56, '(': 57, ')': 48,
'_': 189, '+': 187, '{': 219, '}': 221, '|': 220,
':': 186, '"': 222, '<': 188, '>': 190, '?': 191,
'~': 192,
};
function isAscii(ch: string): boolean {
const code = ch.codePointAt(0);
return code !== undefined && code < 128;
}
function getNearbyKey(ch: string): string {
const lower = ch.toLowerCase();
if (lower in NEARBY_KEYS) {
const neighbors = NEARBY_KEYS[lower];
const wrong = neighbors[Math.floor(Math.random() * neighbors.length)];
return ch === ch.toUpperCase() && ch !== ch.toLowerCase() ? wrong.toUpperCase() : wrong;
}
return ch;
}
function isUpperCase(ch: string): boolean {
return ch.length === 1 && ch >= 'A' && ch <= 'Z';
}
/**
* Type text with human-like per-character timing, mistype simulation,
* and realistic shift handling.
*
* @param cdpSession - If provided, shift symbols use CDP Input.dispatchKeyEvent
* producing isTrusted=true events with no evaluate stack trace.
* If null/undefined, falls back to page.evaluate (detectable).
*/
export async function humanType(
page: Page,
raw: RawKeyboard,
text: string,
cfg: HumanConfig,
cdpSession?: CDPSession | null,
): 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, cdpSession);
} else {
await typeNormalChar(raw, ch, cfg);
}
if (i < chars.length - 1) {
await interCharDelay(cfg);
}
}
}
async function typeNormalChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
await raw.down(ch);
await sleep(randRange(cfg.key_hold));
await raw.up(ch);
}
async function typeShiftedChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay));
await raw.down(ch);
await sleep(randRange(cfg.key_hold));
await raw.up(ch);
await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift');
}
/**
* Type a shift symbol character.
*
* Stealth path (cdpSession provided):
* Uses CDP Input.dispatchKeyEvent isTrusted=true, clean stack.
*
* Fallback path (no cdpSession):
* Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent.
* Detectable via isTrusted=false and evaluate stack frame.
*/
async function typeShiftSymbol(
page: Page,
raw: RawKeyboard,
ch: string,
cfg: HumanConfig,
cdpSession?: CDPSession | null,
): Promise<void> {
if (cdpSession) {
// --- Stealth path: CDP Input.dispatchKeyEvent ---
const code = SHIFT_SYMBOL_CODES[ch] || '';
const keyCode = SHIFT_SYMBOL_KEYCODES[ch] || 0;
await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay));
await cdpSession.send('Input.dispatchKeyEvent', {
type: 'keyDown',
modifiers: 8, // Shift modifier flag
key: ch,
code,
windowsVirtualKeyCode: keyCode,
text: ch,
unmodifiedText: ch,
});
await sleep(randRange(cfg.key_hold));
await cdpSession.send('Input.dispatchKeyEvent', {
type: 'keyUp',
modifiers: 8,
key: ch,
code,
windowsVirtualKeyCode: keyCode,
});
await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift');
} else {
// --- Fallback path: page.evaluate (detectable) ---
await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay));
await raw.insertText(ch);
await page.evaluate((key: string) => {
const el = document.activeElement;
if (el) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
}
}, ch);
await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift');
}
}
async function interCharDelay(cfg: HumanConfig): Promise<void> {
if (Math.random() < cfg.typing_pause_chance) {
await sleep(randRange(cfg.typing_pause_range));
} else {
const delay = cfg.typing_delay + (Math.random() - 0.5) * 2 * cfg.typing_delay_spread;
await sleep(Math.max(10, delay));
}
}
+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;
}
}
+2 -2
View File
@@ -16,7 +16,7 @@
*/
// Launch functions (Playwright API)
export { launch, launchContext } from "./playwright.js";
export { launch, launchContext, launchPersistentContext } from "./playwright.js";
// Binary management
export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download.js";
@@ -25,4 +25,4 @@ export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download
export { CHROMIUM_VERSION, getDefaultStealthArgs } from "./config.js";
// Types
export type { LaunchOptions, LaunchContextOptions, BinaryInfo } from "./types.js";
export type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions, BinaryInfo } from "./types.js";
+153 -48
View File
@@ -3,11 +3,46 @@
* Mirrors Python cloakbrowser/browser.py.
*/
import type { Browser, BrowserContext } from "playwright-core";
import type { LaunchOptions, LaunchContextOptions } from "./types.js";
import { DEFAULT_VIEWPORT, getDefaultStealthArgs } from "./config.js";
import type { Browser, BrowserContext, BrowserContextOptions } from "playwright-core";
import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.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 { resolveProxyConfig } from "./proxy.js";
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
/** @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) {
const merged = { ...options, timezone: options.timezone ?? options.timezoneId };
delete (merged as any).timezoneId;
return merged;
}
return options;
}
/**
* Strip `locale` and `timezoneId` from user-provided contextOptions both route
* through detectable CDP emulation. The wrapper's top-level `locale`/`timezone`
* fields use binary flags instead (undetectable). Warn so users notice.
*/
function filterStealthCtxOptions(ctx?: BrowserContextOptions): Partial<BrowserContextOptions> {
if (!ctx) return {};
const { locale, timezoneId, ...rest } = ctx;
if (locale !== undefined) {
console.warn(
"[cloakbrowser] contextOptions.locale ignored — use top-level `locale` " +
"instead (routes through binary flag, avoids detectable CDP emulation)."
);
}
if (timezoneId !== undefined) {
console.warn(
"[cloakbrowser] contextOptions.timezoneId ignored — use top-level `timezone` " +
"instead (routes through binary flag, avoids detectable CDP emulation)."
);
}
return rest;
}
/**
* Launch stealth Chromium browser via Playwright.
@@ -26,18 +61,34 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
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 });
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy);
let resolvedArgs = await resolveWebrtcArgs(options);
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
}
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
const browser = await chromium.launch({
executablePath: binaryPath,
headless: options.headless ?? true,
args,
ignoreDefaultArgs: ["--enable-automation"],
...(options.proxy ? { proxy: parseProxyUrl(options.proxy) } : {}),
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
...(proxyOption ? { proxy: proxyOption } : {}),
...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 ?? 'default',
options.humanConfig,
);
patchBrowser(browser, cfg);
}
return browser;
}
@@ -60,17 +111,27 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
export async function launchContext(
options: LaunchContextOptions = {}
): Promise<BrowserContext> {
options = resolveTimezone(options);
// Resolve geoip BEFORE launch() to avoid double-resolution
const resolved = await maybeResolveGeoip(options);
const browser = await launch({ ...options, ...resolved, geoip: false });
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
let launchArgs = await resolveWebrtcArgs(options);
// Inject geoip exit IP for WebRTC spoofing (free — no extra HTTP call)
if (exitIp && !(launchArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
launchArgs = [...(launchArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
}
// --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, args: launchArgs, geoip: false });
let context: BrowserContext;
try {
context = await browser.newContext({
// contextOptions first — explicit wrapper fields below override it.
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
...filterStealthCtxOptions(options.contextOptions),
...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport ?? DEFAULT_VIEWPORT,
...(resolved.locale ? { locale: resolved.locale } : {}),
...(resolved.timezone ? { timezoneId: resolved.timezone } : {}),
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
});
} catch (err) {
@@ -85,6 +146,84 @@ 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 ?? 'default',
options.humanConfig,
);
patchContext(context, cfg);
}
return context;
}
/**
* Launch stealth browser with a persistent user profile (non-incognito).
* Uses Playwright's chromium.launchPersistentContext() under the hood.
*
* This avoids incognito detection by services like BrowserScan (-10% penalty)
* and enables session persistence (cookies, localStorage) across launches.
*
* @example
* ```ts
* import { launchPersistentContext } from 'cloakbrowser';
* const context = await launchPersistentContext({
* userDataDir: './chrome-profile',
* headless: false,
* proxy: 'http://user:pass@host:port',
* geoip: true,
* });
* const page = context.pages()[0] || await context.newPage();
* await page.goto('https://example.com');
* await context.close();
* ```
*/
export async function launchPersistentContext(
options: LaunchPersistentContextOptions
): Promise<BrowserContext> {
options = resolveTimezone(options);
const { chromium } = await import("playwright-core");
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy);
let resolvedArgs = await resolveWebrtcArgs(options);
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
}
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
// 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: IGNORE_DEFAULT_ARGS,
...(proxyOption ? { proxy: proxyOption } : {}),
// contextOptions before explicit wrapper fields so explicit wins.
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
...filterStealthCtxOptions(options.contextOptions),
...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
...(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 ?? 'default',
options.humanConfig,
);
patchContext(context, cfg);
}
return context;
}
@@ -92,39 +231,5 @@ export async function launchContext(
// 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 { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(options.proxy);
return {
timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined,
};
}
/** @internal Exposed for unit tests only. */
export function _buildArgsForTest(options: LaunchOptions): string[] {
return buildArgs(options);
}
function buildArgs(options: LaunchOptions): string[] {
const args: string[] = [];
if (options.stealthArgs !== false) {
args.push(...getDefaultStealthArgs());
}
if (options.args) {
args.push(...options.args);
}
// Timezone/locale flags — always inject when set
if (options.timezone) {
args.push(`--fingerprint-timezone=${options.timezone}`);
}
if (options.locale) {
args.push(`--lang=${options.locale}`);
}
return args;
}
export { buildArgs as _buildArgsForTest } from "./args.js";
+73 -2
View File
@@ -8,16 +8,87 @@ 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).
*/
/** Proxy dict shape accepted by Playwright/Puppeteer wrappers. */
export type ProxyDict = { server: string; bypass?: string; username?: string; password?: string };
/** Result of resolveProxyConfig — either Playwright dict OR Chrome arg, never both. */
export interface ProxyConfig {
/** Playwright proxy option (for HTTP proxies). */
proxyOption?: ParsedProxy;
/** Chrome CLI args (for SOCKS5 proxies, e.g. ["--proxy-server=socks5://..."]). */
proxyArgs: string[];
}
/**
* Check if a proxy uses the SOCKS5 protocol.
*/
export function isSocksProxy(proxy: string | ProxyDict | undefined | null): boolean {
if (!proxy) return false;
const url = typeof proxy === "string" ? proxy : proxy.server;
return /^socks5h?:\/\//i.test(url);
}
/**
* Reconstruct a SOCKS5 URL with inline credentials from a proxy dict.
*/
export function reconstructSocksUrl(proxy: ProxyDict): string {
const url = new URL(proxy.server);
if (proxy.username) {
url.username = encodeURIComponent(proxy.username);
if (proxy.password) url.password = encodeURIComponent(proxy.password);
}
return url.href.replace(/\/$/, "");
}
/**
* Resolve proxy into Playwright option and/or Chrome args.
*
* Playwright rejects SOCKS5 proxies with credentials in its proxy dict,
* so SOCKS5 is passed via --proxy-server Chrome arg instead.
*/
export function resolveProxyConfig(proxy: string | ProxyDict | undefined): ProxyConfig {
if (!proxy) return { proxyArgs: [] };
if (isSocksProxy(proxy)) {
// SOCKS5: bypass Playwright, pass directly to Chrome via --proxy-server.
if (typeof proxy === "string") {
return { proxyArgs: [`--proxy-server=${proxy}`] };
}
const socksUrl = reconstructSocksUrl(proxy);
const args = [`--proxy-server=${socksUrl}`];
if (proxy.bypass) args.push(`--proxy-bypass-list=${proxy.bypass}`);
return { proxyArgs: args };
}
// HTTP/HTTPS: use Playwright's proxy dict
if (typeof proxy === "string") {
return { proxyOption: parseProxyUrl(proxy), proxyArgs: [] };
}
return { proxyOption: proxy as ParsedProxy, proxyArgs: [] };
}
export function parseProxyUrl(proxy: string): ParsedProxy {
let url: URL;
// Bare format: "user:pass@host:port" — new URL() throws without a scheme.
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 };
+56 -51
View File
@@ -1,13 +1,16 @@
/**
* Puppeteer launch wrapper for cloakbrowser.
* Alternative to the Playwright wrapper for users who prefer Puppeteer.
* NOW WITH HUMANIZE SUPPORT humanize: true enables human-like
* mouse curves, keyboard timing, and scroll patterns (same as Playwright).
*/
import type { Browser } from "puppeteer-core";
import type { LaunchOptions } from "./types.js";
import { getDefaultStealthArgs } from "./config.js";
import { IGNORE_DEFAULT_ARGS } from "./config.js";
import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js";
import { isSocksProxy, parseProxyUrl, resolveProxyConfig } from "./proxy.js";
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
/**
* Launch stealth Chromium browser via Puppeteer.
@@ -15,29 +18,53 @@ import { parseProxyUrl } from "./proxy.js";
* @example
* ```ts
* import { launch } from 'cloakbrowser/puppeteer';
* const browser = await launch();
* * // With humanize — human-like mouse, keyboard, scroll
* const browser = await launch({ humanize: true });
* const page = await browser.newPage();
* await page.goto('https://bot.incolumitas.com');
* console.log(await page.title());
* await browser.close();
* await page.goto('[https://example.com](https://example.com)');
* await page.click('#login'); // Bézier curve mouse movement
* await page.type('#email', 'user@example.com'); // Per-character timing
* ```
*/
export async function launch(options: LaunchOptions = {}): Promise<Browser> {
const puppeteer = await import("puppeteer-core");
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const resolved = await maybeResolveGeoip(options);
const args = buildArgs({ ...options, ...resolved });
const { exitIp, ...resolved } = (await maybeResolveGeoip(options)) ?? {};
let resolvedArgs = (await resolveWebrtcArgs(options)) ?? options.args;
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
}
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs });
// Puppeteer handles proxy via CLI args, not a separate option.
// Chromium's --proxy-server does NOT support inline credentials,
// so we strip them and use page.authenticate() instead.
// SOCKS5: Chrome supports inline credentials natively (RFC 1929 auth).
// HTTP: Chrome does NOT support inline credentials — strip them and
// use page.authenticate() for Proxy-Authorization headers instead.
let proxyAuth: { username: string; password: string } | undefined;
if (options.proxy) {
const { server, username, password } = parseProxyUrl(options.proxy);
args.push(`--proxy-server=${server}`);
if (username) {
proxyAuth = { username, password: password || "" };
if (isSocksProxy(options.proxy)) {
// SOCKS5: pass full URL with credentials to Chrome directly
const { proxyArgs } = resolveProxyConfig(options.proxy);
args.push(...proxyArgs);
} else if (typeof options.proxy === "string") {
const { server, username, password } = parseProxyUrl(options.proxy);
args.push(`--proxy-server=${server}`);
if (username) {
proxyAuth = { username, password: password ?? "" };
}
} else {
const parsed = parseProxyUrl(options.proxy.server);
args.push(`--proxy-server=${parsed.server}`);
if (options.proxy.bypass) {
args.push(`--proxy-bypass-list=${options.proxy.bypass}`);
}
const username = options.proxy.username ?? parsed.username;
const password = options.proxy.password ?? parsed.password;
if (username) {
proxyAuth = { username, password: password ?? "" };
}
}
}
@@ -45,7 +72,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,
});
@@ -60,40 +87,18 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
};
}
// Human-like behavioral patching — FULL coverage, same as Playwright.
// This enables Bézier mouse movements, organic typing rhythms, and
// natural scrolling to bypass advanced anti-bot detection.
if (options.humanize) {
const { patchBrowser } = await import('./human-puppeteer/index.js');
const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig(
options.humanPreset ?? 'default',
options.humanConfig,
);
patchBrowser(browser, cfg);
}
return 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 { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(options.proxy);
return {
timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined,
};
}
function buildArgs(options: LaunchOptions): string[] {
const args: string[] = [];
if (options.stealthArgs !== false) {
args.push(...getDefaultStealthArgs());
}
if (options.args) {
args.push(...options.args);
}
if (options.timezone) {
args.push(`--fingerprint-timezone=${options.timezone}`);
}
if (options.locale) {
args.push(`--lang=${options.locale}`);
}
return args;
}
+32 -4
View File
@@ -2,11 +2,19 @@
* Shared types for cloakbrowser launch wrappers.
*/
import type { BrowserContextOptions } from "playwright-core";
import type { HumanConfig, HumanPreset } from "./human/config.js";
export interface LaunchOptions {
/** Run in headless mode (default: true). */
headless?: boolean;
/** Proxy server URL, e.g. 'http://proxy:8080' or 'socks5://proxy:1080'. */
proxy?: string;
/**
* Proxy server URL string or Playwright proxy object.
* String: 'http://user:pass@proxy:8080' (credentials auto-extracted).
* Object: { server: "http://proxy:8080", bypass: ".google.com", ... }
* passed directly to Playwright.
*/
proxy?: string | { server: string; bypass?: string; username?: string; password?: string };
/** Additional Chromium CLI arguments. */
args?: string[];
/** Include default stealth fingerprint args (default: true). Set false to use custom --fingerprint flags. */
@@ -19,19 +27,39 @@ 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?: HumanPreset;
/** Override individual human behavior parameters. */
humanConfig?: Partial<HumanConfig>;
}
export interface LaunchContextOptions extends LaunchOptions {
/** Custom user agent string. */
userAgent?: string;
/** Viewport size. */
viewport?: { width: number; height: number };
viewport?: { width: number; height: number } | null;
/** Browser locale, e.g. "en-US". */
locale?: string;
/** Timezone, e.g. "America/New_York". */
/** IANA timezone — alias for `timezone`. Either works. */
timezoneId?: string;
/** Color scheme preference — 'light', 'dark', or 'no-preference'. */
colorScheme?: "light" | "dark" | "no-preference";
/**
* Extra options forwarded directly to Playwright's `browser.newContext()`
* e.g. `storageState`, `permissions`, `geolocation`, `extraHTTPHeaders`,
* `httpCredentials`. Use this for context-level options not surfaced as
* top-level fields. `locale` and `timezoneId` are stripped here to avoid
* detectable CDP emulation use the top-level `locale` and `timezone`
* wrapper fields instead (they route through undetectable binary flags).
*/
contextOptions?: BrowserContextOptions;
}
export interface LaunchPersistentContextOptions extends LaunchContextOptions {
/** Path to user data directory for persistent profile. */
userDataDir: string;
}
export interface BinaryInfo {
+129 -6
View File
@@ -1,13 +1,15 @@
import { describe, it, expect } from "vitest";
import {
CHROMIUM_VERSION,
getArchiveExt,
getChromiumVersion,
getDefaultStealthArgs,
getCacheDir,
getBinaryDir,
getDownloadUrl,
getFallbackDownloadUrl,
} from "../src/config.js";
import { _buildArgsForTest } from "../src/playwright.js";
import { _buildArgsForTest, resolveTimezone } from "../src/playwright.js";
describe("config", () => {
it("CHROMIUM_VERSION matches expected format", () => {
@@ -19,17 +21,17 @@ describe("config", () => {
const isMac = process.platform === "darwin";
expect(args).toContain("--no-sandbox");
expect(args).toContain("--disable-blink-features=AutomationControlled");
if (isMac) {
expect(args).toContain("--fingerprint-platform=macos");
// macOS: no hardware-concurrency or GPU spoofing (uses native values)
expect(args.some((a) => a.includes("hardware-concurrency"))).toBe(false);
} else {
expect(args).toContain("--fingerprint-platform=windows");
expect(args).toContain("--fingerprint-hardware-concurrency=8");
}
// GPU flags removed — binary auto-generates from seed + platform
expect(args.some((a) => a.includes("fingerprint-gpu-vendor"))).toBe(false);
expect(args.some((a) => a.includes("fingerprint-gpu-renderer"))).toBe(false);
// Should have a random fingerprint seed
const fingerprintArg = args.find((a) => a.startsWith("--fingerprint="));
expect(fingerprintArg).toBeDefined();
@@ -68,27 +70,52 @@ describe("config", () => {
});
});
describe("archive helpers", () => {
it("getArchiveExt returns correct extension for platform", () => {
const ext = getArchiveExt();
if (process.platform === "win32") {
expect(ext).toBe(".zip");
} else {
expect(ext).toBe(".tar.gz");
}
});
it("getFallbackDownloadUrl uses GitHub Releases", () => {
const url = getFallbackDownloadUrl("145.0.0.0");
expect(url).toContain("github.com/CloakHQ/cloakbrowser/releases/download");
expect(url).toContain("chromium-v145.0.0.0");
});
it("getFallbackDownloadUrl uses default version", () => {
const url = getFallbackDownloadUrl();
expect(url).toContain(`chromium-v${getChromiumVersion()}`);
});
});
describe("buildArgs timezone/locale", () => {
it("injects --fingerprint-timezone when timezone is set", () => {
const args = _buildArgsForTest({ timezone: "America/New_York" });
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);
});
@@ -96,5 +123,101 @@ 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);
});
});
describe("buildArgs deduplication", () => {
it("user --fingerprint overrides default seed", () => {
const args = _buildArgsForTest({ args: ["--fingerprint=99887"] });
const fpArgs = args.filter(a => a.startsWith("--fingerprint="));
expect(fpArgs).toHaveLength(1);
expect(fpArgs[0]).toBe("--fingerprint=99887");
});
it("user --fingerprint-platform overrides default", () => {
const args = _buildArgsForTest({ args: ["--fingerprint-platform=linux"] });
const platArgs = args.filter(a => a.startsWith("--fingerprint-platform="));
expect(platArgs).toHaveLength(1);
expect(platArgs[0]).toBe("--fingerprint-platform=linux");
});
it("timezone param overrides user --fingerprint-timezone arg", () => {
const args = _buildArgsForTest({
args: ["--fingerprint-timezone=Europe/London"],
timezone: "America/New_York",
});
const tzArgs = args.filter(a => a.startsWith("--fingerprint-timezone="));
expect(tzArgs).toHaveLength(1);
expect(tzArgs[0]).toBe("--fingerprint-timezone=America/New_York");
});
it("locale param overrides user --lang and --fingerprint-locale args", () => {
const args = _buildArgsForTest({
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", () => {
const args = _buildArgsForTest({
args: ["--fingerprint=99887", "--fingerprint-timezone=UTC", "--lang=fr-FR"],
timezone: "Europe/Berlin",
locale: "de-DE",
});
const keys = args.map(a => a.split("=")[0]);
expect(new Set(keys).size).toBe(keys.length);
});
it("non-value flags preserved without dedup issues", () => {
const args = _buildArgsForTest({ args: ["--disable-gpu", "--no-zygote"] });
expect(args).toContain("--disable-gpu");
expect(args).toContain("--no-zygote");
expect(args).toContain("--no-sandbox");
});
});
describe("buildArgs webrtc IP", () => {
it("passes --fingerprint-webrtc-ip from args", () => {
const args = _buildArgsForTest({ args: ["--fingerprint-webrtc-ip=1.2.3.4"] });
expect(args).toContain("--fingerprint-webrtc-ip=1.2.3.4");
});
it("does not inject when not in args", () => {
const args = _buildArgsForTest({});
expect(args.some(a => a.startsWith("--fingerprint-webrtc-ip"))).toBe(false);
});
});
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 = 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 = 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 = 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", () => {
File diff suppressed because it is too large Load Diff
+277 -3
View File
@@ -1,13 +1,13 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { binaryInfo } from "../src/download.js";
import { getChromiumVersion } from "../src/config.js";
import { DEFAULT_VIEWPORT, getChromiumVersion } from "../src/config.js";
describe("binaryInfo", () => {
it("returns correct structure", () => {
const info = binaryInfo();
expect(info.version).toBe(getChromiumVersion());
expect(info.platform).toMatch(/^(linux|darwin)-(x64|arm64)$/);
expect(info.platform).toMatch(/^(linux|darwin|windows)-(x64|arm64)$/);
expect(info.binaryPath).toBeTruthy();
expect(typeof info.installed).toBe("boolean");
expect(info.cacheDir).toContain("cloakbrowser");
@@ -37,3 +37,277 @@ describe.skipIf(!process.env.CLOAKBROWSER_BINARY_PATH)(
}, 30_000);
}
);
// ---------------------------------------------------------------------------
// launchContext / launchPersistentContext unit tests (mock playwright-core)
// ---------------------------------------------------------------------------
describe("launchContext (unit)", () => {
let mockContext: any;
let mockBrowser: any;
let mockChromium: any;
const origEnv = process.env.CLOAKBROWSER_BINARY_PATH;
beforeEach(() => {
process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome";
const origClose = vi.fn();
mockContext = { close: origClose, _origClose: origClose };
mockBrowser = {
newContext: vi.fn().mockResolvedValue(mockContext),
close: vi.fn(),
};
mockChromium = { launch: vi.fn().mockResolvedValue(mockBrowser) };
vi.doMock("playwright-core", () => ({ chromium: mockChromium }));
});
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
if (origEnv) {
process.env.CLOAKBROWSER_BINARY_PATH = origEnv;
} else {
delete process.env.CLOAKBROWSER_BINARY_PATH;
}
});
it("applies DEFAULT_VIEWPORT when no viewport given", async () => {
const { launchContext } = await import("../src/playwright.js");
await launchContext();
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
expect(ctxArgs.viewport).toEqual(DEFAULT_VIEWPORT);
});
it("uses custom viewport when provided", async () => {
const { launchContext } = await import("../src/playwright.js");
const custom = { width: 1280, height: 720 };
await launchContext({ viewport: custom });
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
expect(ctxArgs.viewport).toEqual(custom);
});
it("forwards userAgent to newContext", async () => {
const { launchContext } = await import("../src/playwright.js");
await launchContext({ userAgent: "Custom/1.0" });
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
expect(ctxArgs.userAgent).toBe("Custom/1.0");
});
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 --fingerprint-timezone binary flag
const launchArgs = mockChromium.launch.mock.calls[0][0];
const hasTimezoneFlag = launchArgs.args.some((a: string) =>
a.startsWith("--fingerprint-timezone=America/New_York")
);
expect(hasTimezoneFlag).toBe(true);
// NOT in newContext() — no CDP emulation
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
expect(ctxArgs.timezoneId).toBeUndefined();
});
it("forwards colorScheme to newContext", async () => {
const { launchContext } = await import("../src/playwright.js");
await launchContext({ colorScheme: "dark" });
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
expect(ctxArgs.colorScheme).toBe("dark");
});
it("close() also closes browser", async () => {
const { launchContext } = await import("../src/playwright.js");
const ctx = await launchContext();
await ctx.close();
// Original context close called
expect(mockContext._origClose).toHaveBeenCalledOnce();
// Browser also closed
expect(mockBrowser.close).toHaveBeenCalledOnce();
});
it("forwards contextOptions to newContext (storageState, etc.)", async () => {
const { launchContext } = await import("../src/playwright.js");
await launchContext({
contextOptions: {
storageState: "state.json",
permissions: ["geolocation"],
},
});
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
expect(ctxArgs.storageState).toBe("state.json");
expect(ctxArgs.permissions).toEqual(["geolocation"]);
});
it("explicit top-level fields win over contextOptions on collision", async () => {
const { launchContext } = await import("../src/playwright.js");
await launchContext({
userAgent: "Explicit/1.0",
viewport: { width: 1280, height: 720 },
colorScheme: "dark",
contextOptions: {
userAgent: "ShouldBeOverridden/9.9",
viewport: { width: 9999, height: 9999 },
colorScheme: "light",
},
});
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
expect(ctxArgs.userAgent).toBe("Explicit/1.0");
expect(ctxArgs.viewport).toEqual({ width: 1280, height: 720 });
expect(ctxArgs.colorScheme).toBe("dark");
});
it("strips locale and timezoneId from contextOptions (stealth-sensitive)", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { launchContext } = await import("../src/playwright.js");
await launchContext({
contextOptions: {
storageState: "state.json",
locale: "de-DE",
timezoneId: "Europe/Berlin",
},
});
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
// Stealth-sensitive keys stripped — they would reintroduce detectable CDP emulation.
expect(ctxArgs.locale).toBeUndefined();
expect(ctxArgs.timezoneId).toBeUndefined();
// Benign keys preserved
expect(ctxArgs.storageState).toBe("state.json");
// Warning was logged for both stripped keys
expect(warnSpy).toHaveBeenCalledTimes(2);
});
});
describe("launchPersistentContext (unit)", () => {
let mockContext: any;
let mockChromium: any;
const origEnv = process.env.CLOAKBROWSER_BINARY_PATH;
beforeEach(() => {
process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome";
mockContext = { close: vi.fn(), pages: vi.fn().mockReturnValue([]) };
mockChromium = {
launchPersistentContext: vi.fn().mockResolvedValue(mockContext),
};
vi.doMock("playwright-core", () => ({ chromium: mockChromium }));
});
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
if (origEnv) {
process.env.CLOAKBROWSER_BINARY_PATH = origEnv;
} else {
delete process.env.CLOAKBROWSER_BINARY_PATH;
}
});
it("applies DEFAULT_VIEWPORT", async () => {
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({ userDataDir: "/tmp/profile" });
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.viewport).toEqual(DEFAULT_VIEWPORT);
});
it("passes timezone and locale via binary args, not CDP context", async () => {
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({
userDataDir: "/tmp/profile",
timezone: "Asia/Tokyo",
locale: "ja-JP",
});
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
// 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 () => {
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({
userDataDir: "/tmp/profile",
proxy: "http://user:pass@proxy:8080",
});
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.proxy.server).toBe("http://proxy:8080");
expect(args.proxy.username).toBe("user");
expect(args.proxy.password).toBe("pass");
});
it("forwards userAgent and colorScheme", async () => {
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({
userDataDir: "/tmp/profile",
userAgent: "Custom/1.0",
colorScheme: "dark",
});
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.userAgent).toBe("Custom/1.0");
expect(args.colorScheme).toBe("dark");
});
it("forwards contextOptions to launchPersistentContext", async () => {
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({
userDataDir: "/tmp/profile",
contextOptions: {
permissions: ["geolocation"],
extraHTTPHeaders: { "X-Custom": "1" },
},
});
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.permissions).toEqual(["geolocation"]);
expect(args.extraHTTPHeaders).toEqual({ "X-Custom": "1" });
});
it("explicit top-level fields win over contextOptions in persistent context", async () => {
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({
userDataDir: "/tmp/profile",
userAgent: "Explicit/1.0",
viewport: { width: 1280, height: 720 },
contextOptions: {
userAgent: "ShouldBeOverridden/9.9",
viewport: { width: 9999, height: 9999 },
},
});
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.userAgent).toBe("Explicit/1.0");
expect(args.viewport).toEqual({ width: 1280, height: 720 });
});
it("strips locale and timezoneId from contextOptions (persistent context)", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({
userDataDir: "/tmp/profile",
contextOptions: {
locale: "de-DE",
timezoneId: "Europe/Berlin",
},
});
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.locale).toBeUndefined();
expect(args.timezoneId).toBeUndefined();
expect(warnSpy).toHaveBeenCalledTimes(2);
});
});
+158 -1
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import { parseProxyUrl } from "../src/proxy.js";
import { parseProxyUrl, isSocksProxy, resolveProxyConfig } from "../src/proxy.js";
import type { LaunchOptions } from "../src/types.js";
describe("parseProxyUrl", () => {
it("passes through URL without credentials", () => {
@@ -47,3 +48,159 @@ describe("parseProxyUrl", () => {
expect(parseProxyUrl("not-a-url")).toEqual({ server: "not-a-url" });
});
});
describe("proxy dict type", () => {
it("accepts string proxy in LaunchOptions", () => {
const opts: LaunchOptions = { proxy: "http://proxy:8080" };
expect(typeof opts.proxy).toBe("string");
});
it("accepts dict proxy with bypass in LaunchOptions", () => {
const opts: LaunchOptions = {
proxy: { server: "http://proxy:8080", bypass: ".google.com,localhost" },
};
expect(typeof opts.proxy).toBe("object");
if (typeof opts.proxy === "object") {
expect(opts.proxy.server).toBe("http://proxy:8080");
expect(opts.proxy.bypass).toBe(".google.com,localhost");
}
});
it("accepts dict proxy with auth and bypass in LaunchOptions", () => {
const opts: LaunchOptions = {
proxy: {
server: "http://proxy:8080",
username: "user",
password: "pass",
bypass: ".example.com",
},
};
if (typeof opts.proxy === "object") {
expect(opts.proxy.username).toBe("user");
expect(opts.proxy.password).toBe("pass");
expect(opts.proxy.bypass).toBe(".example.com");
}
});
});
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" });
});
});
describe("isSocksProxy", () => {
it("detects socks5 string", () => {
expect(isSocksProxy("socks5://user:pass@host:1080")).toBe(true);
});
it("detects socks5h string", () => {
expect(isSocksProxy("socks5h://host:1080")).toBe(true);
});
it("case insensitive", () => {
expect(isSocksProxy("SOCKS5://host:1080")).toBe(true);
});
it("rejects http", () => {
expect(isSocksProxy("http://host:8080")).toBe(false);
});
it("detects socks5 dict", () => {
expect(isSocksProxy({ server: "socks5://host:1080" })).toBe(true);
});
it("rejects http dict", () => {
expect(isSocksProxy({ server: "http://host:8080" })).toBe(false);
});
it("returns false for undefined", () => {
expect(isSocksProxy(undefined)).toBe(false);
});
});
describe("resolveProxyConfig", () => {
it("returns empty for undefined", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig(undefined);
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual([]);
});
it("returns playwright dict for http string", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" });
expect(proxyArgs).toEqual([]);
});
it("returns playwright dict for http dict", () => {
const proxy = { server: "http://proxy:8080", bypass: ".example.com" };
const { proxyOption, proxyArgs } = resolveProxyConfig(proxy);
expect(proxyOption).toEqual(proxy);
expect(proxyArgs).toEqual([]);
});
it("returns chrome arg for socks5 string", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5://user:pass@host:1080");
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:pass@host:1080"]);
});
it("returns chrome arg for socks5 no auth", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5://host:1080");
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=socks5://host:1080"]);
});
it("returns chrome arg for socks5h string", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5h://user:pass@host:1080");
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=socks5h://user:pass@host:1080"]);
});
it("reconstructs URL from socks5 dict with auth", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig({
server: "socks5://host:1080",
username: "user",
password: "p@ss",
});
expect(proxyOption).toBeUndefined();
expect(proxyArgs.length).toBe(1);
expect(proxyArgs[0]).toContain("--proxy-server=socks5://user:p%40ss@host:1080");
});
it("includes bypass for socks5 dict", () => {
const { proxyArgs } = resolveProxyConfig({
server: "socks5://host:1080",
bypass: ".example.com",
});
expect(proxyArgs).toContain("--proxy-server=socks5://host:1080");
expect(proxyArgs).toContain("--proxy-bypass-list=.example.com");
});
});
+141
View File
@@ -0,0 +1,141 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
// Mock puppeteer-core and download before importing the module under test
vi.mock("puppeteer-core", () => ({
default: {
launch: vi.fn(),
},
}));
vi.mock("../src/download.js", () => ({
ensureBinary: vi.fn().mockResolvedValue("/fake/chrome"),
}));
vi.mock("../src/geoip.js", () => ({
resolveProxyGeo: vi.fn().mockResolvedValue({ timezone: null, locale: null }),
maybeResolveGeoip: vi.fn().mockResolvedValue({}),
resolveWebrtcArgs: vi.fn().mockImplementation((opts: any) => Promise.resolve(opts.args)),
}));
describe("puppeteer launch", () => {
let puppeteerMock: any;
let mockBrowser: any;
beforeEach(async () => {
delete process.env.CLOAKBROWSER_BINARY_PATH;
puppeteerMock = await import("puppeteer-core");
mockBrowser = {
newPage: vi.fn().mockResolvedValue({
authenticate: vi.fn(),
}),
close: vi.fn(),
};
vi.mocked(puppeteerMock.default.launch).mockResolvedValue(mockBrowser);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("calls ensureBinary and launches with binary path", async () => {
const { launch } = await import("../src/puppeteer.js");
await launch();
expect(puppeteerMock.default.launch).toHaveBeenCalledWith(
expect.objectContaining({
executablePath: "/fake/chrome",
})
);
});
it("includes stealth args by default", async () => {
const { launch } = await import("../src/puppeteer.js");
await launch();
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(true);
expect(callArgs.args).toContain("--no-sandbox");
});
it("excludes stealth args when stealthArgs=false", async () => {
const { launch } = await import("../src/puppeteer.js");
await launch({ stealthArgs: false });
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(false);
});
it("adds --proxy-server for string proxy", async () => {
const { launch } = await import("../src/puppeteer.js");
await launch({ proxy: "http://proxy:8080" });
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--proxy-server=http://proxy:8080");
});
it("adds --proxy-bypass-list for dict proxy with bypass", async () => {
const { launch } = await import("../src/puppeteer.js");
await launch({
proxy: { server: "http://proxy:8080", bypass: ".google.com,localhost" },
});
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--proxy-server=http://proxy:8080");
expect(callArgs.args).toContain("--proxy-bypass-list=.google.com,localhost");
});
it("monkey-patches newPage for proxy auth", async () => {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({ proxy: "http://user:pass@proxy:8080" });
// newPage should auto-authenticate
const page = await browser.newPage();
expect(page.authenticate).toHaveBeenCalledWith({
username: "user",
password: "pass",
});
});
it("injects timezone and locale as binary flags", async () => {
const { launch } = await import("../src/puppeteer.js");
await launch({ timezone: "Asia/Tokyo", locale: "ja-JP" });
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--fingerprint-timezone=Asia/Tokyo");
expect(callArgs.args).toContain("--lang=ja-JP");
});
it("merges extra args", async () => {
const { launch } = await import("../src/puppeteer.js");
await launch({ args: ["--disable-gpu", "--no-first-run"] });
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--disable-gpu");
expect(callArgs.args).toContain("--no-first-run");
});
it("keeps SOCKS5 credentials in --proxy-server URL", async () => {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({ proxy: "socks5://user:pass@proxy:1080" });
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--proxy-server=socks5://user:pass@proxy:1080");
// Should NOT set up page.authenticate for SOCKS5
const page = await browser.newPage();
expect(page.authenticate).not.toHaveBeenCalled();
});
it("reconstructs SOCKS5 dict with auth into --proxy-server URL", async () => {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({
proxy: { server: "socks5://proxy:1080", username: "user", password: "p@ss" },
});
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--proxy-server=socks5://user:p%40ss@proxy:1080");
const page = await browser.newPage();
expect(page.authenticate).not.toHaveBeenCalled();
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+107 -4
View File
@@ -9,7 +9,12 @@ import {
versionNewer,
} from "../src/config.js";
import {
binaryInfo,
checkForUpdate,
checkWrapperUpdate,
clearCache,
ensureBinary,
fetchChecksums,
getLatestChromiumVersion,
parseChecksums,
resetWrapperUpdateChecked,
@@ -100,7 +105,7 @@ describe("latest version (platform-aware)", () => {
{
tag_name: "chromium-v145.0.7718.0",
draft: false,
assets: makeAssets(["linux-x64", "darwin-arm64", "darwin-x64"]),
assets: makeAssets(["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]),
},
]);
expect(await getLatestChromiumVersion()).toBe("145.0.7718.0");
@@ -116,7 +121,7 @@ describe("latest version (platform-aware)", () => {
{
tag_name: "chromium-v142.0.7444.175",
draft: false,
assets: makeAssets(["linux-x64", "darwin-arm64", "darwin-x64"]),
assets: makeAssets(["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]),
},
]);
const result = await getLatestChromiumVersion();
@@ -133,14 +138,14 @@ describe("latest version (platform-aware)", () => {
{
tag_name: "chromium-v145.0.7718.0",
draft: false,
assets: [{ name: "cloakbrowser-windows-x64.tar.gz" }],
assets: [{ name: "cloakbrowser-freebsd-x64.tar.gz" }],
},
]);
expect(await getLatestChromiumVersion()).toBeNull();
});
it("skips draft releases", async () => {
const all = ["linux-x64", "darwin-arm64", "darwin-x64"];
const all = ["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"];
mockFetch([
{ tag_name: "chromium-v999.0.0.0", draft: true, assets: makeAssets(all) },
{ tag_name: "chromium-v145.0.7718.0", draft: false, assets: makeAssets(all) },
@@ -265,9 +270,107 @@ 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
expect(getEffectiveVersion()).toBe(getChromiumVersion());
});
});
describe("ensureBinary", () => {
afterEach(() => {
delete process.env.CLOAKBROWSER_BINARY_PATH;
});
it("returns local override when set", async () => {
// Use this test file as a "binary" that exists
process.env.CLOAKBROWSER_BINARY_PATH = __filename;
const result = await ensureBinary();
expect(result).toBe(__filename);
});
it("throws when local override path missing", async () => {
process.env.CLOAKBROWSER_BINARY_PATH = "/nonexistent/chrome";
await expect(ensureBinary()).rejects.toThrow("does not exist");
});
});
describe("clearCache", () => {
it("does not throw when cache dir missing", () => {
const orig = process.env.CLOAKBROWSER_CACHE_DIR;
process.env.CLOAKBROWSER_CACHE_DIR = "/tmp/cloakbrowser-test-nonexistent";
expect(() => clearCache()).not.toThrow();
if (orig) {
process.env.CLOAKBROWSER_CACHE_DIR = orig;
} else {
delete process.env.CLOAKBROWSER_CACHE_DIR;
}
});
});
describe("checkForUpdate", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("returns null when no newer version", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => [],
} as Response);
expect(await checkForUpdate()).toBeNull();
});
it("returns null on network error", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
expect(await checkForUpdate()).toBeNull();
});
});
+8 -2
View File
@@ -49,12 +49,18 @@ classifiers = [
"Topic :: Software Development :: Testing",
]
dependencies = [
"patchright>=1.40",
"playwright>=1.40",
"httpx>=0.24",
]
[project.optional-dependencies]
geoip = ["geoip2>=4.0"]
geoip = ["geoip2>=4.0", "socksio>=1.0"] # socksio: SOCKS5 transport for httpx
patchright = ["patchright>=1.40"]
serve = ["aiohttp>=3.9", "websockets>=12.0"]
dev = ["pytest>=7.0", "pytest-asyncio>=0.23"]
[project.scripts]
cloakbrowser = "cloakbrowser.__main__:main"
[project.urls]
Homepage = "https://github.com/CloakHQ/CloakBrowser"
+11
View File
@@ -0,0 +1,11 @@
"""Shared test fixtures."""
import os
import pytest
@pytest.fixture(autouse=True)
def _clean_backend_env(monkeypatch):
"""Ensure CLOAKBROWSER_BACKEND doesn't leak into tests from the host environment."""
monkeypatch.delenv("CLOAKBROWSER_BACKEND", raising=False)
+45
View File
@@ -0,0 +1,45 @@
"""Unit tests for backend resolution (_resolve_backend)."""
import os
from unittest.mock import patch
import pytest
from cloakbrowser.browser import _resolve_backend
def test_resolve_backend_default():
"""No param, no env var → 'playwright'."""
with patch.dict(os.environ, {}, clear=True):
assert _resolve_backend(None) == "playwright"
def test_resolve_backend_explicit_playwright():
assert _resolve_backend("playwright") == "playwright"
def test_resolve_backend_explicit_patchright():
assert _resolve_backend("patchright") == "patchright"
def test_resolve_backend_env_var():
"""CLOAKBROWSER_BACKEND env var used when no param."""
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "patchright"}):
assert _resolve_backend(None) == "patchright"
def test_resolve_backend_param_beats_env():
"""Explicit param overrides env var."""
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "patchright"}):
assert _resolve_backend("playwright") == "playwright"
def test_resolve_backend_invalid_raises():
with pytest.raises(ValueError, match="Unknown backend 'bogus'"):
_resolve_backend("bogus")
def test_resolve_backend_invalid_env_raises():
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "bogus"}):
with pytest.raises(ValueError, match="Unknown backend 'bogus'"):
_resolve_backend(None)
+165 -10
View File
@@ -1,46 +1,201 @@
"""Unit tests for _build_args timezone/locale injection."""
"""Unit tests for build_args timezone/locale injection and timezone alias."""
from cloakbrowser.browser import _build_args
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
# --- _resolve_timezone alias ---
def test_resolve_timezone_id_alias():
"""timezone_id in kwargs should be promoted to timezone."""
kwargs = {"timezone_id": "Europe/Paris"}
result = _resolve_timezone(None, kwargs)
assert result == "Europe/Paris"
assert "timezone_id" not in kwargs
def test_resolve_timezone_wins_over_alias():
"""Explicit timezone takes precedence; timezone_id is still popped."""
kwargs = {"timezone_id": "Europe/Paris"}
result = _resolve_timezone("UTC", kwargs)
assert result == "UTC"
assert "timezone_id" not in kwargs
def test_resolve_no_alias():
"""No-op when timezone_id is absent."""
kwargs = {"other": "value"}
result = _resolve_timezone("UTC", kwargs)
assert result == "UTC"
assert "other" in kwargs
def test_resolve_both_none():
"""Neither param set — returns None."""
kwargs = {}
result = _resolve_timezone(None, kwargs)
assert result is None
# --- Deduplication tests ---
def test_user_fingerprint_overrides_default():
"""User --fingerprint should override the random default seed."""
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"
def test_user_platform_overrides_default():
"""User --fingerprint-platform should override the default."""
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"
def test_timezone_param_overrides_user_arg():
"""Dedicated timezone param should override user arg."""
args = build_args(
stealth_args=True,
extra_args=["--fingerprint-timezone=Europe/London"],
timezone="America/New_York",
)
tz_args = [a for a in args if a.startswith("--fingerprint-timezone=")]
assert len(tz_args) == 1
assert tz_args[0] == "--fingerprint-timezone=America/New_York"
def test_locale_param_overrides_user_arg():
"""Dedicated locale param should override user --lang and --fingerprint-locale args."""
args = build_args(
stealth_args=True,
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(
stealth_args=True,
extra_args=["--fingerprint=99887", "--fingerprint-timezone=UTC", "--lang=fr-FR"],
timezone="Europe/Berlin",
locale="de-DE",
)
keys = [a.split("=", 1)[0] for a in args]
assert len(keys) == len(set(keys)), f"Duplicate keys found: {keys}"
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"])
assert "--disable-gpu" in args
assert "--no-zygote" in args
assert "--no-sandbox" in args
def test_override_logs_debug(caplog):
"""Should log debug message when an override happens."""
import logging
with caplog.at_level(logging.DEBUG, logger="cloakbrowser"):
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)
# --- WebRTC IP spoofing ---
def test_webrtc_ip_passed_through_args():
"""--fingerprint-webrtc-ip in args should pass through to output."""
args = build_args(stealth_args=True, extra_args=["--fingerprint-webrtc-ip=1.2.3.4"])
assert "--fingerprint-webrtc-ip=1.2.3.4" in args
def test_webrtc_ip_not_present_by_default():
"""No --fingerprint-webrtc-ip when not in args."""
args = build_args(stealth_args=True, extra_args=None)
assert not any(a.startswith("--fingerprint-webrtc-ip") for a in args)
def test_resolve_webrtc_args_auto():
"""--fingerprint-webrtc-ip=auto should be resolved to an IP."""
from cloakbrowser.browser import _resolve_webrtc_args
from unittest.mock import patch
with patch("cloakbrowser.geoip._resolve_exit_ip", return_value="5.6.7.8"):
result = _resolve_webrtc_args(["--fingerprint-webrtc-ip=auto"], "http://proxy:8080")
assert result == ["--fingerprint-webrtc-ip=5.6.7.8"]
def test_resolve_webrtc_args_explicit_ip_unchanged():
"""Explicit IP in args should not be touched."""
from cloakbrowser.browser import _resolve_webrtc_args
result = _resolve_webrtc_args(["--fingerprint-webrtc-ip=9.9.9.9"], "http://proxy:8080")
assert result == ["--fingerprint-webrtc-ip=9.9.9.9"]
def test_resolve_webrtc_args_no_flag():
"""No webrtc flag in args should return args unchanged."""
from cloakbrowser.browser import _resolve_webrtc_args
result = _resolve_webrtc_args(["--no-sandbox"], "http://proxy:8080")
assert result == ["--no-sandbox"]
+246
View File
@@ -0,0 +1,246 @@
"""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"]
config, passthrough = parse_cli_args(args)
# --fingerprint=999 is consumed into config["default_seed"], not passed through
assert passthrough == ["--no-sandbox", "--disable-gpu"]
assert config["default_seed"] == "999"
def test_port_not_in_passthrough(self):
_, passthrough = parse_cli_args(["--port=9222", "--no-sandbox"])
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
+146
View File
@@ -0,0 +1,146 @@
"""Unit tests for config.py — platform detection, paths, stealth args."""
import os
from unittest.mock import patch
import pytest
from cloakbrowser.config import (
get_archive_ext,
get_archive_name,
get_binary_path,
get_cache_dir,
get_chromium_version,
get_default_stealth_args,
get_fallback_download_url,
get_platform_tag,
)
# ---------------------------------------------------------------------------
# Platform-specific binary paths
# ---------------------------------------------------------------------------
class TestGetBinaryPath:
def test_linux(self):
with patch("cloakbrowser.config.platform.system", return_value="Linux"):
path = get_binary_path("145.0.0.0")
assert str(path).endswith("chromium-145.0.0.0/chrome")
def test_darwin(self):
with patch("cloakbrowser.config.platform.system", return_value="Darwin"):
path = get_binary_path("145.0.0.0")
assert str(path).endswith("chromium-145.0.0.0/Chromium.app/Contents/MacOS/Chromium")
def test_windows(self):
with patch("cloakbrowser.config.platform.system", return_value="Windows"):
path = get_binary_path("145.0.0.0")
assert str(path).endswith("chromium-145.0.0.0/chrome.exe")
# ---------------------------------------------------------------------------
# Archive extension and name
# ---------------------------------------------------------------------------
class TestArchive:
def test_ext_windows(self):
with patch("cloakbrowser.config.platform.system", return_value="Windows"):
assert get_archive_ext() == ".zip"
def test_ext_unix(self):
for system in ("Linux", "Darwin"):
with patch("cloakbrowser.config.platform.system", return_value=system):
assert get_archive_ext() == ".tar.gz"
def test_archive_name(self):
tag = get_platform_tag()
ext = get_archive_ext()
assert get_archive_name() == f"cloakbrowser-{tag}{ext}"
def test_archive_name_custom_tag(self):
name = get_archive_name("linux-x64")
assert "cloakbrowser-linux-x64" in name
# ---------------------------------------------------------------------------
# Download URLs
# ---------------------------------------------------------------------------
class TestFallbackUrl:
def test_github_releases_format(self):
url = get_fallback_download_url("145.0.0.0")
assert "github.com/CloakHQ/cloakbrowser/releases/download" in url
assert "chromium-v145.0.0.0" in url
def test_default_version(self):
url = get_fallback_download_url()
version = get_chromium_version()
assert f"chromium-v{version}" in url
# ---------------------------------------------------------------------------
# Cache directory
# ---------------------------------------------------------------------------
class TestCacheDir:
def test_default_path(self):
with patch.dict(os.environ, {}, clear=False):
# Remove override if set
env = os.environ.copy()
env.pop("CLOAKBROWSER_CACHE_DIR", None)
with patch.dict(os.environ, env, clear=True):
path = get_cache_dir()
assert str(path).endswith(".cloakbrowser")
def test_env_override(self, tmp_path):
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
assert get_cache_dir() == tmp_path
# ---------------------------------------------------------------------------
# Platform tag
# ---------------------------------------------------------------------------
class TestPlatformTag:
def test_unsupported_raises(self):
with patch("cloakbrowser.config.platform.system", return_value="FreeBSD"):
with patch("cloakbrowser.config.platform.machine", return_value="x86_64"):
with pytest.raises(RuntimeError, match="Unsupported platform"):
get_platform_tag()
# ---------------------------------------------------------------------------
# Stealth args
# ---------------------------------------------------------------------------
class TestStealthArgs:
def test_seed_uniqueness(self):
"""Two calls should produce different fingerprint seeds."""
args1 = get_default_stealth_args()
args2 = get_default_stealth_args()
seed1 = [a for a in args1 if a.startswith("--fingerprint=")][0]
seed2 = [a for a in args2 if a.startswith("--fingerprint=")][0]
# Seeds are random 10000-99999 — extremely unlikely to collide
assert seed1 != seed2
def test_macos_profile(self):
with patch("cloakbrowser.config.platform.system", return_value="Darwin"):
args = get_default_stealth_args()
assert "--fingerprint-platform=macos" in args
# GPU flags removed — binary auto-generates from seed + platform
assert not any("fingerprint-gpu-vendor" in a for a in args)
assert not any("fingerprint-gpu-renderer" in a for a in args)
def test_linux_windows_profile(self):
with patch("cloakbrowser.config.platform.system", return_value="Linux"):
args = get_default_stealth_args()
assert "--fingerprint-platform=windows" in args
# GPU flags removed — binary auto-generates from seed + platform
assert not any("fingerprint-gpu-vendor" in a for a in args)
assert not any("fingerprint-gpu-renderer" in a for a in args)
+192
View File
@@ -0,0 +1,192 @@
"""Unit tests for archive extraction — path traversal protection, flattening, permissions."""
import io
import os
import platform
import stat
import tarfile
import zipfile
import pytest
from cloakbrowser.download import (
_extract_tar,
_extract_zip,
_flatten_single_subdir,
_is_executable,
_make_executable,
)
# ---------------------------------------------------------------------------
# tar.gz extraction
# ---------------------------------------------------------------------------
def _create_tar_gz(tmp_path, members: dict[str, bytes]) -> "Path":
"""Create a tar.gz with given {name: content} members."""
archive = tmp_path / "test.tar.gz"
with tarfile.open(archive, "w:gz") as tar:
for name, content in members.items():
info = tarfile.TarInfo(name=name)
info.size = len(content)
tar.addfile(info, io.BytesIO(content))
return archive
class TestExtractTar:
def test_basic(self, tmp_path):
archive = _create_tar_gz(tmp_path, {"chrome": b"binary", "lib/libfoo.so": b"lib"})
dest = tmp_path / "out"
dest.mkdir()
_extract_tar(archive, dest)
assert (dest / "chrome").read_bytes() == b"binary"
assert (dest / "lib" / "libfoo.so").read_bytes() == b"lib"
def test_path_traversal_blocked(self, tmp_path):
archive = tmp_path / "evil.tar.gz"
with tarfile.open(archive, "w:gz") as tar:
info = tarfile.TarInfo(name="../../../etc/passwd")
info.size = 4
tar.addfile(info, io.BytesIO(b"evil"))
dest = tmp_path / "out"
dest.mkdir()
with pytest.raises(RuntimeError, match="path traversal"):
_extract_tar(archive, dest)
def test_suspicious_symlink_skipped(self, tmp_path):
"""Symlinks with absolute targets are skipped (logged as warning)."""
archive = tmp_path / "symlink.tar.gz"
with tarfile.open(archive, "w:gz") as tar:
# Normal file
info = tarfile.TarInfo(name="chrome")
info.size = 6
tar.addfile(info, io.BytesIO(b"binary"))
# Suspicious symlink
sym = tarfile.TarInfo(name="evil_link")
sym.type = tarfile.SYMTYPE
sym.linkname = "/etc/passwd"
tar.addfile(sym)
dest = tmp_path / "out"
dest.mkdir()
_extract_tar(archive, dest)
# Normal file extracted
assert (dest / "chrome").exists()
# Suspicious symlink was skipped
assert not (dest / "evil_link").exists()
# ---------------------------------------------------------------------------
# zip extraction
# ---------------------------------------------------------------------------
def _create_zip(tmp_path, members: dict[str, bytes]) -> "Path":
"""Create a zip with given {name: content} members."""
archive = tmp_path / "test.zip"
with zipfile.ZipFile(archive, "w") as zf:
for name, content in members.items():
zf.writestr(name, content)
return archive
class TestExtractZip:
def test_basic(self, tmp_path):
archive = _create_zip(tmp_path, {"chrome.exe": b"binary", "lib/foo.dll": b"lib"})
dest = tmp_path / "out"
dest.mkdir()
_extract_zip(archive, dest)
assert (dest / "chrome.exe").read_bytes() == b"binary"
assert (dest / "lib" / "foo.dll").read_bytes() == b"lib"
def test_path_traversal_blocked(self, tmp_path):
archive = tmp_path / "evil.zip"
with zipfile.ZipFile(archive, "w") as zf:
zf.writestr("../../../etc/passwd", "evil")
dest = tmp_path / "out"
dest.mkdir()
with pytest.raises(RuntimeError, match="path traversal"):
_extract_zip(archive, dest)
# ---------------------------------------------------------------------------
# Directory flattening
# ---------------------------------------------------------------------------
class TestFlatten:
def test_single_subdir_flattened(self, tmp_path):
"""Single subdir contents moved up."""
dest = tmp_path / "out"
dest.mkdir()
subdir = dest / "fingerprint-chromium-custom-v14"
subdir.mkdir()
(subdir / "chrome").write_bytes(b"binary")
(subdir / "lib").mkdir()
_flatten_single_subdir(dest)
assert (dest / "chrome").read_bytes() == b"binary"
assert (dest / "lib").is_dir()
assert not subdir.exists()
def test_app_bundle_preserved(self, tmp_path):
""".app directory NOT flattened (macOS bundle)."""
dest = tmp_path / "out"
dest.mkdir()
app = dest / "Chromium.app"
app.mkdir()
(app / "Contents").mkdir()
(app / "Contents" / "MacOS").mkdir()
(app / "Contents" / "MacOS" / "Chromium").write_bytes(b"binary")
_flatten_single_subdir(dest)
# .app bundle kept intact
assert app.is_dir()
assert (app / "Contents" / "MacOS" / "Chromium").exists()
def test_noop_multiple_entries(self, tmp_path):
"""Multiple entries at top level — no flattening."""
dest = tmp_path / "out"
dest.mkdir()
(dest / "chrome").write_bytes(b"binary")
(dest / "lib").mkdir()
_flatten_single_subdir(dest)
# Nothing moved
assert (dest / "chrome").exists()
assert (dest / "lib").is_dir()
# ---------------------------------------------------------------------------
# Permissions
# ---------------------------------------------------------------------------
class TestPermissions:
@pytest.mark.skipif(platform.system() == "Windows", reason="chmod not applicable on Windows")
def test_make_executable(self, tmp_path):
binary = tmp_path / "chrome"
binary.write_bytes(b"binary")
binary.chmod(0o644)
assert not _is_executable(binary)
_make_executable(binary)
assert _is_executable(binary)
def test_is_executable_true(self, tmp_path):
binary = tmp_path / "chrome"
binary.write_bytes(b"binary")
binary.chmod(0o755)
assert _is_executable(binary)
def test_is_executable_false(self, tmp_path):
binary = tmp_path / "chrome"
binary.write_bytes(b"binary")
binary.chmod(0o644)
assert not _is_executable(binary)
+38 -12
View File
@@ -4,9 +4,10 @@ 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,
_resolve_proxy_ip,
)
@@ -91,48 +92,73 @@ 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, ip = maybe_resolve_geoip(False, "http://proxy:8080", None, None)
assert tz is None
assert loc is None
assert ip is None
def test_maybe_resolve_skips_when_no_proxy():
tz, loc = _maybe_resolve_geoip(True, None, None, None)
tz, loc, ip = maybe_resolve_geoip(True, None, None, None)
assert tz is None
assert loc is None
assert ip 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")
"""Explicit values should still resolve exit IP for WebRTC."""
with patch("cloakbrowser.geoip._resolve_exit_ip", return_value="1.2.3.4"):
tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", "de-DE")
assert tz == "Europe/Berlin"
assert loc == "de-DE"
assert ip == "1.2.3.4"
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")
with patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4")):
tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", None, "fr-FR")
assert tz == "America/New_York"
assert loc == "fr-FR" # Explicit wins
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)
with patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4")):
tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", "Asia/Tokyo", None)
assert tz == "Asia/Tokyo" # Explicit wins
assert loc == "en-US"
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)
with patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8")):
tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", None, None)
assert tz == "Europe/Berlin"
assert loc == "de-DE"
assert ip == "5.6.7.8"
# ---------------------------------------------------------------------------
# _is_private_ip
# ---------------------------------------------------------------------------
def test_private_ip_loopback():
assert _is_private_ip("127.0.0.1") is True
def test_private_ip_rfc1918():
assert _is_private_ip("192.168.1.1") is True
assert _is_private_ip("10.0.0.1") is True
assert _is_private_ip("172.16.0.1") is True
def test_private_ip_public():
assert _is_private_ip("8.8.8.8") is False
assert _is_private_ip("64.176.168.43") is False
+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);
+363
View File
@@ -0,0 +1,363 @@
"""
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)
# ============================================================
# SCENARIO 7: ElementHandle — query_selector interactions
# ============================================================
step("ElementHandle — query_selector click, type, fill, hover")
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
time.sleep(2)
inject(page)
time.sleep(1)
print(" Watch: get element via query_selector, cursor moves smoothly")
el = page.query_selector('#searchInput')
assert el is not None, "query_selector returned None"
assert getattr(el, '_human_patched', False), "ElementHandle not patched!"
t0 = time.time()
el.click()
eh_click_ms = int((time.time() - t0) * 1000)
check("ElementHandle click", eh_click_ms > 100, f"{eh_click_ms} ms")
time.sleep(0.5)
print(" Watch: ElementHandle type — characters appear one by one")
t0 = time.time()
el.type('ElementHandle typing')
eh_type_ms = int((time.time() - t0) * 1000)
val = page.locator('#searchInput').input_value()
check("ElementHandle type", val == 'ElementHandle typing' and eh_type_ms > 1500, f"{eh_type_ms} ms, value='{val}'")
time.sleep(0.5)
print(" Watch: ElementHandle fill — clears then types")
t0 = time.time()
el.fill('Filled via EH')
eh_fill_ms = int((time.time() - t0) * 1000)
val = page.locator('#searchInput').input_value()
check("ElementHandle fill", val == 'Filled via EH' and eh_fill_ms > 1000, f"{eh_fill_ms} ms, value='{val}'")
time.sleep(0.5)
print(" Watch: ElementHandle hover — cursor moves without clicking")
btn_el = page.query_selector('button[type="submit"]')
t0 = time.time()
btn_el.hover()
eh_hover_ms = int((time.time() - t0) * 1000)
check("ElementHandle hover", eh_hover_ms > 50, f"{eh_hover_ms} ms")
time.sleep(0.5)
print(" Watch: query_selector_all returns patched handles")
page.goto('https://the-internet.herokuapp.com/checkboxes', wait_until='domcontentloaded')
time.sleep(2)
inject(page)
time.sleep(1)
els = page.query_selector_all('input[type="checkbox"]')
all_patched = all(getattr(e, '_human_patched', False) for e in els)
check("query_selector_all all patched", all_patched and len(els) >= 2, f"{len(els)} elements, all_patched={all_patched}")
if els:
print(" Watch: click checkbox via ElementHandle")
t0 = time.time()
els[0].click()
cb_click_ms = int((time.time() - t0) * 1000)
check("ElementHandle checkbox click", cb_click_ms > 100, f"{cb_click_ms} ms")
time.sleep(1)
# ============================================================
# SUMMARY
# ============================================================
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);
File diff suppressed because it is too large Load Diff
+331
View File
@@ -0,0 +1,331 @@
"""Unit tests for launch_context() — context kwargs, viewport defaults, close cleanup."""
from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest
from cloakbrowser.config import DEFAULT_VIEWPORT
# All tests mock launch() to avoid needing a binary.
# launch_context() calls launch() internally, then browser.new_context().
def _make_mock_browser():
"""Create a mock browser with new_context() returning a mock context."""
browser = MagicMock()
context = MagicMock()
browser.new_context.return_value = context
return browser, context
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_default_viewport(mock_launch, _mock_bin):
"""DEFAULT_VIEWPORT applied when no viewport given."""
browser, context = _make_mock_browser()
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
launch_context()
ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["viewport"] == DEFAULT_VIEWPORT
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_custom_viewport(mock_launch, _mock_bin):
"""Custom viewport overrides DEFAULT_VIEWPORT."""
browser, context = _make_mock_browser()
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
custom = {"width": 1280, "height": 720}
launch_context(viewport=custom)
ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["viewport"] == custom
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_user_agent(mock_launch, _mock_bin):
"""user_agent forwarded to new_context()."""
browser, context = _make_mock_browser()
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
launch_context(user_agent="Mozilla/5.0 Custom")
ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["user_agent"] == "Mozilla/5.0 Custom"
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_locale_forwarded(mock_launch, _mock_bin):
"""locale flows to launch() for --lang binary flag, NOT to new_context() CDP."""
browser, context = _make_mock_browser()
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
launch_context(locale="de-DE")
# Locale in launch() call (for --lang binary flag)
assert mock_launch.call_args[1]["locale"] == "de-DE"
# NOT in new_context() — would trigger detectable CDP emulation
ctx_kwargs = browser.new_context.call_args
assert "locale" not in ctx_kwargs[1]
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_timezone_via_binary_not_cdp(mock_launch, _mock_bin):
"""timezone passed to launch() for binary flag, NOT to new_context() CDP.
--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
from cloakbrowser.browser import launch_context
launch_context(timezone="America/New_York")
# 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 "timezone_id" not in ctx_kwargs[1]
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_color_scheme(mock_launch, _mock_bin):
"""color_scheme forwarded to new_context()."""
browser, context = _make_mock_browser()
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
launch_context(color_scheme="dark")
ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["color_scheme"] == "dark"
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8"))
@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 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)
# Both go to launch() for binary flags
assert mock_launch.call_args[1]["locale"] == "de-DE"
assert mock_launch.call_args[1]["timezone"] == "Europe/Berlin"
# Neither in context — no CDP emulation
ctx_kwargs = browser.new_context.call_args
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_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
launch_context(timezone_id="Europe/Paris")
# 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 "timezone_id" not in ctx_kwargs[1]
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_close_closes_browser(mock_launch, _mock_bin):
"""context.close() also calls browser.close()."""
browser, context = _make_mock_browser()
# Save reference before launch_context() monkey-patches context.close
original_ctx_close = context.close
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
ctx = launch_context()
# The returned context has a patched close()
ctx.close()
# Original context close was called
original_ctx_close.assert_called_once()
# Browser close was also called
browser.close.assert_called_once()
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_error_closes_browser(mock_launch, _mock_bin):
"""If new_context() raises, browser is still closed."""
browser = MagicMock()
browser.new_context.side_effect = RuntimeError("context creation failed")
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
with pytest.raises(RuntimeError, match="context creation failed"):
launch_context()
browser.close.assert_called_once()
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_kwargs_passthrough(mock_launch, _mock_bin):
"""Extra kwargs forwarded to new_context(), NOT to launch().
Important contract: kwargs like record_video_dir go to context creation,
not browser launch.
"""
browser, context = _make_mock_browser()
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
launch_context(record_video_dir="/tmp/videos")
# Verify kwarg reached new_context()
ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["record_video_dir"] == "/tmp/videos"
# Verify kwarg did NOT leak to launch()
launch_kwargs = mock_launch.call_args[1]
assert "record_video_dir" not in launch_kwargs
# ---------------------------------------------------------------------------
# Async: launch_context_async()
# ---------------------------------------------------------------------------
def _make_mock_async_browser():
"""Create a mock async browser whose new_context() returns a mock context."""
browser = AsyncMock()
context = AsyncMock()
browser.new_context.return_value = context
return browser, context
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch_async")
async def test_async_storage_state_forwarded(mock_launch_async, _mock_bin):
"""storage_state kwarg forwarded to browser.new_context() in async path.
This is the motivating use case from issue #141.
"""
browser, context = _make_mock_async_browser()
mock_launch_async.return_value = browser
from cloakbrowser.browser import launch_context_async
await launch_context_async(storage_state="state.json")
ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["storage_state"] == "state.json"
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch_async")
async def test_async_default_viewport(mock_launch_async, _mock_bin):
"""DEFAULT_VIEWPORT applied when no viewport given (async)."""
browser, context = _make_mock_async_browser()
mock_launch_async.return_value = browser
from cloakbrowser.browser import launch_context_async
await launch_context_async()
ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["viewport"] == DEFAULT_VIEWPORT
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch_async")
async def test_async_locale_flows_to_binary_not_cdp(mock_launch_async, _mock_bin):
"""locale flows to launch_async() for --lang flag, NOT to new_context() CDP."""
browser, context = _make_mock_async_browser()
mock_launch_async.return_value = browser
from cloakbrowser.browser import launch_context_async
await launch_context_async(locale="de-DE", timezone="Europe/Berlin")
# Binary flags
assert mock_launch_async.call_args[1]["locale"] == "de-DE"
assert mock_launch_async.call_args[1]["timezone"] == "Europe/Berlin"
# Not in context — would trigger detectable CDP emulation
ctx_kwargs = browser.new_context.call_args
assert "locale" not in ctx_kwargs[1]
assert "timezone_id" not in ctx_kwargs[1]
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch_async")
async def test_async_close_closes_browser(mock_launch_async, _mock_bin):
"""await ctx.close() also closes the underlying browser."""
browser, context = _make_mock_async_browser()
original_ctx_close = context.close
mock_launch_async.return_value = browser
from cloakbrowser.browser import launch_context_async
ctx = await launch_context_async()
await ctx.close()
original_ctx_close.assert_called_once()
browser.close.assert_called_once()
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch_async")
async def test_async_error_closes_browser(mock_launch_async, _mock_bin):
"""If new_context() raises in async path, browser is still closed."""
browser = AsyncMock()
browser.new_context.side_effect = RuntimeError("context creation failed")
mock_launch_async.return_value = browser
from cloakbrowser.browser import launch_context_async
with pytest.raises(RuntimeError, match="context creation failed"):
await launch_context_async()
browser.close.assert_called_once()
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch_async")
async def test_async_cancellation_closes_browser(mock_launch_async, _mock_bin):
"""asyncio.CancelledError during new_context() still closes browser.
CancelledError derives from BaseException (not Exception) in Python 3.8+,
so the cleanup must catch BaseException to prevent browser process leaks
when the awaiting task is cancelled.
"""
import asyncio
browser = AsyncMock()
browser.new_context.side_effect = asyncio.CancelledError()
mock_launch_async.return_value = browser
from cloakbrowser.browser import launch_context_async
with pytest.raises(asyncio.CancelledError):
await launch_context_async()
browser.close.assert_called_once()
+259
View File
@@ -0,0 +1,259 @@
"""Unit tests for launch_persistent_context() and launch_persistent_context_async().
All tests mock playwright to avoid needing a binary.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from cloakbrowser.config import DEFAULT_VIEWPORT
def _make_mock_pw_and_context():
"""Create mock sync_playwright chain returning a mock context."""
context = MagicMock()
pw = MagicMock()
pw.chromium.launch_persistent_context.return_value = context
pw_cm = MagicMock()
pw_cm.start.return_value = pw
return pw_cm, pw, context
# ---------------------------------------------------------------------------
# Sync: launch_persistent_context()
# ---------------------------------------------------------------------------
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, 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()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", args=["--disable-gpu"])
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert "--disable-gpu" in call_kwargs["args"]
# Stealth args present by default
assert any(a.startswith("--fingerprint=") for a in call_kwargs["args"])
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, 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()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile")
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["viewport"] == DEFAULT_VIEWPORT
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, 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()
custom = {"width": 1280, "height": 720}
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", viewport=custom)
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["viewport"] == custom
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, 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()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", user_agent="Custom/1.0")
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["user_agent"] == "Custom/1.0"
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
def test_persistent_context_locale_and_timezone(_mock_bin):
"""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):
from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", timezone="Asia/Tokyo", locale="ja-JP")
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
# 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, None))
def test_persistent_context_color_scheme(_mock_geoip, _mock_bin):
"""color_scheme forwarded correctly."""
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
launch_persistent_context("/tmp/profile", color_scheme="dark")
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["color_scheme"] == "dark"
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8"))
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
def test_persistent_context_geoip(_mock_bin, _mock_geoip):
"""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):
from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", proxy="http://proxy:8080", geoip=True)
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
# 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_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
launch_persistent_context("/tmp/profile", timezone_id="Europe/Paris")
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
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, 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()
original_close = context.close
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context
ctx = launch_persistent_context("/tmp/profile")
ctx.close()
original_close.assert_called_once()
pw.stop.assert_called_once()
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, 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()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", proxy="http://user:pass@proxy:8080")
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["proxy"]["server"] == "http://proxy:8080"
assert call_kwargs["proxy"]["username"] == "user"
assert call_kwargs["proxy"]["password"] == "pass"
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, 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()
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"}
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", proxy=proxy_dict)
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["proxy"] == proxy_dict
# ---------------------------------------------------------------------------
# Async: launch_persistent_context_async()
# ---------------------------------------------------------------------------
def _make_mock_async_pw_and_context():
"""Create mock async_playwright chain returning a mock context."""
context = AsyncMock()
pw = AsyncMock()
pw.chromium.launch_persistent_context.return_value = context
pw_cm = AsyncMock()
pw_cm.start.return_value = pw
return pw_cm, pw, context
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, 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()
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context_async
await launch_persistent_context_async("/tmp/profile", args=["--disable-gpu"])
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert "--disable-gpu" in call_kwargs["args"]
assert any(a.startswith("--fingerprint=") for a in call_kwargs["args"])
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, 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()
original_close = context.close
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context_async
ctx = await launch_persistent_context_async("/tmp/profile")
await ctx.close()
original_close.assert_called_once()
pw.stop.assert_called_once()
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
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
await launch_persistent_context_async("/tmp/profile", timezone_id="Europe/Paris")
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert "--fingerprint-timezone=Europe/Paris" in call_kwargs["args"]
assert "timezone_id" not in call_kwargs
+224 -6
View File
@@ -1,6 +1,13 @@
"""Tests for proxy URL parsing and credential extraction."""
from cloakbrowser.browser import _build_proxy_kwargs, _parse_proxy_url
from unittest.mock import patch
from cloakbrowser.browser import (
_is_socks_proxy,
_parse_proxy_url,
_resolve_proxy_config,
maybe_resolve_geoip,
)
class TestParseProxyUrl:
@@ -36,15 +43,226 @@ class TestParseProxyUrl:
class TestBuildProxyKwargs:
"""Tests for _resolve_proxy_config (formerly _build_proxy_kwargs) HTTP path."""
def test_none(self):
assert _build_proxy_kwargs(None) == {}
kwargs, args = _resolve_proxy_config(None)
assert kwargs == {}
assert args == []
def test_simple_proxy(self):
result = _build_proxy_kwargs("http://proxy:8080")
assert result == {"proxy": {"server": "http://proxy:8080"}}
kwargs, args = _resolve_proxy_config("http://proxy:8080")
assert kwargs == {"proxy": {"server": "http://proxy:8080"}}
assert args == []
def test_proxy_with_auth(self):
result = _build_proxy_kwargs("http://user:pass@proxy:8080")
assert result == {
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
assert kwargs == {
"proxy": {"server": "http://proxy:8080", "username": "user", "password": "pass"}
}
assert args == []
def test_proxy_dict_passthrough(self):
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com,localhost"}
kwargs, args = _resolve_proxy_config(proxy_dict)
assert kwargs == {"proxy": proxy_dict}
assert args == []
def test_proxy_dict_with_auth(self):
proxy_dict = {
"server": "http://proxy:8080",
"username": "user",
"password": "pass",
"bypass": ".example.com",
}
kwargs, args = _resolve_proxy_config(proxy_dict)
assert kwargs == {"proxy": proxy_dict}
assert args == []
class TestMaybeResolveGeoip:
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4"))
def test_geoip_with_string_proxy(self, mock_geo):
tz, locale, ip = 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"
assert ip == "1.2.3.4"
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/London", "en-GB", "5.6.7.8"))
def test_geoip_with_dict_proxy_extracts_server(self, mock_geo):
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"}
tz, locale, ip = 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, ip = maybe_resolve_geoip(False, "http://proxy:8080", None, None)
assert tz is None
assert locale is None
assert ip is None
def test_geoip_no_proxy_skips_resolution(self):
tz, locale, ip = maybe_resolve_geoip(True, None, None, None)
assert tz is None
assert locale is None
assert ip is None
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Asia/Tokyo", "ja-JP", "9.8.7.6"))
def test_geoip_preserves_explicit_timezone(self, mock_geo):
tz, locale, _ip = maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None)
assert tz == "Europe/Berlin"
assert locale == "ja-JP"
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4"))
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, _ip = 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_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4"))
def test_geoip_normalizes_schemeless_proxy_no_creds(self, mock_geo):
# "host:port" (no @ and no scheme) must also be normalized.
tz, locale, _ip = maybe_resolve_geoip(True, "proxy:8080", None, None)
mock_geo.assert_called_once_with("http://proxy:8080")
assert tz == "America/New_York"
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8"))
def test_geoip_socks5_dict_reconstructs_credentials(self, mock_geo):
proxy_dict = {"server": "socks5://proxy:1080", "username": "user", "password": "pass"}
tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None)
mock_geo.assert_called_once_with("socks5://user:pass@proxy:1080")
assert tz == "Europe/Berlin"
assert locale == "de-DE"
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8"))
def test_geoip_socks5_dict_no_auth_uses_server(self, mock_geo):
proxy_dict = {"server": "socks5://proxy:1080"}
tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None)
mock_geo.assert_called_once_with("socks5://proxy:1080")
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/London", "en-GB", "1.1.1.1"))
def test_geoip_http_dict_does_not_inline_creds(self, mock_geo):
# HTTP dict: credentials stay separate, only server URL passed
proxy_dict = {"server": "http://proxy:8080", "username": "user", "password": "pass"}
tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None)
mock_geo.assert_called_once_with("http://proxy:8080")
class TestBareProxyFormat:
"""_parse_proxy_url must handle bare 'user:pass@host:port' strings (no scheme)."""
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_resolve_proxy_config_bare(self):
kwargs, args = _resolve_proxy_config("user:pass@proxy:8080")
assert kwargs["proxy"]["username"] == "user"
assert kwargs["proxy"]["password"] == "pass"
assert "user" not in kwargs["proxy"]["server"]
class TestIsSocksProxy:
def test_socks5_string(self):
assert _is_socks_proxy("socks5://user:pass@host:1080") is True
def test_socks5h_string(self):
assert _is_socks_proxy("socks5h://host:1080") is True
def test_socks5_uppercase(self):
assert _is_socks_proxy("SOCKS5://host:1080") is True
def test_http_string(self):
assert _is_socks_proxy("http://host:8080") is False
def test_dict_socks5(self):
assert _is_socks_proxy({"server": "socks5://host:1080"}) is True
def test_dict_http(self):
assert _is_socks_proxy({"server": "http://host:8080"}) is False
def test_none(self):
assert _is_socks_proxy(None) is False
class TestResolveProxyConfig:
def test_none(self):
kwargs, args = _resolve_proxy_config(None)
assert kwargs == {}
assert args == []
def test_http_string_returns_playwright_dict(self):
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
assert "proxy" in kwargs
assert kwargs["proxy"]["server"] == "http://proxy:8080"
assert kwargs["proxy"]["username"] == "user"
assert args == []
def test_http_dict_passthrough(self):
proxy = {"server": "http://proxy:8080", "bypass": ".example.com"}
kwargs, args = _resolve_proxy_config(proxy)
assert kwargs == {"proxy": proxy}
assert args == []
def test_socks5_string_returns_chrome_arg(self):
kwargs, args = _resolve_proxy_config("socks5://user:pass@host:1080")
assert kwargs == {}
assert args == ["--proxy-server=socks5://user:pass@host:1080"]
def test_socks5_no_auth_returns_chrome_arg(self):
kwargs, args = _resolve_proxy_config("socks5://host:1080")
assert kwargs == {}
assert args == ["--proxy-server=socks5://host:1080"]
def test_socks5h_returns_chrome_arg(self):
kwargs, args = _resolve_proxy_config("socks5h://user:pass@host:1080")
assert kwargs == {}
assert args == ["--proxy-server=socks5h://user:pass@host:1080"]
def test_socks5_dict_reconstructs_url(self):
proxy = {"server": "socks5://host:1080", "username": "user", "password": "p@ss"}
kwargs, args = _resolve_proxy_config(proxy)
assert kwargs == {}
assert len(args) == 1
assert args[0].startswith("--proxy-server=socks5://user:p%40ss@host:1080")
def test_socks5_dict_ipv6_preserves_brackets(self):
proxy = {"server": "socks5://[::1]:1080", "username": "user", "password": "pass"}
kwargs, args = _resolve_proxy_config(proxy)
assert kwargs == {}
assert "[::1]" in args[0]
def test_socks5_dict_with_bypass(self):
proxy = {"server": "socks5://host:1080", "bypass": ".example.com"}
kwargs, args = _resolve_proxy_config(proxy)
assert kwargs == {}
assert "--proxy-server=socks5://host:1080" in args
assert "--proxy-bypass-list=.example.com" in args
+67
View File
@@ -217,3 +217,70 @@ class TestBotDetectionSites:
score = results["score"]
assert score is not None, "Could not extract reCAPTCHA score"
assert score >= 0.7, f"reCAPTCHA score too low: {score}"
class TestIssueRegressions:
"""Regression tests for specific GitHub issues.
Uses the shared browser fixture to avoid "Sync API inside asyncio loop"
errors when pytest-asyncio is active.
"""
@pytest.mark.slow
def test_immediate_goto_works(self, browser):
"""Issue #9: page.goto() immediately after launch must not fail.
User reported reCAPTCHA fails if goto is called too quickly after
launch. This test verifies that immediate navigation works without
needing an artificial delay.
"""
page = browser.new_page()
# No delay — goto immediately
page.goto("https://example.com", timeout=30000)
title = page.title()
page.close()
assert "Example Domain" in title, f"Immediate goto failed, title={title}"
@pytest.mark.slow
def test_add_init_script_without_proxy(self, browser):
"""Issue #27: add_init_script must work (baseline without proxy).
The bug is proxy + add_init_script, but we first verify init_script
alone works so we have a baseline.
"""
page = browser.new_page()
page.add_init_script("window.__cloaktest = 42;")
page.goto("https://example.com", timeout=30000)
val = page.evaluate("window.__cloaktest")
page.close()
assert val == 42, f"add_init_script failed, got {val}"
@pytest.mark.slow
def test_add_init_script_with_proxy(self, browser):
"""Issue #27: add_init_script + proxy must not cause ERR_TUNNEL_CONNECTION_FAILED.
Patchright bug: add_init_script breaks proxy auth. This test guards
against regression if/when the upstream fix lands. Uses context-level
proxy to avoid launching a separate browser (event loop conflict).
"""
proxy = os.environ.get("CLOAKBROWSER_TEST_PROXY")
if not proxy:
pytest.skip("CLOAKBROWSER_TEST_PROXY not set")
ctx = browser.new_context(proxy={"server": proxy})
page = ctx.new_page()
page.add_init_script("window.__cloaktest = 99;")
try:
page.goto("https://httpbin.org/ip", timeout=30000)
body = page.evaluate("document.body.innerText")
val = page.evaluate("window.__cloaktest")
assert val == 99, f"init_script value wrong: {val}"
assert "origin" in body, f"Page didn't load through proxy: {body[:100]}"
except Exception as e:
err = str(e)
if "ERR_TUNNEL_CONNECTION_FAILED" in err:
pytest.xfail("Known patchright bug: add_init_script + proxy auth (issue #27)")
raise
finally:
page.close()
ctx.close()
+168
View File
@@ -0,0 +1,168 @@
# tests/test_stealth_reproduction_110.py
"""
Exact reproduction of issue #110 detection vectors.
Proves all three leaks (isInputElement, isSelectorFocused, typeShiftSymbol)
are fixed with CDP isolated worlds.
"""
import asyncio
import pytest
@pytest.mark.slow
class TestIssue110Reproduction:
@pytest.mark.asyncio
async def test_exact_reproduction_from_issue(self):
"""Exact detection script from issue #110 — must produce zero detections."""
from cloakbrowser import launch_async
browser = await launch_async(headless=True, humanize=True)
page = await browser.new_page()
await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
await asyncio.sleep(1)
# === EXACT detection from issue #110 ===
await page.evaluate("""
() => {
window.__detections = {
evaluateQS: [],
untrustedKeydown: []
};
// Detection 1: querySelector from evaluate context
const origQS = document.querySelector.bind(document);
document.querySelector = function(sel) {
try { throw new Error(); } catch (e) {
if (e.stack.includes(':302:')) {
window.__detections.evaluateQS.push(sel);
}
}
return origQS(sel);
};
// Detection 2: untrusted keyboard events
document.addEventListener('keydown', (e) => {
if (!e.isTrusted) {
window.__detections.untrustedKeydown.push(e.key);
}
}, true);
}
""")
# === Trigger all three vectors from issue ===
# Vector 1: isInputElement — click triggers querySelector check
await page.click('#searchInput')
await asyncio.sleep(0.3)
# Vector 2+3: typeShiftSymbol — type text with shift symbols
await page.keyboard.type('Hello!@#$%^&*()')
await asyncio.sleep(0.5)
# === Verify: zero detections ===
detections = await page.evaluate('() => window.__detections')
qs_leaks = detections['evaluateQS']
untrusted = detections['untrustedKeydown']
print(f"\n{'='*60}")
print(f"Issue #110 Reproduction Results:")
print(f" querySelector from evaluate: {len(qs_leaks)} detections")
print(f" Untrusted keyboard events: {len(untrusted)} detections")
print(f"{'='*60}")
assert len(qs_leaks) == 0, (
f"LEAK: querySelector called from evaluate context: {qs_leaks}"
)
assert len(untrusted) == 0, (
f"LEAK: Untrusted keyboard events detected: {untrusted}"
)
await browser.close()
@pytest.mark.asyncio
async def test_all_21_shift_symbols_trusted(self):
"""Every single shift symbol must produce isTrusted=true."""
from cloakbrowser import launch_async
browser = await launch_async(headless=True, humanize=True)
page = await browser.new_page()
await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
await asyncio.sleep(1)
await page.evaluate("""
() => {
window.__keyResults = { trusted: [], untrusted: [] };
const input = document.querySelector('#searchInput');
input.addEventListener('keydown', (e) => {
const list = e.isTrusted ? 'trusted' : 'untrusted';
window.__keyResults[list].push(e.key);
}, true);
}
""")
await page.click('#searchInput')
await asyncio.sleep(0.3)
# Type ALL 21 shift symbols
all_shift = '!@#$%^&*()_+{}|:"<>?~'
await page.keyboard.type(all_shift)
await asyncio.sleep(1)
results = await page.evaluate('() => window.__keyResults')
print(f"\n{'='*60}")
print(f"All 21 Shift Symbols Test:")
print(f" Trusted: {results['trusted']}")
print(f" Untrusted: {results['untrusted']}")
print(f"{'='*60}")
# Every shift symbol must be trusted
for sym in all_shift:
assert sym in results['trusted'], f"'{sym}' NOT in trusted events"
assert sym not in results['untrusted'], f"'{sym}' IS in untrusted events"
await browser.close()
@pytest.mark.asyncio
async def test_clear_uses_isolated_world(self):
"""clear() calls isSelectorFocused — must not leak evaluate."""
from cloakbrowser import launch_async
browser = await launch_async(headless=True, humanize=True)
page = await browser.new_page()
await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
await asyncio.sleep(1)
await page.evaluate("""
() => {
window.__evalLeaks = [];
const origQS = document.querySelector.bind(document);
document.querySelector = function(sel) {
try { throw new Error(); } catch (e) {
if (e.stack.includes(':302:')) {
window.__evalLeaks.push(sel);
}
}
return origQS(sel);
};
}
""")
# fill → click + type (isInputElement + isSelectorFocused)
await page.locator('#searchInput').fill('some text')
await asyncio.sleep(0.3)
# clear → isSelectorFocused check
await page.locator('#searchInput').clear()
await asyncio.sleep(0.3)
leaks = await page.evaluate('() => window.__evalLeaks')
assert len(leaks) == 0, f"clear() leaked via evaluate: {leaks}"
val = await page.locator('#searchInput').input_value()
assert val == '', f"clear() didn't clear: '{val}'"
await browser.close()
File diff suppressed because it is too large Load Diff
+197 -5
View File
@@ -20,10 +20,16 @@ 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,
_verify_checksum,
_write_version_marker,
check_for_update,
clear_cache,
ensure_binary,
)
@@ -166,7 +172,7 @@ class TestGetLatestVersion:
{
"tag_name": "chromium-v145.0.7718.0",
"draft": False,
"assets": self._make_assets(["linux-x64", "darwin-arm64", "darwin-x64"]),
"assets": self._make_assets(["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]),
},
]
mock_response.raise_for_status = MagicMock()
@@ -187,7 +193,7 @@ class TestGetLatestVersion:
{
"tag_name": "chromium-v142.0.7444.175",
"draft": False,
"assets": self._make_assets(["linux-x64", "darwin-arm64", "darwin-x64"]),
"assets": self._make_assets(["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]),
},
]
mock_response.raise_for_status = MagicMock()
@@ -202,7 +208,7 @@ class TestGetLatestVersion:
def test_skips_draft_releases(self):
mock_response = MagicMock()
all_platforms = ["linux-x64", "darwin-arm64", "darwin-x64"]
all_platforms = ["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]
mock_response.json.return_value = [
{"tag_name": "chromium-v999.0.0.0", "draft": True, "assets": self._make_assets(all_platforms)},
{"tag_name": "chromium-v145.0.7718.0", "draft": False, "assets": self._make_assets(all_platforms)},
@@ -215,7 +221,7 @@ class TestGetLatestVersion:
def test_skips_non_chromium_tags(self):
mock_response = MagicMock()
all_platforms = ["linux-x64", "darwin-arm64", "darwin-x64"]
all_platforms = ["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]
mock_response.json.return_value = [
{"tag_name": "v0.2.0", "draft": False, "assets": self._make_assets(all_platforms)},
{"tag_name": "chromium-v145.0.7718.0", "draft": False, "assets": self._make_assets(all_platforms)},
@@ -233,7 +239,7 @@ class TestGetLatestVersion:
{
"tag_name": "chromium-v145.0.7718.0",
"draft": False,
"assets": [{"name": "cloakbrowser-windows-x64.tar.gz"}],
"assets": [{"name": "cloakbrowser-freebsd-x64.tar.gz"}],
},
]
mock_response.raise_for_status = MagicMock()
@@ -356,3 +362,189 @@ class TestVerifyChecksum:
file.write_bytes(b"real content")
with pytest.raises(RuntimeError, match="Checksum verification failed"):
_verify_checksum(file, "0" * 64)
class TestClearCache:
def test_removes_dir(self, tmp_path):
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
# Create some content
(tmp_path / "chromium-145").mkdir()
(tmp_path / "chromium-145" / "chrome").write_bytes(b"binary")
clear_cache()
assert not tmp_path.exists()
def test_noop_if_missing(self, tmp_path):
nonexistent = tmp_path / "nonexistent"
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(nonexistent)}):
clear_cache() # Should not raise
class TestCheckForUpdate:
@patch("cloakbrowser.download._maybe_trigger_update_check")
def test_returns_none_when_current(self, _mock_update):
with patch("cloakbrowser.download._get_latest_chromium_version", return_value=None):
assert check_for_update() is None
@patch("cloakbrowser.download._maybe_trigger_update_check")
def test_returns_none_on_network_error(self, _mock_update):
with patch("cloakbrowser.download._get_latest_chromium_version", side_effect=Exception("timeout")):
# _get_latest_chromium_version catches exceptions internally, but
# check_for_update itself can also fail — test graceful None return
with patch("cloakbrowser.download._get_latest_chromium_version", return_value=None):
assert check_for_update() is None
@patch("cloakbrowser.download._maybe_trigger_update_check")
def test_returns_version_when_newer(self, _mock_update, tmp_path):
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
with patch("cloakbrowser.download._get_latest_chromium_version", return_value="999.0.0.0"):
with patch("cloakbrowser.download._download_and_extract"):
result = check_for_update()
assert result == "999.0.0.0"
@patch("cloakbrowser.download._maybe_trigger_update_check")
def test_skips_download_if_already_cached(self, _mock_update, tmp_path):
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
# Create the binary dir so it looks already downloaded
binary_dir = tmp_path / "chromium-999.0.0.0"
binary_dir.mkdir()
with patch("cloakbrowser.download._get_latest_chromium_version", return_value="999.0.0.0"):
with patch("cloakbrowser.download._download_and_extract") as mock_dl:
result = check_for_update()
assert result == "999.0.0.0"
mock_dl.assert_not_called()
class TestEnsureBinary:
@patch("cloakbrowser.download._maybe_trigger_update_check")
def test_local_override(self, _mock_update, tmp_path):
binary = tmp_path / "chrome"
binary.write_bytes(b"binary")
with patch.dict(os.environ, {"CLOAKBROWSER_BINARY_PATH": str(binary)}):
result = ensure_binary()
assert result == str(binary)
@patch("cloakbrowser.download._maybe_trigger_update_check")
def test_local_override_missing_file(self, _mock_update):
with patch.dict(os.environ, {"CLOAKBROWSER_BINARY_PATH": "/nonexistent/chrome"}):
with pytest.raises(FileNotFoundError, match="does not exist"):
ensure_binary()
@patch("cloakbrowser.download._maybe_trigger_update_check")
def test_cached_binary_found(self, _mock_update, tmp_path):
with patch.dict(os.environ, {
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
"CLOAKBROWSER_BINARY_PATH": "",
}):
# Create a fake cached binary
version = get_chromium_version()
with patch("cloakbrowser.download.get_binary_path") as mock_path:
fake_binary = tmp_path / "chrome"
fake_binary.write_bytes(b"binary")
fake_binary.chmod(0o755)
mock_path.return_value = fake_binary
with patch("cloakbrowser.download.check_platform_available"):
result = ensure_binary()
assert result == str(fake_binary)
@patch("cloakbrowser.download._maybe_trigger_update_check")
def test_downloads_when_missing(self, _mock_update, tmp_path):
with patch.dict(os.environ, {
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
"CLOAKBROWSER_BINARY_PATH": "",
}):
fake_binary = tmp_path / "chrome"
with patch("cloakbrowser.download.check_platform_available"):
with patch("cloakbrowser.download.get_binary_path") as mock_path:
# effective == platform_version (no marker), so fallback block skipped.
# Call 1: get_binary_path(effective) → nonexistent (triggers download)
# Call 2: get_binary_path() → fake_binary (post-download verify)
mock_path.side_effect = [
tmp_path / "nonexistent", # pre-download: not cached
fake_binary, # post-download: binary ready
]
with patch("cloakbrowser.download._download_and_extract") as mock_dl:
fake_binary.write_bytes(b"binary")
result = ensure_binary()
mock_dl.assert_called_once()
assert result == str(fake_binary)
class TestWriteVersionMarker:
def test_creates_file(self, tmp_path):
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
_write_version_marker("999.0.0.0")
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