Compare commits

..
22 Commits
Author SHA1 Message Date
CloakHQ 4c2e06682b docs: add CHANGELOG.md and bump version to 0.3.0
- Add CHANGELOG.md with v0.3.0 release notes (Chromium 145, 26 patches)
- Bump Python and JS package versions from 0.2.2 to 0.3.0
- Update README with v0.3.0 highlights section and changelog link
- Correct roadmap status for Chromium 145 build
2026-03-02 03:07:27 +01:00
CloakHQ cb08a602b0 feat: add SHA-256 checksum verification for binary downloads
Fetches SHA256SUMS sidecar file from download server before extraction.
Mismatch = hard error, unavailable = warn and proceed (graceful for old releases).
Respects CLOAKBROWSER_DOWNLOAD_URL contract (no GitHub fallback for custom mirrors).
Skip with CLOAKBROWSER_SKIP_CHECKSUM=true. Both Python and JS wrappers.
2026-03-02 02:59:25 +01:00
CloakHQ 8eb666885f fix: review fixes — bump CHROMIUM_VERSION to v145, add colorScheme to JS, guard download fallback 2026-03-02 02:59:23 +01:00
CloakHQ f417fe2530 feat: add --fingerprint-device-memory=8 to default stealth args
v10 binary requires explicit --fingerprint-device-memory flag (no longer
hardcoded). Without it, navigator.deviceMemory passes through real value.
2026-03-02 02:59:21 +01:00
CloakHQ c65939af14 feat: wire timezone/locale params to Chromium binary flags
launch() and launch_context() now inject --timezone and --lang binary
flags when timezone/locale params are set. Previously only the Playwright
context layer was configured, leaving a detectable mismatch that CreepJS
flagged as a bot signal.

Closes: WM5
2026-03-02 02:59:18 +01:00
CloakHQ 2f1f592b3a feat: add GitHub Releases fallback for binary downloads
If cloakbrowser.dev is unreachable, automatically retry download
from GitHub Releases. Applies to both Python and JS wrappers.
2026-03-02 02:59:17 +01:00
CloakHQ 0bbc170747 feat: upgrade to Chromium v145 with Patchright CDP stealth
- Migrate from Playwright to Patchright driver for CDP leak prevention
- Add screen dimension spoofing args (--fingerprint-screen-width/height, --fingerprint-taskbar-height)
- Add realistic default viewport (1920x955) to avoid Playwright detection
- Add color_scheme param to launch_context, add fingerprint scan test
- Update READMEs for v145
2026-03-02 02:59:15 +01:00
CloakHQ 6506b5fe44 fix: switch download badges to total counts with consistent green styling
Use shields.io/pepy for PyPI and shields.io/npm for npm — both show
total downloads in green, matching the rest of the badge row.
2026-03-02 01:15:26 +01:00
CloakHQ 1082c810af fix: replace page.wait_for_timeout with time.sleep to avoid CDP leak
page.wait_for_timeout() sends CDP protocol commands that reCAPTCHA and
other antibot systems detect. Replaced with time.sleep() (Python) which
is invisible to the browser.

- examples/stealth_test.py: 7 replacements
- examples/recaptcha_score.py: 1 replacement
- tests/test_stealth.py: 7 replacements
- README.md + js/README.md: added reCAPTCHA troubleshooting section
- Bump version to 0.2.2
2026-03-01 19:37:41 +01:00
CloakHQ 67efadef26 feat: auto-detect timezone/locale from proxy IP via geoip
Adds geoip=True parameter to launch(), launch_async(), and
launch_context(). Resolves proxy exit IP → MaxMind GeoLite2-City
lookup → timezone + locale. Downloads ~70MB DB on first use from
P3TERX mirror, caches in ~/.cloakbrowser/geoip/.

