Compare commits

..
5 Commits
Author SHA1 Message Date
CloakHQ 1315ebcaf6 release: v0.3.4 — 26 patches, auto-spoof, timezone fix, README refresh 2026-03-04 01:11:50 +01:00
CloakHQ 5d7f7360d8 refactor: simplify stealth args — rely on binary auto-generation (v14+)
Binary v14+ auto-generates hardware concurrency, device memory, screen
dimensions, and window size from the fingerprint seed. Remove these
explicit flags from Python/JS wrapper defaults and update README:

- Remove 5 flags from get_default_stealth_args() in both wrappers
- Move hardware-concurrency, device-memory, screen-width, screen-height
  to the Additional Flags table with auto-generated defaults documented
- Update code examples to use --fingerprint instead of --window-size
- Simplify fingerprint defaults table to show only wrapper-set flags
2026-03-03 21:27:01 +01:00
CloakHQ 4e95822446 release: v0.3.3 — Windows x64, macOS v145, auto-spoof docs
Bump wrapper to 0.3.3. Update README fingerprint section to
document auto-spoof behavior (zero-config stealth). Improve
reCAPTCHA test with wait_for_selector instead of blind sleep.
2026-03-03 20:05:16 +01:00
CloakHQandDurafen 408a582117 feat: add Windows x64 support, update macOS to v145 2026-03-03 08:57:07 +01:00
CloakHQandDurafen fe567d7a5a feat: macOS v145 wrapper prep — GPU flags, version bump, README update
- Add explicit Mac GPU flags (Apple M3 Metal renderer) to stealth args
- Bump macOS platform versions to 145.0.7632.109
- Update README fingerprint table to reflect actual Mac GPU defaults
- Add warning about binary requiring explicit flags without wrapper
- Fix stealth_test.py wait_until for reCAPTCHA page
2026-03-03 08:57:07 +01:00
19 changed files with 99 additions and 736 deletions
-10
View File
@@ -6,16 +6,6 @@ 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 ## [0.3.4] — 2026-03-04
Binary v14: auto-spoof restored with seed, wrapper simplified to match. Binary v14: auto-spoof restored with seed, wrapper simplified to match.
+2 -69
View File
@@ -203,9 +203,6 @@ browser = launch(headless=False)
# With proxy # With proxy
browser = launch(proxy="http://user:pass@proxy:8080") 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 # With extra Chrome args
browser = launch(args=["--disable-gpu"]) browser = launch(args=["--disable-gpu"])
@@ -242,7 +239,7 @@ asyncio.run(main())
### `launch_context()` ### `launch_context()`
Convenience function that creates browser + context in one call with user agent, viewport, locale, and timezone: Convenience function that creates browser + context with common options:
```python ```python
from cloakbrowser import launch_context from cloakbrowser import launch_context
@@ -254,37 +251,8 @@ context = launch_context(
timezone_id="America/New_York", timezone_id="America/New_York",
) )
page = context.new_page() 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 ### Utility Functions
```python ```python
@@ -308,7 +276,7 @@ CloakBrowser ships a TypeScript package with full type definitions. Choose Playw
### Playwright (default) ### Playwright (default)
```javascript ```javascript
import { launch, launchContext, launchPersistentContext } from 'cloakbrowser'; import { launch, launchContext } from 'cloakbrowser';
// Basic // Basic
const browser = await launch(); const browser = await launch();
@@ -330,13 +298,6 @@ const context = await launchContext({
timezoneId: 'America/New_York', timezoneId: 'America/New_York',
}); });
const page = await context.newPage(); 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. > **Note:** Each example above is standalone — not meant to run as one block.
@@ -491,14 +452,12 @@ The wrapper auto-downloads the correct binary for your platform.
**Python** — see [`examples/`](examples/): **Python** — see [`examples/`](examples/):
- [`basic.py`](examples/basic.py) — Launch and load a page - [`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 - [`recaptcha_score.py`](examples/recaptcha_score.py) — Check your reCAPTCHA v3 score
- [`stealth_test.py`](examples/stealth_test.py) — Run against all detection services - [`stealth_test.py`](examples/stealth_test.py) — Run against all detection services
- [`fingerprint_scan_test.py`](examples/fingerprint_scan_test.py) — Test against fingerprint-scan.com and CreepJS - [`fingerprint_scan_test.py`](examples/fingerprint_scan_test.py) — Test against fingerprint-scan.com and CreepJS
**JavaScript** — see [`js/examples/`](js/examples/): **JavaScript** — see [`js/examples/`](js/examples/):
- [`basic-playwright.ts`](js/examples/basic-playwright.ts) — Playwright launch and load - [`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 - [`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 - [`stealth-test.ts`](js/examples/stealth-test.ts) — Full 6-site detection test suite
@@ -620,32 +579,6 @@ You do NOT need `playwright install chromium`. CloakBrowser downloads its own bi
patchright install-deps chromium 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.10.3)** **reCAPTCHA v3 scores are low (0.10.3)**
Avoid `page.wait_for_timeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead: Avoid `page.wait_for_timeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
+1 -4
View File
@@ -11,7 +11,7 @@ Usage:
browser.close() browser.close()
""" """
from .browser import launch, launch_async, launch_context, launch_persistent_context, launch_persistent_context_async, ProxySettings from .browser import launch, launch_async, launch_context
from .config import CHROMIUM_VERSION, get_default_stealth_args from .config import CHROMIUM_VERSION, get_default_stealth_args
from .download import binary_info, check_for_update, clear_cache, ensure_binary from .download import binary_info, check_for_update, clear_cache, ensure_binary
from ._version import __version__ from ._version import __version__
@@ -20,14 +20,11 @@ __all__ = [
"launch", "launch",
"launch_async", "launch_async",
"launch_context", "launch_context",
"launch_persistent_context",
"launch_persistent_context_async",
"ensure_binary", "ensure_binary",
"clear_cache", "clear_cache",
"binary_info", "binary_info",
"check_for_update", "check_for_update",
"CHROMIUM_VERSION", "CHROMIUM_VERSION",
"get_default_stealth_args", "get_default_stealth_args",
"ProxySettings",
"__version__", "__version__",
] ]
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.3.6" __version__ = "0.3.4"
+10 -225
View File
@@ -15,8 +15,7 @@ Usage:
from __future__ import annotations from __future__ import annotations
import logging import logging
import os from typing import Any, Literal
from typing import Any, Literal, TypedDict
from urllib.parse import unquote, urlparse, urlunparse from urllib.parse import unquote, urlparse, urlunparse
from .config import DEFAULT_VIEWPORT, get_default_stealth_args from .config import DEFAULT_VIEWPORT, get_default_stealth_args
@@ -25,21 +24,9 @@ from .download import ensure_binary
logger = logging.getLogger("cloakbrowser") 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( def launch(
headless: bool = True, headless: bool = True,
proxy: str | ProxySettings | None = None, proxy: str | None = None,
args: list[str] | None = None, args: list[str] | None = None,
stealth_args: bool = True, stealth_args: bool = True,
timezone: str | None = None, timezone: str | None = None,
@@ -51,10 +38,7 @@ def launch(
Args: Args:
headless: Run in headless mode (default True). headless: Run in headless mode (default True).
proxy: Proxy URL string or Playwright proxy dict. proxy: Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080').
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. args: Additional Chromium CLI arguments to pass.
stealth_args: Include default stealth fingerprint args (default True). stealth_args: Include default stealth fingerprint args (default True).
Set to False if you want to pass your own --fingerprint flags. Set to False if you want to pass your own --fingerprint flags.
@@ -109,7 +93,7 @@ def launch(
async def launch_async( async def launch_async(
headless: bool = True, headless: bool = True,
proxy: str | ProxySettings | None = None, proxy: str | None = None,
args: list[str] | None = None, args: list[str] | None = None,
stealth_args: bool = True, stealth_args: bool = True,
timezone: str | None = None, timezone: str | None = None,
@@ -121,7 +105,7 @@ async def launch_async(
Args: Args:
headless: Run in headless mode (default True). headless: Run in headless mode (default True).
proxy: Proxy URL string or Playwright proxy dict (see launch() for details). proxy: Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080').
args: Additional Chromium CLI arguments to pass. args: Additional Chromium CLI arguments to pass.
stealth_args: Include default stealth fingerprint args (default True). stealth_args: Include default stealth fingerprint args (default True).
timezone: IANA timezone (e.g. 'America/New_York'). Sets --fingerprint-timezone binary flag. timezone: IANA timezone (e.g. 'America/New_York'). Sets --fingerprint-timezone binary flag.
@@ -175,203 +159,9 @@ async def launch_async(
return browser 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( def launch_context(
headless: bool = True, headless: bool = True,
proxy: str | ProxySettings | None = None, proxy: str | None = None,
args: list[str] | None = None, args: list[str] | None = None,
stealth_args: bool = True, stealth_args: bool = True,
user_agent: str | None = None, user_agent: str | None = None,
@@ -389,7 +179,7 @@ def launch_context(
Args: Args:
headless: Run in headless mode (default True). headless: Run in headless mode (default True).
proxy: Proxy URL string or Playwright proxy dict (see launch() for details). proxy: Proxy server URL.
args: Additional Chromium CLI arguments. args: Additional Chromium CLI arguments.
stealth_args: Include default stealth fingerprint args (default True). stealth_args: Include default stealth fingerprint args (default True).
user_agent: Custom user agent string. user_agent: Custom user agent string.
@@ -451,7 +241,7 @@ def launch_context(
def _maybe_resolve_geoip( def _maybe_resolve_geoip(
geoip: bool, geoip: bool,
proxy: str | ProxySettings | None, proxy: str | None,
timezone: str | None, timezone: str | None,
locale: str | None, locale: str | None,
) -> tuple[str | None, str | None]: ) -> tuple[str | None, str | None]:
@@ -461,10 +251,7 @@ def _maybe_resolve_geoip(
from .geoip import resolve_proxy_geo from .geoip import resolve_proxy_geo
proxy_url = proxy.get("server") if isinstance(proxy, dict) else proxy geo_tz, geo_locale = resolve_proxy_geo(proxy)
if not proxy_url:
return timezone, locale
geo_tz, geo_locale = resolve_proxy_geo(proxy_url)
if timezone is None: if timezone is None:
timezone = geo_tz timezone = geo_tz
if locale is None: if locale is None:
@@ -518,10 +305,8 @@ def _parse_proxy_url(proxy: str) -> dict[str, Any]:
return result return result
def _build_proxy_kwargs(proxy: str | ProxySettings | None) -> dict[str, Any]: def _build_proxy_kwargs(proxy: str | None) -> dict[str, Any]:
"""Build proxy kwargs for Playwright launch.""" """Build proxy kwargs for Playwright launch."""
if proxy is None: if proxy is None:
return {} return {}
if isinstance(proxy, dict):
return {"proxy": proxy}
return {"proxy": _parse_proxy_url(proxy)} return {"proxy": _parse_proxy_url(proxy)}
+9 -18
View File
@@ -15,13 +15,13 @@ from ._version import __version__
# CHROMIUM_VERSION is the latest across all platforms (for display/reference). # CHROMIUM_VERSION is the latest across all platforms (for display/reference).
# Use get_chromium_version() for the current platform's actual version. # Use get_chromium_version() for the current platform's actual version.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
CHROMIUM_VERSION = "145.0.7632.109.2" CHROMIUM_VERSION = "145.0.7632.109"
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = { PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
"linux-x64": "145.0.7632.109.2", "linux-x64": "145.0.7632.109",
"darwin-arm64": "145.0.7632.109.2", "darwin-arm64": "145.0.7632.109",
"darwin-x64": "145.0.7632.109.2", "darwin-x64": "145.0.7632.109",
"windows-x64": "145.0.7632.109.2", "windows-x64": "145.0.7632.109",
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -204,27 +204,18 @@ 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: def get_download_url(version: str | None = None) -> str:
"""Return the full download URL for the current platform's binary archive.""" """Return the full download URL for the current platform's binary archive."""
v = version or get_chromium_version() v = version or get_chromium_version()
return f"{DOWNLOAD_BASE_URL}/chromium-v{v}/{get_archive_name()}" tag = get_platform_tag()
return f"{DOWNLOAD_BASE_URL}/chromium-v{v}/cloakbrowser-{tag}.tar.gz"
def get_fallback_download_url(version: str | None = None) -> str: def get_fallback_download_url(version: str | None = None) -> str:
"""Return the GitHub Releases fallback URL for the binary archive.""" """Return the GitHub Releases fallback URL for the binary archive."""
v = version or get_chromium_version() v = version or get_chromium_version()
return f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}/{get_archive_name()}" tag = get_platform_tag()
return f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}/cloakbrowser-{tag}.tar.gz"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+23 -43
View File
@@ -28,8 +28,6 @@ from .config import (
GITHUB_DOWNLOAD_BASE_URL, GITHUB_DOWNLOAD_BASE_URL,
_version_newer, _version_newer,
check_platform_available, check_platform_available,
get_archive_ext,
get_archive_name,
get_binary_dir, get_binary_dir,
get_binary_path, get_binary_path,
get_cache_dir, get_cache_dir,
@@ -125,7 +123,7 @@ def _download_and_extract(version: str | None = None) -> None:
binary_dir.parent.mkdir(parents=True, exist_ok=True) binary_dir.parent.mkdir(parents=True, exist_ok=True)
# Download to temp file first (atomic — no partial downloads in cache) # Download to temp file first (atomic — no partial downloads in cache)
with tempfile.NamedTemporaryFile(suffix=get_archive_ext(), delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
tmp_path = Path(tmp.name) tmp_path = Path(tmp.name)
try: try:
@@ -157,7 +155,7 @@ def _download_and_extract(version: str | None = None) -> None:
def _verify_download_checksum(file_path: Path, 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.""" """Fetch SHA256SUMS and verify the downloaded file. Warn if unavailable, fail on mismatch."""
checksums = _fetch_checksums(version) checksums = _fetch_checksums(version)
tarball_name = get_archive_name() tarball_name = f"cloakbrowser-{get_platform_tag()}.tar.gz"
if checksums is None: if checksums is None:
logger.warning("SHA256SUMS not available for this release — skipping checksum verification") logger.warning("SHA256SUMS not available for this release — skipping checksum verification")
@@ -258,7 +256,7 @@ def _download_file(url: str, dest: Path) -> None:
def _extract_archive( def _extract_archive(
archive_path: Path, dest_dir: Path, binary_path: Path | None = None archive_path: Path, dest_dir: Path, binary_path: Path | None = None
) -> None: ) -> None:
"""Extract tar.gz or zip archive to destination directory.""" """Extract tar.gz archive to destination directory."""
logger.info("Extracting to %s", dest_dir) logger.info("Extracting to %s", dest_dir)
# Clean existing dir if partial download existed # Clean existing dir if partial download existed
@@ -268,12 +266,26 @@ def _extract_archive(
dest_dir.mkdir(parents=True, exist_ok=True) dest_dir.mkdir(parents=True, exist_ok=True)
if str(archive_path).endswith(".zip"): with tarfile.open(archive_path, "r:gz") as tar:
_extract_zip(archive_path, dest_dir) # Security: prevent path traversal
else: safe_members = []
_extract_tar(archive_path, dest_dir) for member in tar.getmembers():
# Allow symlinks — macOS .app bundles require them (Framework layout)
if member.issym() or member.islnk():
link_target = member.linkname
# Reject symlinks that escape the dest dir
if os.path.isabs(link_target) or ".." in link_target.split("/"):
logger.warning("Skipping suspicious symlink: %s -> %s", member.name, link_target)
continue
else:
member_path = (dest_dir / member.name).resolve()
if not str(member_path).startswith(str(dest_dir.resolve())):
raise RuntimeError(f"Archive contains path traversal: {member.name}")
safe_members.append(member)
# If extracted into a single subdirectory, flatten it 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) # (e.g. fingerprint-chromium-142-custom-v2/chrome → chrome)
# But never flatten .app bundles — macOS needs the bundle structure intact # But never flatten .app bundles — macOS needs the bundle structure intact
_flatten_single_subdir(dest_dir) _flatten_single_subdir(dest_dir)
@@ -291,38 +303,6 @@ def _extract_archive(
logger.info("Binary ready: %s", bp) 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: def _flatten_single_subdir(dest_dir: Path) -> None:
"""If extraction created a single subdirectory, move its contents up. """If extraction created a single subdirectory, move its contents up.
@@ -455,7 +435,7 @@ def _get_latest_chromium_version() -> str | None:
GITHUB_API_URL, params={"per_page": 10}, timeout=10.0 GITHUB_API_URL, params={"per_page": 10}, timeout=10.0
) )
resp.raise_for_status() resp.raise_for_status()
platform_tarball = get_archive_name() platform_tarball = f"cloakbrowser-{get_platform_tag()}.tar.gz"
for release in resp.json(): for release in resp.json():
tag = release.get("tag_name", "") tag = release.get("tag_name", "")
if tag.startswith("chromium-v") and not release.get("draft"): if tag.startswith("chromium-v") and not release.get("draft"):
-29
View File
@@ -1,29 +0,0 @@
"""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!")
+1 -31
View File
@@ -60,18 +60,13 @@ await browser.close();
### Options ### Options
```javascript ```javascript
import { launch, launchContext, launchPersistentContext } from 'cloakbrowser'; import { launch, launchContext } from 'cloakbrowser';
// With proxy // With proxy
const browser = await launch({ const browser = await launch({
proxy: 'http://user:pass@proxy:8080', 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) // Headed mode (visible browser window)
const browser = await launch({ headless: false }); const browser = await launch({ headless: false });
@@ -99,16 +94,6 @@ const context = await launchContext({
locale: 'en-US', locale: 'en-US',
timezoneId: 'America/New_York', 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 ### Auto Timezone/Locale from Proxy IP
@@ -200,21 +185,6 @@ const page = await browser.newPage();
## Troubleshooting ## Troubleshooting
**Site detects incognito / private browsing mode**
By default, `launch()` opens an incognito context. Some sites (like BrowserScan) detect this. Use `launchPersistentContext()` instead — it runs with a real user profile:
```javascript
import { launchPersistentContext } from 'cloakbrowser';
const ctx = await launchPersistentContext({
userDataDir: './my-profile',
headless: false,
});
```
This also gives you cookie and localStorage persistence across sessions.
**reCAPTCHA v3 scores are low (0.10.3)** **reCAPTCHA v3 scores are low (0.10.3)**
Avoid `page.waitForTimeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead: Avoid `page.waitForTimeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
-40
View File
@@ -1,40 +0,0 @@
/**
* 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
View File
@@ -1,6 +1,6 @@
{ {
"name": "cloakbrowser", "name": "cloakbrowser",
"version": "0.3.6", "version": "0.3.4",
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.", "description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
+9 -15
View File
@@ -27,13 +27,13 @@ export { WRAPPER_VERSION };
// CHROMIUM_VERSION is the latest across all platforms (for display/reference). // CHROMIUM_VERSION is the latest across all platforms (for display/reference).
// Use getChromiumVersion() for the current platform's actual version. // Use getChromiumVersion() for the current platform's actual version.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const CHROMIUM_VERSION = "145.0.7632.109.2"; export const CHROMIUM_VERSION = "145.0.7632.109";
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = { export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
"linux-x64": "145.0.7632.109.2", "linux-x64": "145.0.7632.109",
"darwin-arm64": "145.0.7632.109.2", "darwin-arm64": "145.0.7632.109",
"darwin-x64": "145.0.7632.109.2", "darwin-x64": "145.0.7632.109",
"windows-x64": "145.0.7632.109.2", "windows-x64": "145.0.7632.109",
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -126,22 +126,16 @@ export const GITHUB_API_URL =
export const GITHUB_DOWNLOAD_BASE_URL = export const GITHUB_DOWNLOAD_BASE_URL =
"https://github.com/CloakHQ/cloakbrowser/releases/download"; "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 { export function getDownloadUrl(version?: string): string {
const v = version || getChromiumVersion(); const v = version || getChromiumVersion();
return `${DOWNLOAD_BASE_URL}/chromium-v${v}/${getArchiveName()}`; const tag = getPlatformTag();
return `${DOWNLOAD_BASE_URL}/chromium-v${v}/cloakbrowser-${tag}.tar.gz`;
} }
export function getFallbackDownloadUrl(version?: string): string { export function getFallbackDownloadUrl(version?: string): string {
const v = version || getChromiumVersion(); const v = version || getChromiumVersion();
return `${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/${getArchiveName()}`; const tag = getPlatformTag();
return `${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/cloakbrowser-${tag}.tar.gz`;
} }
export function getEffectiveVersion(): string { export function getEffectiveVersion(): string {
+28 -67
View File
@@ -19,8 +19,6 @@ import {
GITHUB_DOWNLOAD_BASE_URL, GITHUB_DOWNLOAD_BASE_URL,
WRAPPER_VERSION, WRAPPER_VERSION,
checkPlatformAvailable, checkPlatformAvailable,
getArchiveExt,
getArchiveName,
getBinaryDir, getBinaryDir,
getBinaryPath, getBinaryPath,
getCacheDir, getCacheDir,
@@ -89,8 +87,8 @@ export async function ensureBinary(): Promise<string> {
if (!fs.existsSync(downloadedPath)) { if (!fs.existsSync(downloadedPath)) {
throw new Error( throw new Error(
`Download completed but binary not found at expected path: ${downloadedPath}. ` + `Download completed but binary not found at expected path: ${downloadedPath}. ` +
`This may indicate a packaging issue. Please report at ` + `This may indicate a packaging issue. Please report at ` +
`https://github.com/CloakHQ/cloakbrowser/issues` `https://github.com/CloakHQ/cloakbrowser/issues`
); );
} }
@@ -154,7 +152,7 @@ async function downloadAndExtract(version?: string): Promise<void> {
// Download to temp file (atomic — no partial downloads in cache) // Download to temp file (atomic — no partial downloads in cache)
const tmpPath = path.join( const tmpPath = path.join(
path.dirname(binaryDir), path.dirname(binaryDir),
`_download_${Date.now()}${getArchiveExt()}` `_download_${Date.now()}.tar.gz`
); );
try { try {
@@ -196,7 +194,7 @@ async function downloadAndExtract(version?: string): Promise<void> {
async function verifyDownloadChecksum(filePath: string, version?: string): Promise<void> { async function verifyDownloadChecksum(filePath: string, version?: string): Promise<void> {
const checksums = await fetchChecksums(version); const checksums = await fetchChecksums(version);
const tarballName = getArchiveName(); const tarballName = `cloakbrowser-${getPlatformTag()}.tar.gz`;
if (!checksums) { if (!checksums) {
console.warn("[cloakbrowser] SHA256SUMS not available for this release — skipping checksum verification"); console.warn("[cloakbrowser] SHA256SUMS not available for this release — skipping checksum verification");
@@ -276,9 +274,6 @@ async function downloadFile(url: string, dest: string): Promise<void> {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS); const timeout = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
// Create file stream early so we can ensure cleanup on error
const fileStream = createWriteStream(dest);
try { try {
const response = await fetch(url, { const response = await fetch(url, {
signal: controller.signal, signal: controller.signal,
@@ -297,6 +292,7 @@ async function downloadFile(url: string, dest: string): Promise<void> {
let downloaded = 0; let downloaded = 0;
let lastLoggedPct = -1; let lastLoggedPct = -1;
const fileStream = createWriteStream(dest);
const reader = response.body.getReader(); const reader = response.body.getReader();
// Stream chunks to file with progress logging // Stream chunks to file with progress logging
@@ -320,32 +316,19 @@ async function downloadFile(url: string, dest: string): Promise<void> {
} }
} }
// Wait for file stream to fully close (not just finish) // Wait for file stream to finish
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
fileStream.end(); fileStream.end(() => resolve());
fileStream.on("close", () => resolve());
fileStream.on("error", reject); fileStream.on("error", reject);
}); });
const sizeMB = Math.floor(fs.statSync(dest).size / (1024 * 1024)); const sizeMB = Math.floor(fs.statSync(dest).size / (1024 * 1024));
console.log(`[cloakbrowser] Download complete: ${sizeMB} MB`); 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 { } finally {
clearTimeout(timeout); clearTimeout(timeout);
} }
} }
async function extractArchive( async function extractArchive(
archivePath: string, archivePath: string,
destDir: string, destDir: string,
@@ -359,11 +342,23 @@ async function extractArchive(
} }
fs.mkdirSync(destDir, { recursive: true }); fs.mkdirSync(destDir, { recursive: true });
if (archivePath.endsWith(".zip")) { // Extract with tar — the 'tar' package handles symlink/traversal safety
await extractZip(archivePath, destDir); await tarExtract({
} else { file: archivePath,
await extractTar(archivePath, destDir); 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 // Flatten single subdirectory if needed
flattenSingleSubdir(destDir); flattenSingleSubdir(destDir);
@@ -384,40 +379,6 @@ 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. * If extraction created a single subdirectory, move its contents up.
* Many tarballs wrap files in a top-level directory. * Many tarballs wrap files in a top-level directory.
@@ -491,7 +452,7 @@ export async function getLatestChromiumVersion(): Promise<string | null> {
draft: boolean; draft: boolean;
assets: Array<{ name: string }>; assets: Array<{ name: string }>;
}>; }>;
const platformTarball = getArchiveName(); const platformTarball = `cloakbrowser-${getPlatformTag()}.tar.gz`;
for (const release of releases) { for (const release of releases) {
if (release.tag_name.startsWith("chromium-v") && !release.draft) { if (release.tag_name.startsWith("chromium-v") && !release.draft) {
const assetNames = new Set( const assetNames = new Set(
@@ -539,7 +500,7 @@ export async function checkWrapperUpdate(): Promise<void> {
if (data.version && versionNewer(data.version, WRAPPER_VERSION)) { if (data.version && versionNewer(data.version, WRAPPER_VERSION)) {
console.warn( console.warn(
`[cloakbrowser] Update available: ${WRAPPER_VERSION}${data.version}. ` + `[cloakbrowser] Update available: ${WRAPPER_VERSION}${data.version}. ` +
`Run: npm install cloakbrowser@latest` `Run: npm install cloakbrowser@latest`
); );
} }
} catch { } catch {
@@ -586,10 +547,10 @@ async function checkAndDownloadUpdate(): Promise<void> {
function maybeTriggerUpdateCheck(): void { function maybeTriggerUpdateCheck(): void {
// Wrapper update: once per process, not rate-limited // Wrapper update: once per process, not rate-limited
if (!wrapperUpdateChecked) { if (!wrapperUpdateChecked) {
checkWrapperUpdate().catch(() => { }); checkWrapperUpdate().catch(() => {});
} }
// Binary update: rate-limited to once per hour // Binary update: rate-limited to once per hour
if (!shouldCheckForUpdate()) return; if (!shouldCheckForUpdate()) return;
checkAndDownloadUpdate().catch(() => { }); checkAndDownloadUpdate().catch(() => {});
} }
+2 -2
View File
@@ -16,7 +16,7 @@
*/ */
// Launch functions (Playwright API) // Launch functions (Playwright API)
export { launch, launchContext, launchPersistentContext } from "./playwright.js"; export { launch, launchContext } from "./playwright.js";
// Binary management // Binary management
export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download.js"; 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"; export { CHROMIUM_VERSION, getDefaultStealthArgs } from "./config.js";
// Types // Types
export type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions, BinaryInfo } from "./types.js"; export type { LaunchOptions, LaunchContextOptions, BinaryInfo } from "./types.js";
+4 -60
View File
@@ -4,7 +4,7 @@
*/ */
import type { Browser, BrowserContext } from "playwright-core"; import type { Browser, BrowserContext } from "playwright-core";
import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js"; import type { LaunchOptions, LaunchContextOptions } from "./types.js";
import { DEFAULT_VIEWPORT, getDefaultStealthArgs } from "./config.js"; import { DEFAULT_VIEWPORT, getDefaultStealthArgs } from "./config.js";
import { ensureBinary } from "./download.js"; import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js"; import { parseProxyUrl } from "./proxy.js";
@@ -34,9 +34,7 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
headless: options.headless ?? true, headless: options.headless ?? true,
args, args,
ignoreDefaultArgs: ["--enable-automation"], ignoreDefaultArgs: ["--enable-automation"],
...(options.proxy ...(options.proxy ? { proxy: parseProxyUrl(options.proxy) } : {}),
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
: {}),
...options.launchOptions, ...options.launchOptions,
}); });
@@ -64,10 +62,7 @@ export async function launchContext(
): Promise<BrowserContext> { ): Promise<BrowserContext> {
// Resolve geoip BEFORE launch() to avoid double-resolution // Resolve geoip BEFORE launch() to avoid double-resolution
const resolved = await maybeResolveGeoip(options); const resolved = await maybeResolveGeoip(options);
// Skip --fingerprint-timezone binary flag: it only applies to the default const browser = await launch({ ...options, ...resolved, geoip: false });
// 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; let context: BrowserContext;
try { try {
@@ -93,55 +88,6 @@ export async function launchContext(
return context; 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 // Internal
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -153,9 +99,7 @@ async function maybeResolveGeoip(
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale }; if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
const { resolveProxyGeo } = await import("./geoip.js"); const { resolveProxyGeo } = await import("./geoip.js");
const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server; const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(options.proxy);
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
return { return {
timezone: options.timezone ?? geoTz ?? undefined, timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined, locale: options.locale ?? geoLocale ?? undefined,
+5 -23
View File
@@ -34,26 +34,10 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
// so we strip them and use page.authenticate() instead. // so we strip them and use page.authenticate() instead.
let proxyAuth: { username: string; password: string } | undefined; let proxyAuth: { username: string; password: string } | undefined;
if (options.proxy) { if (options.proxy) {
if (typeof options.proxy === "string") { const { server, username, password } = parseProxyUrl(options.proxy);
const { server, username, password } = parseProxyUrl(options.proxy); args.push(`--proxy-server=${server}`);
args.push(`--proxy-server=${server}`); if (username) {
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 ?? "" };
}
} }
} }
@@ -90,9 +74,7 @@ async function maybeResolveGeoip(
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale }; if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
const { resolveProxyGeo } = await import("./geoip.js"); const { resolveProxyGeo } = await import("./geoip.js");
const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server; const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(options.proxy);
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
return { return {
timezone: options.timezone ?? geoTz ?? undefined, timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined, locale: options.locale ?? geoLocale ?? undefined,
+2 -12
View File
@@ -5,13 +5,8 @@
export interface LaunchOptions { export interface LaunchOptions {
/** Run in headless mode (default: true). */ /** Run in headless mode (default: true). */
headless?: boolean; headless?: boolean;
/** /** Proxy server URL, e.g. 'http://proxy:8080' or 'socks5://proxy:1080'. */
* Proxy server URL string or Playwright proxy object. proxy?: string;
* 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. */ /** Additional Chromium CLI arguments. */
args?: string[]; args?: string[];
/** Include default stealth fingerprint args (default: true). Set false to use custom --fingerprint flags. */ /** Include default stealth fingerprint args (default: true). Set false to use custom --fingerprint flags. */
@@ -39,11 +34,6 @@ export interface LaunchContextOptions extends LaunchOptions {
colorScheme?: "light" | "dark" | "no-preference"; colorScheme?: "light" | "dark" | "no-preference";
} }
export interface LaunchPersistentContextOptions extends LaunchContextOptions {
/** Path to user data directory for persistent profile. */
userDataDir: string;
}
export interface BinaryInfo { export interface BinaryInfo {
version: string; version: string;
platform: string; platform: string;
-35
View File
@@ -1,6 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { parseProxyUrl } from "../src/proxy.js"; import { parseProxyUrl } from "../src/proxy.js";
import type { LaunchOptions } from "../src/types.js";
describe("parseProxyUrl", () => { describe("parseProxyUrl", () => {
it("passes through URL without credentials", () => { it("passes through URL without credentials", () => {
@@ -48,37 +47,3 @@ describe("parseProxyUrl", () => {
expect(parseProxyUrl("not-a-url")).toEqual({ server: "not-a-url" }); 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");
}
});
});
+1 -51
View File
@@ -1,8 +1,6 @@
"""Tests for proxy URL parsing and credential extraction.""" """Tests for proxy URL parsing and credential extraction."""
from unittest.mock import patch from cloakbrowser.browser import _build_proxy_kwargs, _parse_proxy_url
from cloakbrowser.browser import _build_proxy_kwargs, _maybe_resolve_geoip, _parse_proxy_url
class TestParseProxyUrl: class TestParseProxyUrl:
@@ -50,51 +48,3 @@ class TestBuildProxyKwargs:
assert result == { assert result == {
"proxy": {"server": "http://proxy:8080", "username": "user", "password": "pass"} "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"