mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
```
|
||||
|
||||
> Checksums and platform list will be added after binary uploads.
|
||||
>
|
||||
> Release signed with CloakHQ GPG key: `C60C0DDC9D0DE2DD`
|
||||
+12
@@ -37,6 +37,10 @@ htmlcov/
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
|
||||
# JavaScript / Node.js
|
||||
js/node_modules/
|
||||
js/dist/
|
||||
|
||||
# Distribution
|
||||
*.tar.gz
|
||||
*.whl
|
||||
@@ -46,6 +50,14 @@ AGENTS.md
|
||||
# Private docs (launch posts, strategy)
|
||||
docs/
|
||||
|
||||
# Internal test infrastructure (Docker, VPS-specific)
|
||||
test-infra/
|
||||
|
||||
# Website (deployed separately)
|
||||
site/
|
||||
|
||||
# Release scripts
|
||||
publish.sh
|
||||
deploy.sh
|
||||
.env
|
||||
debug
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
<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>
|
||||
|
||||
# CloakBrowser
|
||||
|
||||
[](https://pypi.org/project/cloakbrowser/)
|
||||
[](https://pypi.org/project/cloakbrowser/)
|
||||
[](LICENSE)
|
||||
[](https://github.com/CloakHQ/CloakBrowser)
|
||||
[](https://github.com/CloakHQ/CloakBrowser)
|
||||
<p align="center">
|
||||
<a href="https://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pypi/v/cloakbrowser" alt="PyPI"></a>
|
||||
<a href="https://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/v/cloakbrowser" alt="npm"></a>
|
||||
<a href="https://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pypi/pyversions/cloakbrowser" alt="Python"></a>
|
||||
<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://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pypi/dm/cloakbrowser" alt="PyPI Downloads"></a>
|
||||
<a href="https://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/dm/cloakbrowser" 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.**
|
||||
|
||||
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
|
||||
- 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified
|
||||
- ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 14/14 tests
|
||||
- 🔄 **Drop-in Playwright replacement** — same API, swap one import
|
||||
- 📦 **`pip install cloakbrowser`** — binary auto-downloads, zero config
|
||||
- ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 30/30 tests
|
||||
- 🔄 **Drop-in replacement** — works with Playwright (Python & JS) and Puppeteer (JS)
|
||||
- 📦 **`pip install cloakbrowser`** or **`npm install cloakbrowser`** — binary auto-downloads, zero config
|
||||
- 🦊 **Fills the Camoufox vacuum** — Chromium-based, actively maintained
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
from cloakbrowser import launch
|
||||
|
||||
@@ -30,12 +37,42 @@ page.goto("https://protected-site.com") # no more blocks
|
||||
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
|
||||
|
||||
**Python:**
|
||||
```bash
|
||||
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).
|
||||
|
||||
## Why CloakBrowser?
|
||||
@@ -65,38 +102,43 @@ All tests verified against live detection services. Last tested: Feb 2026 (Chrom
|
||||
| CDP detection | Detected | **Not detected** | `isAutomatedWithCDP: false` |
|
||||
| TLS fingerprint | Mismatch | **Identical to Chrome** | ja3n/ja4/akamai match |
|
||||
|
||||
**14/14 tests passed.**
|
||||
**30/30 tests passed.**
|
||||
|
||||
### Proof
|
||||
|
||||
<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>
|
||||
</p>
|
||||
|
||||
<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>
|
||||
</p>
|
||||
|
||||
<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>
|
||||
</p>
|
||||
|
||||
<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>
|
||||
</p>
|
||||
|
||||
## 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`
|
||||
2. **First launch** → binary auto-downloads for your platform (Linux x64 / macOS arm64)
|
||||
3. **Every launch** → Playwright starts with our binary + stealth args
|
||||
4. **You write code** → standard Playwright API, nothing new to learn
|
||||
1. **You install** → `pip install cloakbrowser` or `npm install cloakbrowser`
|
||||
2. **First launch** → binary auto-downloads for your platform (Linux x64, macOS arm64/x64)
|
||||
3. **Every launch** → Playwright or Puppeteer starts with our binary + stealth args
|
||||
4. **You write code** → standard Playwright/Puppeteer API, nothing new to learn
|
||||
|
||||
The binary includes 16 source-level patches that modify:
|
||||
- Canvas fingerprint generation
|
||||
@@ -185,13 +227,144 @@ clear_cache()
|
||||
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
|
||||
|
||||
| Env Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `CLOAKBROWSER_BINARY_PATH` | — | Skip download, use a local Chromium binary |
|
||||
| `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.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -225,31 +398,40 @@ page.goto("https://example.com")
|
||||
|
||||
| Platform | Status |
|
||||
|---|---|
|
||||
| Linux x86_64 | Supported |
|
||||
| macOS arm64 (Apple Silicon) | Coming soon |
|
||||
| macOS x86_64 (Intel) | Coming soon |
|
||||
| Linux x86_64 | ✅ Available |
|
||||
| macOS arm64 (Apple Silicon) | ✅ Available |
|
||||
| macOS x86_64 (Intel) | ✅ Available |
|
||||
| Windows | Planned |
|
||||
|
||||
**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
|
||||
|
||||
See the [`examples/`](examples/) directory:
|
||||
**Python** — see [`examples/`](examples/):
|
||||
- [`basic.py`](examples/basic.py) — Launch and load a page
|
||||
- [`recaptcha_score.py`](examples/recaptcha_score.py) — Check your reCAPTCHA v3 score
|
||||
- [`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
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| Linux x64 binary | ✅ Released |
|
||||
| macOS arm64 (Apple Silicon) | 🔜 In progress |
|
||||
| macOS arm64 (Apple Silicon) | ✅ Released |
|
||||
| macOS x64 (Intel) | ✅ Released |
|
||||
| Chromium 145 build | 🔜 In progress |
|
||||
| JavaScript/Puppeteer support (`cloakbrowser-js`) | 📋 Planned |
|
||||
| Fingerprint rotation per session | 📋 Planned |
|
||||
| JavaScript/Puppeteer + Playwright support | ✅ Released |
|
||||
| Fingerprint rotation per session | ✅ Released |
|
||||
| Built-in proxy rotation | 📋 Planned |
|
||||
| Windows support | 📋 Planned |
|
||||
|
||||
> ⭐ **Star this repo** to get notified when Chromium 145 and macOS builds drop.
|
||||
> ⭐ **Star this repo** to get notified when Chromium 145 and Windows builds drop.
|
||||
|
||||
## Docker
|
||||
|
||||
@@ -333,10 +515,18 @@ A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
|
||||
**Q: Can I use this with Docker?**
|
||||
A: Yes. A ready-to-use Dockerfile is included — see the [Docker](#docker) section above.
|
||||
|
||||
## Links
|
||||
|
||||
- 🌐 **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
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
## 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 .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__
|
||||
|
||||
__all__ = [
|
||||
@@ -23,6 +23,7 @@ __all__ = [
|
||||
"ensure_binary",
|
||||
"clear_cache",
|
||||
"binary_info",
|
||||
"check_for_update",
|
||||
"CHROMIUM_VERSION",
|
||||
"get_default_stealth_args",
|
||||
"__version__",
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.5"
|
||||
__version__ = "0.1.12"
|
||||
|
||||
+28
-1
@@ -16,6 +16,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib.parse import unquote, urlparse, urlunparse
|
||||
|
||||
from .config import get_default_stealth_args
|
||||
from .download import ensure_binary
|
||||
@@ -217,8 +218,34 @@ def _build_args(stealth_args: bool, extra_args: list[str] | None) -> list[str]:
|
||||
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
|
||||
|
||||
|
||||
def _build_proxy_kwargs(proxy: str | None) -> dict[str, Any]:
|
||||
"""Build proxy kwargs for Playwright launch."""
|
||||
if proxy is None:
|
||||
return {}
|
||||
return {"proxy": {"server": proxy}}
|
||||
return {"proxy": _parse_proxy_url(proxy)}
|
||||
|
||||
+83
-11
@@ -19,12 +19,28 @@ CHROMIUM_VERSION = "142.0.7444.175"
|
||||
# These activate source-level fingerprint patches compiled into the binary.
|
||||
# ---------------------------------------------------------------------------
|
||||
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)
|
||||
return [
|
||||
system = platform.system()
|
||||
|
||||
base = [
|
||||
"--no-sandbox",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
f"--fingerprint={seed}",
|
||||
]
|
||||
|
||||
if system == "Darwin":
|
||||
# Tell the fingerprint patches we're on macOS so GPU/UA match natively
|
||||
return base + [
|
||||
"--fingerprint-platform=macos",
|
||||
]
|
||||
|
||||
# Linux: spoof as Windows
|
||||
return base + [
|
||||
"--fingerprint-platform=windows",
|
||||
"--fingerprint-hardware-concurrency=8",
|
||||
"--fingerprint-gpu-vendor=NVIDIA Corporation",
|
||||
@@ -41,6 +57,10 @@ SUPPORTED_PLATFORMS: dict[tuple[str, str], str] = {
|
||||
("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:
|
||||
"""Return the platform tag for binary download (e.g. 'linux-x64', 'darwin-arm64')."""
|
||||
@@ -70,15 +90,15 @@ def get_cache_dir() -> Path:
|
||||
return Path.home() / ".cloakbrowser"
|
||||
|
||||
|
||||
def get_binary_dir() -> Path:
|
||||
"""Return the directory for the current Chromium version binary."""
|
||||
return get_cache_dir() / f"chromium-{CHROMIUM_VERSION}"
|
||||
def get_binary_dir(version: str | None = None) -> Path:
|
||||
"""Return the directory for a Chromium version binary."""
|
||||
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."""
|
||||
platform_tag = get_platform_tag()
|
||||
binary_dir = get_binary_dir()
|
||||
binary_dir = get_binary_dir(version)
|
||||
|
||||
if platform.system() == "Darwin":
|
||||
# macOS: Chromium.app bundle
|
||||
@@ -88,19 +108,71 @@ def get_binary_path() -> Path:
|
||||
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_BASE_URL = os.environ.get(
|
||||
"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"
|
||||
|
||||
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."""
|
||||
v = version or CHROMIUM_VERSION
|
||||
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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+207
-23
@@ -8,18 +8,28 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import stat
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import (
|
||||
CHROMIUM_VERSION,
|
||||
DOWNLOAD_BASE_URL,
|
||||
GITHUB_API_URL,
|
||||
_version_newer,
|
||||
check_platform_available,
|
||||
get_binary_dir,
|
||||
get_binary_path,
|
||||
get_cache_dir,
|
||||
get_download_url,
|
||||
get_effective_version,
|
||||
get_local_binary_override,
|
||||
get_platform_tag,
|
||||
)
|
||||
@@ -29,6 +39,9 @@ logger = logging.getLogger("cloakbrowser")
|
||||
# Timeout for download (large binary, allow 10 min)
|
||||
DOWNLOAD_TIMEOUT = 600.0
|
||||
|
||||
# Auto-update check interval (1 hour)
|
||||
UPDATE_CHECK_INTERVAL = 3600
|
||||
|
||||
|
||||
def ensure_binary() -> str:
|
||||
"""Ensure the stealth Chromium binary is available. Download if needed.
|
||||
@@ -48,13 +61,27 @@ def ensure_binary() -> str:
|
||||
logger.info("Using local binary override: %s", local_override)
|
||||
return str(path)
|
||||
|
||||
# Check if binary is already cached
|
||||
binary_path = get_binary_path()
|
||||
# Fail fast if no binary available for this platform
|
||||
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):
|
||||
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)
|
||||
|
||||
# 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(
|
||||
"Stealth Chromium %s not found. Downloading for %s...",
|
||||
CHROMIUM_VERSION,
|
||||
@@ -62,6 +89,7 @@ def ensure_binary() -> str:
|
||||
)
|
||||
_download_and_extract()
|
||||
|
||||
binary_path = get_binary_path()
|
||||
if not binary_path.exists():
|
||||
raise RuntimeError(
|
||||
f"Download completed but binary not found at expected path: {binary_path}. "
|
||||
@@ -69,13 +97,15 @@ def ensure_binary() -> str:
|
||||
f"https://github.com/CloakHQ/cloakbrowser/issues"
|
||||
)
|
||||
|
||||
_maybe_trigger_update_check()
|
||||
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."""
|
||||
url = get_download_url()
|
||||
binary_dir = get_binary_dir()
|
||||
url = get_download_url(version)
|
||||
binary_dir = get_binary_dir(version)
|
||||
binary_path = get_binary_path(version)
|
||||
|
||||
# Create cache dir
|
||||
binary_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -86,7 +116,10 @@ def _download_and_extract() -> None:
|
||||
|
||||
try:
|
||||
_download_file(url, tmp_path)
|
||||
_extract_archive(tmp_path, binary_dir)
|
||||
_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:
|
||||
# Clean up temp file
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
@@ -123,7 +156,9 @@ def _download_file(url: str, dest: Path) -> None:
|
||||
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."""
|
||||
logger.info("Extracting to %s", dest_dir)
|
||||
|
||||
@@ -135,28 +170,40 @@ def _extract_archive(archive_path: Path, dest_dir: Path) -> None:
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tarfile.open(archive_path, "r:gz") as tar:
|
||||
# Security: prevent path traversal and symlink attacks
|
||||
# Security: prevent path traversal
|
||||
safe_members = []
|
||||
for member in tar.getmembers():
|
||||
# Allow symlinks — macOS .app bundles require them (Framework layout)
|
||||
if member.issym() or member.islnk():
|
||||
logger.warning("Skipping symlink in archive: %s", member.name)
|
||||
continue
|
||||
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}")
|
||||
link_target = member.linkname
|
||||
# Reject symlinks that escape the dest dir
|
||||
if os.path.isabs(link_target) or ".." in link_target.split("/"):
|
||||
logger.warning("Skipping suspicious symlink: %s -> %s", member.name, link_target)
|
||||
continue
|
||||
else:
|
||||
member_path = (dest_dir / member.name).resolve()
|
||||
if not str(member_path).startswith(str(dest_dir.resolve())):
|
||||
raise RuntimeError(f"Archive contains path traversal: {member.name}")
|
||||
safe_members.append(member)
|
||||
|
||||
tar.extractall(dest_dir, members=safe_members)
|
||||
|
||||
# If tar extracted into a single subdirectory, flatten it
|
||||
# (e.g. fingerprint-chromium-142-custom-v2/chrome → chrome)
|
||||
# But never flatten .app bundles — macOS needs the bundle structure intact
|
||||
_flatten_single_subdir(dest_dir)
|
||||
|
||||
# Make binary executable
|
||||
binary_path = get_binary_path()
|
||||
if binary_path.exists():
|
||||
_make_executable(binary_path)
|
||||
logger.info("Binary ready: %s", binary_path)
|
||||
bp = binary_path or get_binary_path()
|
||||
if bp.exists():
|
||||
_make_executable(bp)
|
||||
|
||||
# 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:
|
||||
@@ -170,6 +217,10 @@ def _flatten_single_subdir(dest_dir: Path) -> None:
|
||||
entries = list(dest_dir.iterdir())
|
||||
if len(entries) == 1 and entries[0].is_dir():
|
||||
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)
|
||||
for item in subdir.iterdir():
|
||||
shutil.move(str(item), str(dest_dir / item.name))
|
||||
@@ -187,6 +238,19 @@ def _make_executable(path: Path) -> None:
|
||||
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:
|
||||
"""Remove all cached binaries. Forces re-download on next launch."""
|
||||
from .config import get_cache_dir
|
||||
@@ -200,12 +264,132 @@ def clear_cache() -> None:
|
||||
|
||||
def binary_info() -> dict:
|
||||
"""Return info about the current binary installation."""
|
||||
binary_path = get_binary_path()
|
||||
effective = get_effective_version()
|
||||
binary_path = get_binary_path(effective)
|
||||
return {
|
||||
"version": CHROMIUM_VERSION,
|
||||
"version": effective,
|
||||
"bundled_version": CHROMIUM_VERSION,
|
||||
"platform": get_platform_tag(),
|
||||
"binary_path": str(binary_path),
|
||||
"installed": binary_path.exists(),
|
||||
"cache_dir": str(get_binary_dir()),
|
||||
"download_url": get_download_url(),
|
||||
"cache_dir": str(get_binary_dir(effective)),
|
||||
"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()
|
||||
|
||||
@@ -119,8 +119,8 @@ def test_deviceandbrowserinfo(page):
|
||||
|
||||
def test_fingerprintjs(page):
|
||||
"""demo.fingerprint.com/web-scraping — industry-standard bot detection."""
|
||||
page.goto("https://demo.fingerprint.com/web-scraping", wait_until="networkidle", timeout=30000)
|
||||
page.wait_for_timeout(5000)
|
||||
page.goto("https://demo.fingerprint.com/web-scraping", wait_until="domcontentloaded", timeout=30000)
|
||||
page.wait_for_timeout(8000)
|
||||
|
||||
# Click search to trigger bot detection — bots get blocked, humans see flights
|
||||
try:
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
<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.
|
||||
|
||||
- 🔒 **16 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'],
|
||||
});
|
||||
|
||||
// Browser + context in one call
|
||||
const context = await launchContext({
|
||||
userAgent: 'Custom UA',
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
locale: 'en-US',
|
||||
timezoneId: 'America/New_York',
|
||||
});
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
## 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
+2924
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"name": "cloakbrowser",
|
||||
"version": "0.1.10",
|
||||
"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": {
|
||||
"playwright-core": ">=1.40.0",
|
||||
"puppeteer-core": ">=21.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"playwright-core": {
|
||||
"optional": true
|
||||
},
|
||||
"puppeteer-core": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"tar": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"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,165 @@
|
||||
/**
|
||||
* 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 = "142.0.7444.175";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 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 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
|
||||
// ---------------------------------------------------------------------------
|
||||
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-gpu-vendor=NVIDIA Corporation",
|
||||
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* 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 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,
|
||||
GITHUB_API_URL,
|
||||
checkPlatformAvailable,
|
||||
getBinaryDir,
|
||||
getBinaryPath,
|
||||
getCacheDir,
|
||||
getDownloadUrl,
|
||||
getEffectiveVersion,
|
||||
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 url = getDownloadUrl(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 {
|
||||
await downloadFile(url, tmpPath);
|
||||
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 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(() => {});
|
||||
}
|
||||
@@ -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,100 @@
|
||||
/**
|
||||
* 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 { 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 args = buildArgs(options);
|
||||
|
||||
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> {
|
||||
const browser = await launch(options);
|
||||
|
||||
let context: BrowserContext;
|
||||
try {
|
||||
context = await browser.newContext({
|
||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
||||
...(options.viewport ? { viewport: options.viewport } : {}),
|
||||
...(options.locale ? { locale: options.locale } : {}),
|
||||
...(options.timezoneId ? { timezoneId: options.timezoneId } : {}),
|
||||
});
|
||||
} 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildArgs(options: LaunchOptions): string[] {
|
||||
const args: string[] = [];
|
||||
if (options.stealthArgs !== false) {
|
||||
args.push(...getDefaultStealthArgs());
|
||||
}
|
||||
if (options.args) {
|
||||
args.push(...options.args);
|
||||
}
|
||||
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,78 @@
|
||||
/**
|
||||
* 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 args = buildArgs(options);
|
||||
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildArgs(options: LaunchOptions): string[] {
|
||||
const args: string[] = [];
|
||||
if (options.stealthArgs !== false) {
|
||||
args.push(...getDefaultStealthArgs());
|
||||
}
|
||||
if (options.args) {
|
||||
args.push(...options.args);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
export interface BinaryInfo {
|
||||
version: string;
|
||||
platform: string;
|
||||
binaryPath: string;
|
||||
installed: boolean;
|
||||
cacheDir: string;
|
||||
downloadUrl: string;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
CHROMIUM_VERSION,
|
||||
getDefaultStealthArgs,
|
||||
getCacheDir,
|
||||
getBinaryDir,
|
||||
getDownloadUrl,
|
||||
} from "../src/config.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");
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"}
|
||||
}
|
||||
@@ -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