mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c2e06682b | ||
|
|
cb08a602b0 | ||
|
|
8eb666885f | ||
|
|
f417fe2530 | ||
|
|
c65939af14 | ||
|
|
2f1f592b3a | ||
|
|
0bbc170747 | ||
|
|
6506b5fe44 | ||
|
|
1082c810af | ||
|
|
67efadef26 | ||
|
|
59b9d71684 | ||
|
|
f480958ba7 | ||
|
|
8ffaf86abe | ||
|
|
8c76a68cb5 | ||
|
|
cee166c2d2 | ||
|
|
b07797f963 | ||
|
|
31c04d5bcc | ||
|
|
cc501d8ef6 | ||
|
|
8cecebf118 | ||
|
|
179531fd17 | ||
|
|
4d96db1448 | ||
|
|
4e809b9678 |
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
[](https://pypi.org/project/cloakbrowser/)
|
<p align="center">
|
||||||
[](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)
|
<a href="https://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/v/cloakbrowser" alt="npm"></a>
|
||||||
[](https://github.com/CloakHQ/CloakBrowser)
|
<a href="https://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pypi/pyversions/cloakbrowser" alt="Python"></a>
|
||||||
[](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 (10000–99999) 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 (10000–99999) | Random (10000–99999) | 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.1–0.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.
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
__version__ = "0.1.5"
|
__version__ = "0.3.0"
|
||||||
|
|||||||
+97
-12
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -0,0 +1,219 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="https://i.imgur.com/cqkp6fG.png" width="500" alt="CloakBrowser">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
# CloakBrowser
|
||||||
|
|
||||||
|
[](https://www.npmjs.com/package/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.1–0.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).
|
||||||
@@ -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.");
|
||||||
@@ -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.");
|
||||||
@@ -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);
|
||||||
Generated
+2940
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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
@@ -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(() => {});
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
@@ -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}"
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user