mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
feat: auto-detect timezone/locale from proxy IP via geoip
Adds geoip=True parameter to launch(), launch_async(), and launch_context(). Resolves proxy exit IP → MaxMind GeoLite2-City lookup → timezone + locale. Downloads ~70MB DB on first use from P3TERX mirror, caches in ~/.cloakbrowser/geoip/. Optional deps: pip install cloakbrowser[geoip] / npm install mmdb-lib Explicit timezone/locale always override auto-detected values.
This commit is contained in:
@@ -173,6 +173,12 @@ browser = launch(proxy="http://user:pass@proxy:8080")
|
|||||||
# With extra Chrome args
|
# With extra Chrome args
|
||||||
browser = launch(args=["--disable-gpu", "--window-size=1920,1080"])
|
browser = launch(args=["--disable-gpu", "--window-size=1920,1080"])
|
||||||
|
|
||||||
|
# With timezone and locale (sets both binary flags and Playwright context)
|
||||||
|
browser = launch(timezone="America/New_York", locale="en-US")
|
||||||
|
|
||||||
|
# Auto-detect timezone/locale from proxy IP (requires: pip install cloakbrowser[geoip])
|
||||||
|
browser = launch(proxy="http://proxy:8080", geoip=True)
|
||||||
|
|
||||||
# Without default stealth args (bring your own fingerprint flags)
|
# Without default stealth args (bring your own fingerprint flags)
|
||||||
browser = launch(stealth_args=False, args=["--fingerprint=12345"])
|
browser = launch(stealth_args=False, args=["--fingerprint=12345"])
|
||||||
```
|
```
|
||||||
@@ -211,6 +217,27 @@ context = launch_context(
|
|||||||
page = context.new_page()
|
page = context.new_page()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Auto Timezone/Locale from Proxy IP
|
||||||
|
|
||||||
|
When using a proxy, antibot systems check that your browser's timezone and locale match the proxy's geographic location. CloakBrowser can auto-detect these from the proxy IP using an offline GeoIP database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install cloakbrowser[geoip] # installs geoip2 + downloads ~70 MB database on first use
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Timezone and locale auto-set from proxy's IP geolocation
|
||||||
|
browser = launch(proxy="http://proxy:8080", geoip=True)
|
||||||
|
|
||||||
|
# Works with launch_context too — sets both binary flags AND Playwright context
|
||||||
|
context = launch_context(proxy="http://proxy:8080", geoip=True)
|
||||||
|
|
||||||
|
# Explicit values always win over auto-detection
|
||||||
|
browser = launch(proxy="http://proxy:8080", geoip=True, timezone="Europe/London")
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** For rotating residential proxies, the DNS-resolved IP may differ from the exit IP. Pass explicit `timezone`/`locale` in those cases.
|
||||||
|
|
||||||
### Utility Functions
|
### Utility Functions
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.2.0"
|
__version__ = "0.2.1"
|
||||||
|
|||||||
+58
-5
@@ -29,6 +29,9 @@ def launch(
|
|||||||
proxy: str | None = None,
|
proxy: str | None = None,
|
||||||
args: list[str] | None = None,
|
args: list[str] | None = None,
|
||||||
stealth_args: bool = True,
|
stealth_args: bool = True,
|
||||||
|
timezone: str | None = None,
|
||||||
|
locale: str | None = None,
|
||||||
|
geoip: bool = False,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Launch stealth Chromium browser. Returns a Playwright Browser object.
|
"""Launch stealth Chromium browser. Returns a Playwright Browser object.
|
||||||
@@ -39,6 +42,12 @@ def launch(
|
|||||||
args: Additional Chromium CLI arguments to pass.
|
args: Additional Chromium CLI arguments to pass.
|
||||||
stealth_args: Include default stealth fingerprint args (default True).
|
stealth_args: Include default stealth fingerprint args (default True).
|
||||||
Set to False if you want to pass your own --fingerprint flags.
|
Set to False if you want to pass your own --fingerprint flags.
|
||||||
|
timezone: IANA timezone (e.g. 'America/New_York'). Sets --timezone binary flag.
|
||||||
|
locale: BCP 47 locale (e.g. 'en-US'). Sets --lang binary flag.
|
||||||
|
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||||
|
Requires ``pip install cloakbrowser[geoip]``. Downloads ~70 MB
|
||||||
|
GeoLite2-City database on first use. Explicit timezone/locale
|
||||||
|
always override geoip results.
|
||||||
**kwargs: Passed directly to playwright.chromium.launch().
|
**kwargs: Passed directly to playwright.chromium.launch().
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -55,7 +64,8 @@ def launch(
|
|||||||
from playwright.sync_api import sync_playwright
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
binary_path = ensure_binary()
|
binary_path = ensure_binary()
|
||||||
chrome_args = _build_args(stealth_args, args)
|
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||||
|
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale)
|
||||||
|
|
||||||
logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args))
|
logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args))
|
||||||
|
|
||||||
@@ -86,6 +96,9 @@ async def launch_async(
|
|||||||
proxy: str | None = None,
|
proxy: str | None = None,
|
||||||
args: list[str] | None = None,
|
args: list[str] | None = None,
|
||||||
stealth_args: bool = True,
|
stealth_args: bool = True,
|
||||||
|
timezone: str | None = None,
|
||||||
|
locale: str | None = None,
|
||||||
|
geoip: bool = False,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Async version of launch(). Returns a Playwright Browser object.
|
"""Async version of launch(). Returns a Playwright Browser object.
|
||||||
@@ -95,6 +108,9 @@ async def launch_async(
|
|||||||
proxy: Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080').
|
proxy: Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080').
|
||||||
args: Additional Chromium CLI arguments to pass.
|
args: Additional Chromium CLI arguments to pass.
|
||||||
stealth_args: Include default stealth fingerprint args (default True).
|
stealth_args: Include default stealth fingerprint args (default True).
|
||||||
|
timezone: IANA timezone (e.g. 'America/New_York'). Sets --timezone binary flag.
|
||||||
|
locale: BCP 47 locale (e.g. 'en-US'). Sets --lang binary flag.
|
||||||
|
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||||
**kwargs: Passed directly to playwright.chromium.launch().
|
**kwargs: Passed directly to playwright.chromium.launch().
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -116,7 +132,8 @@ async def launch_async(
|
|||||||
from playwright.async_api import async_playwright
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
binary_path = ensure_binary()
|
binary_path = ensure_binary()
|
||||||
chrome_args = _build_args(stealth_args, args)
|
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||||
|
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale)
|
||||||
|
|
||||||
logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args))
|
logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args))
|
||||||
|
|
||||||
@@ -151,6 +168,7 @@ def launch_context(
|
|||||||
viewport: dict | None = None,
|
viewport: dict | None = None,
|
||||||
locale: str | None = None,
|
locale: str | None = None,
|
||||||
timezone_id: str | None = None,
|
timezone_id: str | None = None,
|
||||||
|
geoip: bool = False,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Launch stealth browser and return a BrowserContext with common options pre-set.
|
"""Launch stealth browser and return a BrowserContext with common options pre-set.
|
||||||
@@ -167,12 +185,17 @@ def launch_context(
|
|||||||
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
|
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
|
||||||
locale: Browser locale, e.g. "en-US".
|
locale: Browser locale, e.g. "en-US".
|
||||||
timezone_id: Timezone, e.g. "America/New_York".
|
timezone_id: Timezone, e.g. "America/New_York".
|
||||||
|
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||||
**kwargs: Passed to browser.new_context().
|
**kwargs: Passed to browser.new_context().
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Playwright BrowserContext object.
|
Playwright BrowserContext object.
|
||||||
"""
|
"""
|
||||||
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args)
|
# Resolve geoip BEFORE launch() to avoid double-resolution and ensure
|
||||||
|
# resolved values flow to both binary flags AND context params
|
||||||
|
timezone_id, locale = _maybe_resolve_geoip(geoip, proxy, timezone_id, locale)
|
||||||
|
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
|
||||||
|
timezone=timezone_id, locale=locale)
|
||||||
|
|
||||||
context_kwargs: dict[str, Any] = {}
|
context_kwargs: dict[str, Any] = {}
|
||||||
if user_agent:
|
if user_agent:
|
||||||
@@ -208,13 +231,43 @@ def launch_context(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _build_args(stealth_args: bool, extra_args: list[str] | None) -> list[str]:
|
def _maybe_resolve_geoip(
|
||||||
"""Combine stealth args with user-provided args."""
|
geoip: bool,
|
||||||
|
proxy: str | None,
|
||||||
|
timezone: str | None,
|
||||||
|
locale: str | None,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Auto-fill timezone/locale from proxy IP when geoip is enabled."""
|
||||||
|
if not geoip or not proxy or (timezone is not None and locale is not None):
|
||||||
|
return timezone, locale
|
||||||
|
|
||||||
|
from .geoip import resolve_proxy_geo
|
||||||
|
|
||||||
|
geo_tz, geo_locale = resolve_proxy_geo(proxy)
|
||||||
|
if timezone is None:
|
||||||
|
timezone = geo_tz
|
||||||
|
if locale is None:
|
||||||
|
locale = geo_locale
|
||||||
|
return timezone, locale
|
||||||
|
|
||||||
|
|
||||||
|
def _build_args(
|
||||||
|
stealth_args: bool,
|
||||||
|
extra_args: list[str] | None,
|
||||||
|
timezone: str | None = None,
|
||||||
|
locale: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Combine stealth args with user-provided args and locale flags."""
|
||||||
result = []
|
result = []
|
||||||
if stealth_args:
|
if stealth_args:
|
||||||
result.extend(get_default_stealth_args())
|
result.extend(get_default_stealth_args())
|
||||||
if extra_args:
|
if extra_args:
|
||||||
result.extend(extra_args)
|
result.extend(extra_args)
|
||||||
|
# Timezone/locale flags are independent of stealth_args — always inject when set
|
||||||
|
if timezone:
|
||||||
|
result.append(f"--timezone={timezone}")
|
||||||
|
if locale:
|
||||||
|
result.append(f"--lang={locale}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"""GeoIP-based timezone and locale detection from proxy IP.
|
||||||
|
|
||||||
|
Optional feature — requires ``geoip2`` package::
|
||||||
|
|
||||||
|
pip install cloakbrowser[geoip]
|
||||||
|
|
||||||
|
Downloads GeoLite2-City.mmdb (~70 MB) on first use, caches in
|
||||||
|
``~/.cloakbrowser/geoip/``. Background re-download after 30 days.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
logger = logging.getLogger("cloakbrowser")
|
||||||
|
|
||||||
|
# P3TERX mirror of MaxMind GeoLite2-City — no license key needed
|
||||||
|
GEOIP_DB_URL = (
|
||||||
|
"https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb"
|
||||||
|
)
|
||||||
|
GEOIP_DB_FILENAME = "GeoLite2-City.mmdb"
|
||||||
|
GEOIP_UPDATE_INTERVAL = 30 * 86_400 # 30 days
|
||||||
|
|
||||||
|
# Country ISO code → BCP 47 locale (covers ~90 % of proxy traffic)
|
||||||
|
COUNTRY_LOCALE_MAP: dict[str, str] = {
|
||||||
|
"US": "en-US", "GB": "en-GB", "AU": "en-AU", "CA": "en-CA", "NZ": "en-NZ",
|
||||||
|
"IE": "en-IE", "ZA": "en-ZA", "SG": "en-SG",
|
||||||
|
"DE": "de-DE", "AT": "de-AT", "CH": "de-CH",
|
||||||
|
"FR": "fr-FR", "BE": "fr-BE",
|
||||||
|
"ES": "es-ES", "MX": "es-MX", "AR": "es-AR", "CO": "es-CO", "CL": "es-CL",
|
||||||
|
"BR": "pt-BR", "PT": "pt-PT",
|
||||||
|
"IT": "it-IT", "NL": "nl-NL",
|
||||||
|
"JP": "ja-JP", "KR": "ko-KR", "CN": "zh-CN", "TW": "zh-TW", "HK": "zh-HK",
|
||||||
|
"RU": "ru-RU", "UA": "uk-UA", "PL": "pl-PL", "CZ": "cs-CZ", "RO": "ro-RO",
|
||||||
|
"IL": "he-IL", "TR": "tr-TR", "SA": "ar-SA", "AE": "ar-AE", "EG": "ar-EG",
|
||||||
|
"IN": "hi-IN", "ID": "id-ID", "PH": "en-PH",
|
||||||
|
"TH": "th-TH", "VN": "vi-VN", "MY": "ms-MY",
|
||||||
|
"SE": "sv-SE", "NO": "nb-NO", "DK": "da-DK", "FI": "fi-FI",
|
||||||
|
"GR": "el-GR", "HU": "hu-HU", "BG": "bg-BG",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_proxy_geo(proxy_url: str) -> tuple[str | None, str | None]:
|
||||||
|
"""Resolve timezone and locale from a proxy's IP address.
|
||||||
|
|
||||||
|
Returns ``(timezone, locale)`` — either or both may be ``None`` on
|
||||||
|
failure (missing dep, DB download error, lookup miss). Never raises.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import geoip2.database # noqa: F811
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError(
|
||||||
|
"geoip2 is required for geoip=True. Install it with:\n"
|
||||||
|
" pip install cloakbrowser[geoip]"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
db_path = _ensure_geoip_db()
|
||||||
|
if db_path is None:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
|
||||||
|
ip = _resolve_exit_ip(proxy_url)
|
||||||
|
if ip is None:
|
||||||
|
ip = _resolve_proxy_ip(proxy_url)
|
||||||
|
if ip is None:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with geoip2.database.Reader(str(db_path)) as reader:
|
||||||
|
resp = reader.city(ip)
|
||||||
|
timezone = resp.location.time_zone
|
||||||
|
country = resp.country.iso_code
|
||||||
|
locale = COUNTRY_LOCALE_MAP.get(country) if country else None
|
||||||
|
logger.debug(
|
||||||
|
"GeoIP: %s → tz=%s, country=%s, locale=%s",
|
||||||
|
ip, timezone, country, locale,
|
||||||
|
)
|
||||||
|
return timezone, locale
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("GeoIP lookup failed for %s: %s", ip, exc)
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Proxy IP resolution
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_proxy_ip(proxy_url: str) -> str | None:
|
||||||
|
"""Extract proxy hostname from URL and resolve to an IP address."""
|
||||||
|
try:
|
||||||
|
hostname = urlparse(proxy_url).hostname
|
||||||
|
if not hostname:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Already a literal IP?
|
||||||
|
try:
|
||||||
|
socket.inet_pton(socket.AF_INET, hostname)
|
||||||
|
return hostname
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
socket.inet_pton(socket.AF_INET6, hostname)
|
||||||
|
return hostname
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# DNS resolve (returns first result, handles both v4/v6)
|
||||||
|
results = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
||||||
|
if results:
|
||||||
|
ip = results[0][4][0]
|
||||||
|
logger.debug("Resolved proxy %s → %s", hostname, ip)
|
||||||
|
return ip
|
||||||
|
return None
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Failed to resolve proxy hostname: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_private_ip(ip: str) -> bool:
|
||||||
|
"""Check if an IP address is private/internal (not routable on the internet)."""
|
||||||
|
try:
|
||||||
|
return ipaddress.ip_address(ip).is_private
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# IP echo services — fast, no auth, return just the IP
|
||||||
|
_IP_ECHO_URLS = [
|
||||||
|
"https://api.ipify.org",
|
||||||
|
"https://checkip.amazonaws.com",
|
||||||
|
"https://ifconfig.me/ip",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_exit_ip(proxy_url: str) -> str | None:
|
||||||
|
"""Discover the proxy's actual exit IP by connecting through it."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
for url in _IP_ECHO_URLS:
|
||||||
|
try:
|
||||||
|
resp = httpx.get(url, proxy=proxy_url, timeout=10.0)
|
||||||
|
resp.raise_for_status()
|
||||||
|
ip = resp.text.strip()
|
||||||
|
# Validate it looks like an IP
|
||||||
|
ipaddress.ip_address(ip)
|
||||||
|
logger.debug("Exit IP via %s: %s", url, ip)
|
||||||
|
return ip
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
logger.debug("Failed to discover exit IP through proxy")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GeoIP database management
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _get_geoip_dir() -> Path:
|
||||||
|
from .config import get_cache_dir
|
||||||
|
|
||||||
|
return get_cache_dir() / "geoip"
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_geoip_db() -> Path | None:
|
||||||
|
"""Return path to GeoLite2-City.mmdb, downloading on first use."""
|
||||||
|
db_path = _get_geoip_dir() / GEOIP_DB_FILENAME
|
||||||
|
|
||||||
|
if db_path.exists():
|
||||||
|
_maybe_trigger_update(db_path)
|
||||||
|
return db_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
_download_geoip_db(db_path)
|
||||||
|
return db_path
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to download GeoIP database: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _download_geoip_db(dest: Path) -> None:
|
||||||
|
"""Atomic download of GeoLite2-City.mmdb via httpx."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
logger.info("Downloading GeoIP database (~70 MB) …")
|
||||||
|
|
||||||
|
tmp_fd, tmp_name = tempfile.mkstemp(dir=dest.parent, suffix=".tmp")
|
||||||
|
tmp_path = Path(tmp_name)
|
||||||
|
try:
|
||||||
|
with httpx.stream(
|
||||||
|
"GET", GEOIP_DB_URL, follow_redirects=True, timeout=300.0
|
||||||
|
) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
total = int(resp.headers.get("content-length", 0))
|
||||||
|
downloaded = 0
|
||||||
|
last_pct = -1
|
||||||
|
with open(tmp_fd, "wb") as f:
|
||||||
|
for chunk in resp.iter_bytes(chunk_size=65_536):
|
||||||
|
f.write(chunk)
|
||||||
|
downloaded += len(chunk)
|
||||||
|
if total:
|
||||||
|
pct = downloaded * 100 // total
|
||||||
|
if pct >= last_pct + 10:
|
||||||
|
last_pct = pct
|
||||||
|
logger.info("GeoIP download: %d %%", pct)
|
||||||
|
|
||||||
|
tmp_path.rename(dest)
|
||||||
|
logger.info("GeoIP database ready: %s", dest)
|
||||||
|
except Exception:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_trigger_update(db_path: Path) -> None:
|
||||||
|
"""Re-download in background if DB is older than 30 days."""
|
||||||
|
try:
|
||||||
|
age = time.time() - db_path.stat().st_mtime
|
||||||
|
if age < GEOIP_UPDATE_INTERVAL:
|
||||||
|
return
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _bg() -> None:
|
||||||
|
try:
|
||||||
|
_download_geoip_db(db_path)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Background GeoIP update failed", exc_info=True)
|
||||||
|
|
||||||
|
threading.Thread(target=_bg, daemon=True).start()
|
||||||
+34
-1
@@ -75,7 +75,19 @@ const browser = await launch({
|
|||||||
args: ['--window-size=1920,1080'],
|
args: ['--window-size=1920,1080'],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Browser + context in one call
|
// With timezone and locale (sets --timezone and --lang binary flags)
|
||||||
|
const browser = await launch({
|
||||||
|
timezone: 'America/New_York',
|
||||||
|
locale: 'en-US',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-detect timezone/locale from proxy IP (requires: npm install mmdb-lib)
|
||||||
|
const browser = await launch({
|
||||||
|
proxy: 'http://proxy:8080',
|
||||||
|
geoip: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Browser + context in one call (timezone/locale set both binary flags AND context)
|
||||||
const context = await launchContext({
|
const context = await launchContext({
|
||||||
userAgent: 'Custom UA',
|
userAgent: 'Custom UA',
|
||||||
viewport: { width: 1920, height: 1080 },
|
viewport: { width: 1920, height: 1080 },
|
||||||
@@ -84,6 +96,27 @@ const context = await launchContext({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Auto Timezone/Locale from Proxy IP
|
||||||
|
|
||||||
|
When using a proxy, antibot systems check that your browser's timezone and locale match the proxy's location. Install `mmdb-lib` to enable auto-detection from an offline GeoIP database (~70 MB, downloaded on first use):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install mmdb-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Auto-detect — timezone and locale set from proxy's IP geolocation
|
||||||
|
const browser = await launch({ proxy: 'http://proxy:8080', geoip: true });
|
||||||
|
|
||||||
|
// Works with launchContext too
|
||||||
|
const context = await launchContext({ proxy: 'http://proxy:8080', geoip: true });
|
||||||
|
|
||||||
|
// Explicit values always win over auto-detection
|
||||||
|
const browser = await launch({ proxy: 'http://proxy:8080', geoip: true, timezone: 'Europe/London' });
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** For rotating residential proxies, the DNS-resolved IP may differ from the exit IP. Pass explicit `timezone`/`locale` in those cases.
|
||||||
|
|
||||||
### Utilities
|
### Utilities
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
|
|||||||
Generated
+18
-2
@@ -1,18 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "cloakbrowser",
|
"name": "cloakbrowser",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "cloakbrowser",
|
"name": "cloakbrowser",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tar": "^7.0.0"
|
"tar": "^7.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.10.0",
|
"@types/node": "^20.10.0",
|
||||||
|
"mmdb-lib": "^3.0.2",
|
||||||
"playwright-core": "^1.40.0",
|
"playwright-core": "^1.40.0",
|
||||||
"puppeteer-core": "^21.0.0",
|
"puppeteer-core": "^21.0.0",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.3.0",
|
||||||
@@ -22,10 +23,14 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
"mmdb-lib": ">=2.0.0",
|
||||||
"playwright-core": ">=1.40.0",
|
"playwright-core": ">=1.40.0",
|
||||||
"puppeteer-core": ">=21.0.0"
|
"puppeteer-core": ">=21.0.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
|
"mmdb-lib": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"playwright-core": {
|
"playwright-core": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
@@ -1839,6 +1844,17 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/mmdb-lib": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/mmdb-lib/-/mmdb-lib-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-7e87vk0DdWT647wjcfEtWeMtjm+zVGqNohN/aeIymbUfjHQ2T4Sx5kM+1irVDBSloNC3CkGKxswdMoo8yhqTDg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10",
|
||||||
|
"npm": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||||
|
|||||||
+6
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cloakbrowser",
|
"name": "cloakbrowser",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"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",
|
||||||
@@ -43,6 +43,7 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
"mmdb-lib": ">=2.0.0",
|
||||||
"playwright-core": ">=1.40.0",
|
"playwright-core": ">=1.40.0",
|
||||||
"puppeteer-core": ">=21.0.0"
|
"puppeteer-core": ">=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -52,6 +53,9 @@
|
|||||||
},
|
},
|
||||||
"puppeteer-core": {
|
"puppeteer-core": {
|
||||||
"optional": true
|
"optional": true
|
||||||
|
},
|
||||||
|
"mmdb-lib": {
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -59,6 +63,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.10.0",
|
"@types/node": "^20.10.0",
|
||||||
|
"mmdb-lib": "^3.0.2",
|
||||||
"playwright-core": "^1.40.0",
|
"playwright-core": "^1.40.0",
|
||||||
"puppeteer-core": "^21.0.0",
|
"puppeteer-core": "^21.0.0",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.3.0",
|
||||||
|
|||||||
+262
@@ -0,0 +1,262 @@
|
|||||||
|
/**
|
||||||
|
* GeoIP-based timezone and locale detection from proxy IP.
|
||||||
|
*
|
||||||
|
* Optional feature — requires `mmdb-lib` package:
|
||||||
|
* npm install mmdb-lib
|
||||||
|
*
|
||||||
|
* Downloads GeoLite2-City.mmdb (~70 MB) on first use,
|
||||||
|
* caches in `~/.cloakbrowser/geoip/`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { createWriteStream } from "node:fs";
|
||||||
|
import dns from "node:dns/promises";
|
||||||
|
import net from "node:net";
|
||||||
|
import { getCacheDir } from "./config.js";
|
||||||
|
|
||||||
|
// P3TERX mirror of MaxMind GeoLite2-City — no license key needed
|
||||||
|
const GEOIP_DB_URL =
|
||||||
|
"https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb";
|
||||||
|
const GEOIP_DB_FILENAME = "GeoLite2-City.mmdb";
|
||||||
|
const GEOIP_UPDATE_INTERVAL_MS = 30 * 86_400_000; // 30 days
|
||||||
|
|
||||||
|
/** Country ISO code → BCP 47 locale (covers ~90% of proxy traffic). */
|
||||||
|
export const COUNTRY_LOCALE_MAP: Record<string, string> = {
|
||||||
|
US: "en-US", GB: "en-GB", AU: "en-AU", CA: "en-CA", NZ: "en-NZ",
|
||||||
|
IE: "en-IE", ZA: "en-ZA", SG: "en-SG",
|
||||||
|
DE: "de-DE", AT: "de-AT", CH: "de-CH",
|
||||||
|
FR: "fr-FR", BE: "fr-BE",
|
||||||
|
ES: "es-ES", MX: "es-MX", AR: "es-AR", CO: "es-CO", CL: "es-CL",
|
||||||
|
BR: "pt-BR", PT: "pt-PT",
|
||||||
|
IT: "it-IT", NL: "nl-NL",
|
||||||
|
JP: "ja-JP", KR: "ko-KR", CN: "zh-CN", TW: "zh-TW", HK: "zh-HK",
|
||||||
|
RU: "ru-RU", UA: "uk-UA", PL: "pl-PL", CZ: "cs-CZ", RO: "ro-RO",
|
||||||
|
IL: "he-IL", TR: "tr-TR", SA: "ar-SA", AE: "ar-AE", EG: "ar-EG",
|
||||||
|
IN: "hi-IN", ID: "id-ID", PH: "en-PH",
|
||||||
|
TH: "th-TH", VN: "vi-VN", MY: "ms-MY",
|
||||||
|
SE: "sv-SE", NO: "nb-NO", DK: "da-DK", FI: "fi-FI",
|
||||||
|
GR: "el-GR", HU: "hu-HU", BG: "bg-BG",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface GeoResult {
|
||||||
|
timezone: string | null;
|
||||||
|
locale: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve timezone and locale from a proxy's IP address.
|
||||||
|
* Returns `{ timezone, locale }` — either may be null on failure.
|
||||||
|
* Never throws.
|
||||||
|
*/
|
||||||
|
export async function resolveProxyGeo(
|
||||||
|
proxyUrl: string
|
||||||
|
): Promise<GeoResult> {
|
||||||
|
let Reader: any;
|
||||||
|
try {
|
||||||
|
const mmdb = await import("mmdb-lib");
|
||||||
|
Reader = mmdb.default?.Reader ?? mmdb.Reader;
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
"mmdb-lib is required for geoip: true. Install it with:\n npm install mmdb-lib"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbPath = await ensureGeoipDb();
|
||||||
|
if (!dbPath) return { timezone: null, locale: null };
|
||||||
|
|
||||||
|
// Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
|
||||||
|
let ip = await resolveExitIp(proxyUrl);
|
||||||
|
if (!ip) ip = await resolveProxyIp(proxyUrl);
|
||||||
|
if (!ip) return { timezone: null, locale: null };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buf = fs.readFileSync(dbPath);
|
||||||
|
const reader = new Reader(buf);
|
||||||
|
const result = reader.get(ip) as any;
|
||||||
|
const timezone: string | null = result?.location?.time_zone ?? null;
|
||||||
|
const countryCode: string | null = result?.country?.iso_code ?? null;
|
||||||
|
const locale =
|
||||||
|
countryCode ? (COUNTRY_LOCALE_MAP[countryCode] ?? null) : null;
|
||||||
|
return { timezone, locale };
|
||||||
|
} catch {
|
||||||
|
return { timezone: null, locale: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Proxy IP resolution
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** @internal Exported for testing. */
|
||||||
|
export async function resolveProxyIp(
|
||||||
|
proxyUrl: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const url = new URL(proxyUrl);
|
||||||
|
const hostname = url.hostname;
|
||||||
|
if (!hostname) return null;
|
||||||
|
|
||||||
|
// Already a literal IP?
|
||||||
|
if (net.isIP(hostname)) return hostname;
|
||||||
|
|
||||||
|
// DNS resolve
|
||||||
|
const { address } = await dns.lookup(hostname);
|
||||||
|
return address;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateIp(ip: string): boolean {
|
||||||
|
// Quick check for common private ranges
|
||||||
|
if (ip.startsWith("10.") || ip.startsWith("127.") || ip === "::1") return true;
|
||||||
|
if (ip.startsWith("172.")) {
|
||||||
|
const second = parseInt(ip.split(".")[1], 10);
|
||||||
|
if (second >= 16 && second <= 31) return true;
|
||||||
|
}
|
||||||
|
if (ip.startsWith("192.168.")) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IP_ECHO_URLS = [
|
||||||
|
"https://api.ipify.org",
|
||||||
|
"https://checkip.amazonaws.com",
|
||||||
|
"https://ifconfig.me/ip",
|
||||||
|
];
|
||||||
|
|
||||||
|
async function resolveExitIp(proxyUrl: string): Promise<string | null> {
|
||||||
|
// Node.js fetch doesn't support proxy natively — use a CONNECT tunnel via http
|
||||||
|
// For simplicity, use a direct HTTP request to a plain-text IP echo service
|
||||||
|
// through the proxy using Node's http module
|
||||||
|
try {
|
||||||
|
const { default: http } = await import("node:http");
|
||||||
|
const { default: https } = await import("node:https");
|
||||||
|
const proxyUrlObj = new URL(proxyUrl);
|
||||||
|
|
||||||
|
for (const echoUrl of IP_ECHO_URLS) {
|
||||||
|
try {
|
||||||
|
const ip = await new Promise<string | null>((resolve, reject) => {
|
||||||
|
const targetUrl = new URL(echoUrl);
|
||||||
|
const connectReq = http.request({
|
||||||
|
host: proxyUrlObj.hostname,
|
||||||
|
port: parseInt(proxyUrlObj.port || "80", 10),
|
||||||
|
method: "CONNECT",
|
||||||
|
path: `${targetUrl.hostname}:443`,
|
||||||
|
headers: proxyUrlObj.username
|
||||||
|
? {
|
||||||
|
"Proxy-Authorization":
|
||||||
|
"Basic " +
|
||||||
|
Buffer.from(
|
||||||
|
`${decodeURIComponent(proxyUrlObj.username)}:${decodeURIComponent(proxyUrlObj.password || "")}`
|
||||||
|
).toString("base64"),
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
connectReq.on("connect", (_res, socket) => {
|
||||||
|
const req = https.request(
|
||||||
|
echoUrl,
|
||||||
|
{ socket, timeout: 5_000 } as any,
|
||||||
|
(res) => {
|
||||||
|
let data = "";
|
||||||
|
res.on("data", (chunk: Buffer) => (data += chunk.toString()));
|
||||||
|
res.on("end", () => {
|
||||||
|
const ip = data.trim();
|
||||||
|
resolve(net.isIP(ip) ? ip : null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.on("error", () => resolve(null));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
connectReq.on("error", () => resolve(null));
|
||||||
|
connectReq.on("timeout", () => {
|
||||||
|
connectReq.destroy();
|
||||||
|
resolve(null);
|
||||||
|
});
|
||||||
|
connectReq.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ip) return ip;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback: couldn't import http modules
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GeoIP database management
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getGeoipDir(): string {
|
||||||
|
return path.join(getCacheDir(), "geoip");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureGeoipDb(): Promise<string | null> {
|
||||||
|
const dir = getGeoipDir();
|
||||||
|
const dbPath = path.join(dir, GEOIP_DB_FILENAME);
|
||||||
|
|
||||||
|
if (fs.existsSync(dbPath)) {
|
||||||
|
maybeTriggerUpdate(dbPath);
|
||||||
|
return dbPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadGeoipDb(dbPath);
|
||||||
|
return dbPath;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadGeoipDb(dest: string): Promise<void> {
|
||||||
|
const dir = path.dirname(dest);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
console.log("[cloakbrowser] Downloading GeoIP database (~70 MB)…");
|
||||||
|
|
||||||
|
const tmpPath = `${dest}.tmp.${Date.now()}`;
|
||||||
|
try {
|
||||||
|
const response = await fetch(GEOIP_DB_URL, { redirect: "follow" });
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileStream = createWriteStream(tmpPath);
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
fileStream.write(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
fileStream.end(() => resolve());
|
||||||
|
fileStream.on("error", reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.renameSync(tmpPath, dest);
|
||||||
|
console.log(`[cloakbrowser] GeoIP database ready: ${dest}`);
|
||||||
|
} catch (err) {
|
||||||
|
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeTriggerUpdate(dbPath: string): void {
|
||||||
|
try {
|
||||||
|
const age = Date.now() - fs.statSync(dbPath).mtimeMs;
|
||||||
|
if (age < GEOIP_UPDATE_INTERVAL_MS) return;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fire-and-forget background update
|
||||||
|
downloadGeoipDb(dbPath).catch(() => {});
|
||||||
|
}
|
||||||
+33
-4
@@ -26,7 +26,8 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
|||||||
const { chromium } = await import("playwright-core");
|
const { chromium } = await import("playwright-core");
|
||||||
|
|
||||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||||
const args = buildArgs(options);
|
const resolved = await maybeResolveGeoip(options);
|
||||||
|
const args = buildArgs({ ...options, ...resolved });
|
||||||
|
|
||||||
const browser = await chromium.launch({
|
const browser = await chromium.launch({
|
||||||
executablePath: binaryPath,
|
executablePath: binaryPath,
|
||||||
@@ -59,15 +60,17 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
|||||||
export async function launchContext(
|
export async function launchContext(
|
||||||
options: LaunchContextOptions = {}
|
options: LaunchContextOptions = {}
|
||||||
): Promise<BrowserContext> {
|
): Promise<BrowserContext> {
|
||||||
const browser = await launch(options);
|
// Resolve geoip BEFORE launch() to avoid double-resolution
|
||||||
|
const resolved = await maybeResolveGeoip(options);
|
||||||
|
const browser = await launch({ ...options, ...resolved, geoip: false });
|
||||||
|
|
||||||
let context: BrowserContext;
|
let context: BrowserContext;
|
||||||
try {
|
try {
|
||||||
context = await browser.newContext({
|
context = await browser.newContext({
|
||||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
||||||
...(options.viewport ? { viewport: options.viewport } : {}),
|
...(options.viewport ? { viewport: options.viewport } : {}),
|
||||||
...(options.locale ? { locale: options.locale } : {}),
|
...(resolved.locale ? { locale: resolved.locale } : {}),
|
||||||
...(options.timezoneId ? { timezoneId: options.timezoneId } : {}),
|
...(resolved.timezone ? { timezoneId: resolved.timezone } : {}),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
@@ -88,6 +91,25 @@ export async function launchContext(
|
|||||||
// Internal
|
// Internal
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function maybeResolveGeoip(
|
||||||
|
options: LaunchOptions
|
||||||
|
): Promise<{ timezone?: string; locale?: string }> {
|
||||||
|
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
|
||||||
|
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
|
||||||
|
|
||||||
|
const { resolveProxyGeo } = await import("./geoip.js");
|
||||||
|
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(options.proxy);
|
||||||
|
return {
|
||||||
|
timezone: options.timezone ?? geoTz ?? undefined,
|
||||||
|
locale: options.locale ?? geoLocale ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal Exposed for unit tests only. */
|
||||||
|
export function _buildArgsForTest(options: LaunchOptions): string[] {
|
||||||
|
return buildArgs(options);
|
||||||
|
}
|
||||||
|
|
||||||
function buildArgs(options: LaunchOptions): string[] {
|
function buildArgs(options: LaunchOptions): string[] {
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
if (options.stealthArgs !== false) {
|
if (options.stealthArgs !== false) {
|
||||||
@@ -96,5 +118,12 @@ function buildArgs(options: LaunchOptions): string[] {
|
|||||||
if (options.args) {
|
if (options.args) {
|
||||||
args.push(...options.args);
|
args.push(...options.args);
|
||||||
}
|
}
|
||||||
|
// Timezone/locale flags — always inject when set
|
||||||
|
if (options.timezone) {
|
||||||
|
args.push(`--timezone=${options.timezone}`);
|
||||||
|
}
|
||||||
|
if (options.locale) {
|
||||||
|
args.push(`--lang=${options.locale}`);
|
||||||
|
}
|
||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-1
@@ -26,7 +26,8 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
|||||||
const puppeteer = await import("puppeteer-core");
|
const puppeteer = await import("puppeteer-core");
|
||||||
|
|
||||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||||
const args = buildArgs(options);
|
const resolved = await maybeResolveGeoip(options);
|
||||||
|
const args = buildArgs({ ...options, ...resolved });
|
||||||
|
|
||||||
// Puppeteer handles proxy via CLI args, not a separate option.
|
// Puppeteer handles proxy via CLI args, not a separate option.
|
||||||
// Chromium's --proxy-server does NOT support inline credentials,
|
// Chromium's --proxy-server does NOT support inline credentials,
|
||||||
@@ -66,6 +67,20 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
|||||||
// Internal
|
// Internal
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function maybeResolveGeoip(
|
||||||
|
options: LaunchOptions
|
||||||
|
): Promise<{ timezone?: string; locale?: string }> {
|
||||||
|
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
|
||||||
|
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
|
||||||
|
|
||||||
|
const { resolveProxyGeo } = await import("./geoip.js");
|
||||||
|
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(options.proxy);
|
||||||
|
return {
|
||||||
|
timezone: options.timezone ?? geoTz ?? undefined,
|
||||||
|
locale: options.locale ?? geoLocale ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildArgs(options: LaunchOptions): string[] {
|
function buildArgs(options: LaunchOptions): string[] {
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
if (options.stealthArgs !== false) {
|
if (options.stealthArgs !== false) {
|
||||||
@@ -74,5 +89,11 @@ function buildArgs(options: LaunchOptions): string[] {
|
|||||||
if (options.args) {
|
if (options.args) {
|
||||||
args.push(...options.args);
|
args.push(...options.args);
|
||||||
}
|
}
|
||||||
|
if (options.timezone) {
|
||||||
|
args.push(`--timezone=${options.timezone}`);
|
||||||
|
}
|
||||||
|
if (options.locale) {
|
||||||
|
args.push(`--lang=${options.locale}`);
|
||||||
|
}
|
||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ export interface LaunchOptions {
|
|||||||
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. */
|
||||||
stealthArgs?: boolean;
|
stealthArgs?: boolean;
|
||||||
|
/** IANA timezone, e.g. "America/New_York". Sets --timezone binary flag. */
|
||||||
|
timezone?: string;
|
||||||
|
/** BCP 47 locale, e.g. "en-US". Sets --lang binary flag. */
|
||||||
|
locale?: string;
|
||||||
|
/** Auto-detect timezone/locale from proxy IP (requires: npm install mmdb-lib). */
|
||||||
|
geoip?: boolean;
|
||||||
/** Raw options passed directly to playwright/puppeteer launch(). */
|
/** Raw options passed directly to playwright/puppeteer launch(). */
|
||||||
launchOptions?: Record<string, unknown>;
|
launchOptions?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { COUNTRY_LOCALE_MAP, resolveProxyIp } from "../src/geoip.js";
|
||||||
|
|
||||||
|
describe("resolveProxyIp", () => {
|
||||||
|
it("returns literal IPv4 from proxy URL", async () => {
|
||||||
|
expect(await resolveProxyIp("http://10.50.96.5:8888")).toBe("10.50.96.5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles proxy URL with credentials", async () => {
|
||||||
|
expect(await resolveProxyIp("http://user:pass@10.50.96.5:8888")).toBe(
|
||||||
|
"10.50.96.5"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves localhost", async () => {
|
||||||
|
const ip = await resolveProxyIp("http://localhost:8888");
|
||||||
|
expect(ip).toBeTruthy();
|
||||||
|
expect(["127.0.0.1", "::1"]).toContain(ip);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for invalid URL", async () => {
|
||||||
|
expect(await resolveProxyIp("not-a-url")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for empty string", async () => {
|
||||||
|
expect(await resolveProxyIp("")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("COUNTRY_LOCALE_MAP", () => {
|
||||||
|
it("contains common countries", () => {
|
||||||
|
for (const code of ["US", "GB", "DE", "FR", "JP", "BR", "IL", "RU"]) {
|
||||||
|
expect(COUNTRY_LOCALE_MAP[code]).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("values are BCP 47 language-REGION format", () => {
|
||||||
|
for (const [code, locale] of Object.entries(COUNTRY_LOCALE_MAP)) {
|
||||||
|
const parts = locale.split("-");
|
||||||
|
expect(parts).toHaveLength(2);
|
||||||
|
expect(parts[0]).toMatch(/^[a-z]{2,3}$/);
|
||||||
|
expect(parts[1]).toMatch(/^[A-Z]{2}$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -46,6 +46,9 @@ dependencies = [
|
|||||||
"httpx>=0.24",
|
"httpx>=0.24",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
geoip = ["geoip2>=4.0"]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/CloakHQ/CloakBrowser"
|
Homepage = "https://github.com/CloakHQ/CloakBrowser"
|
||||||
Documentation = "https://github.com/CloakHQ/CloakBrowser#readme"
|
Documentation = "https://github.com/CloakHQ/CloakBrowser#readme"
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""Unit tests for GeoIP-based timezone/locale detection."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cloakbrowser.browser import _maybe_resolve_geoip
|
||||||
|
from cloakbrowser.geoip import (
|
||||||
|
COUNTRY_LOCALE_MAP,
|
||||||
|
_resolve_proxy_ip,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _resolve_proxy_ip
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_literal_ipv4():
|
||||||
|
assert _resolve_proxy_ip("http://10.50.96.5:8888") == "10.50.96.5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_literal_ipv4_with_auth():
|
||||||
|
assert _resolve_proxy_ip("http://user:pass@10.50.96.5:8888") == "10.50.96.5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_literal_ipv6():
|
||||||
|
ip = _resolve_proxy_ip("http://[::1]:8888")
|
||||||
|
assert ip == "::1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_hostname():
|
||||||
|
"""DNS resolution of a known hostname should return an IP."""
|
||||||
|
ip = _resolve_proxy_ip("http://localhost:8888")
|
||||||
|
assert ip is not None
|
||||||
|
assert ip in ("127.0.0.1", "::1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_invalid_url():
|
||||||
|
assert _resolve_proxy_ip("not-a-url") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_empty():
|
||||||
|
assert _resolve_proxy_ip("") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# COUNTRY_LOCALE_MAP
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_locale_map_has_common_countries():
|
||||||
|
for code in ("US", "GB", "DE", "FR", "JP", "BR", "IL", "RU"):
|
||||||
|
assert code in COUNTRY_LOCALE_MAP, f"Missing {code}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_locale_map_values_are_bcp47():
|
||||||
|
"""All locales should be language-REGION format."""
|
||||||
|
for code, locale in COUNTRY_LOCALE_MAP.items():
|
||||||
|
parts = locale.split("-")
|
||||||
|
assert len(parts) == 2, f"{code}: {locale} not language-REGION"
|
||||||
|
assert parts[0].islower(), f"{code}: language part should be lowercase"
|
||||||
|
assert parts[1].isupper(), f"{code}: region part should be uppercase"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# resolve_proxy_geo fallbacks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_geo_raises_when_geoip2_missing():
|
||||||
|
"""Should raise ImportError with install instructions when geoip2 not installed."""
|
||||||
|
with patch.dict("sys.modules", {"geoip2": None, "geoip2.database": None}):
|
||||||
|
from importlib import reload
|
||||||
|
import cloakbrowser.geoip as geoip_mod
|
||||||
|
reload(geoip_mod)
|
||||||
|
with pytest.raises(ImportError, match="pip install cloakbrowser"):
|
||||||
|
geoip_mod.resolve_proxy_geo("http://10.50.96.5:8888")
|
||||||
|
# Restore
|
||||||
|
reload(geoip_mod)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_geo_returns_none_when_db_missing():
|
||||||
|
"""Should return (None, None) when DB file doesn't exist."""
|
||||||
|
mock_geoip2 = type("module", (), {"database": type("db", (), {"Reader": None})})()
|
||||||
|
with patch.dict("sys.modules", {"geoip2": mock_geoip2, "geoip2.database": mock_geoip2.database}):
|
||||||
|
with patch("cloakbrowser.geoip._ensure_geoip_db", return_value=None):
|
||||||
|
with patch("cloakbrowser.geoip._resolve_exit_ip", return_value=None):
|
||||||
|
from cloakbrowser.geoip import resolve_proxy_geo
|
||||||
|
assert resolve_proxy_geo("http://10.50.96.5:8888") == (None, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _maybe_resolve_geoip (browser.py helper)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_maybe_resolve_skips_when_geoip_false():
|
||||||
|
tz, loc = _maybe_resolve_geoip(False, "http://proxy:8080", None, None)
|
||||||
|
assert tz is None
|
||||||
|
assert loc is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_maybe_resolve_skips_when_no_proxy():
|
||||||
|
tz, loc = _maybe_resolve_geoip(True, None, None, None)
|
||||||
|
assert tz is None
|
||||||
|
assert loc is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_maybe_resolve_skips_when_both_explicit():
|
||||||
|
"""Explicit values should not trigger geoip resolution."""
|
||||||
|
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", "de-DE")
|
||||||
|
assert tz == "Europe/Berlin"
|
||||||
|
assert loc == "de-DE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_maybe_resolve_fills_missing_timezone():
|
||||||
|
"""When only locale is explicit, geoip should fill timezone."""
|
||||||
|
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")):
|
||||||
|
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", None, "fr-FR")
|
||||||
|
assert tz == "America/New_York"
|
||||||
|
assert loc == "fr-FR" # Explicit wins
|
||||||
|
|
||||||
|
|
||||||
|
def test_maybe_resolve_fills_missing_locale():
|
||||||
|
"""When only timezone is explicit, geoip should fill locale."""
|
||||||
|
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")):
|
||||||
|
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", "Asia/Tokyo", None)
|
||||||
|
assert tz == "Asia/Tokyo" # Explicit wins
|
||||||
|
assert loc == "en-US"
|
||||||
|
|
||||||
|
|
||||||
|
def test_maybe_resolve_fills_both():
|
||||||
|
"""When neither is set, geoip should fill both."""
|
||||||
|
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Europe/Berlin", "de-DE")):
|
||||||
|
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", None, None)
|
||||||
|
assert tz == "Europe/Berlin"
|
||||||
|
assert loc == "de-DE"
|
||||||
Reference in New Issue
Block a user