mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd22e51bc2 | ||
|
|
ca5cce2222 | ||
|
|
de54e67f74 | ||
|
|
ef066fa091 | ||
|
|
5237065385 | ||
|
|
8e83b8c399 | ||
|
|
c44b04a953 | ||
|
|
51c3f464a5 | ||
|
|
ed0ecf0e48 | ||
|
|
46049a15d3 | ||
|
|
11b3bcb701 | ||
|
|
28de7bb147 | ||
|
|
0c64a32122 | ||
|
|
f9887943c0 | ||
|
|
55418add96 |
@@ -6,6 +6,42 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
|
||||
|
||||
---
|
||||
|
||||
## [0.3.6] — 2026-03-04
|
||||
|
||||
- **[wrapper]** `proxy` parameter now accepts a Playwright proxy dict (`{server, bypass, username, password}`) in addition to URL strings — enables bypass lists and separate auth fields (PR #24). **TS note:** type changed from `string` to `string | object` — code that assumed `proxy` is always a string may need a `typeof` narrowing check
|
||||
|
||||
## [0.3.5] — 2026-03-04
|
||||
|
||||
- **[wrapper]** Add `launch_persistent_context()` and `launch_persistent_context_async()` (Python) — persistent browser profiles with cookie/localStorage persistence across sessions, avoids incognito detection (thanks [@evelaa123](https://github.com/evelaa123), [@yahooguntu](https://github.com/yahooguntu) — PRs #22, #17)
|
||||
- **[wrapper]** Add `launchPersistentContext()` (JS/TS) — same feature for JavaScript with full type support
|
||||
- **[wrapper]** Fix Windows zip extraction failure when primary download server is down — file handle leak caused `ERROR_SHARING_VIOLATION` on fallback download (thanks [@evelaa123](https://github.com/evelaa123) — PR #23)
|
||||
|
||||
## [0.3.4] — 2026-03-04
|
||||
|
||||
Binary v14: auto-spoof restored with seed, wrapper simplified to match.
|
||||
|
||||
- **[binary]** Restore full auto-spoof when `--fingerprint=seed` is set — all randomized properties now derive from the seed consistently
|
||||
- **[binary]** Auto-inject random fingerprint seed at startup if none provided. Binary is stealthy with zero flags
|
||||
- **[binary]** 26 source-level C++ patches (up from 25)
|
||||
- **[wrapper]** Simplify default stealth args — remove flags the binary now auto-generates. Wrapper still sets platform profile on Linux and `--no-sandbox`
|
||||
- **[wrapper]** Fix timezone in `launch_context()` — use Playwright's per-context timezone instead of binary flag, fixing mismatch when creating new browser contexts with geoip
|
||||
- **[wrapper]** Clarify README platform detection behavior
|
||||
|
||||
## [0.3.3] — 2026-03-03
|
||||
|
||||
All platforms now run Chromium 145 v2 with 25 patches. Windows x64 added.
|
||||
|
||||
- **[binary]** Auto-spoof by default — binary is stealthy with zero flags. Random fingerprint seed auto-generated at startup, no wrapper or configuration required
|
||||
- **[binary]** Platform-aware auto-detection — GPU, screen dimensions, and User-Agent automatically match the real OS (macOS, Linux, Windows) without explicit flags
|
||||
- **[binary]** Expanded GPU model database for realistic per-session diversity
|
||||
- **[binary]** First macOS v145 builds (arm64 + x64) — 25 patches, up from 16 on v142
|
||||
- **[binary]** First Windows x64 v145 build — 25 patches
|
||||
- **[wrapper]** Add Windows x64 platform support — auto-download, binary path resolution, and platform detection
|
||||
- **[wrapper]** Upgrade macOS (arm64 + x64) from Chromium 142 to 145 — all platforms now ship the same 25-patch build
|
||||
- **[wrapper]** Add explicit Mac GPU flags (`Apple M3 Metal` renderer) to default stealth args for consistent WebGL fingerprints
|
||||
- **[wrapper]** Improve reCAPTCHA stealth test — wait for score element instead of blind sleep
|
||||
- **[wrapper]** JS: add `win32-x64` platform mapping, Windows binary path (`chrome.exe`)
|
||||
|
||||
## [0.3.1] — 2026-03-03
|
||||
|
||||
- **[wrapper]** Auto-check for wrapper updates on startup (PyPI/npm). Notifies users when a newer wrapper version is available. Runs once per process, respects `CLOAKBROWSER_AUTO_UPDATE=false`.
|
||||
|
||||
@@ -35,7 +35,7 @@ Drop-in Playwright/Puppeteer replacement for Python and JavaScript.<br>
|
||||
Same API, same code — just swap the import. <strong>3 lines of code, 30 seconds to unblock.</strong>
|
||||
</p>
|
||||
|
||||
- 🔒 **25 source-level C++ patches** — not JS injection, not config flags
|
||||
- 🔒 **26 source-level C++ patches** — not JS injection, not config flags
|
||||
- 🛡️ **CDP stealth built-in** — uses [Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright) to reduce Playwright's automation footprint
|
||||
- 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified
|
||||
- ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 30/30 tests
|
||||
@@ -104,14 +104,16 @@ page.goto("https://example.com")
|
||||
|
||||
> ⭐ **Star** to show support — **[Watch releases](https://github.com/CloakHQ/CloakBrowser/subscription)** to get notified when new builds drop.
|
||||
|
||||
## What's New in v0.3.0
|
||||
## What's New in v0.3.4
|
||||
|
||||
- **Chromium 145** (Linux) — latest stable, 25 fingerprint patches (up from 16). macOS v145 coming soon
|
||||
- **9 new patches** — screen dimensions, device memory, audio, WebGL, and more
|
||||
- **SHA-256 checksum verification** — binary downloads are verified for integrity
|
||||
- **CDP hardening** — audited and patched known automation detection vectors
|
||||
- **All 4 platforms** — Linux x64, macOS arm64, macOS x64, and Windows x64 all on Chromium 145
|
||||
- **26 fingerprint patches** — 10 new patches since v142 (screen, device memory, audio, WebGL, auto-spoof, and more)
|
||||
- **Stealthy with zero flags** — binary auto-generates a random fingerprint seed at startup. No configuration required
|
||||
- **Deterministic seeds** — `--fingerprint=seed` produces the same identity across launches for session persistence
|
||||
- **Full stealth audit** — every patch reviewed for detection vectors, multiple fixes shipped
|
||||
- **Timezone & locale from proxy IP** — `launch(proxy="...", geoip=True)` auto-detects timezone and locale
|
||||
- **SHA-256 checksum verification** — binary downloads are verified for integrity
|
||||
- **CDP hardening** — audited and patched known automation detection vectors
|
||||
|
||||
See the full [CHANGELOG.md](CHANGELOG.md) for details.
|
||||
|
||||
@@ -175,11 +177,11 @@ All tests verified against live detection services. Last tested: Mar 2026 (Chrom
|
||||
CloakBrowser is a thin wrapper (Python + JavaScript) around a custom-built Chromium binary:
|
||||
|
||||
1. **You install** → `pip install cloakbrowser` or `npm install cloakbrowser`
|
||||
2. **First launch** → binary auto-downloads for your platform (Linux x64: Chromium 145, macOS: Chromium 142)
|
||||
2. **First launch** → binary auto-downloads for your platform (Chromium 145)
|
||||
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 25 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, hardware reporting, and automation signal removal.
|
||||
The binary includes 26 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, hardware reporting, and automation signal removal.
|
||||
|
||||
These are compiled into the Chromium binary — not injected via JavaScript, not set via flags.
|
||||
|
||||
@@ -201,8 +203,11 @@ browser = launch(headless=False)
|
||||
# With proxy
|
||||
browser = launch(proxy="http://user:pass@proxy:8080")
|
||||
|
||||
# With proxy dict (bypass, separate auth fields)
|
||||
browser = launch(proxy={"server": "http://proxy:8080", "bypass": ".google.com", "username": "user", "password": "pass"})
|
||||
|
||||
# With extra Chrome args
|
||||
browser = launch(args=["--disable-gpu", "--window-size=1920,1080"])
|
||||
browser = launch(args=["--disable-gpu"])
|
||||
|
||||
# With timezone and locale (sets both binary flags and Playwright context)
|
||||
browser = launch(timezone="America/New_York", locale="en-US")
|
||||
@@ -237,7 +242,7 @@ asyncio.run(main())
|
||||
|
||||
### `launch_context()`
|
||||
|
||||
Convenience function that creates browser + context with common options:
|
||||
Convenience function that creates browser + context in one call with user agent, viewport, locale, and timezone:
|
||||
|
||||
```python
|
||||
from cloakbrowser import launch_context
|
||||
@@ -249,8 +254,37 @@ context = launch_context(
|
||||
timezone_id="America/New_York",
|
||||
)
|
||||
page = context.new_page()
|
||||
page.goto("https://protected-site.com")
|
||||
context.close()
|
||||
```
|
||||
|
||||
### `launch_persistent_context()`
|
||||
|
||||
Same as `launch_context()`, but with a persistent user profile. Cookies, localStorage, and cache persist across sessions. Also avoids incognito detection by services like BrowserScan.
|
||||
|
||||
Use this when you need to:
|
||||
- **Stay logged in** across runs (cookies/sessions survive restarts)
|
||||
- **Bypass incognito detection** (some sites flag empty, ephemeral profiles)
|
||||
- **Load Chrome extensions** (extensions only work from a real user data dir)
|
||||
- **Build natural browsing history** (cached fonts, service workers, IndexedDB accumulate over time, making the profile look more realistic)
|
||||
|
||||
```python
|
||||
from cloakbrowser import launch_persistent_context
|
||||
|
||||
# First run — creates the profile
|
||||
ctx = launch_persistent_context("./my-profile", headless=False)
|
||||
page = ctx.new_page()
|
||||
page.goto("https://protected-site.com")
|
||||
ctx.close() # profile saved
|
||||
|
||||
# Next run — cookies, localStorage restored automatically
|
||||
ctx = launch_persistent_context("./my-profile", headless=False)
|
||||
```
|
||||
|
||||
Supports all the same options as `launch_context()`: `proxy`, `user_agent`, `viewport`, `locale`, `timezone_id`, `color_scheme`, `geoip`.
|
||||
|
||||
Async version: `launch_persistent_context_async()`.
|
||||
|
||||
### Utility Functions
|
||||
|
||||
```python
|
||||
@@ -274,7 +308,7 @@ CloakBrowser ships a TypeScript package with full type definitions. Choose Playw
|
||||
### Playwright (default)
|
||||
|
||||
```javascript
|
||||
import { launch, launchContext } from 'cloakbrowser';
|
||||
import { launch, launchContext, launchPersistentContext } from 'cloakbrowser';
|
||||
|
||||
// Basic
|
||||
const browser = await launch();
|
||||
@@ -283,7 +317,7 @@ const browser = await launch();
|
||||
const browser = await launch({
|
||||
headless: false,
|
||||
proxy: 'http://user:pass@proxy:8080',
|
||||
args: ['--window-size=1920,1080'],
|
||||
args: ['--fingerprint=12345'],
|
||||
timezone: 'America/New_York',
|
||||
locale: 'en-US',
|
||||
});
|
||||
@@ -296,6 +330,13 @@ const context = await launchContext({
|
||||
timezoneId: 'America/New_York',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
// Persistent profile — cookies/localStorage survive restarts, avoids incognito detection
|
||||
const ctx = await launchPersistentContext({
|
||||
userDataDir: './chrome-profile',
|
||||
headless: false,
|
||||
proxy: 'http://user:pass@proxy:8080',
|
||||
});
|
||||
```
|
||||
|
||||
> **Note:** Each example above is standalone — not meant to run as one block.
|
||||
@@ -342,7 +383,17 @@ clearCache();
|
||||
|
||||
## 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.
|
||||
The binary is **stealthy by default** — no flags needed. It auto-generates a random fingerprint seed at startup and spoofs all detectable values (GPU, hardware specs, screen dimensions, canvas, WebGL, audio, fonts). Every launch produces a fresh, coherent identity.
|
||||
|
||||
**How fingerprinting works:**
|
||||
|
||||
| Scenario | What happens |
|
||||
|----------|-------------|
|
||||
| **No flags** | Random seed auto-generated at startup. GPU, screen, hardware specs, and all noise patches are spoofed automatically. Fresh identity each launch. |
|
||||
| **`--fingerprint=seed`** | Deterministic identity from the seed. Same seed = same fingerprint across launches. Use this for session persistence (returning visitor). |
|
||||
| **`--fingerprint=seed` + explicit flags** | Explicit flags override individual auto-generated values. The seed fills in everything else. |
|
||||
|
||||
The binary detects its platform at compile time — a macOS binary reports as macOS with Apple GPU, a Linux binary reports as Linux with NVIDIA GPU. The **wrapper** overrides this on Linux by passing `--fingerprint-platform=windows`, so sessions appear as Windows desktops (more common fingerprint, harder to cluster). Use `--fingerprint-platform` for cross-platform spoofing when running the binary directly.
|
||||
|
||||
> **Tip: Use a fixed seed when revisiting the same site.** A random seed makes every session look like a different device — which can be suspicious when hitting the same site repeatedly from the same IP. For reCAPTCHA v3 Enterprise and similar scoring systems, a fixed seed produces a consistent fingerprint across sessions, making you look like a returning visitor:
|
||||
> ```python
|
||||
@@ -354,21 +405,20 @@ Every launch automatically generates a **unique fingerprint**. A random seed (10
|
||||
|
||||
### Default Fingerprint
|
||||
|
||||
Every `launch()` call sets these automatically. Defaults are **platform-aware** — macOS runs as a native Mac browser, Linux spoofs Windows:
|
||||
Every `launch()` call sets these automatically. The **wrapper** applies platform-aware defaults — on Linux it spoofs as Windows for a more common fingerprint, on macOS it runs as a native Mac browser:
|
||||
|
||||
| Flag | Linux Default | macOS Default | Controls |
|
||||
| Flag | Linux/Windows 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` |
|
||||
| `--fingerprint-device-memory` | `8` | *(not set)* | `navigator.deviceMemory` |
|
||||
| `--fingerprint-screen-width` | `1920` | *(not set)* | Screen width reporting |
|
||||
| `--fingerprint-screen-height` | `1080` | *(not set)* | Screen height reporting |
|
||||
| `--window-size` | `1920,1080` | *(not set)* | Browser window dimensions |
|
||||
| `--fingerprint-gpu-vendor` | `NVIDIA Corporation` | `Google Inc. (Apple)` | WebGL `UNMASKED_VENDOR_WEBGL` |
|
||||
| `--fingerprint-gpu-renderer` | `NVIDIA GeForce RTX 3070` | `ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)` | WebGL `UNMASKED_RENDERER_WEBGL` |
|
||||
|
||||
> **Important:** `--fingerprint-platform` should always be set. Without it, platform-specific patches (GPU, UA, screen, taskbar) won't activate. The wrapper handles this automatically.
|
||||
The binary auto-generates hardware concurrency (8), device memory (8), and screen dimensions (1920x1080 on Windows/Linux, 1440x900 on macOS) from the seed. Override with explicit flags if needed.
|
||||
|
||||
> **Using the binary directly?** It works out of the box with zero flags — the binary auto-spoofs everything. Pass `--fingerprint=seed` for a persistent identity, or use explicit flags like `--fingerprint-gpu-renderer` to override any auto-generated value.
|
||||
|
||||
> **Production tip:** For better stealth at scale, pass your own GPU, screen, and hardware values instead of relying on defaults. Custom parameters make your sessions harder to cluster by anti-bot systems that look for uniform fingerprint profiles.
|
||||
|
||||
### Additional Flags
|
||||
|
||||
@@ -376,6 +426,10 @@ Supported by the binary but **not set by default** — pass via `args` to custom
|
||||
|
||||
| Flag | Controls |
|
||||
|------|----------|
|
||||
| `--fingerprint-hardware-concurrency` | `navigator.hardwareConcurrency` (auto-generated: `8`) |
|
||||
| `--fingerprint-device-memory` | `navigator.deviceMemory` in GB (auto-generated: `8`) |
|
||||
| `--fingerprint-screen-width` | Screen width (auto-generated: `1920` Win/Linux, `1440` macOS) |
|
||||
| `--fingerprint-screen-height` | Screen height (auto-generated: `1080` Win/Linux, `900` macOS) |
|
||||
| `--fingerprint-brand` | Browser brand: `Chrome`, `Edge`, `Opera`, `Vivaldi` |
|
||||
| `--fingerprint-brand-version` | Brand version (UA + Client Hints) |
|
||||
| `--fingerprint-platform-version` | Client Hints platform version |
|
||||
@@ -397,7 +451,6 @@ browser = launch(args=["--fingerprint=42069"])
|
||||
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",
|
||||
])
|
||||
@@ -425,27 +478,27 @@ browser = launch(args=[
|
||||
|
||||
| Platform | Chromium | Patches | Status |
|
||||
|---|---|---|---|
|
||||
| Linux x86_64 | 145 | 25 | ✅ Latest |
|
||||
| macOS arm64 (Apple Silicon) | 142 | 16 | ✅ Available (v145 coming soon) |
|
||||
| macOS x86_64 (Intel) | 142 | 16 | ✅ Available (v145 coming soon) |
|
||||
| Windows | — | — | Planned |
|
||||
| Linux x86_64 | 145 | 26 | ✅ Latest |
|
||||
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest |
|
||||
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest |
|
||||
| Windows x86_64 | 145 | 26 | ✅ Latest |
|
||||
|
||||
The wrapper auto-downloads the correct binary for your platform. Linux gets Chromium 145 with all 25 patches. macOS currently runs Chromium 142 (16 patches) — the v145 macOS build is in progress.
|
||||
The wrapper auto-downloads the correct binary for your platform.
|
||||
|
||||
**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
|
||||
|
||||
**Python** — see [`examples/`](examples/):
|
||||
- [`basic.py`](examples/basic.py) — Launch and load a page
|
||||
- [`persistent_context.py`](examples/persistent_context.py) — Persistent profile with cookie/localStorage persistence
|
||||
- [`recaptcha_score.py`](examples/recaptcha_score.py) — Check your reCAPTCHA v3 score
|
||||
- [`stealth_test.py`](examples/stealth_test.py) — Run against all detection services
|
||||
- [`fingerprint_scan_test.py`](examples/fingerprint_scan_test.py) — Test against fingerprint-scan.com and CreepJS
|
||||
|
||||
**JavaScript** — see [`js/examples/`](js/examples/):
|
||||
- [`basic-playwright.ts`](js/examples/basic-playwright.ts) — Playwright launch and load
|
||||
- [`persistent-context.ts`](js/examples/persistent-context.ts) — Persistent profile with cookie/localStorage persistence
|
||||
- [`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
|
||||
|
||||
@@ -453,13 +506,12 @@ The wrapper auto-downloads the correct binary for your platform. Linux gets Chro
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| Linux x64 — Chromium 145 (25 patches) | ✅ Released |
|
||||
| macOS arm64/x64 — Chromium 142 (16 patches) | ✅ Released |
|
||||
| macOS arm64/x64 — Chromium 145 | 🔨 In progress |
|
||||
| Linux x64 — Chromium 145 (26 patches) | ✅ Released |
|
||||
| macOS arm64/x64 — Chromium 145 (26 patches) | ✅ Released |
|
||||
| Windows x64 — Chromium 145 (26 patches) | ✅ Released |
|
||||
| JavaScript/Puppeteer + Playwright support | ✅ Released |
|
||||
| Fingerprint rotation per session | ✅ Released |
|
||||
| Built-in proxy rotation | 📋 Planned |
|
||||
| Windows support | 📋 Planned |
|
||||
|
||||
## Docker
|
||||
|
||||
@@ -543,6 +595,13 @@ const browser = await launch({ args: ['--disable-http2'] });
|
||||
|
||||
Only use this flag for sites that require it — most sites work fine with HTTP/2.
|
||||
|
||||
**Something not working? Make sure you're on the latest wrapper**
|
||||
Older versions may use outdated stealth args or download an older binary:
|
||||
```bash
|
||||
pip install -U cloakbrowser # Python
|
||||
npm install cloakbrowser@latest # JavaScript
|
||||
```
|
||||
|
||||
**Binary download fails / timeout**
|
||||
Set a custom download URL or use a local binary:
|
||||
```bash
|
||||
@@ -561,6 +620,32 @@ You do NOT need `playwright install chromium`. CloakBrowser downloads its own bi
|
||||
patchright install-deps chromium
|
||||
```
|
||||
|
||||
**macOS: Blocked on some sites that pass on Linux**
|
||||
|
||||
The macOS fingerprint profile has known inconsistencies that aggressive bot detection catches. If a site blocks you on macOS but works on Linux, switch to a Windows fingerprint profile by passing `stealth_args=False` and manually setting `--fingerprint-platform=windows` with matching GPU flags (see [Fingerprint Management](#fingerprint-management) for the full flag list).
|
||||
|
||||
**Site detects incognito / private browsing mode**
|
||||
|
||||
By default, `launch()` opens an incognito context. Some sites (like BrowserScan) detect this. Use `launch_persistent_context()` instead — it runs with a real user profile, so incognito detection passes:
|
||||
|
||||
```python
|
||||
from cloakbrowser import launch_persistent_context
|
||||
|
||||
ctx = launch_persistent_context("./my-profile", headless=False)
|
||||
page = ctx.new_page()
|
||||
```
|
||||
|
||||
```javascript
|
||||
import { launchPersistentContext } from 'cloakbrowser';
|
||||
|
||||
const ctx = await launchPersistentContext({
|
||||
userDataDir: './my-profile',
|
||||
headless: false,
|
||||
});
|
||||
```
|
||||
|
||||
This also gives you cookie and localStorage persistence across sessions.
|
||||
|
||||
**reCAPTCHA v3 scores are low (0.1–0.3)**
|
||||
|
||||
Avoid `page.wait_for_timeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
|
||||
|
||||
@@ -11,7 +11,7 @@ Usage:
|
||||
browser.close()
|
||||
"""
|
||||
|
||||
from .browser import launch, launch_async, launch_context
|
||||
from .browser import launch, launch_async, launch_context, launch_persistent_context, launch_persistent_context_async, ProxySettings
|
||||
from .config import CHROMIUM_VERSION, get_default_stealth_args
|
||||
from .download import binary_info, check_for_update, clear_cache, ensure_binary
|
||||
from ._version import __version__
|
||||
@@ -20,11 +20,14 @@ __all__ = [
|
||||
"launch",
|
||||
"launch_async",
|
||||
"launch_context",
|
||||
"launch_persistent_context",
|
||||
"launch_persistent_context_async",
|
||||
"ensure_binary",
|
||||
"clear_cache",
|
||||
"binary_info",
|
||||
"check_for_update",
|
||||
"CHROMIUM_VERSION",
|
||||
"get_default_stealth_args",
|
||||
"ProxySettings",
|
||||
"__version__",
|
||||
]
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.3.1"
|
||||
__version__ = "0.3.6"
|
||||
|
||||
+229
-11
@@ -15,7 +15,8 @@ Usage:
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
import os
|
||||
from typing import Any, Literal, TypedDict
|
||||
from urllib.parse import unquote, urlparse, urlunparse
|
||||
|
||||
from .config import DEFAULT_VIEWPORT, get_default_stealth_args
|
||||
@@ -24,9 +25,21 @@ from .download import ensure_binary
|
||||
logger = logging.getLogger("cloakbrowser")
|
||||
|
||||
|
||||
class _ProxySettingsRequired(TypedDict):
|
||||
server: str
|
||||
|
||||
|
||||
class ProxySettings(_ProxySettingsRequired, total=False):
|
||||
"""Playwright-compatible proxy configuration."""
|
||||
|
||||
bypass: str
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
def launch(
|
||||
headless: bool = True,
|
||||
proxy: str | None = None,
|
||||
proxy: str | ProxySettings | None = None,
|
||||
args: list[str] | None = None,
|
||||
stealth_args: bool = True,
|
||||
timezone: str | None = None,
|
||||
@@ -38,7 +51,10 @@ def launch(
|
||||
|
||||
Args:
|
||||
headless: Run in headless mode (default True).
|
||||
proxy: Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080').
|
||||
proxy: Proxy URL string or Playwright proxy dict.
|
||||
String: 'http://user:pass@proxy:8080' (credentials auto-extracted).
|
||||
Dict: {"server": "http://proxy:8080", "bypass": ".google.com", ...}
|
||||
— passed directly to Playwright.
|
||||
args: Additional Chromium CLI arguments to pass.
|
||||
stealth_args: Include default stealth fingerprint args (default True).
|
||||
Set to False if you want to pass your own --fingerprint flags.
|
||||
@@ -93,7 +109,7 @@ def launch(
|
||||
|
||||
async def launch_async(
|
||||
headless: bool = True,
|
||||
proxy: str | None = None,
|
||||
proxy: str | ProxySettings | None = None,
|
||||
args: list[str] | None = None,
|
||||
stealth_args: bool = True,
|
||||
timezone: str | None = None,
|
||||
@@ -105,7 +121,7 @@ async def launch_async(
|
||||
|
||||
Args:
|
||||
headless: Run in headless mode (default True).
|
||||
proxy: Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080').
|
||||
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
|
||||
args: Additional Chromium CLI arguments to pass.
|
||||
stealth_args: Include default stealth fingerprint args (default True).
|
||||
timezone: IANA timezone (e.g. 'America/New_York'). Sets --fingerprint-timezone binary flag.
|
||||
@@ -159,9 +175,203 @@ async def launch_async(
|
||||
return browser
|
||||
|
||||
|
||||
def launch_persistent_context(
|
||||
user_data_dir: str | os.PathLike,
|
||||
headless: bool = True,
|
||||
proxy: str | ProxySettings | None = None,
|
||||
args: list[str] | None = None,
|
||||
stealth_args: bool = True,
|
||||
user_agent: str | None = None,
|
||||
viewport: dict | None = None,
|
||||
locale: str | None = None,
|
||||
timezone_id: str | None = None,
|
||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||
geoip: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Launch stealth browser with a persistent profile and return a BrowserContext.
|
||||
|
||||
This persists cookies, localStorage, cache, and other browser state across
|
||||
sessions by storing them in ``user_data_dir``. Also avoids incognito detection
|
||||
by services like BrowserScan (-10% penalty).
|
||||
|
||||
Args:
|
||||
user_data_dir: Path to the directory where browser profile data is stored.
|
||||
Created automatically if it doesn't exist. Reuse the same path across
|
||||
sessions to restore cookies, localStorage, cached credentials, etc.
|
||||
headless: Run in headless mode (default True).
|
||||
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
|
||||
args: Additional Chromium CLI arguments.
|
||||
stealth_args: Include default stealth fingerprint args (default True).
|
||||
user_agent: Custom user agent string.
|
||||
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
|
||||
locale: Browser locale, e.g. "en-US".
|
||||
timezone_id: Timezone, e.g. "America/New_York".
|
||||
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
|
||||
Default: None (uses Chromium default, which is 'light').
|
||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||
Requires ``pip install cloakbrowser[geoip]``.
|
||||
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
|
||||
|
||||
Returns:
|
||||
Playwright BrowserContext object backed by a persistent profile.
|
||||
Call ``.close()`` when done — this also stops the Playwright instance.
|
||||
|
||||
Example:
|
||||
>>> from cloakbrowser import launch_persistent_context
|
||||
>>> ctx = launch_persistent_context("./my-profile", headless=False)
|
||||
>>> page = ctx.new_page()
|
||||
>>> page.goto("https://protected-site.com")
|
||||
>>> ctx.close() # Profile is saved; re-use path next run to restore state.
|
||||
"""
|
||||
from patchright.sync_api import sync_playwright
|
||||
|
||||
binary_path = ensure_binary()
|
||||
timezone_id, locale = _maybe_resolve_geoip(geoip, proxy, timezone_id, locale)
|
||||
chrome_args = _build_args(stealth_args, args, timezone=timezone_id, locale=locale)
|
||||
|
||||
logger.debug(
|
||||
"Launching persistent stealth Chromium (headless=%s, user_data_dir=%s)",
|
||||
headless,
|
||||
user_data_dir,
|
||||
)
|
||||
|
||||
context_kwargs: dict[str, Any] = {}
|
||||
if user_agent:
|
||||
context_kwargs["user_agent"] = user_agent
|
||||
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
|
||||
if locale:
|
||||
context_kwargs["locale"] = locale
|
||||
if timezone_id:
|
||||
context_kwargs["timezone_id"] = timezone_id
|
||||
if color_scheme:
|
||||
context_kwargs["color_scheme"] = color_scheme
|
||||
context_kwargs.update(kwargs)
|
||||
|
||||
pw = sync_playwright().start()
|
||||
context = pw.chromium.launch_persistent_context(
|
||||
user_data_dir=os.fspath(user_data_dir),
|
||||
executable_path=binary_path,
|
||||
headless=headless,
|
||||
args=chrome_args,
|
||||
ignore_default_args=["--enable-automation"],
|
||||
**_build_proxy_kwargs(proxy),
|
||||
**context_kwargs,
|
||||
)
|
||||
|
||||
# Patch close() to also stop the Playwright instance
|
||||
_original_close = context.close
|
||||
|
||||
def _close_with_cleanup() -> None:
|
||||
_original_close()
|
||||
pw.stop()
|
||||
|
||||
context.close = _close_with_cleanup
|
||||
|
||||
return context
|
||||
|
||||
|
||||
async def launch_persistent_context_async(
|
||||
user_data_dir: str | os.PathLike,
|
||||
headless: bool = True,
|
||||
proxy: str | ProxySettings | None = None,
|
||||
args: list[str] | None = None,
|
||||
stealth_args: bool = True,
|
||||
user_agent: str | None = None,
|
||||
viewport: dict | None = None,
|
||||
locale: str | None = None,
|
||||
timezone_id: str | None = None,
|
||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||
geoip: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Async version of launch_persistent_context().
|
||||
|
||||
Launch stealth browser with a persistent profile and return a BrowserContext.
|
||||
This persists cookies, localStorage, cache, and other browser state across
|
||||
sessions by storing them in ``user_data_dir``.
|
||||
|
||||
Args:
|
||||
user_data_dir: Path to the directory where browser profile data is stored.
|
||||
Created automatically if it doesn't exist.
|
||||
headless: Run in headless mode (default True).
|
||||
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
|
||||
args: Additional Chromium CLI arguments.
|
||||
stealth_args: Include default stealth fingerprint args (default True).
|
||||
user_agent: Custom user agent string.
|
||||
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
|
||||
locale: Browser locale, e.g. "en-US".
|
||||
timezone_id: Timezone, e.g. "America/New_York".
|
||||
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
|
||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
|
||||
|
||||
Returns:
|
||||
Playwright BrowserContext object backed by a persistent profile (async API).
|
||||
Call ``await .close()`` when done.
|
||||
|
||||
Example:
|
||||
>>> import asyncio
|
||||
>>> from cloakbrowser import launch_persistent_context_async
|
||||
>>>
|
||||
>>> async def main():
|
||||
... ctx = await launch_persistent_context_async("./my-profile", headless=False)
|
||||
... page = await ctx.new_page()
|
||||
... await page.goto("https://protected-site.com")
|
||||
... await ctx.close()
|
||||
>>>
|
||||
>>> asyncio.run(main())
|
||||
"""
|
||||
from patchright.async_api import async_playwright
|
||||
|
||||
binary_path = ensure_binary()
|
||||
timezone_id, locale = _maybe_resolve_geoip(geoip, proxy, timezone_id, locale)
|
||||
chrome_args = _build_args(stealth_args, args, timezone=timezone_id, locale=locale)
|
||||
|
||||
logger.debug(
|
||||
"Launching persistent stealth Chromium async (headless=%s, user_data_dir=%s)",
|
||||
headless,
|
||||
user_data_dir,
|
||||
)
|
||||
|
||||
context_kwargs: dict[str, Any] = {}
|
||||
if user_agent:
|
||||
context_kwargs["user_agent"] = user_agent
|
||||
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
|
||||
if locale:
|
||||
context_kwargs["locale"] = locale
|
||||
if timezone_id:
|
||||
context_kwargs["timezone_id"] = timezone_id
|
||||
if color_scheme:
|
||||
context_kwargs["color_scheme"] = color_scheme
|
||||
context_kwargs.update(kwargs)
|
||||
|
||||
pw = await async_playwright().start()
|
||||
context = await pw.chromium.launch_persistent_context(
|
||||
user_data_dir=os.fspath(user_data_dir),
|
||||
executable_path=binary_path,
|
||||
headless=headless,
|
||||
args=chrome_args,
|
||||
ignore_default_args=["--enable-automation"],
|
||||
**_build_proxy_kwargs(proxy),
|
||||
**context_kwargs,
|
||||
)
|
||||
|
||||
# Patch close() to also stop the Playwright instance
|
||||
_original_close = context.close
|
||||
|
||||
async def _close_with_cleanup() -> None:
|
||||
await _original_close()
|
||||
await pw.stop()
|
||||
|
||||
context.close = _close_with_cleanup
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def launch_context(
|
||||
headless: bool = True,
|
||||
proxy: str | None = None,
|
||||
proxy: str | ProxySettings | None = None,
|
||||
args: list[str] | None = None,
|
||||
stealth_args: bool = True,
|
||||
user_agent: str | None = None,
|
||||
@@ -179,7 +389,7 @@ def launch_context(
|
||||
|
||||
Args:
|
||||
headless: Run in headless mode (default True).
|
||||
proxy: Proxy server URL.
|
||||
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
|
||||
args: Additional Chromium CLI arguments.
|
||||
stealth_args: Include default stealth fingerprint args (default True).
|
||||
user_agent: Custom user agent string.
|
||||
@@ -198,8 +408,11 @@ def launch_context(
|
||||
# Resolve geoip BEFORE launch() to avoid double-resolution and ensure
|
||||
# resolved values flow to both binary flags AND context params
|
||||
timezone_id, locale = _maybe_resolve_geoip(geoip, proxy, timezone_id, locale)
|
||||
# Skip --fingerprint-timezone binary flag: it only applies to the default
|
||||
# context and interferes with Playwright's timezone_id on new contexts.
|
||||
# Timezone is set via browser.new_context(timezone_id=...) below instead.
|
||||
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
|
||||
timezone=timezone_id, locale=locale)
|
||||
timezone=None, locale=locale)
|
||||
|
||||
context_kwargs: dict[str, Any] = {}
|
||||
if user_agent:
|
||||
@@ -238,7 +451,7 @@ def launch_context(
|
||||
|
||||
def _maybe_resolve_geoip(
|
||||
geoip: bool,
|
||||
proxy: str | None,
|
||||
proxy: str | ProxySettings | None,
|
||||
timezone: str | None,
|
||||
locale: str | None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
@@ -248,7 +461,10 @@ def _maybe_resolve_geoip(
|
||||
|
||||
from .geoip import resolve_proxy_geo
|
||||
|
||||
geo_tz, geo_locale = resolve_proxy_geo(proxy)
|
||||
proxy_url = proxy.get("server") if isinstance(proxy, dict) else proxy
|
||||
if not proxy_url:
|
||||
return timezone, locale
|
||||
geo_tz, geo_locale = resolve_proxy_geo(proxy_url)
|
||||
if timezone is None:
|
||||
timezone = geo_tz
|
||||
if locale is None:
|
||||
@@ -302,8 +518,10 @@ def _parse_proxy_url(proxy: str) -> dict[str, Any]:
|
||||
return result
|
||||
|
||||
|
||||
def _build_proxy_kwargs(proxy: str | None) -> dict[str, Any]:
|
||||
def _build_proxy_kwargs(proxy: str | ProxySettings | None) -> dict[str, Any]:
|
||||
"""Build proxy kwargs for Playwright launch."""
|
||||
if proxy is None:
|
||||
return {}
|
||||
if isinstance(proxy, dict):
|
||||
return {"proxy": proxy}
|
||||
return {"proxy": _parse_proxy_url(proxy)}
|
||||
|
||||
+30
-19
@@ -11,17 +11,17 @@ from ._version import __version__
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chromium version shipped with this release.
|
||||
# Different platforms may ship different versions (e.g. Linux gets v145 first,
|
||||
# macOS stays on v142 until Mac builds are ready).
|
||||
# Different platforms may ship different versions during transition periods.
|
||||
# CHROMIUM_VERSION is the latest across all platforms (for display/reference).
|
||||
# Use get_chromium_version() for the current platform's actual version.
|
||||
# ---------------------------------------------------------------------------
|
||||
CHROMIUM_VERSION = "145.0.7632.109"
|
||||
CHROMIUM_VERSION = "145.0.7632.109.2"
|
||||
|
||||
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
|
||||
"linux-x64": "145.0.7632.109",
|
||||
"darwin-arm64": "142.0.7444.175",
|
||||
"darwin-x64": "142.0.7444.175",
|
||||
"linux-x64": "145.0.7632.109.2",
|
||||
"darwin-arm64": "145.0.7632.109.2",
|
||||
"darwin-x64": "145.0.7632.109.2",
|
||||
"windows-x64": "145.0.7632.109.2",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -47,18 +47,17 @@ def get_default_stealth_args() -> list[str]:
|
||||
# Tell the fingerprint patches we're on macOS so GPU/UA match natively
|
||||
return base + [
|
||||
"--fingerprint-platform=macos",
|
||||
"--fingerprint-gpu-vendor=Google Inc. (Apple)",
|
||||
"--fingerprint-gpu-renderer=ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)",
|
||||
]
|
||||
|
||||
# Linux: spoof as Windows
|
||||
# Linux/Windows: Windows fingerprint profile
|
||||
# Hardware concurrency, device memory, screen, and window size are
|
||||
# auto-generated by the binary from the seed (v14+).
|
||||
return base + [
|
||||
"--fingerprint-platform=windows",
|
||||
"--fingerprint-hardware-concurrency=8",
|
||||
"--fingerprint-device-memory=8",
|
||||
"--fingerprint-gpu-vendor=NVIDIA Corporation",
|
||||
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
|
||||
"--fingerprint-screen-width=1920",
|
||||
"--fingerprint-screen-height=1080",
|
||||
"--window-size=1920,1080",
|
||||
]
|
||||
|
||||
|
||||
@@ -77,6 +76,8 @@ SUPPORTED_PLATFORMS: dict[tuple[str, str], str] = {
|
||||
("Linux", "aarch64"): "linux-arm64",
|
||||
("Darwin", "arm64"): "darwin-arm64",
|
||||
("Darwin", "x86_64"): "darwin-x64",
|
||||
("Windows", "AMD64"): "windows-x64",
|
||||
("Windows", "x86_64"): "windows-x64",
|
||||
}
|
||||
|
||||
# Platforms with pre-built binaries available for download (derived from version map).
|
||||
@@ -130,6 +131,8 @@ def get_binary_path(version: str | None = None) -> Path:
|
||||
if platform.system() == "Darwin":
|
||||
# macOS: Chromium.app bundle
|
||||
return binary_dir / "Chromium.app" / "Contents" / "MacOS" / "Chromium"
|
||||
elif platform.system() == "Windows":
|
||||
return binary_dir / "chrome.exe"
|
||||
else:
|
||||
# Linux: flat binary
|
||||
return binary_dir / "chrome"
|
||||
@@ -148,9 +151,8 @@ def check_platform_available() -> None:
|
||||
available = ", ".join(sorted(AVAILABLE_PLATFORMS))
|
||||
import sys
|
||||
sys.exit(
|
||||
f"\n\033[1mCloakBrowser\033[0m — Pre-built binaries are currently only available for: {available}.\n"
|
||||
f"Windows builds are coming soon.\n\n"
|
||||
f"To use CloakBrowser now, run in Docker (see README) or set CLOAKBROWSER_BINARY_PATH."
|
||||
f"\n\033[1mCloakBrowser\033[0m — Pre-built binaries are currently only available for: {available}.\n\n"
|
||||
f"To use CloakBrowser now, set CLOAKBROWSER_BINARY_PATH to a local Chromium binary."
|
||||
)
|
||||
|
||||
|
||||
@@ -202,18 +204,27 @@ GITHUB_DOWNLOAD_BASE_URL = (
|
||||
)
|
||||
|
||||
|
||||
def get_archive_ext() -> str:
|
||||
"""Return the archive extension for the current platform (.zip for Windows, .tar.gz otherwise)."""
|
||||
return ".zip" if platform.system() == "Windows" else ".tar.gz"
|
||||
|
||||
|
||||
def get_archive_name(tag: str | None = None) -> str:
|
||||
"""Return the archive filename for a platform tag (e.g. 'cloakbrowser-linux-x64.tar.gz')."""
|
||||
t = tag or get_platform_tag()
|
||||
return f"cloakbrowser-{t}{get_archive_ext()}"
|
||||
|
||||
|
||||
def get_download_url(version: str | None = None) -> str:
|
||||
"""Return the full download URL for the current platform's binary archive."""
|
||||
v = version or get_chromium_version()
|
||||
tag = get_platform_tag()
|
||||
return f"{DOWNLOAD_BASE_URL}/chromium-v{v}/cloakbrowser-{tag}.tar.gz"
|
||||
return f"{DOWNLOAD_BASE_URL}/chromium-v{v}/{get_archive_name()}"
|
||||
|
||||
|
||||
def get_fallback_download_url(version: str | None = None) -> str:
|
||||
"""Return the GitHub Releases fallback URL for the binary archive."""
|
||||
v = version or get_chromium_version()
|
||||
tag = get_platform_tag()
|
||||
return f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}/cloakbrowser-{tag}.tar.gz"
|
||||
return f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}/{get_archive_name()}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+45
-23
@@ -28,6 +28,8 @@ from .config import (
|
||||
GITHUB_DOWNLOAD_BASE_URL,
|
||||
_version_newer,
|
||||
check_platform_available,
|
||||
get_archive_ext,
|
||||
get_archive_name,
|
||||
get_binary_dir,
|
||||
get_binary_path,
|
||||
get_cache_dir,
|
||||
@@ -123,7 +125,7 @@ def _download_and_extract(version: str | None = None) -> None:
|
||||
binary_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Download to temp file first (atomic — no partial downloads in cache)
|
||||
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
|
||||
with tempfile.NamedTemporaryFile(suffix=get_archive_ext(), delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
try:
|
||||
@@ -155,7 +157,7 @@ def _download_and_extract(version: str | None = None) -> None:
|
||||
def _verify_download_checksum(file_path: Path, version: str | None = None) -> None:
|
||||
"""Fetch SHA256SUMS and verify the downloaded file. Warn if unavailable, fail on mismatch."""
|
||||
checksums = _fetch_checksums(version)
|
||||
tarball_name = f"cloakbrowser-{get_platform_tag()}.tar.gz"
|
||||
tarball_name = get_archive_name()
|
||||
|
||||
if checksums is None:
|
||||
logger.warning("SHA256SUMS not available for this release — skipping checksum verification")
|
||||
@@ -256,7 +258,7 @@ def _download_file(url: str, dest: Path) -> None:
|
||||
def _extract_archive(
|
||||
archive_path: Path, dest_dir: Path, binary_path: Path | None = None
|
||||
) -> None:
|
||||
"""Extract tar.gz archive to destination directory."""
|
||||
"""Extract tar.gz or zip archive to destination directory."""
|
||||
logger.info("Extracting to %s", dest_dir)
|
||||
|
||||
# Clean existing dir if partial download existed
|
||||
@@ -266,26 +268,12 @@ def _extract_archive(
|
||||
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tarfile.open(archive_path, "r:gz") as tar:
|
||||
# Security: prevent path traversal
|
||||
safe_members = []
|
||||
for member in tar.getmembers():
|
||||
# Allow symlinks — macOS .app bundles require them (Framework layout)
|
||||
if member.issym() or member.islnk():
|
||||
link_target = member.linkname
|
||||
# Reject symlinks that escape the dest dir
|
||||
if os.path.isabs(link_target) or ".." in link_target.split("/"):
|
||||
logger.warning("Skipping suspicious symlink: %s -> %s", member.name, link_target)
|
||||
continue
|
||||
if str(archive_path).endswith(".zip"):
|
||||
_extract_zip(archive_path, dest_dir)
|
||||
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)
|
||||
_extract_tar(archive_path, dest_dir)
|
||||
|
||||
tar.extractall(dest_dir, members=safe_members)
|
||||
|
||||
# If tar extracted into a single subdirectory, flatten it
|
||||
# If extracted into a single subdirectory, flatten it
|
||||
# (e.g. fingerprint-chromium-142-custom-v2/chrome → chrome)
|
||||
# But never flatten .app bundles — macOS needs the bundle structure intact
|
||||
_flatten_single_subdir(dest_dir)
|
||||
@@ -303,6 +291,38 @@ def _extract_archive(
|
||||
logger.info("Binary ready: %s", bp)
|
||||
|
||||
|
||||
def _extract_tar(archive_path: Path, dest_dir: Path) -> None:
|
||||
"""Extract tar.gz archive with path traversal protection."""
|
||||
with tarfile.open(archive_path, "r:gz") as tar:
|
||||
safe_members = []
|
||||
for member in tar.getmembers():
|
||||
# Allow symlinks — macOS .app bundles require them (Framework layout)
|
||||
if member.issym() or member.islnk():
|
||||
link_target = member.linkname
|
||||
if os.path.isabs(link_target) or ".." in link_target.split("/"):
|
||||
logger.warning("Skipping suspicious symlink: %s -> %s", member.name, link_target)
|
||||
continue
|
||||
else:
|
||||
member_path = (dest_dir / member.name).resolve()
|
||||
if not str(member_path).startswith(str(dest_dir.resolve())):
|
||||
raise RuntimeError(f"Archive contains path traversal: {member.name}")
|
||||
safe_members.append(member)
|
||||
|
||||
tar.extractall(dest_dir, members=safe_members)
|
||||
|
||||
|
||||
def _extract_zip(archive_path: Path, dest_dir: Path) -> None:
|
||||
"""Extract zip archive with path traversal protection."""
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile(archive_path, "r") as zf:
|
||||
for info in zf.infolist():
|
||||
member_path = (dest_dir / info.filename).resolve()
|
||||
if not str(member_path).startswith(str(dest_dir.resolve())):
|
||||
raise RuntimeError(f"Archive contains path traversal: {info.filename}")
|
||||
zf.extractall(dest_dir)
|
||||
|
||||
|
||||
def _flatten_single_subdir(dest_dir: Path) -> None:
|
||||
"""If extraction created a single subdirectory, move its contents up.
|
||||
|
||||
@@ -330,7 +350,9 @@ def _is_executable(path: Path) -> bool:
|
||||
|
||||
|
||||
def _make_executable(path: Path) -> None:
|
||||
"""Make a file executable (chmod +x)."""
|
||||
"""Make a file executable (chmod +x). Skipped on Windows (no-op / AV lock risk)."""
|
||||
if platform.system() == "Windows":
|
||||
return
|
||||
current = path.stat().st_mode
|
||||
path.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
@@ -433,7 +455,7 @@ def _get_latest_chromium_version() -> str | None:
|
||||
GITHUB_API_URL, params={"per_page": 10}, timeout=10.0
|
||||
)
|
||||
resp.raise_for_status()
|
||||
platform_tarball = f"cloakbrowser-{get_platform_tag()}.tar.gz"
|
||||
platform_tarball = get_archive_name()
|
||||
for release in resp.json():
|
||||
tag = release.get("tag_name", "")
|
||||
if tag.startswith("chromium-v") and not release.get("draft"):
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Persistent context example: cookies and localStorage survive across sessions."""
|
||||
|
||||
from cloakbrowser import launch_persistent_context
|
||||
|
||||
PROFILE_DIR = "./my-profile"
|
||||
|
||||
# Session 1 — set some state
|
||||
print("=== Session 1: Setting state ===")
|
||||
ctx = launch_persistent_context(PROFILE_DIR, headless=False)
|
||||
page = ctx.new_page()
|
||||
page.goto("https://example.com")
|
||||
page.evaluate("document.cookie = 'session=abc123; path=/; max-age=3600'")
|
||||
page.evaluate("localStorage.setItem('user', 'returning')")
|
||||
print(f"Cookie: {page.evaluate('document.cookie')}")
|
||||
ls_val = page.evaluate("localStorage.getItem('user')")
|
||||
print(f"localStorage: {ls_val}")
|
||||
ctx.close()
|
||||
|
||||
# Session 2 — state is restored
|
||||
print("\n=== Session 2: Verifying persistence ===")
|
||||
ctx = launch_persistent_context(PROFILE_DIR, headless=False)
|
||||
page = ctx.new_page()
|
||||
page.goto("https://example.com")
|
||||
print(f"Cookie: {page.evaluate('document.cookie')}")
|
||||
ls_val = page.evaluate("localStorage.getItem('user')")
|
||||
print(f"localStorage: {ls_val}")
|
||||
ctx.close()
|
||||
|
||||
print("\nDone!")
|
||||
@@ -143,11 +143,15 @@ def test_recaptcha(page):
|
||||
"""recaptcha-demo.appspot.com — Google's official reCAPTCHA v3 score."""
|
||||
page.goto(
|
||||
"https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php",
|
||||
wait_until="networkidle",
|
||||
wait_until="domcontentloaded",
|
||||
timeout=30000,
|
||||
)
|
||||
# Page auto-submits via grecaptcha.execute() — wait for backend response
|
||||
time.sleep(8)
|
||||
# Wait for backend response (step3 element appears when score arrives)
|
||||
try:
|
||||
page.wait_for_selector("li.step3", timeout=20000)
|
||||
time.sleep(1)
|
||||
except Exception:
|
||||
time.sleep(10) # fallback
|
||||
|
||||
results = page.evaluate("""() => {
|
||||
const text = document.body.innerText;
|
||||
|
||||
+34
-6
@@ -11,7 +11,7 @@
|
||||
|
||||
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.
|
||||
|
||||
- 🔒 **25 source-level C++ patches** — not JS injection, not config flags
|
||||
- 🔒 **26 source-level C++ patches** — not JS injection, not config flags
|
||||
- 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified
|
||||
- ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 30/30 tests
|
||||
- 🔄 **Drop-in replacement** — works with both Playwright and Puppeteer
|
||||
@@ -60,19 +60,24 @@ await browser.close();
|
||||
### Options
|
||||
|
||||
```javascript
|
||||
import { launch, launchContext } from 'cloakbrowser';
|
||||
import { launch, launchContext, launchPersistentContext } from 'cloakbrowser';
|
||||
|
||||
// With proxy
|
||||
const browser = await launch({
|
||||
proxy: 'http://user:pass@proxy:8080',
|
||||
});
|
||||
|
||||
// With proxy object (bypass, separate auth fields)
|
||||
const browser = await launch({
|
||||
proxy: { server: 'http://proxy:8080', bypass: '.google.com', username: 'user', password: 'pass' },
|
||||
});
|
||||
|
||||
// Headed mode (visible browser window)
|
||||
const browser = await launch({ headless: false });
|
||||
|
||||
// Extra Chrome args
|
||||
const browser = await launch({
|
||||
args: ['--window-size=1920,1080'],
|
||||
args: ['--fingerprint=12345'],
|
||||
});
|
||||
|
||||
// With timezone and locale (sets --fingerprint-timezone and --lang binary flags)
|
||||
@@ -94,6 +99,16 @@ const context = await launchContext({
|
||||
locale: 'en-US',
|
||||
timezoneId: 'America/New_York',
|
||||
});
|
||||
|
||||
// Persistent profile — stay logged in, bypass incognito detection, load extensions
|
||||
const ctx = await launchPersistentContext({
|
||||
userDataDir: './chrome-profile',
|
||||
headless: false,
|
||||
proxy: 'http://user:pass@proxy:8080',
|
||||
});
|
||||
const page = ctx.pages()[0] || await ctx.newPage();
|
||||
await page.goto('https://example.com');
|
||||
await ctx.close(); // profile saved — reuse same path to restore state
|
||||
```
|
||||
|
||||
### Auto Timezone/Locale from Proxy IP
|
||||
@@ -176,9 +191,7 @@ const page = await browser.newPage();
|
||||
| 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`.
|
||||
| Windows x86_64 | ✅ Available |
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -187,6 +200,21 @@ const page = await browser.newPage();
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Site detects incognito / private browsing mode**
|
||||
|
||||
By default, `launch()` opens an incognito context. Some sites (like BrowserScan) detect this. Use `launchPersistentContext()` instead — it runs with a real user profile:
|
||||
|
||||
```javascript
|
||||
import { launchPersistentContext } from 'cloakbrowser';
|
||||
|
||||
const ctx = await launchPersistentContext({
|
||||
userDataDir: './my-profile',
|
||||
headless: false,
|
||||
});
|
||||
```
|
||||
|
||||
This also gives you cookie and localStorage persistence across sessions.
|
||||
|
||||
**reCAPTCHA v3 scores are low (0.1–0.3)**
|
||||
|
||||
Avoid `page.waitForTimeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Persistent context example: cookies and localStorage survive across sessions.
|
||||
*
|
||||
* Usage:
|
||||
* CLOAKBROWSER_BINARY_PATH=/path/to/chrome npx tsx examples/persistent-context.ts
|
||||
*/
|
||||
|
||||
import { launchPersistentContext } from "../src/index.js";
|
||||
|
||||
const PROFILE_DIR = "./my-profile";
|
||||
|
||||
// Session 1 — set some state
|
||||
console.log("=== Session 1: Setting state ===");
|
||||
let ctx = await launchPersistentContext({
|
||||
userDataDir: PROFILE_DIR,
|
||||
headless: false,
|
||||
});
|
||||
let page = ctx.pages()[0] || (await ctx.newPage());
|
||||
await page.goto("https://example.com");
|
||||
await page.evaluate(() => {
|
||||
document.cookie = "session=abc123; path=/; max-age=3600";
|
||||
localStorage.setItem("user", "returning");
|
||||
});
|
||||
console.log(`Cookie: ${await page.evaluate(() => document.cookie)}`);
|
||||
console.log(`localStorage: ${await page.evaluate(() => localStorage.getItem("user"))}`);
|
||||
await ctx.close();
|
||||
|
||||
// Session 2 — state is restored
|
||||
console.log("\n=== Session 2: Verifying persistence ===");
|
||||
ctx = await launchPersistentContext({
|
||||
userDataDir: PROFILE_DIR,
|
||||
headless: false,
|
||||
});
|
||||
page = ctx.pages()[0] || (await ctx.newPage());
|
||||
await page.goto("https://example.com");
|
||||
console.log(`Cookie: ${await page.evaluate(() => document.cookie)}`);
|
||||
console.log(`localStorage: ${await page.evaluate(() => localStorage.getItem("user"))}`);
|
||||
await ctx.close();
|
||||
|
||||
console.log("\nDone!");
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloakbrowser",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.6",
|
||||
"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",
|
||||
|
||||
+32
-20
@@ -23,17 +23,17 @@ export { WRAPPER_VERSION };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chromium version shipped with this release.
|
||||
// Different platforms may ship different versions (e.g. Linux gets v145 first,
|
||||
// macOS stays on v142 until Mac builds are ready).
|
||||
// Different platforms may ship different versions during transition periods.
|
||||
// CHROMIUM_VERSION is the latest across all platforms (for display/reference).
|
||||
// Use getChromiumVersion() for the current platform's actual version.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CHROMIUM_VERSION = "145.0.7632.109";
|
||||
export const CHROMIUM_VERSION = "145.0.7632.109.2";
|
||||
|
||||
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
|
||||
"linux-x64": "145.0.7632.109",
|
||||
"darwin-arm64": "142.0.7444.175",
|
||||
"darwin-x64": "142.0.7444.175",
|
||||
"linux-x64": "145.0.7632.109.2",
|
||||
"darwin-arm64": "145.0.7632.109.2",
|
||||
"darwin-x64": "145.0.7632.109.2",
|
||||
"windows-x64": "145.0.7632.109.2",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -44,6 +44,7 @@ const SUPPORTED_PLATFORMS: Record<string, string> = {
|
||||
"linux-arm64": "linux-arm64",
|
||||
"darwin-arm64": "darwin-arm64",
|
||||
"darwin-x64": "darwin-x64",
|
||||
"win32-x64": "windows-x64",
|
||||
};
|
||||
|
||||
// Platforms with pre-built binaries available for download (derived from version map).
|
||||
@@ -64,6 +65,7 @@ export function getPlatformTag(): string {
|
||||
else if (platform === "linux" && arch === "arm64") key = "linux-arm64";
|
||||
else if (platform === "darwin" && arch === "arm64") key = "darwin-arm64";
|
||||
else if (platform === "darwin" && arch === "x64") key = "darwin-x64";
|
||||
else if (platform === "win32" && arch === "x64") key = "win32-x64";
|
||||
else {
|
||||
const supported = Object.values(SUPPORTED_PLATFORMS).join(", ");
|
||||
throw new Error(
|
||||
@@ -92,6 +94,9 @@ export function getBinaryPath(version?: string): string {
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(binaryDir, "Chromium.app", "Contents", "MacOS", "Chromium");
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return path.join(binaryDir, "chrome.exe");
|
||||
}
|
||||
return path.join(binaryDir, "chrome");
|
||||
}
|
||||
|
||||
@@ -102,9 +107,8 @@ export function checkPlatformAvailable(): void {
|
||||
if (!AVAILABLE_PLATFORMS.has(tag)) {
|
||||
const available = [...AVAILABLE_PLATFORMS].sort().join(", ");
|
||||
throw new Error(
|
||||
`CloakBrowser — Pre-built binaries are currently only available for: ${available}.\n` +
|
||||
`Windows builds are coming soon.\n\n` +
|
||||
`To use CloakBrowser now, run in Docker (see README) or set CLOAKBROWSER_BINARY_PATH.`
|
||||
`CloakBrowser — Pre-built binaries are currently only available for: ${available}.\n\n` +
|
||||
`To use CloakBrowser now, set CLOAKBROWSER_BINARY_PATH to a local Chromium binary.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -122,16 +126,22 @@ export const GITHUB_API_URL =
|
||||
export const GITHUB_DOWNLOAD_BASE_URL =
|
||||
"https://github.com/CloakHQ/cloakbrowser/releases/download";
|
||||
|
||||
export function getArchiveExt(): string {
|
||||
return process.platform === "win32" ? ".zip" : ".tar.gz";
|
||||
}
|
||||
|
||||
export function getArchiveName(tag?: string): string {
|
||||
return `cloakbrowser-${tag || getPlatformTag()}${getArchiveExt()}`;
|
||||
}
|
||||
|
||||
export function getDownloadUrl(version?: string): string {
|
||||
const v = version || getChromiumVersion();
|
||||
const tag = getPlatformTag();
|
||||
return `${DOWNLOAD_BASE_URL}/chromium-v${v}/cloakbrowser-${tag}.tar.gz`;
|
||||
return `${DOWNLOAD_BASE_URL}/chromium-v${v}/${getArchiveName()}`;
|
||||
}
|
||||
|
||||
export function getFallbackDownloadUrl(version?: string): string {
|
||||
const v = version || getChromiumVersion();
|
||||
const tag = getPlatformTag();
|
||||
return `${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/cloakbrowser-${tag}.tar.gz`;
|
||||
return `${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/${getArchiveName()}`;
|
||||
}
|
||||
|
||||
export function getEffectiveVersion(): string {
|
||||
@@ -198,19 +208,21 @@ export function getDefaultStealthArgs(): string[] {
|
||||
|
||||
if (isMac) {
|
||||
// macOS: run as native Mac browser — GPU/UA match natively
|
||||
return [...base, "--fingerprint-platform=macos"];
|
||||
return [
|
||||
...base,
|
||||
"--fingerprint-platform=macos",
|
||||
"--fingerprint-gpu-vendor=Google Inc. (Apple)",
|
||||
"--fingerprint-gpu-renderer=ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)",
|
||||
];
|
||||
}
|
||||
|
||||
// Linux: spoof as Windows
|
||||
// Linux/Windows: spoof as Windows desktop
|
||||
// Hardware concurrency, device memory, screen, and window size are
|
||||
// auto-generated by the binary from the seed (v14+).
|
||||
return [
|
||||
...base,
|
||||
"--fingerprint-platform=windows",
|
||||
"--fingerprint-hardware-concurrency=8",
|
||||
"--fingerprint-device-memory=8",
|
||||
"--fingerprint-gpu-vendor=NVIDIA Corporation",
|
||||
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
|
||||
"--fingerprint-screen-width=1920",
|
||||
"--fingerprint-screen-height=1080",
|
||||
"--window-size=1920,1080",
|
||||
];
|
||||
}
|
||||
|
||||
+65
-26
@@ -19,6 +19,8 @@ import {
|
||||
GITHUB_DOWNLOAD_BASE_URL,
|
||||
WRAPPER_VERSION,
|
||||
checkPlatformAvailable,
|
||||
getArchiveExt,
|
||||
getArchiveName,
|
||||
getBinaryDir,
|
||||
getBinaryPath,
|
||||
getCacheDir,
|
||||
@@ -152,7 +154,7 @@ async function downloadAndExtract(version?: string): Promise<void> {
|
||||
// Download to temp file (atomic — no partial downloads in cache)
|
||||
const tmpPath = path.join(
|
||||
path.dirname(binaryDir),
|
||||
`_download_${Date.now()}.tar.gz`
|
||||
`_download_${Date.now()}${getArchiveExt()}`
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -194,7 +196,7 @@ async function downloadAndExtract(version?: string): Promise<void> {
|
||||
|
||||
async function verifyDownloadChecksum(filePath: string, version?: string): Promise<void> {
|
||||
const checksums = await fetchChecksums(version);
|
||||
const tarballName = `cloakbrowser-${getPlatformTag()}.tar.gz`;
|
||||
const tarballName = getArchiveName();
|
||||
|
||||
if (!checksums) {
|
||||
console.warn("[cloakbrowser] SHA256SUMS not available for this release — skipping checksum verification");
|
||||
@@ -274,6 +276,9 @@ async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
|
||||
|
||||
// Create file stream early so we can ensure cleanup on error
|
||||
const fileStream = createWriteStream(dest);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
@@ -292,7 +297,6 @@ async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
let downloaded = 0;
|
||||
let lastLoggedPct = -1;
|
||||
|
||||
const fileStream = createWriteStream(dest);
|
||||
const reader = response.body.getReader();
|
||||
|
||||
// Stream chunks to file with progress logging
|
||||
@@ -316,19 +320,32 @@ async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for file stream to finish
|
||||
// Wait for file stream to fully close (not just finish)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
fileStream.end(() => resolve());
|
||||
fileStream.end();
|
||||
fileStream.on("close", () => resolve());
|
||||
fileStream.on("error", reject);
|
||||
});
|
||||
|
||||
const sizeMB = Math.floor(fs.statSync(dest).size / (1024 * 1024));
|
||||
console.log(`[cloakbrowser] Download complete: ${sizeMB} MB`);
|
||||
} catch (err) {
|
||||
// Ensure file stream is destroyed on error to release the handle
|
||||
if (!fileStream.destroyed) {
|
||||
await new Promise<void>((resolve) => {
|
||||
fileStream.destroy();
|
||||
fileStream.on("close", () => resolve());
|
||||
// Safety timeout in case close never fires
|
||||
setTimeout(resolve, 2000);
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function extractArchive(
|
||||
archivePath: string,
|
||||
destDir: string,
|
||||
@@ -342,30 +359,18 @@ async function extractArchive(
|
||||
}
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
|
||||
// Extract with tar — the 'tar' package handles symlink/traversal safety
|
||||
await tarExtract({
|
||||
file: archivePath,
|
||||
cwd: destDir,
|
||||
// Security: strip leading path components and reject absolute paths
|
||||
strip: 0,
|
||||
filter: (entryPath: string) => {
|
||||
// Reject absolute paths and path traversal
|
||||
if (path.isAbsolute(entryPath) || entryPath.includes("..")) {
|
||||
console.warn(
|
||||
`[cloakbrowser] Skipping suspicious archive entry: ${entryPath}`
|
||||
);
|
||||
return false;
|
||||
if (archivePath.endsWith(".zip")) {
|
||||
await extractZip(archivePath, destDir);
|
||||
} else {
|
||||
await extractTar(archivePath, destDir);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// Flatten single subdirectory if needed
|
||||
flattenSingleSubdir(destDir);
|
||||
|
||||
// Make binary executable
|
||||
// Make binary executable (skip on Windows — no-op / AV lock risk)
|
||||
const bp = binaryPath || getBinaryPath();
|
||||
if (fs.existsSync(bp)) {
|
||||
if (process.platform !== "win32" && fs.existsSync(bp)) {
|
||||
fs.chmodSync(bp, 0o755);
|
||||
}
|
||||
|
||||
@@ -379,6 +384,40 @@ async function extractArchive(
|
||||
}
|
||||
}
|
||||
|
||||
async function extractTar(archivePath: string, destDir: string): Promise<void> {
|
||||
await tarExtract({
|
||||
file: archivePath,
|
||||
cwd: destDir,
|
||||
strip: 0,
|
||||
filter: (entryPath: string) => {
|
||||
if (path.isAbsolute(entryPath) || entryPath.includes("..")) {
|
||||
console.warn(
|
||||
`[cloakbrowser] Skipping suspicious archive entry: ${entryPath}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function extractZip(archivePath: string, destDir: string): Promise<void> {
|
||||
// Brief delay to ensure OS fully releases file handles (Windows)
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
if (process.platform === "win32") {
|
||||
// PowerShell 5.1's Expand-Archive uses .NET FileStream which can conflict
|
||||
// with recently-closed Node.js file handles. Use ZipFile API directly.
|
||||
execFileSync("powershell", [
|
||||
"-NoProfile", "-Command",
|
||||
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
|
||||
`[System.IO.Compression.ZipFile]::ExtractToDirectory('${archivePath}', '${destDir}')`,
|
||||
], { timeout: 120_000 });
|
||||
} else {
|
||||
execFileSync("unzip", ["-o", archivePath, "-d", destDir], { timeout: 120_000 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If extraction created a single subdirectory, move its contents up.
|
||||
* Many tarballs wrap files in a top-level directory.
|
||||
@@ -452,7 +491,7 @@ export async function getLatestChromiumVersion(): Promise<string | null> {
|
||||
draft: boolean;
|
||||
assets: Array<{ name: string }>;
|
||||
}>;
|
||||
const platformTarball = `cloakbrowser-${getPlatformTag()}.tar.gz`;
|
||||
const platformTarball = getArchiveName();
|
||||
for (const release of releases) {
|
||||
if (release.tag_name.startsWith("chromium-v") && !release.draft) {
|
||||
const assetNames = new Set(
|
||||
@@ -547,10 +586,10 @@ async function checkAndDownloadUpdate(): Promise<void> {
|
||||
function maybeTriggerUpdateCheck(): void {
|
||||
// Wrapper update: once per process, not rate-limited
|
||||
if (!wrapperUpdateChecked) {
|
||||
checkWrapperUpdate().catch(() => {});
|
||||
checkWrapperUpdate().catch(() => { });
|
||||
}
|
||||
|
||||
// Binary update: rate-limited to once per hour
|
||||
if (!shouldCheckForUpdate()) return;
|
||||
checkAndDownloadUpdate().catch(() => {});
|
||||
checkAndDownloadUpdate().catch(() => { });
|
||||
}
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
// Launch functions (Playwright API)
|
||||
export { launch, launchContext } from "./playwright.js";
|
||||
export { launch, launchContext, launchPersistentContext } from "./playwright.js";
|
||||
|
||||
// Binary management
|
||||
export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download.js";
|
||||
@@ -25,4 +25,4 @@ export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download
|
||||
export { CHROMIUM_VERSION, getDefaultStealthArgs } from "./config.js";
|
||||
|
||||
// Types
|
||||
export type { LaunchOptions, LaunchContextOptions, BinaryInfo } from "./types.js";
|
||||
export type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions, BinaryInfo } from "./types.js";
|
||||
|
||||
+60
-4
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { Browser, BrowserContext } from "playwright-core";
|
||||
import type { LaunchOptions, LaunchContextOptions } from "./types.js";
|
||||
import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js";
|
||||
import { DEFAULT_VIEWPORT, getDefaultStealthArgs } from "./config.js";
|
||||
import { ensureBinary } from "./download.js";
|
||||
import { parseProxyUrl } from "./proxy.js";
|
||||
@@ -34,7 +34,9 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
headless: options.headless ?? true,
|
||||
args,
|
||||
ignoreDefaultArgs: ["--enable-automation"],
|
||||
...(options.proxy ? { proxy: parseProxyUrl(options.proxy) } : {}),
|
||||
...(options.proxy
|
||||
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
|
||||
: {}),
|
||||
...options.launchOptions,
|
||||
});
|
||||
|
||||
@@ -62,7 +64,10 @@ export async function launchContext(
|
||||
): Promise<BrowserContext> {
|
||||
// Resolve geoip BEFORE launch() to avoid double-resolution
|
||||
const resolved = await maybeResolveGeoip(options);
|
||||
const browser = await launch({ ...options, ...resolved, geoip: false });
|
||||
// Skip --fingerprint-timezone binary flag: it only applies to the default
|
||||
// context and interferes with Playwright's timezoneId on new contexts.
|
||||
// Timezone is set via browser.newContext(timezoneId: ...) below instead.
|
||||
const browser = await launch({ ...options, ...resolved, geoip: false, timezone: undefined });
|
||||
|
||||
let context: BrowserContext;
|
||||
try {
|
||||
@@ -88,6 +93,55 @@ export async function launchContext(
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch stealth browser with a persistent user profile (non-incognito).
|
||||
* Uses Playwright's chromium.launchPersistentContext() under the hood.
|
||||
*
|
||||
* This avoids incognito detection by services like BrowserScan (-10% penalty)
|
||||
* and enables session persistence (cookies, localStorage) across launches.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { launchPersistentContext } from 'cloakbrowser';
|
||||
* const context = await launchPersistentContext({
|
||||
* userDataDir: './chrome-profile',
|
||||
* headless: false,
|
||||
* proxy: 'http://user:pass@host:port',
|
||||
* geoip: true,
|
||||
* });
|
||||
* const page = context.pages()[0] || await context.newPage();
|
||||
* await page.goto('https://example.com');
|
||||
* await context.close();
|
||||
* ```
|
||||
*/
|
||||
export async function launchPersistentContext(
|
||||
options: LaunchPersistentContextOptions
|
||||
): Promise<BrowserContext> {
|
||||
const { chromium } = await import("playwright-core");
|
||||
|
||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||
const resolved = await maybeResolveGeoip(options);
|
||||
const args = buildArgs({ ...options, ...resolved });
|
||||
|
||||
const context = await chromium.launchPersistentContext(options.userDataDir, {
|
||||
executablePath: binaryPath,
|
||||
headless: options.headless ?? true,
|
||||
args,
|
||||
ignoreDefaultArgs: ["--enable-automation"],
|
||||
...(options.proxy
|
||||
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
|
||||
: {}),
|
||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
||||
viewport: options.viewport ?? DEFAULT_VIEWPORT,
|
||||
...(resolved.locale ? { locale: resolved.locale } : {}),
|
||||
...(resolved.timezone ? { timezoneId: resolved.timezone } : {}),
|
||||
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
||||
...options.launchOptions,
|
||||
});
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -99,7 +153,9 @@ async function maybeResolveGeoip(
|
||||
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
|
||||
|
||||
const { resolveProxyGeo } = await import("./geoip.js");
|
||||
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(options.proxy);
|
||||
const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
|
||||
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
|
||||
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
|
||||
return {
|
||||
timezone: options.timezone ?? geoTz ?? undefined,
|
||||
locale: options.locale ?? geoLocale ?? undefined,
|
||||
|
||||
+20
-2
@@ -34,10 +34,26 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
// so we strip them and use page.authenticate() instead.
|
||||
let proxyAuth: { username: string; password: string } | undefined;
|
||||
if (options.proxy) {
|
||||
if (typeof options.proxy === "string") {
|
||||
const { server, username, password } = parseProxyUrl(options.proxy);
|
||||
args.push(`--proxy-server=${server}`);
|
||||
if (username) {
|
||||
proxyAuth = { username, password: password || "" };
|
||||
proxyAuth = { username, password: password ?? "" };
|
||||
}
|
||||
} else {
|
||||
// Strip any inline credentials from the server URL — Chromium's
|
||||
// --proxy-server doesn't support them; use page.authenticate() instead.
|
||||
const parsed = parseProxyUrl(options.proxy.server);
|
||||
args.push(`--proxy-server=${parsed.server}`);
|
||||
if (options.proxy.bypass) {
|
||||
args.push(`--proxy-bypass-list=${options.proxy.bypass}`);
|
||||
}
|
||||
// Explicit username/password fields take precedence over inline creds
|
||||
const username = options.proxy.username ?? parsed.username;
|
||||
const password = options.proxy.password ?? parsed.password;
|
||||
if (username) {
|
||||
proxyAuth = { username, password: password ?? "" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +90,9 @@ async function maybeResolveGeoip(
|
||||
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
|
||||
|
||||
const { resolveProxyGeo } = await import("./geoip.js");
|
||||
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(options.proxy);
|
||||
const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
|
||||
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
|
||||
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
|
||||
return {
|
||||
timezone: options.timezone ?? geoTz ?? undefined,
|
||||
locale: options.locale ?? geoLocale ?? undefined,
|
||||
|
||||
+12
-2
@@ -5,8 +5,13 @@
|
||||
export interface LaunchOptions {
|
||||
/** Run in headless mode (default: true). */
|
||||
headless?: boolean;
|
||||
/** Proxy server URL, e.g. 'http://proxy:8080' or 'socks5://proxy:1080'. */
|
||||
proxy?: string;
|
||||
/**
|
||||
* Proxy server — URL string or Playwright proxy object.
|
||||
* String: 'http://user:pass@proxy:8080' (credentials auto-extracted).
|
||||
* Object: { server: "http://proxy:8080", bypass: ".google.com", ... }
|
||||
* — passed directly to Playwright.
|
||||
*/
|
||||
proxy?: string | { server: string; bypass?: string; username?: string; password?: string };
|
||||
/** Additional Chromium CLI arguments. */
|
||||
args?: string[];
|
||||
/** Include default stealth fingerprint args (default: true). Set false to use custom --fingerprint flags. */
|
||||
@@ -34,6 +39,11 @@ export interface LaunchContextOptions extends LaunchOptions {
|
||||
colorScheme?: "light" | "dark" | "no-preference";
|
||||
}
|
||||
|
||||
export interface LaunchPersistentContextOptions extends LaunchContextOptions {
|
||||
/** Path to user data directory for persistent profile. */
|
||||
userDataDir: string;
|
||||
}
|
||||
|
||||
export interface BinaryInfo {
|
||||
version: string;
|
||||
platform: string;
|
||||
|
||||
@@ -7,7 +7,7 @@ describe("binaryInfo", () => {
|
||||
const info = binaryInfo();
|
||||
|
||||
expect(info.version).toBe(getChromiumVersion());
|
||||
expect(info.platform).toMatch(/^(linux|darwin)-(x64|arm64)$/);
|
||||
expect(info.platform).toMatch(/^(linux|darwin|windows)-(x64|arm64)$/);
|
||||
expect(info.binaryPath).toBeTruthy();
|
||||
expect(typeof info.installed).toBe("boolean");
|
||||
expect(info.cacheDir).toContain("cloakbrowser");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseProxyUrl } from "../src/proxy.js";
|
||||
import type { LaunchOptions } from "../src/types.js";
|
||||
|
||||
describe("parseProxyUrl", () => {
|
||||
it("passes through URL without credentials", () => {
|
||||
@@ -47,3 +48,37 @@ describe("parseProxyUrl", () => {
|
||||
expect(parseProxyUrl("not-a-url")).toEqual({ server: "not-a-url" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("proxy dict type", () => {
|
||||
it("accepts string proxy in LaunchOptions", () => {
|
||||
const opts: LaunchOptions = { proxy: "http://proxy:8080" };
|
||||
expect(typeof opts.proxy).toBe("string");
|
||||
});
|
||||
|
||||
it("accepts dict proxy with bypass in LaunchOptions", () => {
|
||||
const opts: LaunchOptions = {
|
||||
proxy: { server: "http://proxy:8080", bypass: ".google.com,localhost" },
|
||||
};
|
||||
expect(typeof opts.proxy).toBe("object");
|
||||
if (typeof opts.proxy === "object") {
|
||||
expect(opts.proxy.server).toBe("http://proxy:8080");
|
||||
expect(opts.proxy.bypass).toBe(".google.com,localhost");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts dict proxy with auth and bypass in LaunchOptions", () => {
|
||||
const opts: LaunchOptions = {
|
||||
proxy: {
|
||||
server: "http://proxy:8080",
|
||||
username: "user",
|
||||
password: "pass",
|
||||
bypass: ".example.com",
|
||||
},
|
||||
};
|
||||
if (typeof opts.proxy === "object") {
|
||||
expect(opts.proxy.username).toBe("user");
|
||||
expect(opts.proxy.password).toBe("pass");
|
||||
expect(opts.proxy.bypass).toBe(".example.com");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,7 +100,7 @@ describe("latest version (platform-aware)", () => {
|
||||
{
|
||||
tag_name: "chromium-v145.0.7718.0",
|
||||
draft: false,
|
||||
assets: makeAssets(["linux-x64", "darwin-arm64", "darwin-x64"]),
|
||||
assets: makeAssets(["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]),
|
||||
},
|
||||
]);
|
||||
expect(await getLatestChromiumVersion()).toBe("145.0.7718.0");
|
||||
@@ -116,7 +116,7 @@ describe("latest version (platform-aware)", () => {
|
||||
{
|
||||
tag_name: "chromium-v142.0.7444.175",
|
||||
draft: false,
|
||||
assets: makeAssets(["linux-x64", "darwin-arm64", "darwin-x64"]),
|
||||
assets: makeAssets(["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]),
|
||||
},
|
||||
]);
|
||||
const result = await getLatestChromiumVersion();
|
||||
@@ -133,14 +133,14 @@ describe("latest version (platform-aware)", () => {
|
||||
{
|
||||
tag_name: "chromium-v145.0.7718.0",
|
||||
draft: false,
|
||||
assets: [{ name: "cloakbrowser-windows-x64.tar.gz" }],
|
||||
assets: [{ name: "cloakbrowser-freebsd-x64.tar.gz" }],
|
||||
},
|
||||
]);
|
||||
expect(await getLatestChromiumVersion()).toBeNull();
|
||||
});
|
||||
|
||||
it("skips draft releases", async () => {
|
||||
const all = ["linux-x64", "darwin-arm64", "darwin-x64"];
|
||||
const all = ["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"];
|
||||
mockFetch([
|
||||
{ tag_name: "chromium-v999.0.0.0", draft: true, assets: makeAssets(all) },
|
||||
{ tag_name: "chromium-v145.0.7718.0", draft: false, assets: makeAssets(all) },
|
||||
|
||||
+51
-1
@@ -1,6 +1,8 @@
|
||||
"""Tests for proxy URL parsing and credential extraction."""
|
||||
|
||||
from cloakbrowser.browser import _build_proxy_kwargs, _parse_proxy_url
|
||||
from unittest.mock import patch
|
||||
|
||||
from cloakbrowser.browser import _build_proxy_kwargs, _maybe_resolve_geoip, _parse_proxy_url
|
||||
|
||||
|
||||
class TestParseProxyUrl:
|
||||
@@ -48,3 +50,51 @@ class TestBuildProxyKwargs:
|
||||
assert result == {
|
||||
"proxy": {"server": "http://proxy:8080", "username": "user", "password": "pass"}
|
||||
}
|
||||
|
||||
def test_proxy_dict_passthrough(self):
|
||||
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com,localhost"}
|
||||
result = _build_proxy_kwargs(proxy_dict)
|
||||
assert result == {"proxy": proxy_dict}
|
||||
|
||||
def test_proxy_dict_with_auth(self):
|
||||
proxy_dict = {
|
||||
"server": "http://proxy:8080",
|
||||
"username": "user",
|
||||
"password": "pass",
|
||||
"bypass": ".example.com",
|
||||
}
|
||||
result = _build_proxy_kwargs(proxy_dict)
|
||||
assert result == {"proxy": proxy_dict}
|
||||
|
||||
|
||||
class TestMaybeResolveGeoip:
|
||||
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
|
||||
def test_geoip_with_string_proxy(self, mock_geo):
|
||||
tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", None, None)
|
||||
mock_geo.assert_called_once_with("http://proxy:8080")
|
||||
assert tz == "America/New_York"
|
||||
assert locale == "en-US"
|
||||
|
||||
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Europe/London", "en-GB"))
|
||||
def test_geoip_with_dict_proxy_extracts_server(self, mock_geo):
|
||||
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"}
|
||||
tz, locale = _maybe_resolve_geoip(True, proxy_dict, None, None)
|
||||
mock_geo.assert_called_once_with("http://proxy:8080")
|
||||
assert tz == "Europe/London"
|
||||
assert locale == "en-GB"
|
||||
|
||||
def test_geoip_disabled_skips_resolution(self):
|
||||
tz, locale = _maybe_resolve_geoip(False, "http://proxy:8080", None, None)
|
||||
assert tz is None
|
||||
assert locale is None
|
||||
|
||||
def test_geoip_no_proxy_skips_resolution(self):
|
||||
tz, locale = _maybe_resolve_geoip(True, None, None, None)
|
||||
assert tz is None
|
||||
assert locale is None
|
||||
|
||||
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Asia/Tokyo", "ja-JP"))
|
||||
def test_geoip_preserves_explicit_timezone(self, mock_geo):
|
||||
tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None)
|
||||
assert tz == "Europe/Berlin"
|
||||
assert locale == "ja-JP"
|
||||
|
||||
@@ -166,7 +166,7 @@ class TestGetLatestVersion:
|
||||
{
|
||||
"tag_name": "chromium-v145.0.7718.0",
|
||||
"draft": False,
|
||||
"assets": self._make_assets(["linux-x64", "darwin-arm64", "darwin-x64"]),
|
||||
"assets": self._make_assets(["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]),
|
||||
},
|
||||
]
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
@@ -187,7 +187,7 @@ class TestGetLatestVersion:
|
||||
{
|
||||
"tag_name": "chromium-v142.0.7444.175",
|
||||
"draft": False,
|
||||
"assets": self._make_assets(["linux-x64", "darwin-arm64", "darwin-x64"]),
|
||||
"assets": self._make_assets(["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]),
|
||||
},
|
||||
]
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
@@ -202,7 +202,7 @@ class TestGetLatestVersion:
|
||||
|
||||
def test_skips_draft_releases(self):
|
||||
mock_response = MagicMock()
|
||||
all_platforms = ["linux-x64", "darwin-arm64", "darwin-x64"]
|
||||
all_platforms = ["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]
|
||||
mock_response.json.return_value = [
|
||||
{"tag_name": "chromium-v999.0.0.0", "draft": True, "assets": self._make_assets(all_platforms)},
|
||||
{"tag_name": "chromium-v145.0.7718.0", "draft": False, "assets": self._make_assets(all_platforms)},
|
||||
@@ -215,7 +215,7 @@ class TestGetLatestVersion:
|
||||
|
||||
def test_skips_non_chromium_tags(self):
|
||||
mock_response = MagicMock()
|
||||
all_platforms = ["linux-x64", "darwin-arm64", "darwin-x64"]
|
||||
all_platforms = ["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]
|
||||
mock_response.json.return_value = [
|
||||
{"tag_name": "v0.2.0", "draft": False, "assets": self._make_assets(all_platforms)},
|
||||
{"tag_name": "chromium-v145.0.7718.0", "draft": False, "assets": self._make_assets(all_platforms)},
|
||||
@@ -233,7 +233,7 @@ class TestGetLatestVersion:
|
||||
{
|
||||
"tag_name": "chromium-v145.0.7718.0",
|
||||
"draft": False,
|
||||
"assets": [{"name": "cloakbrowser-windows-x64.tar.gz"}],
|
||||
"assets": [{"name": "cloakbrowser-freebsd-x64.tar.gz"}],
|
||||
},
|
||||
]
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
Reference in New Issue
Block a user