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:
CloakHQ
2026-03-01 01:53:50 +01:00
parent 59b9d71684
commit 67efadef26
14 changed files with 891 additions and 15 deletions
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.2.0"
__version__ = "0.2.1"
+58 -5
View File
@@ -29,6 +29,9 @@ def launch(
proxy: str | None = None,
args: list[str] | None = None,
stealth_args: bool = True,
timezone: str | None = None,
locale: str | None = None,
geoip: bool = False,
**kwargs: Any,
) -> Any:
"""Launch stealth Chromium browser. Returns a Playwright Browser object.
@@ -39,6 +42,12 @@ def launch(
args: Additional Chromium CLI arguments to pass.
stealth_args: Include default stealth fingerprint args (default True).
Set to False if you want to pass your own --fingerprint flags.
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().
Returns:
@@ -55,7 +64,8 @@ def launch(
from playwright.sync_api import sync_playwright
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))
@@ -86,6 +96,9 @@ async def launch_async(
proxy: str | None = None,
args: list[str] | None = None,
stealth_args: bool = True,
timezone: str | None = None,
locale: str | None = None,
geoip: bool = False,
**kwargs: Any,
) -> Any:
"""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').
args: Additional Chromium CLI arguments to pass.
stealth_args: Include default stealth fingerprint args (default True).
timezone: IANA timezone (e.g. 'America/New_York'). Sets --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().
Returns:
@@ -116,7 +132,8 @@ async def launch_async(
from playwright.async_api import async_playwright
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))
@@ -151,6 +168,7 @@ def launch_context(
viewport: dict | None = None,
locale: str | None = None,
timezone_id: str | None = None,
geoip: bool = False,
**kwargs: Any,
) -> Any:
"""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}.
locale: Browser locale, e.g. "en-US".
timezone_id: Timezone, e.g. "America/New_York".
geoip: Auto-detect timezone/locale from proxy IP (default False).
**kwargs: Passed to browser.new_context().
Returns:
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] = {}
if user_agent:
@@ -208,13 +231,43 @@ def launch_context(
# ---------------------------------------------------------------------------
def _build_args(stealth_args: bool, extra_args: list[str] | None) -> list[str]:
"""Combine stealth args with user-provided args."""
def _maybe_resolve_geoip(
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 = []
if stealth_args:
result.extend(get_default_stealth_args())
if 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
+238
View File
@@ -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()