Optional deps: pip install cloakbrowser[geoip] / npm install mmdb-lib
Explicit timezone/locale always override auto-detected values.
2026-03-01 01:53:50 +01:00
CloakHQ 59b9d71684 docs: add fixed fingerprint seed tip for site revisiting 2026-02-28 04:38:27 +01:00
CloakHQ f480958ba7 test: add real bot detection assertions to stealth tests
Replace hollow page-load checks with actual verdict parsing for 6
detection sites: sannysoft, incolumitas, browserscan, deviceandbrowserinfo,
fingerprintjs, and recaptcha v3. Add proxy support via CLOAKBROWSER_TEST_PROXY
env var. All 12 stealth tests pass.
2026-02-27 09:20:56 +01:00
CloakHQ 8ffaf86abe chore: bump version to 0.2.0 — macOS platform release 2026-02-27 07:51:33 +01:00
CloakHQ 8c76a68cb5 fix: macOS binary download — preserve .app symlinks, remove quarantine xattrs
macOS downloads were broken: symlinks in Chromium.app Framework layout were
skipped and flatten logic removed the .app bundle structure. Now allows safe
symlinks, skips flattening .app dirs, and runs xattr -cr post-extraction to
prevent Gatekeeper prompts. Also adds Turnstile GIF to README proof section.
2026-02-27 07:32:43 +01:00
CloakHQ cee166c2d2 docs: add links section, fix download badges, post-download CTAs
- Add Links section to both READMEs (website, issues, PyPI, npm, email)
- Replace broken pepy.tech badge with shields.io PyPI + npm download badges
- Post-download messages: website, issues link, star CTA (Python + JS)
- Fix CLOAKBROWSER_DOWNLOAD_URL default in docs (github → cloakbrowser.dev)
- Fix tests: download URL assertions, platform-aware stealth args test
2026-02-27 03:52:53 +01:00
CloakHQ b07797f963 feat: add macOS platform support, GPG-signed release workflow
- Native macOS fingerprint config (platform=macos, skip GPU/concurrency spoofing)
- Enable darwin-arm64 and darwin-x64 in AVAILABLE_PLATFORMS (Python + JS)
- Update release workflow: multi-platform title, patch count, GPG key reference
- Update platform tables in both READMEs: macOS Intel now available
- Fix stealth test timeouts
2026-02-27 02:15:36 +01:00
CloakHQ 31c04d5bcc docs: add fingerprint management section, document all 10 fingerprint flags
- Document default fingerprint config (5 flags set per launch)
- Document additional 5 flags (brand, platform-version, location, timezone)
- Add usage examples for seed pinning, GPU override, timezone
- Update roadmap: fingerprint rotation per session → Released
- Bump Python 0.1.11 → 0.1.12, JS 0.1.9 → 0.1.10
2026-02-26 08:42:36 +01:00
CloakHQ cc501d8ef6 fix: use mirror for binary downloads, imgur for images while GitHub org is flagged 2026-02-26 06:20:33 +01:00
CloakHQ 8cecebf118 feat: move binary releases to wrapper repo, add auto-update check
- Binary downloads now served from CloakHQ/cloakbrowser releases (chromium-v* tags)
- Auto-update: background version check on launch, downloads newer binary for next use
- Graceful error on macOS/Windows (Linux-only binaries for now)
- Rate-limited (1hr), opt-out via CLOAKBROWSER_AUTO_UPDATE=false
- Add release-binary.yml workflow for anonymous binary releases
2026-02-25 19:38:32 +01:00
CloakHQ 179531fd17 docs: add PyPI downloads badge, note Puppeteer reCAPTCHA limitation 2026-02-25 18:32:09 +01:00
CloakHQ 4d96db1448 fix: support proxy authentication credentials in URL (closes #4)
Parse user:pass from proxy URLs into separate Playwright username/password
fields. Puppeteer wrapper strips credentials from --proxy-server and
auto-calls page.authenticate(). Bump Python 0.1.6, JS 0.1.3.
2026-02-24 19:00:12 +01:00
CloakHQ 4e809b9678 feat: add JavaScript/TypeScript wrapper with Playwright + Puppeteer support
Adds js/ package mirroring the Python wrapper architecture:
- Dual API: import from 'cloakbrowser' (Playwright) or 'cloakbrowser/puppeteer'
- TypeScript with full type definitions
- Same binary download/cache logic, same stealth args, same env vars
- Optional peer deps: users install only the runtime they need
- Full 6-site stealth test suite (sannysoft, incolumitas, BrowserScan, deviceandbrowserinfo, FingerprintJS, reCAPTCHA v3)
- Published to npm as cloakbrowser@0.1.2
2026-02-24 07:33:18 +01:00
39 changed files with 7189 additions and 126 deletions
+45
View File
@@ -0,0 +1,45 @@
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`
+12
View File
@@ -37,6 +37,10 @@ htmlcov/
CLAUDE.md CLAUDE.md
.claude/ .claude/
# JavaScript / Node.js
js/node_modules/
js/dist/
# Distribution # Distribution
*.tar.gz *.tar.gz
*.whl *.whl
@@ -46,6 +50,14 @@ AGENTS.md
# Private docs (launch posts, strategy) # Private docs (launch posts, strategy)
docs/ docs/
# Internal test infrastructure (Docker, VPS-specific)
test-infra/
# Website (deployed separately)
site/
# Release scripts # Release scripts
publish.sh publish.sh
deploy.sh
.env .env
debug
+113
View File
@@ -0,0 +1,113 @@
# Changelog
All notable changes to CloakBrowser — wrapper and binary — are documented here.
Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromium patches.
---
## [0.3.0] — Unreleased
Chromium v145 upgrade. 26 fingerprint patches (up from 16). New download verification and fallback system. Pending: macOS v145 binary builds.
### 2026-03-01
- **[wrapper]** Upgrade wrapper to Chromium v145.0.7632.109
- **[wrapper]** Add GitHub Releases fallback when primary download mirror is unavailable
- **[wrapper]** Add SHA-256 checksum verification for binary downloads
- **[wrapper]** Wire timezone and locale params to Chromium binary flags
- **[wrapper]** Add device memory to default stealth args
- **[wrapper]** JS: add `colorScheme` support, guard download fallback against partial failures
### 2026-02-28
- **[binary]** Enforce strict flag discipline — patches only activate when explicitly configured via command-line flags
- **[binary]** Improved fingerprint consistency across multiple browser APIs
- **[binary]** 3 new fingerprint patches + bug fixes in existing patches
- **[binary]** New command-line flag for device memory spoofing
- **[infra]** Automated test matrix: 8 groups, 41+ tests across core stealth, fingerprint noise, bot detection, reCAPTCHA, TLS, Turnstile, residential proxy, and enterprise reCAPTCHA
- **[infra]** Docker-based test runner with subprocess isolation per test group
### 2026-02-25
- **[binary]** Reduced automation markers visible to detection scripts
- **[binary]** Added browser API support at build time
- **[binary]** Improved screen property consistency
### 2026-02-24
- **[binary]** Comprehensive fingerprint audit and hardening pass
- **[binary]** Fixed font rendering edge case on cross-platform spoofing
- **[binary]** 4 new fingerprint patches
### 2026-02-22
- **[binary]** Start Chromium v145 build (v145.0.7632.109)
- **[binary]** 24 fingerprint patches ported and adapted
---
## [0.2.2] — 2026-03-01
### 2026-03-01
- **[wrapper]** Fix: replace `page.wait_for_timeout()` with `time.sleep()` to avoid timing leak
- **[wrapper]** Add auto-detect timezone and locale from proxy IP via GeoIP lookup
- **[binary]** CDP detection vector audit and hardening
---
## [0.2.0] — 2026-02-27
macOS platform release. JavaScript/TypeScript wrapper. Self-hosted binary mirror.
### 2026-02-27
- **[wrapper]** Add macOS support: Apple Silicon (arm64) and Intel (x64) binary downloads
- **[wrapper]** Add GPG-signed release workflow via GitHub Actions
- **[wrapper]** Fix macOS binary download: preserve `.app` symlinks, remove quarantine xattrs
- **[wrapper]** Add real bot detection assertions to stealth tests
- **[wrapper]** Bump version to 0.2.0
### 2026-02-26
- **[wrapper]** Switch binary downloads to self-hosted mirror (`cloakbrowser.dev`) as GitHub backup
- **[wrapper]** Set up GitLab mirror at `gitlab.com/CloakHQ/cloakbrowser`
### 2026-02-25
- **[wrapper]** Move binary releases from separate repo to wrapper repo
- **[wrapper]** Add auto-update check on launch
- **[infra]** Initial Docker test infrastructure + matrix test runner
### 2026-02-24
- **[wrapper]** Add JavaScript/TypeScript wrapper with Playwright + Puppeteer support (`npm install cloakbrowser`)
- **[wrapper]** Fix proxy authentication credentials support in URL (closes #4)
---
## [0.1.4] — 2026-02-23
### 2026-02-23
- **[wrapper]** Stealth hardening: additional launch args and detection evasion improvements
- **[wrapper]** Full test suite rewrite with real detection site assertions
- **[wrapper]** Add Docker support with Dockerfile and compose config
- **[wrapper]** Add headed mode documentation
---
## [0.1.0] — 2026-02-22
Initial release. Chromium v142 with 16 fingerprint patches.
### 2026-02-22
- **[binary]** Chromium v142.0.7444.175 with 16 source-level fingerprint patches
- **[binary]** Fix browser brand string to match Chrome 142 format
- **[wrapper]** `launch()` and `launch_async()` — drop-in Playwright replacements
- **[wrapper]** Auto-download binary from GitHub Releases, cached in `~/.cloakbrowser/`
- **[wrapper]** Linux x64 platform support
- **[wrapper]** Passes 14/14 bot detection tests
- **[wrapper]** reCAPTCHA v3: 0.9 (server-verified), Cloudflare Turnstile: pass
+330 -46
View File
@@ -1,26 +1,34 @@
<p align="center"> <p align="center">
<img src="https://raw.githubusercontent.com/CloakHQ/CloakBrowser/main/images/logo.png" width="500" alt="CloakBrowser"> <img src="https://i.imgur.com/cqkp6fG.png" width="500" alt="CloakBrowser">
</p> </p>
# CloakBrowser # CloakBrowser
[![PyPI](https://img.shields.io/pypi/v/cloakbrowser)](https://pypi.org/project/cloakbrowser/) <p align="center">
[![Python](https://img.shields.io/pypi/pyversions/cloakbrowser)](https://pypi.org/project/cloakbrowser/) <a href="https://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pypi/v/cloakbrowser" alt="PyPI"></a>
[![License](https://img.shields.io/github/license/CloakHQ/CloakBrowser)](LICENSE) <a href="https://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/v/cloakbrowser" alt="npm"></a>
[![Stars](https://img.shields.io/github/stars/CloakHQ/CloakBrowser)](https://github.com/CloakHQ/CloakBrowser) <a href="https://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pypi/pyversions/cloakbrowser" alt="Python"></a>
[![Last Commit](https://img.shields.io/github/last-commit/CloakHQ/CloakBrowser)](https://github.com/CloakHQ/CloakBrowser) <a href="LICENSE"><img src="https://img.shields.io/github/license/CloakHQ/CloakBrowser" alt="License"></a>
<br>
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/stars/CloakHQ/CloakBrowser" alt="Stars"></a>
<a href="https://pepy.tech/projects/cloakbrowser"><img src="https://img.shields.io/pepy/dt/cloakbrowser?label=pypi&logo=pypi&logoColor=white" alt="PyPI Downloads"></a>
<a href="https://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/dt/cloakbrowser?label=npm&logo=npm&logoColor=white" alt="npm Downloads"></a>
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/last-commit/CloakHQ/CloakBrowser" alt="Last Commit"></a>
</p>
**Stealth Chromium that passes every bot detection test.** **Stealth Chromium that passes every bot detection test.**
Drop-in Playwright replacement. Same API, same code — just swap the import. Your browser now scores **0.9 on reCAPTCHA v3**, passes **Cloudflare Turnstile**, and clears **14 out of 14** stealth detection tests. Drop-in Playwright/Puppeteer replacement for Python and JavaScript. Same API, same code — just swap the import. Your browser now scores **0.9 on reCAPTCHA v3**, passes **Cloudflare Turnstile**, and clears **30 out of 30** stealth detection tests.
- 🔒 **16 source-level C++ patches** — not JS injection, not config flags - 🔒 **26 source-level C++ patches** — not JS injection, not config flags
- 🛡️ **CDP stealth built-in** — powered by [Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright), hides Playwright's automation signals
- 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified - 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified
- ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 14/14 tests - ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 30/30 tests
- 🔄 **Drop-in Playwright replacement**same API, swap one import - 🔄 **Drop-in replacement**works with Playwright (Python & JS) and Puppeteer (JS)
- 📦 **`pip install cloakbrowser`** — binary auto-downloads, zero config - 📦 **`pip install cloakbrowser`** or **`npm install cloakbrowser`** — binary auto-downloads, zero config
- 🦊 **Fills the Camoufox vacuum** — Chromium-based, actively maintained - 🦊 **Fills the Camoufox vacuum** — Chromium-based, actively maintained
**Python:**
```python ```python
from cloakbrowser import launch from cloakbrowser import launch
@@ -30,23 +38,65 @@ page.goto("https://protected-site.com") # no more blocks
browser.close() browser.close()
``` ```
**JavaScript (Playwright):**
```javascript
import { launch } from 'cloakbrowser';
const browser = await launch();
const page = await browser.newPage();
await page.goto('https://protected-site.com');
await browser.close();
```
**JavaScript (Puppeteer):**
```javascript
import { launch } from 'cloakbrowser/puppeteer';
const browser = await launch();
const page = await browser.newPage();
await page.goto('https://protected-site.com');
await browser.close();
```
## Install ## Install
**Python:**
```bash ```bash
pip install cloakbrowser pip install cloakbrowser
``` ```
**JavaScript / Node.js:**
```bash
# With Playwright
npm install cloakbrowser playwright-core
# With Puppeteer
npm install cloakbrowser puppeteer-core
```
On first run, the stealth Chromium binary is automatically downloaded (~200MB, cached locally). On first run, the stealth Chromium binary is automatically downloaded (~200MB, cached locally).
## What's New in v0.3.0
- **Chromium 145** — latest stable, 26 fingerprint patches
- **10 new patches** — screen dimensions, audio, WebGL, and more
- **CDP hardening** — audited and patched known automation detection vectors
- **Timezone & locale from proxy IP** — `launch(proxy="...", geoip=True)` auto-detects timezone and locale
- **Improved cross-platform spoofing** — fixed edge cases in font rendering and GPU reporting
- **Automated test matrix** — 41+ tests across 8 groups (stealth, fingerprint, reCAPTCHA, Turnstile, TLS, enterprise) running in Docker
See the full [CHANGELOG.md](CHANGELOG.md) for details.
## Why CloakBrowser? ## Why CloakBrowser?
- **Config-level patches break** — `playwright-stealth`, `undetected-chromedriver`, and `puppeteer-extra` inject JavaScript or tweak flags. Every Chrome update breaks them. Antibot systems detect the patches themselves. - **Config-level patches break** — `playwright-stealth`, `undetected-chromedriver`, and `puppeteer-extra` inject JavaScript or tweak flags. Every Chrome update breaks them. Antibot systems detect the patches themselves.
- **CloakBrowser patches Chromium source code** — fingerprints are modified at the C++ level, compiled into the binary. Detection sites see a real browser because it *is* a real browser. - **CloakBrowser patches Chromium source code** — fingerprints are modified at the C++ level, compiled into the binary. Detection sites see a real browser because it *is* a real browser.
- **Two layers of stealth** — C++ patches handle fingerprints (GPU, screen, UA, hardware reporting), while the Patchright driver eliminates CDP automation leaks. Most stealth tools only do one or the other.
- **One line to switch** — same Playwright API, no new abstractions, no CAPTCHA-solving services. - **One line to switch** — same Playwright API, no new abstractions, no CAPTCHA-solving services.
## Test Results ## Test Results
All tests verified against live detection services. Last tested: Feb 2026 (Chromium 142). All tests verified against live detection services. Last tested: Feb 2026 (Chromium 145).
| Detection Service | Stock Playwright | CloakBrowser | Notes | | Detection Service | Stock Playwright | CloakBrowser | Notes |
|---|---|---|---| |---|---|---|---|
@@ -61,54 +111,49 @@ All tests verified against live detection services. Last tested: Feb 2026 (Chrom
| `navigator.webdriver` | `true` | **`false`** | Source-level patch | | `navigator.webdriver` | `true` | **`false`** | Source-level patch |
| `navigator.plugins.length` | 0 | **5** | Real plugin list | | `navigator.plugins.length` | 0 | **5** | Real plugin list |
| `window.chrome` | `undefined` | **`object`** | Present like real Chrome | | `window.chrome` | `undefined` | **`object`** | Present like real Chrome |
| UA string | `HeadlessChrome` | **`Chrome/142.0.0.0`** | No headless leak | | UA string | `HeadlessChrome` | **`Chrome/145.0.0.0`** | No headless leak |
| CDP detection | Detected | **Not detected** | `isAutomatedWithCDP: false` | | CDP detection | Detected | **Not detected** | `isAutomatedWithCDP: false` |
| TLS fingerprint | Mismatch | **Identical to Chrome** | ja3n/ja4/akamai match | | TLS fingerprint | Mismatch | **Identical to Chrome** | ja3n/ja4/akamai match |
**14/14 tests passed.** **30/30 tests passed.**
### Proof ### Proof
<p align="center"> <p align="center">
<img src="https://raw.githubusercontent.com/CloakHQ/CloakBrowser/main/images/recaptcha_v3_score_09.png" width="600" alt="reCAPTCHA v3 — Score 0.9"> <img src="https://i.imgur.com/IvB0It7.gif" width="600" alt="Cloudflare Turnstile — 3 Tests Passing (Headed Mode)">
<br><em>Cloudflare Turnstile — 3 live tests passing in headed mode (macOS)</em>
</p>
<p align="center">
<img src="https://i.imgur.com/hvIQyMv.png" width="600" alt="reCAPTCHA v3 — Score 0.9">
<br><em>reCAPTCHA v3 score 0.9 — server-side verified (human-level)</em> <br><em>reCAPTCHA v3 score 0.9 — server-side verified (human-level)</em>
</p> </p>
<p align="center"> <p align="center">
<img src="https://raw.githubusercontent.com/CloakHQ/CloakBrowser/main/images/turnstile_non_interactive.png" width="600" alt="Cloudflare Turnstile — Success"> <img src="https://i.imgur.com/qMIRfhq.png" width="600" alt="Cloudflare Turnstile — Success">
<br><em>Cloudflare Turnstile non-interactive challenge — auto-resolved</em> <br><em>Cloudflare Turnstile non-interactive challenge — auto-resolved</em>
</p> </p>
<p align="center"> <p align="center">
<img src="https://raw.githubusercontent.com/CloakHQ/CloakBrowser/main/images/browserscan_normal.png" width="600" alt="BrowserScan — Normal"> <img src="https://i.imgur.com/PRsw6rT.png" width="600" alt="BrowserScan — Normal">
<br><em>BrowserScan bot detection — NORMAL (4/4 checks passed)</em> <br><em>BrowserScan bot detection — NORMAL (4/4 checks passed)</em>
</p> </p>
<p align="center"> <p align="center">
<img src="https://raw.githubusercontent.com/CloakHQ/CloakBrowser/main/images/fingerprintjs_pass.png" width="600" alt="FingerprintJS — Passed"> <img src="https://i.imgur.com/9n2C7tu.png" width="600" alt="FingerprintJS — Passed">
<br><em>FingerprintJS web-scraping demo — data served, not blocked</em> <br><em>FingerprintJS web-scraping demo — data served, not blocked</em>
</p> </p>
## How It Works ## How It Works
CloakBrowser is a thin Python wrapper around a custom-built Chromium binary: CloakBrowser is a thin wrapper (Python + JavaScript) around a custom-built Chromium binary:
1. **You install**`pip install cloakbrowser` 1. **You install**`pip install cloakbrowser` or `npm install cloakbrowser`
2. **First launch** → binary auto-downloads for your platform (Linux x64 / macOS arm64) 2. **First launch** → binary auto-downloads for your platform (Linux x64, macOS arm64/x64)
3. **Every launch** → Playwright starts with our binary + stealth args 3. **Every launch** → Playwright or Puppeteer starts with our binary + stealth args
4. **You write code** → standard Playwright API, nothing new to learn 4. **You write code** → standard Playwright/Puppeteer API, nothing new to learn
The binary includes 16 source-level patches that modify: The binary includes 26 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, hardware reporting, and automation signal removal.
- Canvas fingerprint generation
- WebGL renderer output
- Audio processing fingerprint
- Font enumeration results
- Hardware concurrency reporting
- Client rect measurements
- GPU vendor/renderer strings
- WebDriver flag
- Headless detection signals
- And more...
These are compiled into the Chromium binary — not injected via JavaScript, not set via flags. These are compiled into the Chromium binary — not injected via JavaScript, not set via flags.
@@ -131,6 +176,12 @@ browser = launch(proxy="http://user:pass@proxy:8080")
# With extra Chrome args # With extra Chrome args
browser = launch(args=["--disable-gpu", "--window-size=1920,1080"]) browser = launch(args=["--disable-gpu", "--window-size=1920,1080"])
# With timezone and locale (sets both binary flags and Playwright context)
browser = launch(timezone="America/New_York", locale="en-US")
# Auto-detect timezone/locale from proxy IP (requires: pip install cloakbrowser[geoip])
browser = launch(proxy="http://proxy:8080", geoip=True)
# Without default stealth args (bring your own fingerprint flags) # Without default stealth args (bring your own fingerprint flags)
browser = launch(stealth_args=False, args=["--fingerprint=12345"]) browser = launch(stealth_args=False, args=["--fingerprint=12345"])
``` ```
@@ -169,6 +220,27 @@ context = launch_context(
page = context.new_page() page = context.new_page()
``` ```
### Auto Timezone/Locale from Proxy IP
When using a proxy, antibot systems check that your browser's timezone and locale match the proxy's geographic location. CloakBrowser can auto-detect these from the proxy IP using an offline GeoIP database:
```bash
pip install cloakbrowser[geoip] # installs geoip2 + downloads ~70 MB database on first use
```
```python
# Timezone and locale auto-set from proxy's IP geolocation
browser = launch(proxy="http://proxy:8080", geoip=True)
# Works with launch_context too — sets both binary flags AND Playwright context
context = launch_context(proxy="http://proxy:8080", geoip=True)
# Explicit values always win over auto-detection
browser = launch(proxy="http://proxy:8080", geoip=True, timezone="Europe/London")
```
> **Note:** For rotating residential proxies, the DNS-resolved IP may differ from the exit IP. Pass explicit `timezone`/`locale` in those cases.
### Utility Functions ### Utility Functions
```python ```python
@@ -185,13 +257,152 @@ clear_cache()
ensure_binary() ensure_binary()
``` ```
## JavaScript / Node.js API
CloakBrowser ships a TypeScript package with full type definitions. Choose Playwright or Puppeteer — same stealth binary underneath.
### Playwright (default)
```javascript
import { launch, launchContext } from 'cloakbrowser';
// Basic
const browser = await launch();
// With options
const browser = await launch({
headless: false,
proxy: 'http://user:pass@proxy:8080',
args: ['--window-size=1920,1080'],
});
// Convenience: browser + context in one call
const context = await launchContext({
userAgent: 'Custom UA',
viewport: { width: 1920, height: 1080 },
locale: 'en-US',
timezoneId: 'America/New_York',
});
const page = await context.newPage();
```
> **Note:** Each example above is standalone — not meant to run as one block.
### Puppeteer
> **Note:** The Playwright wrapper is recommended for sites with reCAPTCHA Enterprise. Puppeteer's CDP protocol leaks automation signals that reCAPTCHA Enterprise can detect, causing intermittent 403 errors. This is a known Puppeteer limitation, not specific to CloakBrowser. Use Playwright for best results.
```javascript
import { launch } from 'cloakbrowser/puppeteer';
const browser = await launch({ headless: true });
const page = await browser.newPage();
await page.goto('https://example.com');
await browser.close();
```
### Utility Functions (JS)
```javascript
import { ensureBinary, clearCache, binaryInfo } from 'cloakbrowser';
// Pre-download binary (e.g., during Docker build)
await ensureBinary();
// Check installation status
console.log(binaryInfo());
// Force re-download
clearCache();
```
## Configuration ## Configuration
| Env Variable | Default | Description | | Env Variable | Default | Description |
|---|---|---| |---|---|---|
| `CLOAKBROWSER_BINARY_PATH` | — | Skip download, use a local Chromium binary | | `CLOAKBROWSER_BINARY_PATH` | — | Skip download, use a local Chromium binary |
| `CLOAKBROWSER_CACHE_DIR` | `~/.cloakbrowser` | Binary cache directory | | `CLOAKBROWSER_CACHE_DIR` | `~/.cloakbrowser` | Binary cache directory |
| `CLOAKBROWSER_DOWNLOAD_URL` | GitHub Releases | Custom download URL for binary | | `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL for binary |
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
## Fingerprint Management
Every launch automatically generates a **unique fingerprint**. A random seed (1000099999) drives all seed-based patches — canvas, WebGL, audio, fonts, and client rects all produce consistent, correlated values derived from that single seed.
> **Tip: Use a fixed seed when revisiting the same site.** A random seed makes every session look like a different device — which can be suspicious when hitting the same site repeatedly from the same IP. For reCAPTCHA v3 Enterprise and similar scoring systems, a fixed seed produces a consistent fingerprint across sessions, making you look like a returning visitor:
> ```python
> browser = launch(args=["--fingerprint=12345"])
> ```
> ```javascript
> const browser = await launch({ args: ['--fingerprint=12345'] });
> ```
### Default Fingerprint
Every `launch()` call sets these automatically. Defaults are **platform-aware** — macOS runs as a native Mac browser, Linux spoofs Windows:
| Flag | Linux Default | macOS Default | Controls |
|------|--------------|---------------|----------|
| `--fingerprint` | Random (1000099999) | Random (1000099999) | Master seed for canvas, WebGL, audio, fonts, client rects |
| `--fingerprint-platform` | `windows` | `macos` | `navigator.platform`, User-Agent OS, GPU pool selection |
| `--fingerprint-hardware-concurrency` | `8` | *(not set — uses real value)* | `navigator.hardwareConcurrency` |
| `--fingerprint-gpu-vendor` | `NVIDIA Corporation` | *(not set — native Apple GPU)* | WebGL `UNMASKED_VENDOR_WEBGL` |
| `--fingerprint-gpu-renderer` | `NVIDIA GeForce RTX 3070` | *(not set — native Metal renderer)* | WebGL `UNMASKED_RENDERER_WEBGL` |
> **Important:** `--fingerprint-platform` must always be set. The binary defaults to `windows` internally when this flag is missing, which causes GPU/UA mismatches on non-Windows systems. The wrapper handles this automatically.
### Additional Flags
Supported by the binary but **not set by default** — pass via `args` to customize:
| Flag | Controls |
|------|----------|
| `--fingerprint-brand` | Browser brand: `Chrome`, `Edge`, `Opera`, `Vivaldi` |
| `--fingerprint-brand-version` | Brand version (UA + Client Hints) |
| `--fingerprint-platform-version` | Client Hints platform version |
| `--fingerprint-location` | Geolocation coordinates |
| `--timezone` | Timezone (e.g. `America/New_York`) |
> **Note:** All stealth tests were verified with the default fingerprint config above. Changing these flags may affect detection results — test your configuration before using in production.
### Examples
```python
# Default — unique fingerprint every launch
browser = launch()
# Pin a seed for a persistent identity
browser = launch(args=["--fingerprint=42069"])
# Full control — disable defaults, set everything yourself
browser = launch(stealth_args=False, args=[
"--fingerprint=42069",
"--fingerprint-platform=windows",
"--fingerprint-hardware-concurrency=8",
"--fingerprint-gpu-vendor=NVIDIA Corporation",
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
])
# Add timezone and location on top of defaults
browser = launch(args=[
"--timezone=America/New_York",
"--fingerprint-location=40.7128,-74.0060",
])
# Override GPU to look like a different machine
browser = launch(args=[
"--fingerprint-gpu-vendor=Intel Inc.",
"--fingerprint-gpu-renderer=Intel Iris OpenGL Engine",
])
```
```javascript
// JavaScript — same flags
const browser = await launch({
args: ['--fingerprint=42069', '--timezone=Europe/London'],
});
```
## Use With Existing Playwright Code ## Use With Existing Playwright Code
@@ -225,31 +436,42 @@ page.goto("https://example.com")
| Platform | Status | | Platform | Status |
|---|---| |---|---|
| Linux x86_64 | Supported | | Linux x86_64 | ✅ Available |
| macOS arm64 (Apple Silicon) | Coming soon | | macOS arm64 (Apple Silicon) | ✅ Available |
| macOS x86_64 (Intel) | Coming soon | | macOS x86_64 (Intel) | ✅ Available |
| Windows | Planned | | Windows | Planned |
**macOS (early access):** macOS builds are new — tested but not yet battle-tested at scale like Linux. If you hit any issues, [please open a GitHub issue](https://github.com/CloakHQ/CloakBrowser/issues).
**macOS first launch:** The binary is ad-hoc signed. On first run, macOS Gatekeeper will block it. Right-click the app → **Open** → click **Open** in the dialog. This is only needed once.
**On Windows?** You can still use CloakBrowser via Docker or with your own Chromium binary by setting `CLOAKBROWSER_BINARY_PATH=/path/to/chrome`.
## Examples ## Examples
See the [`examples/`](examples/) directory: **Python** — see [`examples/`](examples/):
- [`basic.py`](examples/basic.py) — Launch and load a page - [`basic.py`](examples/basic.py) — Launch and load a page
- [`recaptcha_score.py`](examples/recaptcha_score.py) — Check your reCAPTCHA v3 score - [`recaptcha_score.py`](examples/recaptcha_score.py) — Check your reCAPTCHA v3 score
- [`stealth_test.py`](examples/stealth_test.py) — Run against all detection services - [`stealth_test.py`](examples/stealth_test.py) — Run against all detection services
**JavaScript** — see [`js/examples/`](js/examples/):
- [`basic-playwright.ts`](js/examples/basic-playwright.ts) — Playwright launch and load
- [`basic-puppeteer.ts`](js/examples/basic-puppeteer.ts) — Puppeteer launch and load
- [`stealth-test.ts`](js/examples/stealth-test.ts) — Full 6-site detection test suite
## Roadmap ## Roadmap
| Feature | Status | | Feature | Status |
|---------|--------| |---------|--------|
| Linux x64 binary | ✅ Released | | Linux x64 binary | ✅ Released |
| macOS arm64 (Apple Silicon) | 🔜 In progress | | macOS arm64 (Apple Silicon) | ✅ Released |
| Chromium 145 build | 🔜 In progress | | macOS x64 (Intel) | ✅ Released |
| JavaScript/Puppeteer support (`cloakbrowser-js`) | 📋 Planned | | Chromium 145 build (26 patches) | 🔧 In progress |
| Fingerprint rotation per session | 📋 Planned | | JavaScript/Puppeteer + Playwright support | ✅ Released |
| Fingerprint rotation per session | ✅ Released |
| Built-in proxy rotation | 📋 Planned | | Built-in proxy rotation | 📋 Planned |
| Windows support | 📋 Planned | | Windows support | 📋 Planned |
> ⭐ **Star this repo** to get notified when Chromium 145 and macOS builds drop. > ⭐ **Star this repo** to get notified when new builds drop.
## Docker ## Docker
@@ -304,18 +526,71 @@ This runs a real headed browser rendered on a virtual display — no physical mo
## Troubleshooting ## Troubleshooting
**Reddit or similar sites show CAPTCHA / "Prove your humanity"**
Some sites (notably Reddit homepage) use HTTP/2 fingerprinting that detects Playwright's connection layer. Pass `--disable-http2` to fall back to HTTP/1.1:
```python
browser = launch(args=["--disable-http2"])
```
```javascript
const browser = await launch({ args: ['--disable-http2'] });
```
Only use this flag for sites that require it — most sites work fine with HTTP/2.
**Binary download fails / timeout** **Binary download fails / timeout**
Set a custom download URL or use a local binary: Set a custom download URL or use a local binary:
```bash ```bash
export CLOAKBROWSER_BINARY_PATH=/path/to/your/chrome export CLOAKBROWSER_BINARY_PATH=/path/to/your/chrome
``` ```
**macOS: "App is damaged" or Gatekeeper blocks launch**
The binary is ad-hoc signed. macOS quarantines downloaded files. Run once to clear it:
```bash
xattr -cr ~/.cloakbrowser/chromium-*/Chromium.app
```
**"playwright install" vs CloakBrowser binary** **"playwright install" vs CloakBrowser binary**
You do NOT need `playwright install chromium`. CloakBrowser downloads its own binary. You only need Playwright's system deps: You do NOT need `playwright install chromium`. CloakBrowser downloads its own binary. You only need Playwright's system deps:
```bash ```bash
playwright install-deps chromium patchright install-deps chromium
``` ```
**reCAPTCHA v3 scores are low (0.10.3)**
Avoid `page.wait_for_timeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
```python
# Bad — sends CDP commands, reCAPTCHA detects this
page.wait_for_timeout(3000)
# Good — invisible to the browser
import time
time.sleep(3)
```
```javascript
// Bad — sends CDP commands
await page.waitForTimeout(3000);
// Good — invisible to the browser
await new Promise(r => setTimeout(r, 3000));
```
Other tips for maximizing reCAPTCHA scores:
- **Use Playwright, not Puppeteer** — Puppeteer sends more CDP protocol traffic that reCAPTCHA detects ([details](#puppeteer))
- **Use residential proxies** — datacenter IPs are flagged by IP reputation, not browser fingerprint
- **Spend 15+ seconds on the page** before triggering reCAPTCHA — short visits score lower
- **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:
```python
page.type("#email", "user@example.com", delay=50)
```
- **Minimize `page.evaluate()` calls** before the reCAPTCHA check fires — each one sends CDP traffic
## FAQ ## FAQ
**Q: Is this legal?** **Q: Is this legal?**
@@ -333,10 +608,19 @@ A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
**Q: Can I use this with Docker?** **Q: Can I use this with Docker?**
A: Yes. A ready-to-use Dockerfile is included — see the [Docker](#docker) section above. A: Yes. A ready-to-use Dockerfile is included — see the [Docker](#docker) section above.
## Links
- 📋 **Changelog** — [CHANGELOG.md](CHANGELOG.md)
- 🌐 **Website** — [cloakbrowser.dev](https://cloakbrowser.dev)
- 🐛 **Bug reports & feature requests** — [GitHub Issues](https://github.com/CloakHQ/CloakBrowser/issues)
- 📦 **PyPI** — [pypi.org/project/cloakbrowser](https://pypi.org/project/cloakbrowser/)
- 📦 **npm** — [npmjs.com/package/cloakbrowser](https://www.npmjs.com/package/cloakbrowser)
- 📧 **Contact** — cloakhq@pm.me
## License ## License
MIT — see [LICENSE](LICENSE). MIT — see [LICENSE](LICENSE).
## Contributing ## Contributing
Issues and PRs welcome. Contact: cloakhq@pm.me Issues and PRs welcome. If something isn't working, [open an issue](https://github.com/CloakHQ/CloakBrowser/issues) — we respond fast.
+2 -1
View File
@@ -13,7 +13,7 @@ Usage:
from .browser import launch, launch_async, launch_context from .browser import launch, launch_async, launch_context
from .config import CHROMIUM_VERSION, get_default_stealth_args from .config import CHROMIUM_VERSION, get_default_stealth_args
from .download import binary_info, clear_cache, ensure_binary from .download import binary_info, check_for_update, clear_cache, ensure_binary
from ._version import __version__ from ._version import __version__
__all__ = [ __all__ = [
@@ -23,6 +23,7 @@ __all__ = [
"ensure_binary", "ensure_binary",
"clear_cache", "clear_cache",
"binary_info", "binary_info",
"check_for_update",
"CHROMIUM_VERSION", "CHROMIUM_VERSION",
"get_default_stealth_args", "get_default_stealth_args",
"__version__", "__version__",
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.5" __version__ = "0.3.0"
+97 -12
View File
@@ -15,9 +15,10 @@ Usage:
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any from typing import Any, Literal
from urllib.parse import unquote, urlparse, urlunparse
from .config import get_default_stealth_args from .config import DEFAULT_VIEWPORT, get_default_stealth_args
from .download import ensure_binary from .download import ensure_binary
logger = logging.getLogger("cloakbrowser") logger = logging.getLogger("cloakbrowser")
@@ -28,6 +29,9 @@ def launch(
proxy: str | None = None, proxy: str | None = None,
args: list[str] | None = None, args: list[str] | None = None,
stealth_args: bool = True, stealth_args: bool = True,
timezone: str | None = None,
locale: str | None = None,
geoip: bool = False,
**kwargs: Any, **kwargs: Any,
) -> Any: ) -> Any:
"""Launch stealth Chromium browser. Returns a Playwright Browser object. """Launch stealth Chromium browser. Returns a Playwright Browser object.
@@ -38,6 +42,12 @@ def launch(
args: Additional Chromium CLI arguments to pass. args: Additional Chromium CLI arguments to pass.
stealth_args: Include default stealth fingerprint args (default True). stealth_args: Include default stealth fingerprint args (default True).
Set to False if you want to pass your own --fingerprint flags. Set to False if you want to pass your own --fingerprint flags.
timezone: IANA timezone (e.g. 'America/New_York'). Sets --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).
Requires ``pip install cloakbrowser[geoip]``. Downloads ~70 MB
GeoLite2-City database on first use. Explicit timezone/locale
always override geoip results.
**kwargs: Passed directly to playwright.chromium.launch(). **kwargs: Passed directly to playwright.chromium.launch().
Returns: Returns:
@@ -51,10 +61,11 @@ def launch(
>>> print(page.title()) >>> print(page.title())
>>> browser.close() >>> browser.close()
""" """
from playwright.sync_api import sync_playwright from patchright.sync_api import sync_playwright
binary_path = ensure_binary() binary_path = ensure_binary()
chrome_args = _build_args(stealth_args, args) timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale)
logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args)) logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args))
@@ -85,6 +96,9 @@ async def launch_async(
proxy: str | None = None, proxy: str | None = None,
args: list[str] | None = None, args: list[str] | None = None,
stealth_args: bool = True, stealth_args: bool = True,
timezone: str | None = None,
locale: str | None = None,
geoip: bool = False,
**kwargs: Any, **kwargs: Any,
) -> Any: ) -> Any:
"""Async version of launch(). Returns a Playwright Browser object. """Async version of launch(). Returns a Playwright Browser object.
@@ -94,6 +108,9 @@ async def launch_async(
proxy: Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080'). proxy: Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080').
args: Additional Chromium CLI arguments to pass. args: Additional Chromium CLI arguments to pass.
stealth_args: Include default stealth fingerprint args (default True). stealth_args: Include default stealth fingerprint args (default True).
timezone: IANA timezone (e.g. 'America/New_York'). Sets --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).
**kwargs: Passed directly to playwright.chromium.launch(). **kwargs: Passed directly to playwright.chromium.launch().
Returns: Returns:
@@ -112,10 +129,11 @@ async def launch_async(
>>> >>>
>>> asyncio.run(main()) >>> asyncio.run(main())
""" """
from playwright.async_api import async_playwright from patchright.async_api import async_playwright
binary_path = ensure_binary() binary_path = ensure_binary()
chrome_args = _build_args(stealth_args, args) timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale)
logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args)) logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args))
@@ -150,6 +168,8 @@ def launch_context(
viewport: dict | None = None, viewport: dict | None = None,
locale: str | None = None, locale: str | None = None,
timezone_id: str | None = None, timezone_id: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False,
**kwargs: Any, **kwargs: Any,
) -> Any: ) -> Any:
"""Launch stealth browser and return a BrowserContext with common options pre-set. """Launch stealth browser and return a BrowserContext with common options pre-set.
@@ -166,22 +186,31 @@ def launch_context(
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}. viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
locale: Browser locale, e.g. "en-US". locale: Browser locale, e.g. "en-US".
timezone_id: Timezone, e.g. "America/New_York". timezone_id: 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).
**kwargs: Passed to browser.new_context(). **kwargs: Passed to browser.new_context().
Returns: Returns:
Playwright BrowserContext object. Playwright BrowserContext object.
""" """
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args) # 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)
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
timezone=timezone_id, locale=locale)
context_kwargs: dict[str, Any] = {} context_kwargs: dict[str, Any] = {}
if user_agent: if user_agent:
context_kwargs["user_agent"] = user_agent context_kwargs["user_agent"] = user_agent
if viewport: context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
context_kwargs["viewport"] = viewport
if locale: if locale:
context_kwargs["locale"] = locale context_kwargs["locale"] = locale
if timezone_id: if timezone_id:
context_kwargs["timezone_id"] = timezone_id context_kwargs["timezone_id"] = timezone_id
if color_scheme:
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs) context_kwargs.update(kwargs)
try: try:
@@ -207,13 +236,69 @@ def launch_context(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _build_args(stealth_args: bool, extra_args: list[str] | None) -> list[str]: def _maybe_resolve_geoip(
"""Combine stealth args with user-provided args.""" geoip: bool,
proxy: str | 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
from .geoip import resolve_proxy_geo
geo_tz, geo_locale = resolve_proxy_geo(proxy)
if timezone is None:
timezone = geo_tz
if locale is None:
locale = geo_locale
return timezone, locale
def _build_args(
stealth_args: bool,
extra_args: list[str] | None,
timezone: str | None = None,
locale: str | None = None,
) -> list[str]:
"""Combine stealth args with user-provided args and locale flags."""
result = [] result = []
if stealth_args: if stealth_args:
result.extend(get_default_stealth_args()) result.extend(get_default_stealth_args())
if extra_args: if extra_args:
result.extend(extra_args) result.extend(extra_args)
# Timezone/locale flags are independent of stealth_args — always inject when set
if timezone:
result.append(f"--timezone={timezone}")
if locale:
result.append(f"--lang={locale}")
return result
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.
"""
parsed = urlparse(proxy)
if not parsed.username:
return {"server": proxy}
# Rebuild server URL without credentials
netloc = parsed.hostname or ""
if parsed.port:
netloc += f":{parsed.port}"
server = urlunparse((parsed.scheme, netloc, parsed.path, "", "", ""))
result: dict[str, Any] = {"server": server}
result["username"] = unquote(parsed.username)
if parsed.password:
result["password"] = unquote(parsed.password)
return result return result
@@ -221,4 +306,4 @@ def _build_proxy_kwargs(proxy: str | None) -> dict[str, Any]:
"""Build proxy kwargs for Playwright launch.""" """Build proxy kwargs for Playwright launch."""
if proxy is None: if proxy is None:
return {} return {}
return {"proxy": {"server": proxy}} return {"proxy": _parse_proxy_url(proxy)}
+108 -12
View File
@@ -12,25 +12,54 @@ from ._version import __version__
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Chromium version shipped with this release # Chromium version shipped with this release
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
CHROMIUM_VERSION = "142.0.7444.175" CHROMIUM_VERSION = "145.0.7632.109"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Default stealth arguments passed to the patched Chromium binary. # Default stealth arguments passed to the patched Chromium binary.
# These activate source-level fingerprint patches compiled into the binary. # These activate source-level fingerprint patches compiled into the binary.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def get_default_stealth_args() -> list[str]: def get_default_stealth_args() -> list[str]:
"""Build stealth args with a random fingerprint seed per launch.""" """Build stealth args with a random fingerprint seed per launch.
On macOS, skips platform/GPU spoofing — runs as a native Mac browser.
Spoofing Windows on Mac creates detectable mismatches (fonts, GPU, etc.).
"""
seed = random.randint(10000, 99999) seed = random.randint(10000, 99999)
return [ system = platform.system()
base = [
"--no-sandbox", "--no-sandbox",
"--disable-blink-features=AutomationControlled", "--disable-blink-features=AutomationControlled",
f"--fingerprint={seed}", f"--fingerprint={seed}",
]
if system == "Darwin":
# Tell the fingerprint patches we're on macOS so GPU/UA match natively
return base + [
"--fingerprint-platform=macos",
]
# Linux: spoof as Windows
return base + [
"--fingerprint-platform=windows", "--fingerprint-platform=windows",
"--fingerprint-hardware-concurrency=8", "--fingerprint-hardware-concurrency=8",
"--fingerprint-device-memory=8",
"--fingerprint-gpu-vendor=NVIDIA Corporation", "--fingerprint-gpu-vendor=NVIDIA Corporation",
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070", "--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
"--fingerprint-taskbar-height=40",
"--fingerprint-screen-width=1920",
"--fingerprint-screen-height=1080",
"--window-size=1920,1080",
] ]
# ---------------------------------------------------------------------------
# Default viewport — realistic maximized Chrome on 1080p Windows
# screen=1920x1080, availHeight=1040 (minus 40px taskbar),
# innerHeight=955 (minus ~85px Chrome UI: tabs + address bar + bookmarks)
# ---------------------------------------------------------------------------
DEFAULT_VIEWPORT = {"width": 1920, "height": 955}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Platform detection # Platform detection
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -41,6 +70,10 @@ SUPPORTED_PLATFORMS: dict[tuple[str, str], str] = {
("Darwin", "x86_64"): "darwin-x64", ("Darwin", "x86_64"): "darwin-x64",
} }
# Platforms with pre-built binaries available for download.
# Update this set as new platform builds are released.
AVAILABLE_PLATFORMS: set[str] = {"linux-x64", "darwin-arm64", "darwin-x64"}
def get_platform_tag() -> str: def get_platform_tag() -> str:
"""Return the platform tag for binary download (e.g. 'linux-x64', 'darwin-arm64').""" """Return the platform tag for binary download (e.g. 'linux-x64', 'darwin-arm64')."""
@@ -70,15 +103,15 @@ def get_cache_dir() -> Path:
return Path.home() / ".cloakbrowser" return Path.home() / ".cloakbrowser"
def get_binary_dir() -> Path: def get_binary_dir(version: str | None = None) -> Path:
"""Return the directory for the current Chromium version binary.""" """Return the directory for a Chromium version binary."""
return get_cache_dir() / f"chromium-{CHROMIUM_VERSION}" v = version or CHROMIUM_VERSION
return get_cache_dir() / f"chromium-{v}"
def get_binary_path() -> Path: def get_binary_path(version: str | None = None) -> Path:
"""Return the expected path to the chrome executable.""" """Return the expected path to the chrome executable."""
platform_tag = get_platform_tag() binary_dir = get_binary_dir(version)
binary_dir = get_binary_dir()
if platform.system() == "Darwin": if platform.system() == "Darwin":
# macOS: Chromium.app bundle # macOS: Chromium.app bundle
@@ -88,19 +121,82 @@ def get_binary_path() -> Path:
return binary_dir / "chrome" return binary_dir / "chrome"
def check_platform_available() -> None:
"""Raise a clear error if no pre-built binary exists for this platform.
Skipped when CLOAKBROWSER_BINARY_PATH is set (user has their own build).
"""
if get_local_binary_override():
return
tag = get_platform_tag() # raises if platform unsupported entirely
if tag not in AVAILABLE_PLATFORMS:
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."
)
def get_effective_version() -> str:
"""Return the best available version: auto-updated if available, else hardcoded.
Reads the latest_version marker file from the cache directory.
Returns CHROMIUM_VERSION if no update has been downloaded.
"""
marker = get_cache_dir() / "latest_version"
if marker.exists():
try:
version = marker.read_text().strip()
if version and _version_newer(version, CHROMIUM_VERSION):
# Verify the binary actually exists
binary = get_binary_path(version)
if binary.exists():
return version
except (ValueError, OSError):
pass
return CHROMIUM_VERSION
def _version_tuple(v: str) -> tuple[int, ...]:
"""Parse '145.0.7718.0' into (145, 0, 7718, 0) for comparison."""
return tuple(int(x) for x in v.split("."))
def _version_newer(a: str, b: str) -> bool:
"""Return True if version a is strictly newer than version b."""
return _version_tuple(a) > _version_tuple(b)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Download URL # Download URL
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
DOWNLOAD_BASE_URL = os.environ.get( DOWNLOAD_BASE_URL = os.environ.get(
"CLOAKBROWSER_DOWNLOAD_URL", "CLOAKBROWSER_DOWNLOAD_URL",
"https://github.com/CloakHQ/chromium-stealth-builds/releases/download", "https://cloakbrowser.dev",
)
GITHUB_API_URL = "https://api.github.com/repos/CloakHQ/cloakbrowser/releases"
GITHUB_DOWNLOAD_BASE_URL = (
"https://github.com/CloakHQ/cloakbrowser/releases/download"
) )
def get_download_url() -> str: def get_download_url(version: str | None = None) -> str:
"""Return the full download URL for the current platform's binary archive.""" """Return the full download URL for the current platform's binary archive."""
v = version or CHROMIUM_VERSION
tag = get_platform_tag() tag = get_platform_tag()
return f"{DOWNLOAD_BASE_URL}/v{CHROMIUM_VERSION}/cloakbrowser-{tag}.tar.gz" return f"{DOWNLOAD_BASE_URL}/chromium-v{v}/cloakbrowser-{tag}.tar.gz"
def get_fallback_download_url(version: str | None = None) -> str:
"""Return the GitHub Releases fallback URL for the binary archive."""
v = version or CHROMIUM_VERSION
tag = get_platform_tag()
return f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}/cloakbrowser-{tag}.tar.gz"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+303 -25
View File
@@ -6,20 +6,33 @@ Similar to how Playwright downloads its own bundled Chromium.
from __future__ import annotations from __future__ import annotations
import hashlib
import logging import logging
import os import os
import platform
import stat import stat
import subprocess
import tarfile import tarfile
import tempfile import tempfile
import threading
import time
from pathlib import Path from pathlib import Path
import httpx import httpx
from .config import ( from .config import (
CHROMIUM_VERSION, CHROMIUM_VERSION,
DOWNLOAD_BASE_URL,
GITHUB_API_URL,
GITHUB_DOWNLOAD_BASE_URL,
_version_newer,
check_platform_available,
get_binary_dir, get_binary_dir,
get_binary_path, get_binary_path,
get_cache_dir,
get_download_url, get_download_url,
get_effective_version,
get_fallback_download_url,
get_local_binary_override, get_local_binary_override,
get_platform_tag, get_platform_tag,
) )
@@ -29,6 +42,9 @@ logger = logging.getLogger("cloakbrowser")
# Timeout for download (large binary, allow 10 min) # Timeout for download (large binary, allow 10 min)
DOWNLOAD_TIMEOUT = 600.0 DOWNLOAD_TIMEOUT = 600.0
# Auto-update check interval (1 hour)
UPDATE_CHECK_INTERVAL = 3600
def ensure_binary() -> str: def ensure_binary() -> str:
"""Ensure the stealth Chromium binary is available. Download if needed. """Ensure the stealth Chromium binary is available. Download if needed.
@@ -48,13 +64,27 @@ def ensure_binary() -> str:
logger.info("Using local binary override: %s", local_override) logger.info("Using local binary override: %s", local_override)
return str(path) return str(path)
# Check if binary is already cached # Fail fast if no binary available for this platform
binary_path = get_binary_path() check_platform_available()
# Check for auto-updated version first, then fall back to hardcoded
effective = get_effective_version()
binary_path = get_binary_path(effective)
if binary_path.exists() and _is_executable(binary_path): if binary_path.exists() and _is_executable(binary_path):
logger.debug("Binary found in cache: %s", binary_path) logger.debug("Binary found in cache: %s (version %s)", binary_path, effective)
_maybe_trigger_update_check()
return str(binary_path) return str(binary_path)
# Download # Fall back to hardcoded version if effective version binary doesn't exist
if effective != CHROMIUM_VERSION:
fallback_path = get_binary_path()
if fallback_path.exists() and _is_executable(fallback_path):
logger.debug("Binary found in cache: %s", fallback_path)
_maybe_trigger_update_check()
return str(fallback_path)
# Download hardcoded version
logger.info( logger.info(
"Stealth Chromium %s not found. Downloading for %s...", "Stealth Chromium %s not found. Downloading for %s...",
CHROMIUM_VERSION, CHROMIUM_VERSION,
@@ -62,6 +92,7 @@ def ensure_binary() -> str:
) )
_download_and_extract() _download_and_extract()
binary_path = get_binary_path()
if not binary_path.exists(): if not binary_path.exists():
raise RuntimeError( raise RuntimeError(
f"Download completed but binary not found at expected path: {binary_path}. " f"Download completed but binary not found at expected path: {binary_path}. "
@@ -69,13 +100,21 @@ def ensure_binary() -> str:
f"https://github.com/CloakHQ/cloakbrowser/issues" f"https://github.com/CloakHQ/cloakbrowser/issues"
) )
_maybe_trigger_update_check()
return str(binary_path) return str(binary_path)
def _download_and_extract() -> None: def _download_and_extract(version: str | None = None) -> None:
"""Download the binary archive and extract to cache directory.""" """Download the binary archive and extract to cache directory.
url = get_download_url()
binary_dir = get_binary_dir() Tries the primary server (cloakbrowser.dev) first, falls back to
GitHub Releases if the primary is unreachable or returns an error.
Verifies SHA-256 checksum before extraction when available.
"""
primary_url = get_download_url(version)
fallback_url = get_fallback_download_url(version)
binary_dir = get_binary_dir(version)
binary_path = get_binary_path(version)
# Create cache dir # Create cache dir
binary_dir.parent.mkdir(parents=True, exist_ok=True) binary_dir.parent.mkdir(parents=True, exist_ok=True)
@@ -85,13 +124,101 @@ def _download_and_extract() -> None:
tmp_path = Path(tmp.name) tmp_path = Path(tmp.name)
try: try:
_download_file(url, tmp_path) # Try primary, fall back to GitHub Releases (skip fallback if custom URL)
_extract_archive(tmp_path, binary_dir) try:
_download_file(primary_url, tmp_path)
except Exception as primary_err:
if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"):
raise
logger.warning(
"Primary download failed (%s), trying GitHub Releases...",
primary_err,
)
_download_file(fallback_url, tmp_path)
# Verify checksum before extraction
if os.environ.get("CLOAKBROWSER_SKIP_CHECKSUM", "").lower() != "true":
_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")
finally: finally:
# Clean up temp file # Clean up temp file
tmp_path.unlink(missing_ok=True) tmp_path.unlink(missing_ok=True)
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"
if checksums is None:
logger.warning("SHA256SUMS not available for this release — skipping checksum verification")
return
expected = checksums.get(tarball_name)
if expected is None:
logger.warning("SHA256SUMS found but no entry for %s — skipping verification", tarball_name)
return
_verify_checksum(file_path, expected)
def _fetch_checksums(version: str | None = None) -> dict[str, str] | None:
"""Fetch SHA256SUMS file for a version. Returns {filename: hash} or None."""
v = version or CHROMIUM_VERSION
has_custom_url = os.environ.get("CLOAKBROWSER_DOWNLOAD_URL")
# Build URL list — respect custom URL contract (no GitHub fallback)
urls = [f"{DOWNLOAD_BASE_URL}/chromium-v{v}/SHA256SUMS"]
if not has_custom_url:
urls.append(f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}/SHA256SUMS")
for url in urls:
try:
resp = httpx.get(url, follow_redirects=True, timeout=10.0)
resp.raise_for_status()
return _parse_checksums(resp.text)
except Exception:
continue
return None
def _parse_checksums(text: str) -> dict[str, str]:
"""Parse SHA256SUMS format: 'hash filename' per line."""
result = {}
for line in text.strip().splitlines():
line = line.strip()
if not line:
continue
parts = line.split(None, 1)
if len(parts) == 2:
hash_val, filename = parts
filename = filename.lstrip("*")
result[filename] = hash_val.lower()
return result
def _verify_checksum(file_path: Path, expected_hash: str) -> None:
"""Verify SHA-256 of a file. Raises RuntimeError on mismatch."""
sha256 = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha256.update(chunk)
actual = sha256.hexdigest().lower()
if actual != expected_hash:
raise RuntimeError(
f"Checksum verification failed!\n"
f" Expected: {expected_hash}\n"
f" Got: {actual}\n"
f" File may be corrupted or tampered with. "
f"Please retry or report at https://github.com/CloakHQ/cloakbrowser/issues"
)
logger.info("Checksum verified: SHA-256 OK")
def _download_file(url: str, dest: Path) -> None: def _download_file(url: str, dest: Path) -> None:
"""Download a file with progress logging.""" """Download a file with progress logging."""
logger.info("Downloading from %s", url) logger.info("Downloading from %s", url)
@@ -123,7 +250,9 @@ def _download_file(url: str, dest: Path) -> None:
logger.info("Download complete: %d MB", dest.stat().st_size // (1024 * 1024)) logger.info("Download complete: %d MB", dest.stat().st_size // (1024 * 1024))
def _extract_archive(archive_path: Path, dest_dir: 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 archive to destination directory."""
logger.info("Extracting to %s", dest_dir) logger.info("Extracting to %s", dest_dir)
@@ -135,28 +264,40 @@ def _extract_archive(archive_path: Path, dest_dir: Path) -> None:
dest_dir.mkdir(parents=True, exist_ok=True) dest_dir.mkdir(parents=True, exist_ok=True)
with tarfile.open(archive_path, "r:gz") as tar: with tarfile.open(archive_path, "r:gz") as tar:
# Security: prevent path traversal and symlink attacks # Security: prevent path traversal
safe_members = [] safe_members = []
for member in tar.getmembers(): for member in tar.getmembers():
# Allow symlinks — macOS .app bundles require them (Framework layout)
if member.issym() or member.islnk(): if member.issym() or member.islnk():
logger.warning("Skipping symlink in archive: %s", member.name) link_target = member.linkname
continue # Reject symlinks that escape the dest dir
member_path = (dest_dir / member.name).resolve() if os.path.isabs(link_target) or ".." in link_target.split("/"):
if not str(member_path).startswith(str(dest_dir.resolve())): logger.warning("Skipping suspicious symlink: %s -> %s", member.name, link_target)
raise RuntimeError(f"Archive contains path traversal: {member.name}") 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) safe_members.append(member)
tar.extractall(dest_dir, members=safe_members) tar.extractall(dest_dir, members=safe_members)
# If tar extracted into a single subdirectory, flatten it # If tar extracted into a single subdirectory, flatten it
# (e.g. fingerprint-chromium-142-custom-v2/chrome → chrome) # (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) _flatten_single_subdir(dest_dir)
# Make binary executable # Make binary executable
binary_path = get_binary_path() bp = binary_path or get_binary_path()
if binary_path.exists(): if bp.exists():
_make_executable(binary_path) _make_executable(bp)
logger.info("Binary ready: %s", binary_path)
# macOS: remove quarantine/provenance xattrs to prevent Gatekeeper prompts
if platform.system() == "Darwin":
_remove_quarantine(dest_dir)
if bp.exists():
logger.info("Binary ready: %s", bp)
def _flatten_single_subdir(dest_dir: Path) -> None: def _flatten_single_subdir(dest_dir: Path) -> None:
@@ -170,6 +311,10 @@ def _flatten_single_subdir(dest_dir: Path) -> None:
entries = list(dest_dir.iterdir()) entries = list(dest_dir.iterdir())
if len(entries) == 1 and entries[0].is_dir(): if len(entries) == 1 and entries[0].is_dir():
subdir = entries[0] subdir = entries[0]
# Never flatten .app bundles — macOS needs the bundle structure
if subdir.name.endswith(".app"):
logger.debug("Keeping .app bundle intact: %s", subdir.name)
return
logger.debug("Flattening single subdirectory: %s", subdir.name) logger.debug("Flattening single subdirectory: %s", subdir.name)
for item in subdir.iterdir(): for item in subdir.iterdir():
shutil.move(str(item), str(dest_dir / item.name)) shutil.move(str(item), str(dest_dir / item.name))
@@ -187,6 +332,19 @@ def _make_executable(path: Path) -> None:
path.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) path.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
def _remove_quarantine(path: Path) -> None:
"""Remove macOS quarantine/provenance xattrs so Gatekeeper doesn't block the binary."""
try:
subprocess.run(
["xattr", "-cr", str(path)],
capture_output=True,
timeout=30,
)
logger.debug("Removed quarantine attributes from %s", path)
except Exception:
logger.debug("Failed to remove quarantine attributes", exc_info=True)
def clear_cache() -> None: def clear_cache() -> None:
"""Remove all cached binaries. Forces re-download on next launch.""" """Remove all cached binaries. Forces re-download on next launch."""
from .config import get_cache_dir from .config import get_cache_dir
@@ -200,12 +358,132 @@ def clear_cache() -> None:
def binary_info() -> dict: def binary_info() -> dict:
"""Return info about the current binary installation.""" """Return info about the current binary installation."""
binary_path = get_binary_path() effective = get_effective_version()
binary_path = get_binary_path(effective)
return { return {
"version": CHROMIUM_VERSION, "version": effective,
"bundled_version": CHROMIUM_VERSION,
"platform": get_platform_tag(), "platform": get_platform_tag(),
"binary_path": str(binary_path), "binary_path": str(binary_path),
"installed": binary_path.exists(), "installed": binary_path.exists(),
"cache_dir": str(get_binary_dir()), "cache_dir": str(get_binary_dir(effective)),
"download_url": get_download_url(), "download_url": get_download_url(effective),
} }
# ---------------------------------------------------------------------------
# Auto-update
# ---------------------------------------------------------------------------
def check_for_update() -> str | None:
"""Manually check for a newer Chromium version. Returns new version or None.
This is the public API for triggering an update check. Unlike the
background check in ensure_binary(), this blocks until complete.
"""
latest = _get_latest_chromium_version()
if latest is None:
return None
if not _version_newer(latest, CHROMIUM_VERSION):
return None
binary_dir = get_binary_dir(latest)
if binary_dir.exists():
# Already downloaded
_write_version_marker(latest)
return latest
logger.info("Downloading Chromium %s...", latest)
_download_and_extract(version=latest)
_write_version_marker(latest)
return latest
def _should_check_for_update() -> bool:
"""Check if auto-update is enabled and rate limit hasn't been hit."""
if os.environ.get("CLOAKBROWSER_AUTO_UPDATE", "").lower() == "false":
return False
if get_local_binary_override():
return False
if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"):
return False
check_file = get_cache_dir() / ".last_update_check"
if check_file.exists():
try:
last_check = float(check_file.read_text().strip())
if time.time() - last_check < UPDATE_CHECK_INTERVAL:
return False
except (ValueError, OSError):
pass
return True
def _get_latest_chromium_version() -> str | None:
"""Hit GitHub Releases API, return latest chromium-v* version string or None."""
try:
resp = httpx.get(
GITHUB_API_URL, params={"per_page": 10}, timeout=10.0
)
resp.raise_for_status()
for release in resp.json():
tag = release.get("tag_name", "")
if tag.startswith("chromium-v") and not release.get("draft"):
return tag.removeprefix("chromium-v")
return None
except Exception:
logger.debug("Auto-update check failed", exc_info=True)
return None
def _write_version_marker(version: str) -> None:
"""Write the latest version marker to cache dir."""
cache_dir = get_cache_dir()
cache_dir.mkdir(parents=True, exist_ok=True)
marker = cache_dir / "latest_version"
# Write to temp file then rename for atomicity
tmp = marker.with_suffix(".tmp")
tmp.write_text(version)
tmp.rename(marker)
def _check_and_download_update() -> None:
"""Background task: check for newer binary, download if available."""
try:
# Record check timestamp first (rate limiting)
check_file = get_cache_dir() / ".last_update_check"
check_file.parent.mkdir(parents=True, exist_ok=True)
check_file.write_text(str(time.time()))
latest = _get_latest_chromium_version()
if latest is None:
return
if not _version_newer(latest, CHROMIUM_VERSION):
return
# Already downloaded?
if get_binary_dir(latest).exists():
_write_version_marker(latest)
return
logger.info(
"Newer Chromium available: %s (current: %s). Downloading in background...",
latest,
CHROMIUM_VERSION,
)
_download_and_extract(version=latest)
_write_version_marker(latest)
logger.info(
"Background update complete: Chromium %s ready. Will use on next launch.",
latest,
)
except Exception:
logger.debug("Background update failed", exc_info=True)
def _maybe_trigger_update_check() -> None:
"""Fire-and-forget update check in a daemon thread."""
if not _should_check_for_update():
return
t = threading.Thread(target=_check_and_download_update, daemon=True)
t.start()
+238
View File
@@ -0,0 +1,238 @@
"""GeoIP-based timezone and locale detection from proxy IP.
Optional feature — requires ``geoip2`` package::
pip install cloakbrowser[geoip]
Downloads GeoLite2-City.mmdb (~70 MB) on first use, caches in
``~/.cloakbrowser/geoip/``. Background re-download after 30 days.
"""
from __future__ import annotations
import ipaddress
import logging
import socket
import tempfile
import threading
import time
from pathlib import Path
from urllib.parse import urlparse
logger = logging.getLogger("cloakbrowser")
# P3TERX mirror of MaxMind GeoLite2-City — no license key needed
GEOIP_DB_URL = (
"https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb"
)
GEOIP_DB_FILENAME = "GeoLite2-City.mmdb"
GEOIP_UPDATE_INTERVAL = 30 * 86_400 # 30 days
# Country ISO code → BCP 47 locale (covers ~90 % of proxy traffic)
COUNTRY_LOCALE_MAP: dict[str, str] = {
"US": "en-US", "GB": "en-GB", "AU": "en-AU", "CA": "en-CA", "NZ": "en-NZ",
"IE": "en-IE", "ZA": "en-ZA", "SG": "en-SG",
"DE": "de-DE", "AT": "de-AT", "CH": "de-CH",
"FR": "fr-FR", "BE": "fr-BE",
"ES": "es-ES", "MX": "es-MX", "AR": "es-AR", "CO": "es-CO", "CL": "es-CL",
"BR": "pt-BR", "PT": "pt-PT",
"IT": "it-IT", "NL": "nl-NL",
"JP": "ja-JP", "KR": "ko-KR", "CN": "zh-CN", "TW": "zh-TW", "HK": "zh-HK",
"RU": "ru-RU", "UA": "uk-UA", "PL": "pl-PL", "CZ": "cs-CZ", "RO": "ro-RO",
"IL": "he-IL", "TR": "tr-TR", "SA": "ar-SA", "AE": "ar-AE", "EG": "ar-EG",
"IN": "hi-IN", "ID": "id-ID", "PH": "en-PH",
"TH": "th-TH", "VN": "vi-VN", "MY": "ms-MY",
"SE": "sv-SE", "NO": "nb-NO", "DK": "da-DK", "FI": "fi-FI",
"GR": "el-GR", "HU": "hu-HU", "BG": "bg-BG",
}
def resolve_proxy_geo(proxy_url: str) -> tuple[str | None, str | None]:
"""Resolve timezone and locale from a proxy's IP address.
Returns ``(timezone, locale)`` — either or both may be ``None`` on
failure (missing dep, DB download error, lookup miss). Never raises.
"""
try:
import geoip2.database # noqa: F811
except ImportError:
raise ImportError(
"geoip2 is required for geoip=True. Install it with:\n"
" pip install cloakbrowser[geoip]"
) from None
db_path = _ensure_geoip_db()
if db_path is None:
return 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
try:
with geoip2.database.Reader(str(db_path)) as reader:
resp = reader.city(ip)
timezone = resp.location.time_zone
country = resp.country.iso_code
locale = COUNTRY_LOCALE_MAP.get(country) if country else None
logger.debug(
"GeoIP: %s → tz=%s, country=%s, locale=%s",
ip, timezone, country, locale,
)
return timezone, locale
except Exception as exc:
logger.debug("GeoIP lookup failed for %s: %s", ip, exc)
return None, None
# ---------------------------------------------------------------------------
# Proxy IP resolution
# ---------------------------------------------------------------------------
def _resolve_proxy_ip(proxy_url: str) -> str | None:
"""Extract proxy hostname from URL and resolve to an IP address."""
try:
hostname = urlparse(proxy_url).hostname
if not hostname:
return None
# Already a literal IP?
try:
socket.inet_pton(socket.AF_INET, hostname)
return hostname
except OSError:
pass
try:
socket.inet_pton(socket.AF_INET6, hostname)
return hostname
except OSError:
pass
# DNS resolve (returns first result, handles both v4/v6)
results = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
if results:
ip = results[0][4][0]
logger.debug("Resolved proxy %s%s", hostname, ip)
return ip
return None
except Exception as exc:
logger.debug("Failed to resolve proxy hostname: %s", exc)
return None
def _is_private_ip(ip: str) -> bool:
"""Check if an IP address is private/internal (not routable on the internet)."""
try:
return ipaddress.ip_address(ip).is_private
except ValueError:
return False
# IP echo services — fast, no auth, return just the IP
_IP_ECHO_URLS = [
"https://api.ipify.org",
"https://checkip.amazonaws.com",
"https://ifconfig.me/ip",
]
def _resolve_exit_ip(proxy_url: str) -> str | None:
"""Discover the proxy's actual exit IP by connecting through it."""
import httpx
for url in _IP_ECHO_URLS:
try:
resp = httpx.get(url, proxy=proxy_url, timeout=10.0)
resp.raise_for_status()
ip = resp.text.strip()
# Validate it looks like an IP
ipaddress.ip_address(ip)
logger.debug("Exit IP via %s: %s", url, ip)
return ip
except Exception:
continue
logger.debug("Failed to discover exit IP through proxy")
return None
# ---------------------------------------------------------------------------
# GeoIP database management
# ---------------------------------------------------------------------------
def _get_geoip_dir() -> Path:
from .config import get_cache_dir
return get_cache_dir() / "geoip"
def _ensure_geoip_db() -> Path | None:
"""Return path to GeoLite2-City.mmdb, downloading on first use."""
db_path = _get_geoip_dir() / GEOIP_DB_FILENAME
if db_path.exists():
_maybe_trigger_update(db_path)
return db_path
try:
_download_geoip_db(db_path)
return db_path
except Exception as exc:
logger.warning("Failed to download GeoIP database: %s", exc)
return None
def _download_geoip_db(dest: Path) -> None:
"""Atomic download of GeoLite2-City.mmdb via httpx."""
import httpx
dest.parent.mkdir(parents=True, exist_ok=True)
logger.info("Downloading GeoIP database (~70 MB) …")
tmp_fd, tmp_name = tempfile.mkstemp(dir=dest.parent, suffix=".tmp")
tmp_path = Path(tmp_name)
try:
with httpx.stream(
"GET", GEOIP_DB_URL, follow_redirects=True, timeout=300.0
) as resp:
resp.raise_for_status()
total = int(resp.headers.get("content-length", 0))
downloaded = 0
last_pct = -1
with open(tmp_fd, "wb") as f:
for chunk in resp.iter_bytes(chunk_size=65_536):
f.write(chunk)
downloaded += len(chunk)
if total:
pct = downloaded * 100 // total
if pct >= last_pct + 10:
last_pct = pct
logger.info("GeoIP download: %d %%", pct)
tmp_path.rename(dest)
logger.info("GeoIP database ready: %s", dest)
except Exception:
tmp_path.unlink(missing_ok=True)
raise
def _maybe_trigger_update(db_path: Path) -> None:
"""Re-download in background if DB is older than 30 days."""
try:
age = time.time() - db_path.stat().st_mtime
if age < GEOIP_UPDATE_INTERVAL:
return
except OSError:
return
def _bg() -> None:
try:
_download_geoip_db(db_path)
except Exception:
logger.debug("Background GeoIP update failed", exc_info=True)
threading.Thread(target=_bg, daemon=True).start()
+227
View File
@@ -0,0 +1,227 @@
"""Test against fingerprint-scan.com and CreepJS.
Tests the specific headless detection signals flagged by the community:
- noTaskbar, noContentIndex, noContactsManager, noDownlinkMax
- Bot risk score (fingerprint-scan.com)
- Headless/stealth percentages (CreepJS)
- Full CreepJS signal breakdown (likeHeadless, headless, stealth)
Usage:
python examples/fingerprint_scan_test.py
python examples/fingerprint_scan_test.py --proxy http://10.50.96.5:8888
python examples/fingerprint_scan_test.py --headless
"""
import sys
from cloakbrowser import launch_context
HEADLESS = "--headless" in sys.argv
PROXY = None
for i, arg in enumerate(sys.argv):
if arg == "--proxy" and i + 1 < len(sys.argv):
PROXY = sys.argv[i + 1]
def test_fingerprint_scan(page):
"""fingerprint-scan.com — bot risk score + headless detection signals."""
print("=== fingerprint-scan.com ===")
page.goto("https://fingerprint-scan.com/", wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(20000) # Castle.js needs time to compute score
# Check bot risk score
score = page.evaluate(
'document.getElementById("fingerprintScore")?.textContent || "Score not rendered"'
)
print(f"Bot Risk Score: {score}")
# Check headless detection signals
apis = page.evaluate("""() => ({
noTaskbar: screen.height === screen.availHeight,
taskbarSize: screen.height - screen.availHeight,
noContentIndex: typeof window.ContentIndex === "undefined",
noContactsManager: !("contacts" in navigator),
noDownlinkMax: !("downlinkMax" in (navigator.connection || {})),
downlinkMax: navigator.connection?.downlinkMax ?? null,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
webdriver: navigator.webdriver,
isPlaywright: "__pwInitScripts" in window || "__playwright__binding__" in window,
webgpu: typeof navigator.gpu !== "undefined" ? "available" : "NOT_AVAILABLE",
scrollbarWidth: (() => { const d = document.createElement("div"); d.style.cssText = "overflow:scroll;width:100px;height:100px;position:absolute;top:-999px"; document.body.appendChild(d); const w = d.offsetWidth - d.clientWidth; d.remove(); return w; })()
})""")
print("\nHeadless detection signals:")
headless_fails = 0
for k, v in apis.items():
is_fail = k.startswith("no") and v is True
if is_fail:
headless_fails += 1
flag = "FAIL" if is_fail else ""
print(f" {k}: {v} {flag}")
# Extract bot test results from page
bot_tests = page.evaluate("""() => {
const text = document.body.innerText;
const tests = {};
for (const key of ['WebDriver', 'Is Selenium Chrome', 'CDP Check', 'Is Playwright']) {
const match = text.match(new RegExp(key + '\\\\s+(true|false)'));
if (match) tests[key] = match[1];
}
return tests;
}""")
print("\nBot Detection Tests:")
for k, v in bot_tests.items():
status = "PASS" if v == "false" else "FAIL"
print(f" {k}: {v} [{status}]")
page.screenshot(path="/results/fingerprint-scan.png", full_page=True)
print("\nScreenshot: /results/fingerprint-scan.png")
return {
"score": score,
"headless_fails": headless_fails,
"apis": apis,
"bot_tests": bot_tests,
}
def test_creepjs(page):
"""abrahamjuliot.github.io/creepjs — comprehensive fingerprint analysis."""
print("\n=== CreepJS ===")
page.goto(
"https://abrahamjuliot.github.io/creepjs/", wait_until="domcontentloaded", timeout=30000
)
print("Waiting 30s for CreepJS analysis...")
page.wait_for_timeout(30000)
# Extract % scores from page text (matches test-infra/matrix_tests/group3_bot_detection.py)
scores = page.evaluate("""() => {
const text = document.body.innerText;
const likeMatch = text.match(/(\\d+)%\\s*like headless/i);
const headlessMatch = text.match(/(\\d+)%\\s*headless:/i);
const stealthMatch = text.match(/(\\d+)%\\s*stealth:/i);
return {
likeHeadlessPct: likeMatch ? parseInt(likeMatch[1]) : null,
headlessPct: headlessMatch ? parseInt(headlessMatch[1]) : null,
stealthPct: stealthMatch ? parseInt(stealthMatch[1]) : null,
};
}""")
print(f"\nScores:")
print(f" like-headless: {scores['likeHeadlessPct']}% (target: <=30%)")
print(f" headless: {scores['headlessPct']}% (target: 0%)")
print(f" stealth: {scores['stealthPct']}% (target: 0%)")
# Extract full signal breakdown from window.Fingerprint.headless (CreepJS internal object)
signals = page.evaluate("""() => {
try {
const fp = window.Fingerprint;
if (!fp || !fp.headless) return null;
return {
likeHeadless: fp.headless.likeHeadless || null,
headless: fp.headless.headless || null,
stealth: fp.headless.stealth || null,
};
} catch { return null; }
}""")
if signals:
if signals.get("likeHeadless"):
print("\nlikeHeadless signals:")
fails = 0
for k, v in signals["likeHeadless"].items():
is_fail = v is True
if is_fail:
fails += 1
flag = " FAIL" if is_fail else ""
print(f" {k}: {v}{flag}")
print(f" ({fails} fails)")
if signals.get("headless"):
print("\nheadless signals:")
for k, v in signals["headless"].items():
flag = " FAIL" if v is True else ""
print(f" {k}: {v}{flag}")
if signals.get("stealth"):
print("\nstealth signals:")
for k, v in signals["stealth"].items():
flag = " FAIL" if v is True else ""
print(f" {k}: {v}{flag}")
else:
print("\n(window.Fingerprint.headless not available — signals not extracted)")
# Extract platform estimate
platform = page.evaluate("""() => {
try {
const fp = window.Fingerprint;
if (!fp || !fp.platformEstimate) return null;
return fp.platformEstimate;
} catch { return null; }
}""")
if platform:
print(f"\nPlatform estimate: {platform}")
passed = (
scores["headlessPct"] is not None
and scores["headlessPct"] <= 30
and scores["stealthPct"] is not None
and scores["stealthPct"] <= 30
)
print(f"\nVerdict: {'PASS' if passed else 'FAIL'} (<=30% headless, <=30% stealth)")
page.screenshot(path="/results/creepjs.png", full_page=True)
print("Screenshot: /results/creepjs.png")
return {**scores, "signals": signals, "platform": platform}
def main():
print("=" * 60)
print("CloakBrowser — Fingerprint & Headless Detection Tests")
print("=" * 60)
print(f"Mode: {'headless' if HEADLESS else 'headed'}")
print(f"Proxy: {PROXY or 'none'}")
print()
context = launch_context(
headless=HEADLESS,
proxy=PROXY,
args=[
"--fingerprint-screen-width=1920",
"--fingerprint-screen-height=1080",
"--timezone=Asia/Jerusalem",
],
)
page = context.new_page()
try:
fp_result = test_fingerprint_scan(page)
creep_result = test_creepjs(page)
finally:
context.close()
# Summary
print("\n" + "=" * 60)
print("SUMMARY")
print("=" * 60)
print(f"fingerprint-scan.com: {fp_result['score']}")
print(f" Headless signal fails: {fp_result['headless_fails']}")
like = creep_result["likeHeadlessPct"]
headless = creep_result["headlessPct"]
stealth = creep_result["stealthPct"]
print(f"CreepJS: like-headless={like}%, headless={headless}%, stealth={stealth}%")
# Count CreepJS signal fails
sigs = creep_result.get("signals")
if sigs and sigs.get("likeHeadless"):
fail_names = [k for k, v in sigs["likeHeadless"].items() if v is True]
if fail_names:
print(f" likeHeadless fails: {', '.join(fail_names)}")
print("=" * 60)
return 0
if __name__ == "__main__":
sys.exit(main())
+3 -1
View File
@@ -5,6 +5,8 @@ Expected: 0.9 (human-level) with cloakbrowser.
Default Playwright typically scores 0.1-0.3. Default Playwright typically scores 0.1-0.3.
""" """
import time
from cloakbrowser import launch from cloakbrowser import launch
browser = launch(headless=True) browser = launch(headless=True)
@@ -18,7 +20,7 @@ page.wait_for_load_state("networkidle")
button = page.query_selector("button") button = page.query_selector("button")
if button: if button:
button.click() button.click()
page.wait_for_timeout(3000) time.sleep(3)
# Extract score from page # Extract score from page
content = page.content() content = page.content()
+8 -8
View File
@@ -27,7 +27,7 @@ for i, arg in enumerate(sys.argv):
def test_bot_sannysoft(page): def test_bot_sannysoft(page):
"""bot.sannysoft.com — classic bot detection checks.""" """bot.sannysoft.com — classic bot detection checks."""
page.goto("https://bot.sannysoft.com", wait_until="networkidle", timeout=30000) page.goto("https://bot.sannysoft.com", wait_until="networkidle", timeout=30000)
page.wait_for_timeout(3000) time.sleep(3)
results = page.evaluate("""() => { results = page.evaluate("""() => {
const rows = document.querySelectorAll('table tr'); const rows = document.querySelectorAll('table tr');
@@ -53,7 +53,7 @@ def test_bot_sannysoft(page):
def test_bot_incolumitas(page): def test_bot_incolumitas(page):
"""bot.incolumitas.com — comprehensive 30+ check bot detection.""" """bot.incolumitas.com — comprehensive 30+ check bot detection."""
page.goto("https://bot.incolumitas.com", wait_until="networkidle", timeout=30000) page.goto("https://bot.incolumitas.com", wait_until="networkidle", timeout=30000)
page.wait_for_timeout(12000) # needs time to run all detection tests time.sleep(12) # needs time to run all detection tests
# Site outputs JSON blocks in page text, not HTML tables # Site outputs JSON blocks in page text, not HTML tables
results = page.evaluate("""() => { results = page.evaluate("""() => {
@@ -74,7 +74,7 @@ def test_bot_incolumitas(page):
def test_browserscan(page): def test_browserscan(page):
"""browserscan.net/bot-detection — WebDriver, UA, CDP, Navigator checks.""" """browserscan.net/bot-detection — WebDriver, UA, CDP, Navigator checks."""
page.goto("https://www.browserscan.net/bot-detection", wait_until="networkidle", timeout=30000) page.goto("https://www.browserscan.net/bot-detection", wait_until="networkidle", timeout=30000)
page.wait_for_timeout(5000) time.sleep(5)
results = page.evaluate("""() => { results = page.evaluate("""() => {
const items = document.querySelectorAll('[class*="result"], [class*="item"], [class*="check"]'); const items = document.querySelectorAll('[class*="result"], [class*="item"], [class*="check"]');
@@ -95,7 +95,7 @@ def test_browserscan(page):
def test_deviceandbrowserinfo(page): def test_deviceandbrowserinfo(page):
"""deviceandbrowserinfo.com/are_you_a_bot — fingerprint + behavioral detection.""" """deviceandbrowserinfo.com/are_you_a_bot — fingerprint + behavioral detection."""
page.goto("https://deviceandbrowserinfo.com/are_you_a_bot", wait_until="domcontentloaded", timeout=30000) page.goto("https://deviceandbrowserinfo.com/are_you_a_bot", wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(8000) time.sleep(8)
results = page.evaluate("""() => { results = page.evaluate("""() => {
const text = document.body.innerText; const text = document.body.innerText;
@@ -119,13 +119,13 @@ def test_deviceandbrowserinfo(page):
def test_fingerprintjs(page): def test_fingerprintjs(page):
"""demo.fingerprint.com/web-scraping — industry-standard bot detection.""" """demo.fingerprint.com/web-scraping — industry-standard bot detection."""
page.goto("https://demo.fingerprint.com/web-scraping", wait_until="networkidle", timeout=30000) page.goto("https://demo.fingerprint.com/web-scraping", wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(5000) time.sleep(8)
# Click search to trigger bot detection — bots get blocked, humans see flights # Click search to trigger bot detection — bots get blocked, humans see flights
try: try:
page.click("button:has-text('Search')", timeout=5000) page.click("button:has-text('Search')", timeout=5000)
page.wait_for_timeout(5000) time.sleep(5)
except Exception: except Exception:
pass pass
@@ -147,7 +147,7 @@ def test_recaptcha(page):
timeout=30000, timeout=30000,
) )
# Page auto-submits via grecaptcha.execute() — wait for backend response # Page auto-submits via grecaptcha.execute() — wait for backend response
page.wait_for_timeout(8000) time.sleep(8)
results = page.evaluate("""() => { results = page.evaluate("""() => {
const text = document.body.innerText; const text = document.body.innerText;
+219
View File
@@ -0,0 +1,219 @@
<p align="center">
<img src="https://i.imgur.com/cqkp6fG.png" width="500" alt="CloakBrowser">
</p>
# CloakBrowser
[![npm](https://img.shields.io/npm/v/cloakbrowser)](https://www.npmjs.com/package/cloakbrowser)
[![License](https://img.shields.io/github/license/CloakHQ/CloakBrowser)](https://github.com/CloakHQ/CloakBrowser/blob/main/LICENSE)
**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.
- 🔒 **26 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
## Install
```bash
# With Playwright
npm install cloakbrowser playwright-core
# With Puppeteer
npm install cloakbrowser puppeteer-core
```
On first launch, the stealth Chromium binary auto-downloads (~200MB, cached at `~/.cloakbrowser/`).
## Usage
### Playwright (default)
```javascript
import { launch } from 'cloakbrowser';
const browser = await launch();
const page = await browser.newPage();
await page.goto('https://protected-site.com');
console.log(await page.title());
await browser.close();
```
### Puppeteer
> **Note:** Playwright is recommended for sites with reCAPTCHA Enterprise. Puppeteer's CDP protocol leaks automation signals that reCAPTCHA Enterprise can detect. This is a known Puppeteer limitation, not specific to CloakBrowser.
```javascript
import { launch } from 'cloakbrowser/puppeteer';
const browser = await launch();
const page = await browser.newPage();
await page.goto('https://protected-site.com');
console.log(await page.title());
await browser.close();
```
### Options
```javascript
import { launch, launchContext } from 'cloakbrowser';
// With proxy
const browser = await launch({
proxy: 'http://user:pass@proxy:8080',
});
// Headed mode (visible browser window)
const browser = await launch({ headless: false });
// Extra Chrome args
const browser = await launch({
args: ['--window-size=1920,1080'],
});
// With timezone and locale (sets --timezone and --lang binary flags)
const browser = await launch({
timezone: 'America/New_York',
locale: 'en-US',
});
// Auto-detect timezone/locale from proxy IP (requires: npm install mmdb-lib)
const browser = await launch({
proxy: 'http://proxy:8080',
geoip: true,
});
// Browser + context in one call (timezone/locale set both binary flags AND context)
const context = await launchContext({
userAgent: 'Custom UA',
viewport: { width: 1920, height: 1080 },
locale: 'en-US',
timezoneId: 'America/New_York',
});
```
### Auto Timezone/Locale from Proxy IP
When using a proxy, antibot systems check that your browser's timezone and locale match the proxy's location. Install `mmdb-lib` to enable auto-detection from an offline GeoIP database (~70 MB, downloaded on first use):
```bash
npm install mmdb-lib
```
```javascript
// Auto-detect — timezone and locale set from proxy's IP geolocation
const browser = await launch({ proxy: 'http://proxy:8080', geoip: true });
// Works with launchContext too
const context = await launchContext({ proxy: 'http://proxy:8080', geoip: true });
// Explicit values always win over auto-detection
const browser = await launch({ proxy: 'http://proxy:8080', geoip: true, timezone: 'Europe/London' });
```
> **Note:** For rotating residential proxies, the DNS-resolved IP may differ from the exit IP. Pass explicit `timezone`/`locale` in those cases.
### Utilities
```javascript
import { ensureBinary, clearCache, binaryInfo, checkForUpdate } from 'cloakbrowser';
// Pre-download binary (e.g., during Docker build)
await ensureBinary();
// Check installation
console.log(binaryInfo());
// Force re-download
clearCache();
// Manually check for newer Chromium version
const newVersion = await checkForUpdate();
if (newVersion) console.log(`Updated to ${newVersion}`);
```
## Test Results
| Detection Service | Stock Browser | CloakBrowser |
|---|---|---|
| **reCAPTCHA v3** | 0.1 (bot) | **0.9** (human) |
| **Cloudflare Turnstile** | FAIL | **PASS** |
| **FingerprintJS** | DETECTED | **PASS** |
| **BrowserScan** | DETECTED | **NORMAL** (4/4) |
| **bot.incolumitas.com** | 13 fails | **1 fail** |
| `navigator.webdriver` | `true` | **`false`** |
## Configuration
| Env Variable | Default | Description |
|---|---|---|
| `CLOAKBROWSER_BINARY_PATH` | — | Skip download, use a local Chromium binary |
| `CLOAKBROWSER_CACHE_DIR` | `~/.cloakbrowser` | Binary cache directory |
| `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL |
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
## Migrate From Playwright
```diff
- import { chromium } from 'playwright';
- const browser = await chromium.launch();
+ import { launch } from 'cloakbrowser';
+ const browser = await launch();
const page = await browser.newPage();
// ... rest of your code works unchanged
```
## 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`.
## Requirements
- Node.js >= 18
- One of: `playwright-core` >= 1.40 or `puppeteer-core` >= 21
## Troubleshooting
**reCAPTCHA v3 scores are low (0.10.3)**
Avoid `page.waitForTimeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
```javascript
// Bad — sends CDP commands, reCAPTCHA detects this
await page.waitForTimeout(3000);
// Good — invisible to the browser
await new Promise(r => setTimeout(r, 3000));
```
Other tips for maximizing reCAPTCHA scores:
- **Use Playwright, not Puppeteer** — Puppeteer sends more CDP protocol traffic that reCAPTCHA detects ([details](#puppeteer))
- **Use residential proxies** — datacenter IPs are flagged by IP reputation, not browser fingerprint
- **Spend 15+ seconds on the page** before triggering reCAPTCHA — short visits score lower
- **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
- **Minimize `page.evaluate()` calls** before the reCAPTCHA check fires — each one sends CDP traffic
## Links
- 🌐 [Website](https://cloakbrowser.dev)
- 🐛 [Bug reports & feature requests](https://github.com/CloakHQ/CloakBrowser/issues)
- 📦 [PyPI (Python package)](https://pypi.org/project/cloakbrowser/)
- 📖 [Full documentation](https://github.com/CloakHQ/CloakBrowser#readme)
- 📧 Contact: cloakhq@pm.me
## License
MIT — see [LICENSE](https://github.com/CloakHQ/CloakBrowser/blob/main/LICENSE).
+18
View File
@@ -0,0 +1,18 @@
/**
* Basic CloakBrowser example using Playwright API.
*
* Usage:
* CLOAKBROWSER_BINARY_PATH=/path/to/chrome npx tsx examples/basic-playwright.ts
*/
import { launch } from "../src/index.js";
const browser = await launch({ headless: true });
const page = await browser.newPage();
await page.goto("https://example.com");
console.log(`Title: ${await page.title()}`);
console.log(`URL: ${page.url()}`);
await browser.close();
console.log("Done.");
+18
View File
@@ -0,0 +1,18 @@
/**
* Basic CloakBrowser example using Puppeteer API.
*
* Usage:
* CLOAKBROWSER_BINARY_PATH=/path/to/chrome npx tsx examples/basic-puppeteer.ts
*/
import { launch } from "../src/puppeteer.js";
const browser = await launch({ headless: true });
const page = await browser.newPage();
await page.goto("https://example.com");
console.log(`Title: ${await page.title()}`);
console.log(`URL: ${page.url()}`);
await browser.close();
console.log("Done.");
+280
View File
@@ -0,0 +1,280 @@
/**
* Full stealth test suite — validates CloakBrowser against live detection services.
* Mirrors Python examples/stealth_test.py.
*
* Usage:
* CLOAKBROWSER_BINARY_PATH=/path/to/chrome npx tsx examples/stealth-test.ts
* CLOAKBROWSER_BINARY_PATH=/path/to/chrome npx tsx examples/stealth-test.ts --proxy http://10.50.96.5:8888
*/
import { launch } from "../src/index.js";
const PROXY = process.argv.includes("--proxy")
? process.argv[process.argv.indexOf("--proxy") + 1]
: undefined;
interface TestResult {
name: string;
status: "PASS" | "FAIL" | "ERROR";
verdict: string;
}
const results: TestResult[] = [];
console.log("=".repeat(60));
console.log("CloakBrowser JS — Stealth Test Suite");
console.log("=".repeat(60));
console.log(`Proxy: ${PROXY || "none"}\n`);
const browser = await launch({ headless: true, proxy: PROXY });
const page = await browser.newPage();
// ---------------------------------------------------------------------------
// Test 1: bot.sannysoft.com
// ---------------------------------------------------------------------------
async function testSannysoft() {
console.log("--- bot.sannysoft.com ---");
await page.goto("https://bot.sannysoft.com", {
waitUntil: "networkidle",
timeout: 30000,
});
await page.waitForTimeout(3000);
const result = await page.evaluate(() => {
const rows = document.querySelectorAll("table tr");
let passed = 0;
let total = 0;
const failed: string[] = [];
rows.forEach((r) => {
const cells = r.querySelectorAll("td");
if (cells.length >= 2) {
total++;
const key = cells[0]!.innerText.trim();
const cls = cells[1]!.className || "";
if (cls.includes("failed")) {
failed.push(key);
} else {
passed++;
}
}
});
return { passed, total, failed };
});
const verdict =
result.failed.length === 0
? `${result.passed}/${result.total} — ALL GREEN`
: `${result.passed}/${result.total} (FAILED: ${result.failed.join(", ")})`;
const status = result.failed.length === 0 ? "PASS" : "FAIL";
console.log(`Result: [${status}] ${verdict}\n`);
results.push({ name: "bot.sannysoft.com", status, verdict });
}
// ---------------------------------------------------------------------------
// Test 2: bot.incolumitas.com
// ---------------------------------------------------------------------------
async function testIncolumitas() {
console.log("--- bot.incolumitas.com ---");
await page.goto("https://bot.incolumitas.com", {
waitUntil: "networkidle",
timeout: 30000,
});
await page.waitForTimeout(12000); // needs time for all detection tests
const result = await 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) => {
const match = m.match(/"(\w+)"/);
return match ? match[1] : m;
});
return {
passed: okMatches.length,
failed: failMatches.length,
failedTests,
total: okMatches.length + failMatches.length,
};
});
const verdict =
result.failed === 0
? `${result.passed}/${result.total} — ALL GREEN`
: `${result.passed}/${result.total} (FAILED: ${result.failedTests.join(", ")})`;
// WEBDRIVER false positive is expected
const status = result.failed <= 1 ? "PASS" : "FAIL";
console.log(`Result: [${status}] ${verdict}\n`);
results.push({ name: "bot.incolumitas.com", status, verdict });
}
// ---------------------------------------------------------------------------
// Test 3: BrowserScan
// ---------------------------------------------------------------------------
async function testBrowserScan() {
console.log("--- BrowserScan ---");
await page.goto("https://www.browserscan.net/bot-detection", {
waitUntil: "networkidle",
timeout: 30000,
});
await page.waitForTimeout(5000);
const result = await page.evaluate(() => {
const text = document.body.innerText;
const normalMatches = text.match(/Normal/g);
const abnormalMatches = text.match(/Abnormal/g);
return {
normal: normalMatches ? normalMatches.length : 0,
abnormal: abnormalMatches ? abnormalMatches.length : 0,
};
});
const verdict = `Normal: ${result.normal}, Abnormal: ${result.abnormal}`;
const status = result.abnormal === 0 ? "PASS" : "FAIL";
console.log(`Result: [${status}] ${verdict}\n`);
results.push({ name: "BrowserScan", status, verdict });
}
// ---------------------------------------------------------------------------
// Test 4: deviceandbrowserinfo.com
// ---------------------------------------------------------------------------
async function testDeviceAndBrowserInfo() {
console.log("--- deviceandbrowserinfo.com ---");
await page.goto("https://deviceandbrowserinfo.com/are_you_a_bot", {
waitUntil: "domcontentloaded",
timeout: 30000,
});
await page.waitForTimeout(8000);
const result = await page.evaluate(() => {
const text = document.body.innerText;
const botMatch = text.match(/"isBot":\s*(true|false)/);
const isBot = botMatch ? botMatch[1] === "true" : null;
const checks: Record<string, boolean> = {};
const patterns = [
"isBot",
"hasBotUserAgent",
"hasWebdriverTrue",
"isHeadlessChrome",
"isAutomatedWithCDP",
"hasSuspiciousWeakSignals",
"isPlaywright",
"hasInconsistentChromeObject",
];
patterns.forEach((p) => {
const match = text.match(new RegExp('"' + p + '":\\s*(true|false)'));
if (match) checks[p] = match[1] === "true";
});
return { isBot, checks };
});
const trueFlags = Object.entries(result.checks)
.filter(([, v]) => v)
.map(([k]) => k);
const verdict =
`isBot: ${result.isBot}` +
(trueFlags.length > 0 ? ` (flagged: ${trueFlags.join(", ")})` : " — all clear");
const status = !result.isBot ? "PASS" : "FAIL";
console.log(`Result: [${status}] ${verdict}\n`);
results.push({ name: "deviceandbrowserinfo.com", status, verdict });
}
// ---------------------------------------------------------------------------
// Test 5: FingerprintJS
// ---------------------------------------------------------------------------
async function testFingerprintJS() {
console.log("--- FingerprintJS ---");
await page.goto("https://demo.fingerprint.com/web-scraping", {
waitUntil: "networkidle",
timeout: 30000,
});
await page.waitForTimeout(5000);
try {
await page.click("button:has-text('Search')", { timeout: 5000 });
await page.waitForTimeout(5000);
} catch {
// Search button may not be present
}
const result = await page.evaluate(() => {
const text = document.body.innerText;
const hasFlights =
text.includes("Price per adult") || text.includes("$");
const isBlocked =
text.includes("request was blocked") ||
text.includes("bot visit detected");
return { passed: hasFlights && !isBlocked, isBlocked, hasFlights };
});
const verdict = result.passed
? "PASSED (flights shown)"
: result.isBlocked
? "BLOCKED"
: "NO FLIGHTS";
const status = result.passed ? "PASS" : "FAIL";
console.log(`Result: [${status}] ${verdict}\n`);
results.push({ name: "FingerprintJS", status, verdict });
}
// ---------------------------------------------------------------------------
// Test 6: reCAPTCHA v3
// ---------------------------------------------------------------------------
async function testRecaptcha() {
console.log("--- reCAPTCHA v3 (Google) ---");
await page.goto(
"https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php",
{ waitUntil: "networkidle", timeout: 30000 }
);
await page.waitForTimeout(8000);
const result = await page.evaluate(() => {
const text = document.body.innerText;
const scoreMatch = text.match(/"score":\s*(\d+\.\d+)/);
return {
score: scoreMatch ? parseFloat(scoreMatch[1]) : null,
};
});
const verdict = `Score: ${result.score ?? "N/A"}`;
const status = (result.score ?? 0) >= 0.7 ? "PASS" : "FAIL";
console.log(`Result: [${status}] ${verdict}\n`);
results.push({ name: "reCAPTCHA v3", status, verdict });
}
// ---------------------------------------------------------------------------
// Run all tests
// ---------------------------------------------------------------------------
const tests = [
testSannysoft,
testIncolumitas,
testBrowserScan,
testDeviceAndBrowserInfo,
testFingerprintJS,
testRecaptcha,
];
for (const test of tests) {
try {
await test();
} catch (err) {
const name = test.name.replace("test", "");
console.log(`Error: ${err}\n`);
results.push({ name, status: "ERROR", verdict: String(err) });
}
}
await browser.close();
// Summary
console.log("=".repeat(60));
console.log("RESULTS SUMMARY");
console.log("=".repeat(60));
for (const r of results) {
const icon = { PASS: "+", FAIL: "!", ERROR: "x" }[r.status];
console.log(` [${icon}] ${r.name}: ${r.verdict}`);
}
const passedCount = results.filter((r) => r.status === "PASS").length;
console.log(`\n ${passedCount}/${results.length} tests passed`);
console.log("=".repeat(60));
process.exit(passedCount === results.length ? 0 : 1);
+2940
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
{
"name": "cloakbrowser",
"version": "0.3.0",
"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",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./puppeteer": {
"types": "./dist/puppeteer.d.ts",
"import": "./dist/puppeteer.js"
}
},
"files": [
"dist"
],
"keywords": [
"stealth",
"browser",
"chromium",
"playwright",
"puppeteer",
"scraping",
"anti-detect",
"bot-detection",
"fingerprint",
"recaptcha",
"cloudflare",
"datadome"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/CloakHQ/cloakbrowser",
"directory": "js"
},
"homepage": "https://github.com/CloakHQ/cloakbrowser#javascript--nodejs",
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"mmdb-lib": ">=2.0.0",
"playwright-core": ">=1.40.0",
"puppeteer-core": ">=21.0.0"
},
"peerDependenciesMeta": {
"playwright-core": {
"optional": true
},
"puppeteer-core": {
"optional": true
},
"mmdb-lib": {
"optional": true
}
},
"dependencies": {
"tar": "^7.0.0"
},
"devDependencies": {
"@types/node": "^20.10.0",
"mmdb-lib": "^3.0.2",
"playwright-core": "^1.40.0",
"puppeteer-core": "^21.0.0",
"typescript": "^5.3.0",
"vitest": "^1.0.0"
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run"
}
}
+184
View File
@@ -0,0 +1,184 @@
/**
* Stealth configuration and platform detection for cloakbrowser.
* Mirrors Python cloakbrowser/config.py.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// ---------------------------------------------------------------------------
// Chromium version shipped with this release
// ---------------------------------------------------------------------------
export const CHROMIUM_VERSION = "145.0.7632.109";
// ---------------------------------------------------------------------------
// Platform detection
// ---------------------------------------------------------------------------
const SUPPORTED_PLATFORMS: Record<string, string> = {
"linux-x64": "linux-x64",
"linux-arm64": "linux-arm64",
"darwin-arm64": "darwin-arm64",
"darwin-x64": "darwin-x64",
};
// Platforms with pre-built binaries available for download.
// Update this set as new platform builds are released.
const AVAILABLE_PLATFORMS = new Set(["linux-x64", "darwin-arm64", "darwin-x64"]);
export function getPlatformTag(): string {
const platform = process.platform;
const arch = process.arch;
// Map Node.js platform/arch to our tag format
let key: string;
if (platform === "linux" && arch === "x64") key = "linux-x64";
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 {
const supported = Object.values(SUPPORTED_PLATFORMS).join(", ");
throw new Error(
`Unsupported platform: ${platform} ${arch}. Supported: ${supported}`
);
}
return SUPPORTED_PLATFORMS[key]!;
}
// ---------------------------------------------------------------------------
// Binary cache paths
// ---------------------------------------------------------------------------
export function getCacheDir(): string {
const custom = process.env.CLOAKBROWSER_CACHE_DIR;
if (custom) return custom;
return path.join(os.homedir(), ".cloakbrowser");
}
export function getBinaryDir(version?: string): string {
return path.join(getCacheDir(), `chromium-${version || CHROMIUM_VERSION}`);
}
export function getBinaryPath(version?: string): string {
const binaryDir = getBinaryDir(version);
if (process.platform === "darwin") {
return path.join(binaryDir, "Chromium.app", "Contents", "MacOS", "Chromium");
}
return path.join(binaryDir, "chrome");
}
export function checkPlatformAvailable(): void {
if (getLocalBinaryOverride()) return;
const tag = getPlatformTag(); // throws if unsupported entirely
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.`
);
}
}
// ---------------------------------------------------------------------------
// Download URL
// ---------------------------------------------------------------------------
export const DOWNLOAD_BASE_URL =
process.env.CLOAKBROWSER_DOWNLOAD_URL ||
"https://cloakbrowser.dev";
export const GITHUB_API_URL =
"https://api.github.com/repos/CloakHQ/cloakbrowser/releases";
export const GITHUB_DOWNLOAD_BASE_URL =
"https://github.com/CloakHQ/cloakbrowser/releases/download";
export function getDownloadUrl(version?: string): string {
const v = version || CHROMIUM_VERSION;
const tag = getPlatformTag();
return `${DOWNLOAD_BASE_URL}/chromium-v${v}/cloakbrowser-${tag}.tar.gz`;
}
export function getFallbackDownloadUrl(version?: string): string {
const v = version || CHROMIUM_VERSION;
const tag = getPlatformTag();
return `${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/cloakbrowser-${tag}.tar.gz`;
}
export function getEffectiveVersion(): string {
const marker = path.join(getCacheDir(), "latest_version");
try {
if (fs.existsSync(marker)) {
const version = fs.readFileSync(marker, "utf-8").trim();
if (version && versionNewer(version, CHROMIUM_VERSION)) {
const binary = getBinaryPath(version);
if (fs.existsSync(binary)) {
return version;
}
}
}
} catch {
// Marker unreadable — fall back to hardcoded
}
return CHROMIUM_VERSION;
}
export function parseVersion(v: string): number[] {
return v.split(".").map(Number);
}
export function versionNewer(a: string, b: string): boolean {
const va = parseVersion(a);
const vb = parseVersion(b);
for (let i = 0; i < Math.max(va.length, vb.length); i++) {
if ((va[i] ?? 0) > (vb[i] ?? 0)) return true;
if ((va[i] ?? 0) < (vb[i] ?? 0)) return false;
}
return false;
}
// ---------------------------------------------------------------------------
// Local binary override
// ---------------------------------------------------------------------------
export function getLocalBinaryOverride(): string | undefined {
return process.env.CLOAKBROWSER_BINARY_PATH || undefined;
}
// ---------------------------------------------------------------------------
// Default stealth arguments
// ---------------------------------------------------------------------------
// Default viewport — realistic maximized Chrome on 1080p Windows
// screen=1920x1080, availHeight=1040 (minus 40px taskbar),
// innerHeight=955 (minus ~85px Chrome UI: tabs + address bar + bookmarks)
export const DEFAULT_VIEWPORT = { width: 1920, height: 955 };
export function getDefaultStealthArgs(): string[] {
const seed = Math.floor(Math.random() * 90000) + 10000; // 10000-99999
const isMac = process.platform === "darwin";
const base = [
"--no-sandbox",
"--disable-blink-features=AutomationControlled",
`--fingerprint=${seed}`,
];
if (isMac) {
// macOS: run as native Mac browser — GPU/UA match natively
return [...base, "--fingerprint-platform=macos"];
}
// 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-taskbar-height=40",
"--fingerprint-screen-width=1920",
"--fingerprint-screen-height=1080",
"--window-size=1920,1080",
];
}
+509
View File
@@ -0,0 +1,509 @@
/**
* Binary download and cache management for cloakbrowser.
* Downloads the patched Chromium binary on first use, caches it locally.
* Mirrors Python cloakbrowser/download.py.
*/
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import { createWriteStream } from "node:fs";
import { extract as tarExtract } from "tar";
import type { BinaryInfo } from "./types.js";
import {
CHROMIUM_VERSION,
DOWNLOAD_BASE_URL,
GITHUB_API_URL,
GITHUB_DOWNLOAD_BASE_URL,
checkPlatformAvailable,
getBinaryDir,
getBinaryPath,
getCacheDir,
getDownloadUrl,
getEffectiveVersion,
getFallbackDownloadUrl,
getLocalBinaryOverride,
getPlatformTag,
versionNewer,
} from "./config.js";
const DOWNLOAD_TIMEOUT_MS = 600_000; // 10 minutes
const UPDATE_CHECK_INTERVAL_MS = 3_600_000; // 1 hour
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Ensure the stealth Chromium binary is available. Download if needed.
* Returns the path to the chrome executable.
*/
export async function ensureBinary(): Promise<string> {
// Check for local override
const localOverride = getLocalBinaryOverride();
if (localOverride) {
if (!fs.existsSync(localOverride)) {
throw new Error(
`CLOAKBROWSER_BINARY_PATH set to '${localOverride}' but file does not exist`
);
}
console.log(`[cloakbrowser] Using local binary override: ${localOverride}`);
return localOverride;
}
// Fail fast if no binary available for this platform
checkPlatformAvailable();
// Check for auto-updated version first, then fall back to hardcoded
const effective = getEffectiveVersion();
const binaryPath = getBinaryPath(effective);
if (fs.existsSync(binaryPath) && isExecutable(binaryPath)) {
maybeTriggerUpdateCheck();
return binaryPath;
}
// Fall back to hardcoded version if effective version binary doesn't exist
if (effective !== CHROMIUM_VERSION) {
const fallbackPath = getBinaryPath();
if (fs.existsSync(fallbackPath) && isExecutable(fallbackPath)) {
maybeTriggerUpdateCheck();
return fallbackPath;
}
}
// Download hardcoded version
console.log(
`[cloakbrowser] Stealth Chromium ${CHROMIUM_VERSION} not found. Downloading for ${getPlatformTag()}...`
);
await downloadAndExtract();
const downloadedPath = getBinaryPath();
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`
);
}
maybeTriggerUpdateCheck();
return downloadedPath;
}
/** Remove all cached binaries. Forces re-download on next launch. */
export function clearCache(): void {
const cacheDir = getCacheDir();
if (fs.existsSync(cacheDir)) {
fs.rmSync(cacheDir, { recursive: true, force: true });
console.log(`[cloakbrowser] Cache cleared: ${cacheDir}`);
}
}
/** Return info about the current binary installation. */
export function binaryInfo(): BinaryInfo {
const effective = getEffectiveVersion();
const binaryPath = getBinaryPath(effective);
return {
version: effective,
platform: getPlatformTag(),
binaryPath,
installed: fs.existsSync(binaryPath),
cacheDir: getBinaryDir(effective),
downloadUrl: getDownloadUrl(effective),
};
}
/** Manually check for a newer Chromium version. Returns new version or null. */
export async function checkForUpdate(): Promise<string | null> {
const latest = await getLatestChromiumVersion();
if (!latest || !versionNewer(latest, CHROMIUM_VERSION)) return null;
const binaryDir = getBinaryDir(latest);
if (fs.existsSync(binaryDir)) {
writeVersionMarker(latest);
return latest;
}
console.log(`[cloakbrowser] Downloading Chromium ${latest}...`);
await downloadAndExtract(latest);
writeVersionMarker(latest);
return latest;
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
async function downloadAndExtract(version?: string): Promise<void> {
const primaryUrl = getDownloadUrl(version);
const fallbackUrl = getFallbackDownloadUrl(version);
const binaryDir = getBinaryDir(version);
const binaryPath = getBinaryPath(version);
// Create cache dir
fs.mkdirSync(path.dirname(binaryDir), { recursive: true });
// Download to temp file (atomic — no partial downloads in cache)
const tmpPath = path.join(
path.dirname(binaryDir),
`_download_${Date.now()}.tar.gz`
);
try {
// Try primary server, fall back to GitHub Releases (skip fallback if custom URL)
try {
await downloadFile(primaryUrl, tmpPath);
} catch (primaryErr) {
if (process.env.CLOAKBROWSER_DOWNLOAD_URL) {
throw primaryErr;
}
console.warn(
`[cloakbrowser] Primary download failed (${primaryErr instanceof Error ? primaryErr.message : primaryErr}), trying GitHub Releases...`
);
await downloadFile(fallbackUrl, tmpPath);
}
// Verify checksum before extraction
if (process.env.CLOAKBROWSER_SKIP_CHECKSUM?.toLowerCase() !== "true") {
await verifyDownloadChecksum(tmpPath, version);
}
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`
);
} finally {
// Clean up temp file
if (fs.existsSync(tmpPath)) {
fs.unlinkSync(tmpPath);
}
}
}
async function verifyDownloadChecksum(filePath: string, version?: string): Promise<void> {
const checksums = await fetchChecksums(version);
const tarballName = `cloakbrowser-${getPlatformTag()}.tar.gz`;
if (!checksums) {
console.warn("[cloakbrowser] SHA256SUMS not available for this release — skipping checksum verification");
return;
}
const expected = checksums.get(tarballName);
if (!expected) {
console.warn(`[cloakbrowser] SHA256SUMS found but no entry for ${tarballName} — skipping verification`);
return;
}
await verifyChecksum(filePath, expected);
}
async function fetchChecksums(version?: string): Promise<Map<string, string> | null> {
const v = version || CHROMIUM_VERSION;
const hasCustomUrl = !!process.env.CLOAKBROWSER_DOWNLOAD_URL;
// Respect custom URL contract — no GitHub fallback when custom URL is set
const urls = [`${DOWNLOAD_BASE_URL}/chromium-v${v}/SHA256SUMS`];
if (!hasCustomUrl) {
urls.push(`${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/SHA256SUMS`);
}
for (const url of urls) {
try {
const resp = await fetch(url, {
redirect: "follow",
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) continue;
return parseChecksums(await resp.text());
} catch {
continue;
}
}
return null;
}
function parseChecksums(text: string): Map<string, string> {
const result = new Map<string, string>();
for (const line of text.trim().split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = trimmed.match(/^([a-f0-9]{64})\s+\*?(.+)$/);
if (match) {
result.set(match[2]!, match[1]!.toLowerCase());
}
}
return result;
}
async function verifyChecksum(filePath: string, expectedHash: string): Promise<void> {
const hash = createHash("sha256");
const stream = fs.createReadStream(filePath);
for await (const chunk of stream) {
hash.update(chunk);
}
const actual = hash.digest("hex").toLowerCase();
if (actual !== expectedHash) {
throw new Error(
`Checksum verification failed!\n` +
` Expected: ${expectedHash}\n` +
` Got: ${actual}\n` +
` File may be corrupted or tampered with. ` +
`Please retry or report at https://github.com/CloakHQ/cloakbrowser/issues`
);
}
console.log("[cloakbrowser] Checksum verified: SHA-256 OK");
}
async function downloadFile(url: string, dest: string): Promise<void> {
console.log(`[cloakbrowser] Downloading from ${url}`);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
try {
const response = await fetch(url, {
signal: controller.signal,
redirect: "follow",
});
if (!response.ok) {
throw new Error(`Download failed: HTTP ${response.status} ${response.statusText}`);
}
if (!response.body) {
throw new Error("Download failed: empty response body");
}
const total = Number(response.headers.get("content-length") || 0);
let downloaded = 0;
let lastLoggedPct = -1;
const fileStream = createWriteStream(dest);
const reader = response.body.getReader();
// Stream chunks to file with progress logging
while (true) {
const { done, value } = await reader.read();
if (done) break;
fileStream.write(value);
downloaded += value.length;
if (total > 0) {
const pct = Math.floor((downloaded / total) * 100);
if (pct >= lastLoggedPct + 10) {
lastLoggedPct = pct;
const dlMB = Math.floor(downloaded / (1024 * 1024));
const totalMB = Math.floor(total / (1024 * 1024));
console.log(
`[cloakbrowser] Download progress: ${pct}% (${dlMB}/${totalMB} MB)`
);
}
}
}
// Wait for file stream to finish
await new Promise<void>((resolve, reject) => {
fileStream.end(() => resolve());
fileStream.on("error", reject);
});
const sizeMB = Math.floor(fs.statSync(dest).size / (1024 * 1024));
console.log(`[cloakbrowser] Download complete: ${sizeMB} MB`);
} finally {
clearTimeout(timeout);
}
}
async function extractArchive(
archivePath: string,
destDir: string,
binaryPath?: string
): Promise<void> {
console.log(`[cloakbrowser] Extracting to ${destDir}`);
// Clean existing dir if partial download existed
if (fs.existsSync(destDir)) {
fs.rmSync(destDir, { recursive: true, force: true });
}
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;
},
});
// Flatten single subdirectory if needed
flattenSingleSubdir(destDir);
// Make binary executable
const bp = binaryPath || getBinaryPath();
if (fs.existsSync(bp)) {
fs.chmodSync(bp, 0o755);
}
// macOS: remove quarantine/provenance xattrs to prevent Gatekeeper prompts
if (process.platform === "darwin") {
removeQuarantine(destDir);
}
if (fs.existsSync(bp)) {
console.log(`[cloakbrowser] Binary ready: ${bp}`);
}
}
/**
* If extraction created a single subdirectory, move its contents up.
* Many tarballs wrap files in a top-level directory.
*/
function flattenSingleSubdir(destDir: string): void {
const entries = fs.readdirSync(destDir);
if (entries.length === 1) {
const subdir = path.join(destDir, entries[0]!);
// Never flatten .app bundles — macOS needs the bundle structure
if (entries[0]!.endsWith(".app")) return;
if (fs.statSync(subdir).isDirectory()) {
const children = fs.readdirSync(subdir);
for (const child of children) {
fs.renameSync(
path.join(subdir, child),
path.join(destDir, child)
);
}
fs.rmdirSync(subdir);
}
}
}
/** Remove macOS quarantine/provenance xattrs so Gatekeeper doesn't block the binary. */
function removeQuarantine(dirPath: string): void {
try {
execFileSync("xattr", ["-cr", dirPath], { timeout: 30_000 });
} catch {
// Non-fatal — user can manually run: xattr -cr ~/.cloakbrowser/
}
}
function isExecutable(filePath: string): boolean {
try {
fs.accessSync(filePath, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Auto-update
// ---------------------------------------------------------------------------
function shouldCheckForUpdate(): boolean {
if (process.env.CLOAKBROWSER_AUTO_UPDATE?.toLowerCase() === "false")
return false;
if (getLocalBinaryOverride()) return false;
if (process.env.CLOAKBROWSER_DOWNLOAD_URL) return false;
const checkFile = path.join(getCacheDir(), ".last_update_check");
try {
const lastCheck = Number(fs.readFileSync(checkFile, "utf-8").trim());
if (Date.now() - lastCheck < UPDATE_CHECK_INTERVAL_MS) return false;
} catch {
/* file doesn't exist or unreadable */
}
return true;
}
async function getLatestChromiumVersion(): Promise<string | null> {
try {
const resp = await fetch(`${GITHUB_API_URL}?per_page=10`, {
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) return null;
const releases = (await resp.json()) as Array<{
tag_name: string;
draft: boolean;
}>;
for (const release of releases) {
if (release.tag_name.startsWith("chromium-v") && !release.draft) {
return release.tag_name.replace("chromium-v", "");
}
}
return null;
} catch {
return null;
}
}
function writeVersionMarker(version: string): void {
const cacheDir = getCacheDir();
fs.mkdirSync(cacheDir, { recursive: true });
const marker = path.join(cacheDir, "latest_version");
const tmp = `${marker}.tmp`;
fs.writeFileSync(tmp, version);
fs.renameSync(tmp, marker);
}
async function checkAndDownloadUpdate(): Promise<void> {
try {
// Record check timestamp first (rate limiting)
const cacheDir = getCacheDir();
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(
path.join(cacheDir, ".last_update_check"),
String(Date.now())
);
const latest = await getLatestChromiumVersion();
if (!latest || !versionNewer(latest, CHROMIUM_VERSION)) return;
// Already downloaded?
if (fs.existsSync(getBinaryDir(latest))) {
writeVersionMarker(latest);
return;
}
console.log(
`[cloakbrowser] Newer Chromium available: ${latest} (current: ${CHROMIUM_VERSION}). Downloading in background...`
);
await downloadAndExtract(latest);
writeVersionMarker(latest);
console.log(
`[cloakbrowser] Background update complete: Chromium ${latest} ready. Will use on next launch.`
);
} catch (err) {
// Background update failed — don't disrupt the user
if (process.env.DEBUG) {
console.error("[cloakbrowser] Background update failed:", err);
}
}
}
function maybeTriggerUpdateCheck(): void {
if (!shouldCheckForUpdate()) return;
// Fire-and-forget — don't await
checkAndDownloadUpdate().catch(() => {});
}
+262
View File
@@ -0,0 +1,262 @@
/**
* GeoIP-based timezone and locale detection from proxy IP.
*
* Optional feature — requires `mmdb-lib` package:
* npm install mmdb-lib
*
* Downloads GeoLite2-City.mmdb (~70 MB) on first use,
* caches in `~/.cloakbrowser/geoip/`.
*/
import fs from "node:fs";
import path from "node:path";
import { createWriteStream } from "node:fs";
import dns from "node:dns/promises";
import net from "node:net";
import { getCacheDir } from "./config.js";
// P3TERX mirror of MaxMind GeoLite2-City — no license key needed
const GEOIP_DB_URL =
"https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb";
const GEOIP_DB_FILENAME = "GeoLite2-City.mmdb";
const GEOIP_UPDATE_INTERVAL_MS = 30 * 86_400_000; // 30 days
/** Country ISO code → BCP 47 locale (covers ~90% of proxy traffic). */
export const COUNTRY_LOCALE_MAP: Record<string, string> = {
US: "en-US", GB: "en-GB", AU: "en-AU", CA: "en-CA", NZ: "en-NZ",
IE: "en-IE", ZA: "en-ZA", SG: "en-SG",
DE: "de-DE", AT: "de-AT", CH: "de-CH",
FR: "fr-FR", BE: "fr-BE",
ES: "es-ES", MX: "es-MX", AR: "es-AR", CO: "es-CO", CL: "es-CL",
BR: "pt-BR", PT: "pt-PT",
IT: "it-IT", NL: "nl-NL",
JP: "ja-JP", KR: "ko-KR", CN: "zh-CN", TW: "zh-TW", HK: "zh-HK",
RU: "ru-RU", UA: "uk-UA", PL: "pl-PL", CZ: "cs-CZ", RO: "ro-RO",
IL: "he-IL", TR: "tr-TR", SA: "ar-SA", AE: "ar-AE", EG: "ar-EG",
IN: "hi-IN", ID: "id-ID", PH: "en-PH",
TH: "th-TH", VN: "vi-VN", MY: "ms-MY",
SE: "sv-SE", NO: "nb-NO", DK: "da-DK", FI: "fi-FI",
GR: "el-GR", HU: "hu-HU", BG: "bg-BG",
};
export interface GeoResult {
timezone: string | null;
locale: string | null;
}
/**
* Resolve timezone and locale from a proxy's IP address.
* Returns `{ timezone, locale }` — either may be null on failure.
* Never throws.
*/
export async function resolveProxyGeo(
proxyUrl: string
): Promise<GeoResult> {
let Reader: any;
try {
const mmdb = await import("mmdb-lib");
Reader = mmdb.default?.Reader ?? mmdb.Reader;
} catch {
throw new Error(
"mmdb-lib is required for geoip: true. Install it with:\n npm install mmdb-lib"
);
}
const dbPath = await ensureGeoipDb();
if (!dbPath) return { timezone: null, locale: 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 };
try {
const buf = fs.readFileSync(dbPath);
const reader = new Reader(buf);
const result = reader.get(ip) as any;
const timezone: string | null = result?.location?.time_zone ?? null;
const countryCode: string | null = result?.country?.iso_code ?? null;
const locale =
countryCode ? (COUNTRY_LOCALE_MAP[countryCode] ?? null) : null;
return { timezone, locale };
} catch {
return { timezone: null, locale: null };
}
}
// ---------------------------------------------------------------------------
// Proxy IP resolution
// ---------------------------------------------------------------------------
/** @internal Exported for testing. */
export async function resolveProxyIp(
proxyUrl: string
): Promise<string | null> {
try {
const url = new URL(proxyUrl);
const hostname = url.hostname;
if (!hostname) return null;
// Already a literal IP?
if (net.isIP(hostname)) return hostname;
// DNS resolve
const { address } = await dns.lookup(hostname);
return address;
} catch {
return null;
}
}
function isPrivateIp(ip: string): boolean {
// Quick check for common private ranges
if (ip.startsWith("10.") || ip.startsWith("127.") || ip === "::1") return true;
if (ip.startsWith("172.")) {
const second = parseInt(ip.split(".")[1], 10);
if (second >= 16 && second <= 31) return true;
}
if (ip.startsWith("192.168.")) return true;
return false;
}
const IP_ECHO_URLS = [
"https://api.ipify.org",
"https://checkip.amazonaws.com",
"https://ifconfig.me/ip",
];
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
try {
const { default: http } = await import("node:http");
const { default: https } = await import("node:https");
const proxyUrlObj = new URL(proxyUrl);
for (const echoUrl of IP_ECHO_URLS) {
try {
const ip = await new Promise<string | null>((resolve, reject) => {
const targetUrl = new URL(echoUrl);
const connectReq = http.request({
host: proxyUrlObj.hostname,
port: parseInt(proxyUrlObj.port || "80", 10),
method: "CONNECT",
path: `${targetUrl.hostname}:443`,
headers: proxyUrlObj.username
? {
"Proxy-Authorization":
"Basic " +
Buffer.from(
`${decodeURIComponent(proxyUrlObj.username)}:${decodeURIComponent(proxyUrlObj.password || "")}`
).toString("base64"),
}
: {},
timeout: 10_000,
});
connectReq.on("connect", (_res, socket) => {
const req = https.request(
echoUrl,
{ socket, timeout: 5_000 } as any,
(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.end();
});
connectReq.on("error", () => resolve(null));
connectReq.on("timeout", () => {
connectReq.destroy();
resolve(null);
});
connectReq.end();
});
if (ip) return ip;
} catch {
continue;
}
}
} catch {
// Fallback: couldn't import http modules
}
return null;
}
// ---------------------------------------------------------------------------
// GeoIP database management
// ---------------------------------------------------------------------------
function getGeoipDir(): string {
return path.join(getCacheDir(), "geoip");
}
async function ensureGeoipDb(): Promise<string | null> {
const dir = getGeoipDir();
const dbPath = path.join(dir, GEOIP_DB_FILENAME);
if (fs.existsSync(dbPath)) {
maybeTriggerUpdate(dbPath);
return dbPath;
}
try {
await downloadGeoipDb(dbPath);
return dbPath;
} catch {
return null;
}
}
async function downloadGeoipDb(dest: string): Promise<void> {
const dir = path.dirname(dest);
fs.mkdirSync(dir, { recursive: true });
console.log("[cloakbrowser] Downloading GeoIP database (~70 MB)…");
const tmpPath = `${dest}.tmp.${Date.now()}`;
try {
const response = await fetch(GEOIP_DB_URL, { redirect: "follow" });
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}`);
}
const fileStream = createWriteStream(tmpPath);
const reader = response.body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
fileStream.write(value);
}
await new Promise<void>((resolve, reject) => {
fileStream.end(() => resolve());
fileStream.on("error", reject);
});
fs.renameSync(tmpPath, dest);
console.log(`[cloakbrowser] GeoIP database ready: ${dest}`);
} catch (err) {
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
throw err;
}
}
function maybeTriggerUpdate(dbPath: string): void {
try {
const age = Date.now() - fs.statSync(dbPath).mtimeMs;
if (age < GEOIP_UPDATE_INTERVAL_MS) return;
} catch {
return;
}
// Fire-and-forget background update
downloadGeoipDb(dbPath).catch(() => {});
}
+28
View File
@@ -0,0 +1,28 @@
/**
* CloakBrowser — Stealth Chromium for Node.js
*
* Default export uses Playwright. For Puppeteer, import from 'cloakbrowser/puppeteer'.
*
* @example
* ```ts
* // Playwright (default)
* import { launch } from 'cloakbrowser';
* const browser = await launch();
*
* // Puppeteer
* import { launch } from 'cloakbrowser/puppeteer';
* const browser = await launch();
* ```
*/
// Launch functions (Playwright API)
export { launch, launchContext } from "./playwright.js";
// Binary management
export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download.js";
// Config
export { CHROMIUM_VERSION, getDefaultStealthArgs } from "./config.js";
// Types
export type { LaunchOptions, LaunchContextOptions, BinaryInfo } from "./types.js";
+130
View File
@@ -0,0 +1,130 @@
/**
* Playwright launch wrapper for cloakbrowser.
* 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 { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js";
/**
* Launch stealth Chromium browser via Playwright.
*
* @example
* ```ts
* import { launch } from 'cloakbrowser';
* const browser = await launch();
* const page = await browser.newPage();
* await page.goto('https://bot.incolumitas.com');
* console.log(await page.title());
* await browser.close();
* ```
*/
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 browser = await chromium.launch({
executablePath: binaryPath,
headless: options.headless ?? true,
args,
ignoreDefaultArgs: ["--enable-automation"],
...(options.proxy ? { proxy: parseProxyUrl(options.proxy) } : {}),
...options.launchOptions,
});
return browser;
}
/**
* Launch stealth browser and return a BrowserContext with common options pre-set.
* Closing the context also closes the browser.
*
* @example
* ```ts
* import { launchContext } from 'cloakbrowser';
* const context = await launchContext({
* userAgent: 'Mozilla/5.0...',
* viewport: { width: 1920, height: 1080 },
* });
* const page = await context.newPage();
* await page.goto('https://example.com');
* await context.close(); // also closes browser
* ```
*/
export async function launchContext(
options: LaunchContextOptions = {}
): Promise<BrowserContext> {
// Resolve geoip BEFORE launch() to avoid double-resolution
const resolved = await maybeResolveGeoip(options);
const browser = await launch({ ...options, ...resolved, geoip: false });
let context: BrowserContext;
try {
context = await browser.newContext({
...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport ?? DEFAULT_VIEWPORT,
...(resolved.locale ? { locale: resolved.locale } : {}),
...(resolved.timezone ? { timezoneId: resolved.timezone } : {}),
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
});
} catch (err) {
await browser.close();
throw err;
}
// Patch close() to also close the browser
const origClose = context.close.bind(context);
context.close = async () => {
await origClose();
await browser.close();
};
return context;
}
// ---------------------------------------------------------------------------
// 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(`--timezone=${options.timezone}`);
}
if (options.locale) {
args.push(`--lang=${options.locale}`);
}
return args;
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Shared proxy URL parsing for Playwright and Puppeteer wrappers.
*/
export interface ParsedProxy {
server: string;
username?: string;
password?: string;
}
/**
* 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.
*/
export function parseProxyUrl(proxy: string): ParsedProxy {
let url: URL;
try {
url = new URL(proxy);
} catch {
// Not a parseable URL (e.g. bare "host:port") — pass through as-is
return { server: proxy };
}
if (!url.username) {
return { server: proxy };
}
// Rebuild server URL without credentials
const server = `${url.protocol}//${url.hostname}${url.port ? `:${url.port}` : ""}`;
const result: ParsedProxy = {
server,
username: decodeURIComponent(url.username),
};
if (url.password) {
result.password = decodeURIComponent(url.password);
}
return result;
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Puppeteer launch wrapper for cloakbrowser.
* Alternative to the Playwright wrapper for users who prefer Puppeteer.
*/
import type { Browser } from "puppeteer-core";
import type { LaunchOptions } from "./types.js";
import { getDefaultStealthArgs } from "./config.js";
import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js";
/**
* Launch stealth Chromium browser via Puppeteer.
*
* @example
* ```ts
* import { launch } from 'cloakbrowser/puppeteer';
* const browser = await launch();
* const page = await browser.newPage();
* await page.goto('https://bot.incolumitas.com');
* console.log(await page.title());
* await browser.close();
* ```
*/
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 });
// 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.
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 || "" };
}
}
const browser = await puppeteer.default.launch({
executablePath: binaryPath,
headless: options.headless ?? true,
args,
ignoreDefaultArgs: ["--enable-automation"],
...options.launchOptions,
});
// Monkey-patch newPage() to auto-authenticate proxy credentials
if (proxyAuth) {
const origNewPage = browser.newPage.bind(browser);
const auth = proxyAuth;
browser.newPage = async (...pageArgs: Parameters<typeof origNewPage>) => {
const page = await origNewPage(...pageArgs);
await page.authenticate(auth);
return page;
};
}
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(`--timezone=${options.timezone}`);
}
if (options.locale) {
args.push(`--lang=${options.locale}`);
}
return args;
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Shared types for cloakbrowser launch wrappers.
*/
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;
/** Additional Chromium CLI arguments. */
args?: string[];
/** Include default stealth fingerprint args (default: true). Set false to use custom --fingerprint flags. */
stealthArgs?: boolean;
/** IANA timezone, e.g. "America/New_York". Sets --timezone binary flag. */
timezone?: string;
/** BCP 47 locale, e.g. "en-US". Sets --lang binary flag. */
locale?: string;
/** Auto-detect timezone/locale from proxy IP (requires: npm install mmdb-lib). */
geoip?: boolean;
/** Raw options passed directly to playwright/puppeteer launch(). */
launchOptions?: Record<string, unknown>;
}
export interface LaunchContextOptions extends LaunchOptions {
/** Custom user agent string. */
userAgent?: string;
/** Viewport size. */
viewport?: { width: number; height: number };
/** Browser locale, e.g. "en-US". */
locale?: string;
/** Timezone, e.g. "America/New_York". */
timezoneId?: string;
/** Color scheme preference — 'light', 'dark', or 'no-preference'. */
colorScheme?: "light" | "dark" | "no-preference";
}
export interface BinaryInfo {
version: string;
platform: string;
binaryPath: string;
installed: boolean;
cacheDir: string;
downloadUrl: string;
}
+99
View File
@@ -0,0 +1,99 @@
import { describe, it, expect } from "vitest";
import {
CHROMIUM_VERSION,
getDefaultStealthArgs,
getCacheDir,
getBinaryDir,
getDownloadUrl,
} from "../src/config.js";
import { _buildArgsForTest } from "../src/playwright.js";
describe("config", () => {
it("CHROMIUM_VERSION matches expected format", () => {
expect(CHROMIUM_VERSION).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
});
it("getDefaultStealthArgs returns expected flags", () => {
const args = getDefaultStealthArgs();
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");
}
// Should have a random fingerprint seed
const fingerprintArg = args.find((a) => a.startsWith("--fingerprint="));
expect(fingerprintArg).toBeDefined();
const seed = Number(fingerprintArg!.split("=")[1]);
expect(seed).toBeGreaterThanOrEqual(10000);
expect(seed).toBeLessThanOrEqual(99999);
});
it("getDefaultStealthArgs generates different seeds", () => {
const seeds = new Set<string>();
for (let i = 0; i < 10; i++) {
const args = getDefaultStealthArgs();
const fp = args.find((a) => a.startsWith("--fingerprint="))!;
seeds.add(fp);
}
// With 90k possible seeds, 10 calls should produce at least 2 unique
expect(seeds.size).toBeGreaterThan(1);
});
it("getCacheDir returns ~/.cloakbrowser by default", () => {
const dir = getCacheDir();
expect(dir).toContain(".cloakbrowser");
});
it("getBinaryDir includes version", () => {
const dir = getBinaryDir();
expect(dir).toContain(`chromium-${CHROMIUM_VERSION}`);
});
it("getDownloadUrl contains version and platform tag", () => {
const url = getDownloadUrl();
expect(url).toContain(CHROMIUM_VERSION);
expect(url).toContain("cloakbrowser-");
expect(url).toContain(".tar.gz");
expect(url).toContain("cloakbrowser.dev");
});
});
describe("buildArgs timezone/locale", () => {
it("injects --timezone when timezone is set", () => {
const args = _buildArgsForTest({ timezone: "America/New_York" });
expect(args).toContain("--timezone=America/New_York");
});
it("injects --lang when locale is set", () => {
const args = _buildArgsForTest({ locale: "en-US" });
expect(args).toContain("--lang=en-US");
});
it("injects both when both are set", () => {
const args = _buildArgsForTest({ timezone: "Europe/Berlin", locale: "de-DE" });
expect(args).toContain("--timezone=Europe/Berlin");
expect(args).toContain("--lang=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("--timezone=America/New_York");
expect(args).toContain("--lang=en-US");
expect(args.some(a => a.startsWith("--fingerprint="))).toBe(false);
});
it("does not inject flags when not set", () => {
const args = _buildArgsForTest({});
expect(args.some(a => a.startsWith("--timezone="))).toBe(false);
expect(args.some(a => a.startsWith("--lang="))).toBe(false);
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import { COUNTRY_LOCALE_MAP, resolveProxyIp } from "../src/geoip.js";
describe("resolveProxyIp", () => {
it("returns literal IPv4 from proxy URL", async () => {
expect(await resolveProxyIp("http://10.50.96.5:8888")).toBe("10.50.96.5");
});
it("handles proxy URL with credentials", async () => {
expect(await resolveProxyIp("http://user:pass@10.50.96.5:8888")).toBe(
"10.50.96.5"
);
});
it("resolves localhost", async () => {
const ip = await resolveProxyIp("http://localhost:8888");
expect(ip).toBeTruthy();
expect(["127.0.0.1", "::1"]).toContain(ip);
});
it("returns null for invalid URL", async () => {
expect(await resolveProxyIp("not-a-url")).toBeNull();
});
it("returns null for empty string", async () => {
expect(await resolveProxyIp("")).toBeNull();
});
});
describe("COUNTRY_LOCALE_MAP", () => {
it("contains common countries", () => {
for (const code of ["US", "GB", "DE", "FR", "JP", "BR", "IL", "RU"]) {
expect(COUNTRY_LOCALE_MAP[code]).toBeDefined();
}
});
it("values are BCP 47 language-REGION format", () => {
for (const [code, locale] of Object.entries(COUNTRY_LOCALE_MAP)) {
const parts = locale.split("-");
expect(parts).toHaveLength(2);
expect(parts[0]).toMatch(/^[a-z]{2,3}$/);
expect(parts[1]).toMatch(/^[A-Z]{2}$/);
}
});
});
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import { binaryInfo } from "../src/download.js";
import { CHROMIUM_VERSION } from "../src/config.js";
describe("binaryInfo", () => {
it("returns correct structure", () => {
const info = binaryInfo();
expect(info.version).toBe(CHROMIUM_VERSION);
expect(info.platform).toMatch(/^(linux|darwin)-(x64|arm64)$/);
expect(info.binaryPath).toBeTruthy();
expect(typeof info.installed).toBe("boolean");
expect(info.cacheDir).toContain("cloakbrowser");
expect(info.downloadUrl).toContain(".tar.gz");
});
});
// Integration tests require the binary — run with:
// CLOAKBROWSER_BINARY_PATH=/path/to/chrome npm test
describe.skipIf(!process.env.CLOAKBROWSER_BINARY_PATH)(
"launch (integration)",
() => {
it("launches browser and checks stealth", async () => {
const { launch } = await import("../src/playwright.js");
const browser = await launch({ headless: true });
const page = await browser.newPage();
await page.goto("about:blank");
const webdriver = await page.evaluate(() => navigator.webdriver);
expect(webdriver).toBeFalsy();
const plugins = await page.evaluate(() => navigator.plugins.length);
expect(plugins).toBeGreaterThan(0);
await browser.close();
}, 30_000);
}
);
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { parseProxyUrl } from "../src/proxy.js";
describe("parseProxyUrl", () => {
it("passes through URL without credentials", () => {
expect(parseProxyUrl("http://proxy:8080")).toEqual({
server: "http://proxy:8080",
});
});
it("extracts credentials from URL", () => {
expect(parseProxyUrl("http://user:pass@proxy:8080")).toEqual({
server: "http://proxy:8080",
username: "user",
password: "pass",
});
});
it("decodes URL-encoded special chars", () => {
const result = parseProxyUrl("http://user:p%40ss%3Aword@proxy:8080");
expect(result.password).toBe("p@ss:word");
expect(result.username).toBe("user");
expect(result.server).toBe("http://proxy:8080");
});
it("handles socks5 protocol", () => {
const result = parseProxyUrl("socks5://user:pass@proxy:1080");
expect(result.server).toBe("socks5://proxy:1080");
expect(result.username).toBe("user");
expect(result.password).toBe("pass");
});
it("handles URL without port", () => {
const result = parseProxyUrl("http://user:pass@proxy");
expect(result.server).toBe("http://proxy");
expect(result.username).toBe("user");
});
it("handles username only (no password)", () => {
const result = parseProxyUrl("http://user@proxy:8080");
expect(result.server).toBe("http://proxy:8080");
expect(result.username).toBe("user");
expect(result.password).toBeUndefined();
});
it("passes through unparseable string", () => {
expect(parseProxyUrl("not-a-url")).toEqual({ server: "not-a-url" });
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import {
CHROMIUM_VERSION,
getDownloadUrl,
getEffectiveVersion,
parseVersion,
versionNewer,
} from "../src/config.js";
describe("version comparison", () => {
it("parseVersion handles 4-part versions", () => {
expect(parseVersion("145.0.7718.0")).toEqual([145, 0, 7718, 0]);
expect(parseVersion("142.0.7444.175")).toEqual([142, 0, 7444, 175]);
});
it("detects newer version", () => {
expect(versionNewer("145.0.7718.0", "142.0.7444.175")).toBe(true);
});
it("detects older version", () => {
expect(versionNewer("142.0.7444.175", "145.0.7718.0")).toBe(false);
});
it("same version is not newer", () => {
expect(versionNewer("142.0.7444.175", "142.0.7444.175")).toBe(false);
});
it("patch bump detected", () => {
expect(versionNewer("142.0.7444.176", "142.0.7444.175")).toBe(true);
});
it("major bump wins over minor", () => {
expect(versionNewer("143.0.0.0", "142.9.9999.999")).toBe(true);
});
});
describe("download URL", () => {
it("uses chromium-v prefix and cloakbrowser repo", () => {
const url = getDownloadUrl();
expect(url).toContain("cloakbrowser.dev");
expect(url).toContain(`chromium-v${CHROMIUM_VERSION}`);
expect(url.endsWith(".tar.gz")).toBe(true);
});
it("accepts custom version", () => {
const url = getDownloadUrl("145.0.7718.0");
expect(url).toContain("chromium-v145.0.7718.0");
});
it("does not reference old repo", () => {
const url = getDownloadUrl();
expect(url).not.toContain("chromium-stealth-builds");
});
});
describe("effective version", () => {
it("returns CHROMIUM_VERSION when no marker exists", () => {
// Default behavior — no marker file in test environment
expect(getEffectiveVersion()).toBe(CHROMIUM_VERSION);
});
});
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src"],
"exclude": ["dist", "node_modules", "tests", "examples"]
}
+4 -1
View File
@@ -42,10 +42,13 @@ classifiers = [
"Topic :: Software Development :: Testing", "Topic :: Software Development :: Testing",
] ]
dependencies = [ dependencies = [
"playwright>=1.40", "patchright>=1.40",
"httpx>=0.24", "httpx>=0.24",
] ]
[project.optional-dependencies]
geoip = ["geoip2>=4.0"]
[project.urls] [project.urls]
Homepage = "https://github.com/CloakHQ/CloakBrowser" Homepage = "https://github.com/CloakHQ/CloakBrowser"
Documentation = "https://github.com/CloakHQ/CloakBrowser#readme" Documentation = "https://github.com/CloakHQ/CloakBrowser#readme"
+46
View File
@@ -0,0 +1,46 @@
"""Unit tests for _build_args timezone/locale injection."""
from cloakbrowser.browser import _build_args
def test_timezone_injected():
"""--timezone flag should appear when timezone is set."""
args = _build_args(stealth_args=True, extra_args=None, timezone="America/New_York")
assert "--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")
assert "--lang=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")
assert "--timezone=Europe/Berlin" in args
assert "--lang=de-DE" in args
def test_timezone_independent_of_stealth_args():
"""--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")
assert "--timezone=America/New_York" in args
assert "--lang=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)
assert not any(a.startswith("--timezone=") for a in args)
assert not any(a.startswith("--lang=") 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")
assert "--disable-gpu" in args
assert "--timezone=Asia/Tokyo" in args
assert "--lang=ja-JP" in args
+138
View File
@@ -0,0 +1,138 @@
"""Unit tests for GeoIP-based timezone/locale detection."""
from unittest.mock import patch
import pytest
from cloakbrowser.browser import _maybe_resolve_geoip
from cloakbrowser.geoip import (
COUNTRY_LOCALE_MAP,
_resolve_proxy_ip,
)
# ---------------------------------------------------------------------------
# _resolve_proxy_ip
# ---------------------------------------------------------------------------
def test_resolve_literal_ipv4():
assert _resolve_proxy_ip("http://10.50.96.5:8888") == "10.50.96.5"
def test_resolve_literal_ipv4_with_auth():
assert _resolve_proxy_ip("http://user:pass@10.50.96.5:8888") == "10.50.96.5"
def test_resolve_literal_ipv6():
ip = _resolve_proxy_ip("http://[::1]:8888")
assert ip == "::1"
def test_resolve_hostname():
"""DNS resolution of a known hostname should return an IP."""
ip = _resolve_proxy_ip("http://localhost:8888")
assert ip is not None
assert ip in ("127.0.0.1", "::1")
def test_resolve_invalid_url():
assert _resolve_proxy_ip("not-a-url") is None
def test_resolve_empty():
assert _resolve_proxy_ip("") is None
# ---------------------------------------------------------------------------
# COUNTRY_LOCALE_MAP
# ---------------------------------------------------------------------------
def test_locale_map_has_common_countries():
for code in ("US", "GB", "DE", "FR", "JP", "BR", "IL", "RU"):
assert code in COUNTRY_LOCALE_MAP, f"Missing {code}"
def test_locale_map_values_are_bcp47():
"""All locales should be language-REGION format."""
for code, locale in COUNTRY_LOCALE_MAP.items():
parts = locale.split("-")
assert len(parts) == 2, f"{code}: {locale} not language-REGION"
assert parts[0].islower(), f"{code}: language part should be lowercase"
assert parts[1].isupper(), f"{code}: region part should be uppercase"
# ---------------------------------------------------------------------------
# resolve_proxy_geo fallbacks
# ---------------------------------------------------------------------------
def test_resolve_geo_raises_when_geoip2_missing():
"""Should raise ImportError with install instructions when geoip2 not installed."""
with patch.dict("sys.modules", {"geoip2": None, "geoip2.database": None}):
from importlib import reload
import cloakbrowser.geoip as geoip_mod
reload(geoip_mod)
with pytest.raises(ImportError, match="pip install cloakbrowser"):
geoip_mod.resolve_proxy_geo("http://10.50.96.5:8888")
# Restore
reload(geoip_mod)
def test_resolve_geo_returns_none_when_db_missing():
"""Should return (None, None) when DB file doesn't exist."""
mock_geoip2 = type("module", (), {"database": type("db", (), {"Reader": None})})()
with patch.dict("sys.modules", {"geoip2": mock_geoip2, "geoip2.database": mock_geoip2.database}):
with patch("cloakbrowser.geoip._ensure_geoip_db", return_value=None):
with patch("cloakbrowser.geoip._resolve_exit_ip", return_value=None):
from cloakbrowser.geoip import resolve_proxy_geo
assert resolve_proxy_geo("http://10.50.96.5:8888") == (None, None)
# ---------------------------------------------------------------------------
# _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)
assert tz is None
assert loc is None
def test_maybe_resolve_skips_when_no_proxy():
tz, loc = _maybe_resolve_geoip(True, None, None, None)
assert tz is None
assert loc is None
def test_maybe_resolve_skips_when_both_explicit():
"""Explicit values should not trigger geoip resolution."""
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", "de-DE")
assert tz == "Europe/Berlin"
assert loc == "de-DE"
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")
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)
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)
assert tz == "Europe/Berlin"
assert loc == "de-DE"
+50
View File
@@ -0,0 +1,50 @@
"""Tests for proxy URL parsing and credential extraction."""
from cloakbrowser.browser import _build_proxy_kwargs, _parse_proxy_url
class TestParseProxyUrl:
def test_no_credentials(self):
assert _parse_proxy_url("http://proxy:8080") == {"server": "http://proxy:8080"}
def test_with_credentials(self):
result = _parse_proxy_url("http://user:pass@proxy:8080")
assert result == {"server": "http://proxy:8080", "username": "user", "password": "pass"}
def test_url_encoded_password(self):
result = _parse_proxy_url("http://user:p%40ss%3Aword@proxy:8080")
assert result["password"] == "p@ss:word"
assert result["username"] == "user"
assert result["server"] == "http://proxy:8080"
def test_socks5(self):
result = _parse_proxy_url("socks5://user:pass@proxy:1080")
assert result["server"] == "socks5://proxy:1080"
assert result["username"] == "user"
assert result["password"] == "pass"
def test_no_port(self):
result = _parse_proxy_url("http://user:pass@proxy")
assert result["server"] == "http://proxy"
assert result["username"] == "user"
def test_username_only(self):
result = _parse_proxy_url("http://user@proxy:8080")
assert result["server"] == "http://proxy:8080"
assert result["username"] == "user"
assert "password" not in result
class TestBuildProxyKwargs:
def test_none(self):
assert _build_proxy_kwargs(None) == {}
def test_simple_proxy(self):
result = _build_proxy_kwargs("http://proxy:8080")
assert result == {"proxy": {"server": "http://proxy:8080"}}
def test_proxy_with_auth(self):
result = _build_proxy_kwargs("http://user:pass@proxy:8080")
assert result == {
"proxy": {"server": "http://proxy:8080", "username": "user", "password": "pass"}
}
+130 -19
View File
@@ -4,14 +4,19 @@ These tests verify that the stealth Chromium binary passes common
bot detection checks. They require network access. bot detection checks. They require network access.
""" """
import os
import time
import pytest import pytest
from cloakbrowser import launch from cloakbrowser import launch
PROXY = os.environ.get("CLOAKBROWSER_TEST_PROXY")
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def browser(): def browser():
"""Shared browser instance for stealth tests.""" """Shared browser instance for stealth tests."""
b = launch(headless=True) b = launch(headless=True, proxy=PROXY)
yield b yield b
b.close() b.close()
@@ -48,7 +53,7 @@ class TestWebDriverDetection:
"""Must have browser plugins (real Chrome has 5).""" """Must have browser plugins (real Chrome has 5)."""
page.goto("https://example.com") page.goto("https://example.com")
count = page.evaluate("navigator.plugins.length") count = page.evaluate("navigator.plugins.length")
assert count >= 1, f"Expected plugins, got {count}" assert count >= 5, f"Expected 5+ plugins (real Chrome), got {count}"
def test_languages_present(self, page): def test_languages_present(self, page):
"""navigator.languages must be populated.""" """navigator.languages must be populated."""
@@ -81,28 +86,134 @@ class TestBotDetectionSites:
Mark with pytest -m slow to skip in CI. Mark with pytest -m slow to skip in CI.
""" """
@pytest.mark.slow
def test_bot_sannysoft(self, page):
"""bot.sannysoft.com — all checks should pass (0 failures)."""
page.goto("https://bot.sannysoft.com", wait_until="networkidle", timeout=30000)
time.sleep(3)
results = page.evaluate("""() => {
const rows = document.querySelectorAll('table tr');
const failed = [];
let total = 0;
rows.forEach(r => {
const cells = r.querySelectorAll('td');
if (cells.length >= 2) {
total++;
const cls = cells[1].className || '';
if (cls.includes('failed')) {
failed.push(cells[0].innerText.trim());
}
}
});
return {total, failed};
}""")
failed = results["failed"]
assert len(failed) == 0, f"Sannysoft failures: {', '.join(failed)}"
@pytest.mark.slow @pytest.mark.slow
def test_bot_incolumitas(self, page): def test_bot_incolumitas(self, page):
"""bot.incolumitas.com should detect minimal flags.""" """bot.incolumitas.com — max 1 failure (WEBDRIVER false positive expected)."""
page.goto("https://bot.incolumitas.com", timeout=30000) page.goto("https://bot.incolumitas.com", wait_until="networkidle", timeout=30000)
page.wait_for_timeout(5000) time.sleep(12)
# Check that we're not immediately flagged
title = page.title() # Known acceptable failures (not browser fingerprint issues):
assert title # Page loaded successfully # - WEBDRIVER: spec-level false positive across all builds
# - connectionRTT: detects datacenter/proxy network latency, not browser
KNOWN_ACCEPTABLE = {"WEBDRIVER", "connectionRTT"}
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};
}""")
failed_names = results["failedTests"]
real_failures = [f for f in failed_names if f not in KNOWN_ACCEPTABLE]
assert len(real_failures) == 0, f"Incolumitas unexpected failures: {', '.join(real_failures)}"
@pytest.mark.slow @pytest.mark.slow
def test_browserscan(self, page): def test_browserscan(self, page):
"""BrowserScan bot detection should show NORMAL.""" """BrowserScan bot detection — 0 abnormal checks."""
page.goto("https://www.browserscan.net/bot-detection", timeout=30000) page.goto("https://www.browserscan.net/bot-detection", wait_until="networkidle", timeout=30000)
page.wait_for_timeout(5000) time.sleep(5)
title = page.title()
assert title # Page loaded results = page.evaluate("""() => {
const text = document.body.innerText;
const normalMatches = text.match(/Normal/g);
const abnormalMatches = text.match(/Abnormal/g);
return {
normal: normalMatches ? normalMatches.length : 0,
abnormal: abnormalMatches ? abnormalMatches.length : 0
};
}""")
assert results["abnormal"] == 0, \
f"BrowserScan: {results['abnormal']} abnormal, {results['normal']} normal"
@pytest.mark.slow @pytest.mark.slow
def test_device_and_browser_info(self, page): def test_device_and_browser_info(self, page):
"""deviceandbrowserinfo.com should report isBot: false.""" """deviceandbrowserinfo.com — isBot must be false."""
page.goto("https://deviceandbrowserinfo.com/are_you_a_bot", timeout=30000) page.goto("https://deviceandbrowserinfo.com/are_you_a_bot", wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(5000) time.sleep(8)
content = page.content()
# The page shows bot detection results results = page.evaluate("""() => {
assert "deviceandbrowserinfo" in page.url.lower() const text = document.body.innerText;
const botMatch = text.match(/"isBot":\\s*(true|false)/);
const isBot = botMatch ? botMatch[1] === 'true' : null;
const checks = {};
['isBot', 'hasBotUserAgent', 'hasWebdriverTrue', 'isHeadlessChrome',
'isAutomatedWithCDP', 'hasSuspiciousWeakSignals', 'isPlaywright',
'hasInconsistentChromeObject'].forEach(p => {
const match = text.match(new RegExp('"' + p + '":\\s*(true|false)'));
if (match) checks[p] = match[1] === 'true';
});
return {isBot, checks};
}""")
assert results["isBot"] is False, f"Detected as bot! Checks: {results['checks']}"
@pytest.mark.slow
def test_fingerprintjs(self, page):
"""FingerprintJS — must not be blocked, should see flight data."""
page.goto("https://demo.fingerprint.com/web-scraping", wait_until="domcontentloaded", timeout=30000)
time.sleep(8)
try:
page.click("button:has-text('Search')", timeout=5000)
time.sleep(5)
except Exception:
pass
results = page.evaluate("""() => {
const text = document.body.innerText;
const hasFlights = text.includes('Price per adult') || text.includes('$');
const isBlocked = text.includes('request was blocked') || text.includes('bot visit detected');
return {passed: hasFlights && !isBlocked, isBlocked, hasFlights};
}""")
assert not results["isBlocked"], "FingerprintJS blocked us as a bot"
assert results["passed"], "FingerprintJS: no flight data shown"
@pytest.mark.slow
def test_recaptcha_v3(self, page):
"""reCAPTCHA v3 — score must be >= 0.7."""
page.goto(
"https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php",
wait_until="domcontentloaded",
timeout=60000,
)
time.sleep(8)
results = page.evaluate("""() => {
const text = document.body.innerText;
const scoreMatch = text.match(/"score":\\s*(\\d+\\.\\d+)/);
return {score: scoreMatch ? parseFloat(scoreMatch[1]) : null};
}""")
score = results["score"]
assert score is not None, "Could not extract reCAPTCHA score"
assert score >= 0.7, f"reCAPTCHA score too low: {score}"
+172
View File
@@ -0,0 +1,172 @@
"""Tests for auto-update and version management."""
from __future__ import annotations
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from cloakbrowser.config import (
CHROMIUM_VERSION,
_version_newer,
_version_tuple,
get_download_url,
get_effective_version,
)
from cloakbrowser.download import (
_get_latest_chromium_version,
_should_check_for_update,
)
class TestVersionComparison:
def test_version_tuple_parsing(self):
assert _version_tuple("145.0.7718.0") == (145, 0, 7718, 0)
assert _version_tuple("142.0.7444.175") == (142, 0, 7444, 175)
def test_newer_version(self):
assert _version_newer("145.0.7718.0", "142.0.7444.175") is True
def test_older_version(self):
assert _version_newer("142.0.7444.175", "145.0.7718.0") is False
def test_same_version(self):
assert _version_newer("142.0.7444.175", "142.0.7444.175") is False
def test_patch_bump(self):
assert _version_newer("142.0.7444.176", "142.0.7444.175") is True
def test_major_bump(self):
assert _version_newer("143.0.0.0", "142.9.9999.999") is True
class TestDownloadUrl:
def test_default_url_format(self):
url = get_download_url()
assert "cloakbrowser.dev" in url
assert f"chromium-v{CHROMIUM_VERSION}" in url
assert url.endswith(".tar.gz")
def test_custom_version_url(self):
url = get_download_url("145.0.7718.0")
assert "chromium-v145.0.7718.0" in url
def test_no_old_repo_reference(self):
url = get_download_url()
assert "chromium-stealth-builds" not in url
class TestShouldCheckForUpdate:
def test_disabled_by_env(self):
with patch.dict(os.environ, {"CLOAKBROWSER_AUTO_UPDATE": "false"}):
assert _should_check_for_update() is False
def test_disabled_by_env_case_insensitive(self):
with patch.dict(os.environ, {"CLOAKBROWSER_AUTO_UPDATE": "False"}):
assert _should_check_for_update() is False
def test_disabled_by_binary_override(self):
with patch.dict(os.environ, {"CLOAKBROWSER_BINARY_PATH": "/some/path"}):
assert _should_check_for_update() is False
def test_disabled_by_custom_download_url(self):
with patch.dict(
os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": "https://my-mirror.com"}
):
assert _should_check_for_update() is False
def test_rate_limited(self, tmp_path):
import time
with patch.dict(
os.environ,
{
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
"CLOAKBROWSER_BINARY_PATH": "",
"CLOAKBROWSER_AUTO_UPDATE": "",
"CLOAKBROWSER_DOWNLOAD_URL": "",
},
):
check_file = tmp_path / ".last_update_check"
check_file.write_text(str(time.time()))
assert _should_check_for_update() is False
def test_stale_rate_limit_allows_check(self, tmp_path):
import time
with patch.dict(
os.environ,
{
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
"CLOAKBROWSER_BINARY_PATH": "",
"CLOAKBROWSER_AUTO_UPDATE": "",
"CLOAKBROWSER_DOWNLOAD_URL": "",
},
):
check_file = tmp_path / ".last_update_check"
check_file.write_text(str(time.time() - 7200)) # 2 hours ago
assert _should_check_for_update() is True
class TestEffectiveVersion:
def test_no_marker_returns_hardcoded(self, tmp_path):
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
assert get_effective_version() == CHROMIUM_VERSION
def test_marker_with_newer_version(self, tmp_path):
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
marker = tmp_path / "latest_version"
marker.write_text("999.0.0.0")
# Binary doesn't exist, so should fall back
assert get_effective_version() == CHROMIUM_VERSION
def test_marker_with_older_version_ignored(self, tmp_path):
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
marker = tmp_path / "latest_version"
marker.write_text("100.0.0.0")
assert get_effective_version() == CHROMIUM_VERSION
class TestGetLatestVersion:
def test_parses_chromium_tag(self):
mock_response = MagicMock()
mock_response.json.return_value = [
{"tag_name": "chromium-v145.0.7718.0", "draft": False},
{"tag_name": "chromium-v142.0.7444.175", "draft": False},
]
mock_response.raise_for_status = MagicMock()
with patch("cloakbrowser.download.httpx.get", return_value=mock_response):
result = _get_latest_chromium_version()
assert result == "145.0.7718.0"
def test_skips_draft_releases(self):
mock_response = MagicMock()
mock_response.json.return_value = [
{"tag_name": "chromium-v999.0.0.0", "draft": True},
{"tag_name": "chromium-v145.0.7718.0", "draft": False},
]
mock_response.raise_for_status = MagicMock()
with patch("cloakbrowser.download.httpx.get", return_value=mock_response):
result = _get_latest_chromium_version()
assert result == "145.0.7718.0"
def test_skips_non_chromium_tags(self):
mock_response = MagicMock()
mock_response.json.return_value = [
{"tag_name": "v0.2.0", "draft": False},
{"tag_name": "chromium-v145.0.7718.0", "draft": False},
]
mock_response.raise_for_status = MagicMock()
with patch("cloakbrowser.download.httpx.get", return_value=mock_response):
result = _get_latest_chromium_version()
assert result == "145.0.7718.0"
def test_network_error_returns_none(self):
with patch("cloakbrowser.download.httpx.get", side_effect=Exception("timeout")):
result = _get_latest_chromium_version()
assert result is None