mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1315ebcaf6 | ||
|
|
5d7f7360d8 | ||
|
|
4e95822446 | ||
|
|
408a582117 | ||
|
|
fe567d7a5a |
@@ -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.
|
||||||
|
|||||||
@@ -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.1–0.3)**
|
**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:
|
Avoid `page.wait_for_timeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
__version__ = "0.3.6"
|
__version__ = "0.3.4"
|
||||||
|
|||||||
+10
-225
@@ -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
@@ -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
@@ -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"):
|
||||||
|
|||||||
@@ -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
@@ -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.1–0.3)**
|
**reCAPTCHA v3 scores are low (0.1–0.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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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"
|
|
||||||
|
|||||||
Reference in New Issue
Block a user