From 3d3e5efb9e47c05b2838e921808145052ca92d7f Mon Sep 17 00:00:00 2001 From: lilos Date: Mon, 9 Mar 2026 05:18:36 +0300 Subject: [PATCH] feat(proxy): add ProxyRotator with multi-strategy rotation, health tracking, and auto-failover - Round-robin, random, least-used, least-failures strategies - Per-proxy health tracking with cooldown and auto-recovery - Sticky sessions (reuse same proxy for N requests) - Dynamic pool management (add/remove at runtime) - Context manager / withSession with auto success/failure reporting - Thread-safe implementation with Lock - Credential masking in stats and logs - SOCKS5 without auth supported; SOCKS5 with auth rejected (Chromium limitation) - Bare proxy format support (user:pass@host:port) - Full test coverage: 49 pytest, 45 vitest, 7 real-proxy, 9 real-proxy JS - Integration with launch(proxy=rotator) --- cloakbrowser/__init__.py | 3 + cloakbrowser/browser.py | 42 ++- cloakbrowser/proxy_rotator.py | 476 ++++++++++++++++++++++++++++++ examples/proxy_rotation.py | 101 +++++++ examples/proxy_verify.py | 157 ++++++++++ js/examples/proxy-rotation.ts | 103 +++++++ js/package-lock.json | 4 +- js/src/index.ts | 4 + js/src/playwright.ts | 32 +- js/src/proxy-rotator.ts | 522 +++++++++++++++++++++++++++++++++ js/src/proxy.ts | 19 +- js/src/puppeteer.ts | 28 +- js/src/types.ts | 5 +- js/tests/proxy-rotator.test.ts | 454 ++++++++++++++++++++++++++++ tests/test_proxy.py | 33 +++ tests/test_proxy_real.mjs | 148 ++++++++++ tests/test_proxy_real.py | 92 ++++++ tests/test_proxy_rotator.py | 473 +++++++++++++++++++++++++++++ 18 files changed, 2659 insertions(+), 37 deletions(-) create mode 100644 cloakbrowser/proxy_rotator.py create mode 100644 examples/proxy_rotation.py create mode 100644 examples/proxy_verify.py create mode 100644 js/examples/proxy-rotation.ts create mode 100644 js/src/proxy-rotator.ts create mode 100644 js/tests/proxy-rotator.test.ts create mode 100644 tests/test_proxy_real.mjs create mode 100644 tests/test_proxy_real.py create mode 100644 tests/test_proxy_rotator.py diff --git a/cloakbrowser/__init__.py b/cloakbrowser/__init__.py index 2fb96a2..0f5a0a3 100644 --- a/cloakbrowser/__init__.py +++ b/cloakbrowser/__init__.py @@ -14,6 +14,7 @@ from .browser import launch, launch_async, launch_context, launch_persistent_context, launch_persistent_context_async, ProxySettings from .config import CHROMIUM_VERSION, get_default_stealth_args from .download import binary_info, check_for_update, clear_cache, ensure_binary +from .proxy_rotator import ProxyRotator, Strategy as ProxyStrategy from ._version import __version__ # Human-like behavioral layer (optional) @@ -41,6 +42,8 @@ def __getattr__(name): "CHROMIUM_VERSION", "get_default_stealth_args", "ProxySettings", + "ProxyRotator", + "ProxyStrategy", "HumanConfig", "resolve_human_config", "__version__", diff --git a/cloakbrowser/browser.py b/cloakbrowser/browser.py index ce41fed..4d5bbb0 100644 --- a/cloakbrowser/browser.py +++ b/cloakbrowser/browser.py @@ -22,6 +22,7 @@ from .config import DEFAULT_VIEWPORT, get_default_stealth_args from .download import ensure_binary +from .proxy_rotator import ProxyRotator logger = logging.getLogger("cloakbrowser") @@ -51,7 +52,7 @@ class ProxySettings(_ProxySettingsRequired, total=False): def launch( headless: bool = True, - proxy: str | ProxySettings | None = None, + proxy: str | ProxySettings | ProxyRotator | None = None, args: list[str] | None = None, stealth_args: bool = True, timezone: str | None = None, @@ -102,6 +103,7 @@ def launch( """ sync_playwright = _import_sync_playwright(_resolve_backend(backend)) + proxy = _resolve_proxy_rotator(proxy) binary_path = ensure_binary() timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale) chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale) @@ -139,7 +141,7 @@ def _close_with_cleanup() -> None: async def launch_async( # noqa: C901 headless: bool = True, - proxy: str | ProxySettings | None = None, + proxy: str | ProxySettings | ProxyRotator | None = None, args: list[str] | None = None, stealth_args: bool = True, timezone: str | None = None, @@ -185,6 +187,7 @@ async def launch_async( # noqa: C901 """ async_playwright = _import_async_playwright(_resolve_backend(backend)) + proxy = _resolve_proxy_rotator(proxy) binary_path = ensure_binary() timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale) chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale) @@ -223,7 +226,7 @@ async def _close_with_cleanup() -> None: def launch_persistent_context( user_data_dir: str | os.PathLike, headless: bool = True, - proxy: str | ProxySettings | None = None, + proxy: str | ProxySettings | ProxyRotator | None = None, args: list[str] | None = None, stealth_args: bool = True, user_agent: str | None = None, @@ -279,6 +282,7 @@ def launch_persistent_context( """ sync_playwright = _import_sync_playwright(_resolve_backend(backend)) + proxy = _resolve_proxy_rotator(proxy) timezone = _migrate_timezone_id(timezone, kwargs) binary_path = ensure_binary() @@ -336,7 +340,7 @@ def _close_with_cleanup() -> None: async def launch_persistent_context_async( user_data_dir: str | os.PathLike, headless: bool = True, - proxy: str | ProxySettings | None = None, + proxy: str | ProxySettings | ProxyRotator | None = None, args: list[str] | None = None, stealth_args: bool = True, user_agent: str | None = None, @@ -394,6 +398,7 @@ async def launch_persistent_context_async( """ async_playwright = _import_async_playwright(_resolve_backend(backend)) + proxy = _resolve_proxy_rotator(proxy) timezone = _migrate_timezone_id(timezone, kwargs) binary_path = ensure_binary() @@ -450,7 +455,7 @@ async def _close_with_cleanup() -> None: def launch_context( headless: bool = True, - proxy: str | ProxySettings | None = None, + proxy: str | ProxySettings | ProxyRotator | None = None, args: list[str] | None = None, stealth_args: bool = True, user_agent: str | None = None, @@ -493,6 +498,9 @@ def launch_context( """ timezone = _migrate_timezone_id(timezone, kwargs) + # Resolve proxy rotator before geoip/launch to ensure consistent proxy + proxy = _resolve_proxy_rotator(proxy) + # Resolve geoip BEFORE launch() to avoid double-resolution and ensure # resolved values flow to both binary flags AND context params timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale) @@ -653,12 +661,23 @@ def _parse_proxy_url(proxy: str) -> dict[str, Any]: """Parse proxy URL, extracting credentials into separate Playwright fields. Handles: http://user:pass@host:port -> {server: "http://host:port", username: "user", password: "pass"} - Also handles: no credentials, URL-encoded special chars, socks5://, missing port. + Also handles: no credentials, URL-encoded special chars, socks5://, missing port, + and bare proxy strings without a scheme (e.g. 'user:pass@host:port' -> treated as http). """ - parsed = urlparse(proxy) + # Bare format: "user:pass@host:port" — no scheme. + # urlparse needs a scheme to correctly extract credentials. + normalized = proxy + had_scheme = "://" in proxy + if not had_scheme and "@" in proxy: + normalized = f"http://{proxy}" + + parsed = urlparse(normalized) if not parsed.username: - return {"server": proxy} + # No credentials found — if we added a scheme, use it; otherwise keep original. + if not had_scheme and "@" not in proxy: + return {"server": proxy} + return {"server": normalized} # Rebuild server URL without credentials netloc = parsed.hostname or "" @@ -675,6 +694,13 @@ def _parse_proxy_url(proxy: str) -> dict[str, Any]: return result +def _resolve_proxy_rotator(proxy: str | ProxySettings | ProxyRotator | None) -> str | ProxySettings | None: + """If proxy is a ProxyRotator, call .next() to get the actual proxy.""" + if isinstance(proxy, ProxyRotator): + return proxy.next() + return proxy + + def _build_proxy_kwargs(proxy: str | ProxySettings | None) -> dict[str, Any]: """Build proxy kwargs for Playwright launch.""" if proxy is None: diff --git a/cloakbrowser/proxy_rotator.py b/cloakbrowser/proxy_rotator.py new file mode 100644 index 0000000..5e1e80f --- /dev/null +++ b/cloakbrowser/proxy_rotator.py @@ -0,0 +1,476 @@ +"""Proxy rotation for cloakbrowser. + +Provides ProxyRotator — a thread-safe proxy pool with multiple rotation +strategies, health tracking, and automatic failover. + +Usage: + from cloakbrowser import ProxyRotator, launch + + rotator = ProxyRotator([ + "http://user:pass@proxy1:8080", + "http://user:pass@proxy2:8080", + "http://user:pass@proxy3:8080", + ]) + + # Each call picks the next proxy + browser = launch(proxy=rotator.next()) + page = browser.new_page() + page.goto("https://example.com") + browser.close() + + # Or use the context manager for auto-rotation per page + with rotator.session() as proxy: + browser = launch(proxy=proxy) + ... +""" + +from __future__ import annotations + +import logging +import random +import threading +import time +from contextlib import contextmanager +from dataclasses import dataclass, field +from enum import Enum +from typing import Iterator, Sequence +from urllib.parse import urlparse + +logger = logging.getLogger("cloakbrowser.proxy_rotator") + + +class Strategy(Enum): + """Proxy rotation strategies.""" + ROUND_ROBIN = "round_robin" + RANDOM = "random" + LEAST_USED = "least_used" + LEAST_FAILURES = "least_failures" + + +@dataclass +class _ProxyState: + """Internal tracking state for a single proxy.""" + url: str + use_count: int = 0 + fail_count: int = 0 + consecutive_fails: int = 0 + last_used: float = 0.0 + last_failed: float = 0.0 + cooldown_until: float = 0.0 + + @property + def is_available(self) -> bool: + """Check if this proxy is available (not in cooldown).""" + return time.monotonic() >= self.cooldown_until + + def record_use(self) -> None: + self.use_count += 1 + self.last_used = time.monotonic() + + def record_success(self) -> None: + self.consecutive_fails = 0 + self.cooldown_until = 0.0 + + def record_failure(self, cooldown: float, max_consecutive: int) -> None: + self.fail_count += 1 + self.consecutive_fails += 1 + self.last_failed = time.monotonic() + if self.consecutive_fails >= max_consecutive: + self.cooldown_until = time.monotonic() + cooldown + logger.info( + "Proxy %s placed on cooldown for %.0fs (%d consecutive failures)", + _mask_proxy(self.url), cooldown, self.consecutive_fails, + ) + + +class ProxyRotator: + """Thread-safe proxy rotator with health tracking. + + Args: + proxies: List of proxy URLs or Playwright proxy dicts. + Strings: 'http://user:pass@host:port', 'socks5://host:port'. + Dicts: {"server": "...", "username": "...", "password": "..."}. + strategy: Rotation strategy (default: round_robin). + - round_robin: Cycle through proxies in order. + - random: Pick a random proxy each time. + - least_used: Pick the proxy with the fewest uses. + - least_failures: Pick the proxy with the fewest failures. + cooldown: Seconds to sideline a proxy after max consecutive failures + (default: 300 = 5 minutes). + max_failures: Number of consecutive failures before cooldown + (default: 3). + sticky_count: Number of requests to stick with the same proxy + before rotating (default: 1 = rotate every request). + + Example: + >>> rotator = ProxyRotator([ + ... "http://user:pass@proxy1:8080", + ... "http://user:pass@proxy2:8080", + ... ], strategy="round_robin") + >>> proxy = rotator.next() + >>> rotator.report_success(proxy) + """ + + def __init__( + self, + proxies: Sequence[str | dict], + strategy: str | Strategy = Strategy.ROUND_ROBIN, + cooldown: float = 300.0, + max_failures: int = 3, + sticky_count: int = 1, + ) -> None: + if not proxies: + raise ValueError("proxies list must not be empty") + + self._strategy = Strategy(strategy) if isinstance(strategy, str) else strategy + self._cooldown = cooldown + self._max_failures = max_failures + self._sticky_count = max(1, sticky_count) + self._lock = threading.Lock() + self._rr_index = 0 + self._sticky_counter = 0 + self._sticky_current: str | dict | None = None + + # Normalize: store original proxy value, track by canonical URL key + self._proxies: list[str | dict] = list(proxies) + self._states: dict[str, _ProxyState] = {} + for p in self._proxies: + self._validate_proxy(p) + key = self._proxy_key(p) + if key not in self._states: + self._states[key] = _ProxyState(url=key) + + # ------------------------------------------------------------------ + # Validation + # ------------------------------------------------------------------ + + @staticmethod + def _validate_proxy(proxy: str | dict) -> None: + """Raise if proxy uses unsupported configuration. + + Chromium does not support SOCKS5 proxy authentication. + This catches the issue early instead of failing at launch time. + """ + if isinstance(proxy, dict): + server = proxy.get("server", "") + has_auth = bool(proxy.get("username") or proxy.get("password")) + if server.startswith("socks5://") and has_auth: + raise ValueError( + "SOCKS5 with authentication is not supported by Chromium. " + "Use the HTTP port of the same proxy, or a local SOCKS5 relay." + ) + else: + if proxy.startswith("socks5://") and "@" in proxy: + raise ValueError( + "SOCKS5 with authentication is not supported by Chromium. " + "Use the HTTP port of the same proxy, or a local SOCKS5 relay." + ) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def next(self) -> str | dict: + """Return the next proxy according to the rotation strategy. + + Returns the proxy in its original format (string or dict). + Raises RuntimeError if all proxies are in cooldown. + """ + with self._lock: + # Sticky: keep returning the same proxy for `sticky_count` requests + if ( + self._sticky_current is not None + and self._sticky_counter < self._sticky_count + ): + key = self._proxy_key(self._sticky_current) + state = self._states.get(key) + if state and state.is_available: + self._sticky_counter += 1 + state.record_use() + logger.debug("Sticky proxy %s (use %d/%d)", _mask_proxy(key), self._sticky_counter, self._sticky_count) + return self._sticky_current + + # Select next proxy + proxy = self._select() + key = self._proxy_key(proxy) + self._states[key].record_use() + + # Reset sticky tracking + self._sticky_current = proxy + self._sticky_counter = 1 + + logger.debug("Selected proxy %s (strategy=%s)", _mask_proxy(key), self._strategy.value) + return proxy + + def current(self) -> str | dict | None: + """Return the currently sticky proxy, or None if not set. + + Useful for calling report_success()/report_failure() after using + the rotator with launch(proxy=rotator). + """ + with self._lock: + return self._sticky_current + + def report_success(self, proxy: str | dict) -> None: + """Report that a proxy request succeeded. Resets failure counters.""" + key = self._proxy_key(proxy) + with self._lock: + if key in self._states: + self._states[key].record_success() + + def report_failure(self, proxy: str | dict) -> None: + """Report that a proxy request failed. May trigger cooldown.""" + key = self._proxy_key(proxy) + with self._lock: + if key in self._states: + self._states[key].record_failure(self._cooldown, self._max_failures) + # If current sticky proxy failed, force rotation + if ( + self._sticky_current is not None + and self._proxy_key(self._sticky_current) == key + ): + self._sticky_current = None + self._sticky_counter = 0 + + @contextmanager + def session(self) -> Iterator[str | dict]: + """Context manager that yields a proxy and auto-reports success/failure. + + Usage: + with rotator.session() as proxy: + browser = launch(proxy=proxy) + page = browser.new_page() + page.goto("https://example.com") + browser.close() + """ + proxy = self.next() + try: + yield proxy + self.report_success(proxy) + except Exception: + self.report_failure(proxy) + raise + + def stats(self) -> list[dict]: + """Return usage statistics for all proxies. + + Returns a list of dicts with keys: + proxy, use_count, fail_count, consecutive_fails, available + """ + with self._lock: + result = [] + for p in self._proxies: + key = self._proxy_key(p) + state = self._states[key] + # Deduplicate (same key may appear if list has duplicates) + if any(r["proxy"] == _mask_proxy(key) for r in result): + continue + result.append({ + "proxy": _mask_proxy(key), + "use_count": state.use_count, + "fail_count": state.fail_count, + "consecutive_fails": state.consecutive_fails, + "available": state.is_available, + }) + return result + + def reset(self) -> None: + """Reset all proxy states (counters, cooldowns).""" + with self._lock: + for state in self._states.values(): + state.use_count = 0 + state.fail_count = 0 + state.consecutive_fails = 0 + state.last_used = 0.0 + state.last_failed = 0.0 + state.cooldown_until = 0.0 + self._rr_index = 0 + self._sticky_counter = 0 + self._sticky_current = None + + def add(self, proxy: str | dict) -> None: + """Add a proxy to the pool at runtime.""" + self._validate_proxy(proxy) + key = self._proxy_key(proxy) + with self._lock: + self._proxies.append(proxy) + if key not in self._states: + self._states[key] = _ProxyState(url=key) + + def remove(self, proxy: str | dict) -> None: + """Remove a proxy from the pool at runtime. + + Raises ValueError if the proxy is not in the pool or would leave + the pool empty. + """ + key = self._proxy_key(proxy) + with self._lock: + # Build filtered list without modifying _proxies yet + filtered = [p for p in self._proxies if self._proxy_key(p) != key] + if len(filtered) == len(self._proxies): + raise ValueError(f"Proxy not in pool: {_mask_proxy(key)}") + if not filtered: + raise ValueError("Cannot remove last proxy — pool would be empty") + # Safe to apply now — both checks passed + self._proxies = filtered + self._states.pop(key, None) + # Clamp round-robin index to new pool size + if self._rr_index >= len(self._proxies): + self._rr_index = 0 + # Clear sticky if it was the removed proxy + if ( + self._sticky_current is not None + and self._proxy_key(self._sticky_current) == key + ): + self._sticky_current = None + self._sticky_counter = 0 + + @property + def available_count(self) -> int: + """Number of proxies currently available (not in cooldown).""" + with self._lock: + return sum(1 for s in self._states.values() if s.is_available) + + def __len__(self) -> int: + with self._lock: + return len(self._proxies) + + def __repr__(self) -> str: + return ( + f"ProxyRotator(proxies={len(self._proxies)}, " + f"strategy={self._strategy.value}, " + f"available={self.available_count})" + ) + + # ------------------------------------------------------------------ + # Internal selection logic + # ------------------------------------------------------------------ + + def _get_available(self) -> list[tuple[int, str | dict]]: + """Return list of (index, proxy) for proxies not in cooldown.""" + available = [] + for i, p in enumerate(self._proxies): + key = self._proxy_key(p) + if self._states[key].is_available: + available.append((i, p)) + return available + + def _select(self) -> str | dict: + """Select next proxy based on strategy. Lock must be held by caller.""" + available = self._get_available() + if not available: + raise RuntimeError( + f"All {len(self._proxies)} proxies are in cooldown. " + f"Wait {self._cooldown:.0f}s or call reset()." + ) + + if self._strategy == Strategy.ROUND_ROBIN: + return self._select_round_robin(available) + elif self._strategy == Strategy.RANDOM: + return self._select_random(available) + elif self._strategy == Strategy.LEAST_USED: + return self._select_least_used(available) + elif self._strategy == Strategy.LEAST_FAILURES: + return self._select_least_failures(available) + else: + raise ValueError(f"Unknown strategy: {self._strategy}") + + def _select_round_robin(self, available: list[tuple[int, str | dict]]) -> str | dict: + """Round-robin: pick the next proxy in order, skipping unavailable ones.""" + n = len(self._proxies) + for _ in range(n): + idx = self._rr_index % n + self._rr_index = (self._rr_index + 1) % n + proxy = self._proxies[idx] + key = self._proxy_key(proxy) + if self._states[key].is_available: + return proxy + # Fallback to first available + return available[0][1] + + def _select_random(self, available: list[tuple[int, str | dict]]) -> str | dict: + """Random: pick a random available proxy.""" + _, proxy = random.choice(available) + return proxy + + def _select_least_used(self, available: list[tuple[int, str | dict]]) -> str | dict: + """Least-used: pick the proxy with the fewest total uses.""" + return min( + available, + key=lambda item: self._states[self._proxy_key(item[1])].use_count, + )[1] + + def _select_least_failures(self, available: list[tuple[int, str | dict]]) -> str | dict: + """Least-failures: pick the proxy with the fewest total failures.""" + return min( + available, + key=lambda item: self._states[self._proxy_key(item[1])].fail_count, + )[1] + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _proxy_key(proxy: str | dict) -> str: + """Canonical string key for a proxy (for dedup/tracking). + + For dict proxies, combines server + username into a unique key. + Uses '||' separator to avoid ambiguity with URL '@' characters. + """ + if isinstance(proxy, dict): + server = proxy.get("server", "") + username = proxy.get("username", "") + return f"{server}||{username}" if username else server + return proxy + + # ------------------------------------------------------------------ + # Async helpers (convenience wrappers — the class itself is sync-safe) + # ------------------------------------------------------------------ + + async def next_async(self) -> str | dict: + """Async wrapper for next(). Same logic, just awaitable.""" + return self.next() + + async def report_success_async(self, proxy: str | dict) -> None: + """Async wrapper for report_success().""" + self.report_success(proxy) + + async def report_failure_async(self, proxy: str | dict) -> None: + """Async wrapper for report_failure().""" + self.report_failure(proxy) + + +# --------------------------------------------------------------------------- +# Utility +# --------------------------------------------------------------------------- + +def _mask_proxy(url: str) -> str: + """Mask credentials in a proxy URL for logging. + + Handles both plain proxy URLs and internal dict-key format + ('server||username'). Also handles bare proxy strings without + a scheme (e.g. 'user:pass@host:port'). + """ + # Internal dict-key format: "http://server:port||username" + if "||" in url: + server, _ = url.split("||", 1) + return f"{server}||***" + try: + # Bare format: "user:pass@host:port" — no scheme. + # urlparse needs a scheme to extract credentials correctly. + normalized = url + if "@" in url and "://" not in url: + normalized = f"http://{url}" + parsed = urlparse(normalized) + if parsed.username: + host = parsed.hostname or "" + port = f":{parsed.port}" if parsed.port else "" + # Return masked version using the *original* scheme if present, + # otherwise omit scheme to keep the bare format recognizable. + if "://" in url: + return f"{parsed.scheme}://***:***@{host}{port}" + return f"***:***@{host}{port}" + return url + except Exception: + return "***" diff --git a/examples/proxy_rotation.py b/examples/proxy_rotation.py new file mode 100644 index 0000000..d6a0ad8 --- /dev/null +++ b/examples/proxy_rotation.py @@ -0,0 +1,101 @@ +"""Proxy rotation example: rotate through a pool of proxies with health tracking. + +Usage: + python examples/proxy_rotation.py + +Replace the proxy URLs below with your actual proxy servers. +""" + +from cloakbrowser import ProxyRotator, launch + +# ---- 1. Basic setup: create a rotator with multiple proxies ---- +# Supported proxy formats: +# - "http://user:pass@host:port" — HTTP with scheme +# - "socks5://user:pass@host:port" — SOCKS5 with scheme +# - "user:pass@host:port" — bare format (auto-detected as HTTP) +# - {"server": "http://host:port", "username": "u", "password": "p"} — Playwright dict +rotator = ProxyRotator( + proxies=[ + "http://user:pass@proxy1.example.com:8080", + "http://user:pass@proxy2.example.com:8080", + "http://user:pass@proxy3.example.com:8080", + ], + strategy="round_robin", # Options: round_robin, random, least_used, least_failures +) + +# ---- 2. Simple usage: get next proxy for each browser launch ---- +print(f"Pool: {rotator}") +print(f"Available: {rotator.available_count}/{len(rotator)}") + +for i in range(3): + proxy = rotator.next() + print(f"\nRequest {i + 1}: Using proxy {proxy}") + + # In real usage: + # browser = launch(proxy=proxy) + # page = browser.new_page() + # page.goto("https://example.com") + # rotator.report_success(proxy) + # browser.close() + + # Simulate success + rotator.report_success(proxy) + + +# ---- 3. Context manager: auto-reports success/failure ---- +print("\n--- Context manager ---") +try: + with rotator.session() as proxy: + print(f"Using proxy: {proxy}") + # browser = launch(proxy=proxy) + # ... do work ... + # browser.close() +except Exception as e: + print(f"Failed: {e}") + + +# ---- 4. Direct integration: pass rotator to launch() ---- +print("\n--- Direct integration ---") +# CloakBrowser accepts ProxyRotator directly — it calls .next() internally +# browser = launch(proxy=rotator) +# page = browser.new_page() +# page.goto("https://example.com") +# # Use current() to report success/failure for the proxy that was used +# rotator.report_success(rotator.current()) +# browser.close() +print("launch(proxy=rotator) — rotator.next() is called automatically") +print("rotator.current() — returns the proxy that was last selected") + + +# ---- 5. Sticky sessions: reuse the same proxy for N requests ---- +print("\n--- Sticky sessions (3 requests per proxy) ---") +sticky_rotator = ProxyRotator( + proxies=[ + "http://user:pass@proxy1.example.com:8080", + "http://user:pass@proxy2.example.com:8080", + ], + strategy="round_robin", + sticky_count=3, # Use same proxy for 3 consecutive requests +) + +for i in range(6): + proxy = sticky_rotator.next() + print(f" Request {i + 1}: {proxy}") + + +# ---- 6. Health tracking & stats ---- +print("\n--- Stats ---") +for stat in rotator.stats(): + print(f" {stat['proxy']}: uses={stat['use_count']}, fails={stat['fail_count']}, available={stat['available']}") + + +# ---- 7. Dynamic pool management ---- +print("\n--- Dynamic pool ---") +print(f"Before: {len(rotator)} proxies") +rotator.add("http://user:pass@proxy4.example.com:8080") +print(f"After add: {len(rotator)} proxies") +rotator.remove("http://user:pass@proxy4.example.com:8080") +print(f"After remove: {len(rotator)} proxies") + + +print("\nDone!") diff --git a/examples/proxy_verify.py b/examples/proxy_verify.py new file mode 100644 index 0000000..3212c44 --- /dev/null +++ b/examples/proxy_verify.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Verify proxy rotation with a real Playwright browser. + +This script launches CloakBrowser through real proxies and confirms that +the exit IP changes. Replace the proxy list with your own servers. + +Usage: + # Single proxy — verify IP changes + python examples/proxy_verify.py --proxy "http://user:pass@host:port" + + # Two proxies — verify rotation + python examples/proxy_verify.py \ + --proxy "http://user:pass@proxy1:port" \ + --proxy "http://user:pass@proxy2:port" + + # Bare format (user:pass@host:port) is also supported + python examples/proxy_verify.py --proxy "user:pass@host:port" + + # SOCKS5 + python examples/proxy_verify.py --proxy "socks5://user:pass@host:port" + + # Playwright dict format (JSON string) + python examples/proxy_verify.py \ + --proxy '{"server":"http://host:port","username":"u","password":"p"}' + + # With bypass option (Playwright dict only) + python examples/proxy_verify.py \ + --proxy '{"server":"http://host:port","username":"u","password":"p","bypass":".google.com"}' + +Requirements: + pip install cloakbrowser + # On first run, CloakBrowser downloads its stealth Chromium binary (~200 MB). +""" + +from __future__ import annotations + +import argparse +import json +import sys + + +def parse_proxy(value: str) -> str | dict: + """Parse a proxy argument — supports strings and JSON dicts.""" + value = value.strip() + if value.startswith("{"): + try: + return json.loads(value) + except json.JSONDecodeError: + print(f"ERROR: Invalid JSON proxy dict: {value}", file=sys.stderr) + sys.exit(1) + return value + + +def get_exit_ip(page) -> str | None: + """Navigate to an IP-echo service and return the exit IP.""" + services = [ + ("https://api.ipify.org?format=json", "ip"), + ("https://checkip.amazonaws.com", None), + ("https://ifconfig.me/ip", None), + ] + for url, json_key in services: + try: + page.goto(url, timeout=15000) + text = page.inner_text("body").strip() + if json_key: + return json.loads(text).get(json_key, text) + return text + except Exception: + continue + return None + + +def main(): + parser = argparse.ArgumentParser(description="Verify CloakBrowser proxy rotation with real Playwright") + parser.add_argument( + "--proxy", action="append", required=True, + help='Proxy URL or JSON dict. Can be repeated for rotation test.', + ) + parser.add_argument("--headless", action="store_true", default=True, help="Run headless (default)") + parser.add_argument("--no-headless", action="store_true", help="Run with visible browser window") + args = parser.parse_args() + + headless = not args.no_headless + proxies = [parse_proxy(p) for p in args.proxy] + + from cloakbrowser import ProxyRotator, launch + + # ---- Step 1: Get our real IP (no proxy) ---- + print("Step 1: Detecting real IP (no proxy)...") + browser = launch(headless=headless) + page = browser.new_page() + real_ip = get_exit_ip(page) + browser.close() + print(f" Real IP: {real_ip}") + + if len(proxies) == 1: + # ---- Single proxy: verify IP changes ---- + proxy = proxies[0] + proxy_display = proxy if isinstance(proxy, str) else proxy.get("server", str(proxy)) + print(f"\nStep 2: Launching with proxy: {proxy_display}") + + browser = launch(proxy=proxy, headless=headless) + page = browser.new_page() + proxy_ip = get_exit_ip(page) + browser.close() + print(f" Proxy IP: {proxy_ip}") + + if proxy_ip and proxy_ip != real_ip: + print(f"\n SUCCESS: IP changed from {real_ip} to {proxy_ip}") + elif proxy_ip == real_ip: + print(f"\n WARNING: IP did NOT change — proxy may not be working") + else: + print(f"\n ERROR: Could not detect IP through proxy") + + else: + # ---- Multiple proxies: verify rotation ---- + rotator = ProxyRotator(proxies, strategy="round_robin") + print(f"\nStep 2: Testing rotation with {len(proxies)} proxies...") + print(f" Pool: {rotator}") + + seen_ips = set() + for i in range(len(proxies)): + with rotator.session() as proxy: + proxy_display = proxy if isinstance(proxy, str) else proxy.get("server", str(proxy)) + print(f"\n Request {i + 1}: Using {proxy_display}") + + browser = launch(proxy=proxy, headless=headless) + page = browser.new_page() + ip = get_exit_ip(page) + browser.close() + + print(f" Exit IP: {ip}") + if ip: + seen_ips.add(ip) + + print(f"\n Summary:") + print(f" Real IP: {real_ip}") + print(f" Proxy IPs: {seen_ips}") + print(f" Unique IPs: {len(seen_ips)}") + + if real_ip not in seen_ips and len(seen_ips) > 0: + print(f" SUCCESS: All traffic went through proxies") + elif len(seen_ips) > 1: + print(f" PARTIAL: Multiple proxy IPs detected") + else: + print(f" WARNING: Check proxy configuration") + + # ---- Show stats ---- + print(f"\n Health stats:") + for s in rotator.stats(): + print(f" {s['proxy']}: uses={s['use_count']}, fails={s['fail_count']}, ok={s['available']}") + + print("\nDone!") + + +if __name__ == "__main__": + main() diff --git a/js/examples/proxy-rotation.ts b/js/examples/proxy-rotation.ts new file mode 100644 index 0000000..62bfb12 --- /dev/null +++ b/js/examples/proxy-rotation.ts @@ -0,0 +1,103 @@ +/** + * Proxy rotation example: rotate through a pool of proxies with health tracking. + * + * Usage: + * npx tsx examples/proxy-rotation.ts + * + * Replace the proxy URLs below with your actual proxy servers. + */ + +import { ProxyRotator } from "../src/index.js"; +// import { launch } from "../src/index.js"; + +// ---- 1. Basic setup: create a rotator with multiple proxies ---- +const rotator = new ProxyRotator( + [ + "http://user:pass@proxy1.example.com:8080", + "http://user:pass@proxy2.example.com:8080", + "http://user:pass@proxy3.example.com:8080", + ], + { + strategy: "round_robin", // Options: round_robin, random, least_used, least_failures + } +); + +// ---- 2. Simple usage: get next proxy for each browser launch ---- +console.log(`Pool: ${rotator}`); +console.log(`Available: ${rotator.availableCount}/${rotator.size}`); + +for (let i = 0; i < 3; i++) { + const proxy = rotator.next(); + console.log(`\nRequest ${i + 1}: Using proxy ${proxy}`); + + // In real usage: + // const browser = await launch({ proxy }); + // const page = await browser.newPage(); + // await page.goto("https://example.com"); + // rotator.reportSuccess(proxy); + // await browser.close(); + + // Simulate success + rotator.reportSuccess(proxy); +} + +// ---- 3. withSession: auto-reports success/failure ---- +console.log("\n--- withSession ---"); +try { + await rotator.withSession(async (proxy) => { + console.log(`Using proxy: ${proxy}`); + // const browser = await launch({ proxy }); + // ... do work ... + // await browser.close(); + }); +} catch (e) { + console.error(`Failed: ${e}`); +} + +// ---- 4. Direct integration: pass rotator to launch() ---- +console.log("\n--- Direct integration ---"); +// CloakBrowser accepts ProxyRotator directly — it calls .next() internally +// const browser = await launch({ proxy: rotator }); +// const page = await browser.newPage(); +// await page.goto("https://example.com"); +// // Use current() to report success/failure for the proxy that was used +// rotator.reportSuccess(rotator.current()!); +// await browser.close(); +console.log("launch({ proxy: rotator }) — rotator.next() is called automatically"); +console.log("rotator.current() — returns the proxy that was last selected"); + +// ---- 5. Sticky sessions: reuse the same proxy for N requests ---- +console.log("\n--- Sticky sessions (3 requests per proxy) ---"); +const stickyRotator = new ProxyRotator( + [ + "http://user:pass@proxy1.example.com:8080", + "http://user:pass@proxy2.example.com:8080", + ], + { + strategy: "round_robin", + stickyCount: 3, // Use same proxy for 3 consecutive requests + } +); + +for (let i = 0; i < 6; i++) { + const proxy = stickyRotator.next(); + console.log(` Request ${i + 1}: ${proxy}`); +} + +// ---- 6. Health tracking & stats ---- +console.log("\n--- Stats ---"); +for (const stat of rotator.stats()) { + console.log( + ` ${stat.proxy}: uses=${stat.useCount}, fails=${stat.failCount}, available=${stat.available}` + ); +} + +// ---- 7. Dynamic pool management ---- +console.log("\n--- Dynamic pool ---"); +console.log(`Before: ${rotator.size} proxies`); +rotator.add("http://user:pass@proxy4.example.com:8080"); +console.log(`After add: ${rotator.size} proxies`); +rotator.remove("http://user:pass@proxy4.example.com:8080"); +console.log(`After remove: ${rotator.size} proxies`); + +console.log("\nDone!"); diff --git a/js/package-lock.json b/js/package-lock.json index 26250a1..0a2f94a 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -1,12 +1,12 @@ { "name": "cloakbrowser", - "version": "0.3.9", + "version": "0.3.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cloakbrowser", - "version": "0.3.9", + "version": "0.3.11", "license": "MIT", "dependencies": { "tar": "^7.0.0" diff --git a/js/src/index.ts b/js/src/index.ts index 52609b6..04dad1f 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -24,5 +24,9 @@ export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download // Config export { CHROMIUM_VERSION, getDefaultStealthArgs } from "./config.js"; +// Proxy rotation +export { ProxyRotator, maskProxy } from "./proxy-rotator.js"; +export type { ProxyRotationStrategy, ProxyValue, ProxyRotatorOptions, ProxyStats } from "./proxy-rotator.js"; + // Types export type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions, BinaryInfo } from "./types.js"; diff --git a/js/src/playwright.ts b/js/src/playwright.ts index 57e74b4..e08beb0 100644 --- a/js/src/playwright.ts +++ b/js/src/playwright.ts @@ -9,6 +9,7 @@ import { DEFAULT_VIEWPORT } from "./config.js"; import { buildArgs } from "./args.js"; import { ensureBinary } from "./download.js"; import { parseProxyUrl } from "./proxy.js"; +import { resolveProxyRotator } from "./proxy-rotator.js"; /** @internal Migrate deprecated timezoneId → timezone, warn once. Exported for testing. */ export function migrateTimezoneId(options: T): T { @@ -38,16 +39,18 @@ export async function launch(options: LaunchOptions = {}): Promise { 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 resolvedProxy = resolveProxyRotator(options.proxy); + const opts = { ...options, proxy: resolvedProxy }; + const resolved = await maybeResolveGeoip(opts); + const args = buildArgs({ ...opts, ...resolved }); const browser = await chromium.launch({ executablePath: binaryPath, headless: options.headless ?? true, args, ignoreDefaultArgs: ["--enable-automation"], - ...(options.proxy - ? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy } + ...(resolvedProxy + ? { proxy: typeof resolvedProxy === "string" ? parseProxyUrl(resolvedProxy) : resolvedProxy } : {}), ...options.launchOptions, }); @@ -87,11 +90,13 @@ export async function launchContext( ): Promise { options = migrateTimezoneId(options); // Resolve geoip BEFORE launch() to avoid double-resolution - const resolved = await maybeResolveGeoip(options); + const resolvedProxy = resolveProxyRotator(options.proxy); + const opts = { ...options, proxy: resolvedProxy }; + const resolved = await maybeResolveGeoip(opts); // Skip --fingerprint-timezone binary flag: it only applies to the default // context and interferes with Playwright's timezoneId on new contexts. // Timezone is set via browser.newContext(timezoneId: ...) below instead. - const browser = await launch({ ...options, ...resolved, geoip: false, timezone: undefined }); + const browser = await launch({ ...opts, ...resolved, geoip: false, timezone: undefined }); let context: BrowserContext; try { @@ -156,16 +161,18 @@ export async function launchPersistentContext( 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 resolvedProxy = resolveProxyRotator(options.proxy); + const opts = { ...options, proxy: resolvedProxy }; + const resolved = await maybeResolveGeoip(opts); + const args = buildArgs({ ...opts, ...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 } + ...(resolvedProxy + ? { proxy: typeof resolvedProxy === "string" ? parseProxyUrl(resolvedProxy) : resolvedProxy } : {}), ...(options.userAgent ? { userAgent: options.userAgent } : {}), viewport: options.viewport ?? DEFAULT_VIEWPORT, @@ -200,7 +207,10 @@ async function maybeResolveGeoip( if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale }; const { resolveProxyGeo } = await import("./geoip.js"); - const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server; + const proxy = options.proxy; + const proxyUrl = typeof proxy === "string" + ? proxy + : "server" in proxy ? proxy.server : undefined; if (!proxyUrl) return { timezone: options.timezone, locale: options.locale }; const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl); return { diff --git a/js/src/proxy-rotator.ts b/js/src/proxy-rotator.ts new file mode 100644 index 0000000..69bc28d --- /dev/null +++ b/js/src/proxy-rotator.ts @@ -0,0 +1,522 @@ +/** + * Proxy rotation for cloakbrowser. + * + * Provides ProxyRotator — a proxy pool with multiple rotation strategies, + * health tracking, and automatic failover. + * + * @example + * ```ts + * import { ProxyRotator, launch } from 'cloakbrowser'; + * + * const rotator = new ProxyRotator([ + * 'http://user:pass@proxy1:8080', + * 'http://user:pass@proxy2:8080', + * 'http://user:pass@proxy3:8080', + * ]); + * + * // Each call picks the next proxy + * const browser = await launch({ proxy: rotator.next() }); + * const page = await browser.newPage(); + * await page.goto('https://example.com'); + * rotator.reportSuccess(rotator.current()!); + * await browser.close(); + * ``` + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Proxy rotation strategies. */ +export type ProxyRotationStrategy = + | "round_robin" + | "random" + | "least_used" + | "least_failures"; + +/** A proxy value — either a URL string or a Playwright-style proxy object. */ +export type ProxyValue = + | string + | { server: string; bypass?: string; username?: string; password?: string }; + +/** Configuration options for ProxyRotator. */ +export interface ProxyRotatorOptions { + /** Rotation strategy (default: "round_robin"). */ + strategy?: ProxyRotationStrategy; + /** Seconds to sideline a proxy after max consecutive failures (default: 300). */ + cooldown?: number; + /** Number of consecutive failures before cooldown (default: 3). */ + maxFailures?: number; + /** Number of requests to stick with the same proxy before rotating (default: 1). */ + stickyCount?: number; +} + +/** Usage statistics for a single proxy. */ +export interface ProxyStats { + proxy: string; + useCount: number; + failCount: number; + consecutiveFails: number; + available: boolean; +} + +// --------------------------------------------------------------------------- +// Internal state +// --------------------------------------------------------------------------- + +interface ProxyState { + url: string; + useCount: number; + failCount: number; + consecutiveFails: number; + lastUsed: number; + lastFailed: number; + cooldownUntil: number; +} + +function createState(url: string): ProxyState { + return { + url, + useCount: 0, + failCount: 0, + consecutiveFails: 0, + lastUsed: 0, + lastFailed: 0, + cooldownUntil: 0, + }; +} + +function isAvailable(state: ProxyState): boolean { + return performance.now() >= state.cooldownUntil; +} + +// --------------------------------------------------------------------------- +// ProxyRotator +// --------------------------------------------------------------------------- + +/** + * Proxy rotator with health tracking. + * + * Supports four strategies: + * - `round_robin`: Cycle through proxies in order (default). + * - `random`: Pick a random proxy each time. + * - `least_used`: Pick the proxy with the fewest uses. + * - `least_failures`: Pick the proxy with the fewest failures. + * + * Proxies that fail consecutively are put on cooldown and automatically + * excluded from selection until the cooldown period expires. + */ +export class ProxyRotator { + private readonly proxies: ProxyValue[]; + private readonly states: Map; + private readonly strategy: ProxyRotationStrategy; + private readonly cooldownMs: number; + private readonly maxFailures: number; + private readonly stickyCount: number; + + private rrIndex = 0; + private stickyCounter = 0; + private stickyCurrent: ProxyValue | null = null; + + constructor(proxies: ProxyValue[], options: ProxyRotatorOptions = {}) { + if (!proxies.length) { + throw new Error("proxies list must not be empty"); + } + + this.proxies = [...proxies]; + this.strategy = options.strategy ?? "round_robin"; + this.cooldownMs = (options.cooldown ?? 300) * 1000; // seconds → ms + this.maxFailures = options.maxFailures ?? 3; + this.stickyCount = Math.max(1, options.stickyCount ?? 1); + + this.states = new Map(); + for (const p of this.proxies) { + ProxyRotator.validateProxy(p); + const key = ProxyRotator.proxyKey(p); + if (!this.states.has(key)) { + this.states.set(key, createState(key)); + } + } + } + + // ------------------------------------------------------------------ + // Validation + // ------------------------------------------------------------------ + + /** + * Validate that a proxy does not use an unsupported configuration. + * Chromium does not support SOCKS5 proxy authentication. + */ + private static validateProxy(proxy: ProxyValue): void { + if (typeof proxy === "object") { + const server = proxy.server ?? ""; + const hasAuth = !!(proxy.username || proxy.password); + if (server.startsWith("socks5://") && hasAuth) { + throw new Error( + "SOCKS5 with authentication is not supported by Chromium. " + + "Use the HTTP port of the same proxy, or a local SOCKS5 relay." + ); + } + } else { + if (proxy.startsWith("socks5://") && proxy.includes("@")) { + throw new Error( + "SOCKS5 with authentication is not supported by Chromium. " + + "Use the HTTP port of the same proxy, or a local SOCKS5 relay." + ); + } + } + } + + // ------------------------------------------------------------------ + // Public API + // ------------------------------------------------------------------ + + /** + * Return the next proxy according to the rotation strategy. + * + * @throws {Error} If all proxies are in cooldown. + */ + next(): ProxyValue { + // Sticky: keep returning the same proxy for `stickyCount` requests + if (this.stickyCurrent !== null && this.stickyCounter < this.stickyCount) { + const key = ProxyRotator.proxyKey(this.stickyCurrent); + const state = this.states.get(key); + if (state && isAvailable(state)) { + this.stickyCounter++; + state.useCount++; + state.lastUsed = performance.now(); + return this.stickyCurrent; + } + } + + // Select next proxy + const proxy = this.select(); + const key = ProxyRotator.proxyKey(proxy); + const state = this.states.get(key)!; + state.useCount++; + state.lastUsed = performance.now(); + + // Reset sticky tracking + this.stickyCurrent = proxy; + this.stickyCounter = 1; + + return proxy; + } + + /** Return the currently sticky proxy, or null. */ + current(): ProxyValue | null { + return this.stickyCurrent; + } + + /** Report that a proxy request succeeded. Resets failure counters. */ + reportSuccess(proxy: ProxyValue): void { + const key = ProxyRotator.proxyKey(proxy); + const state = this.states.get(key); + if (state) { + state.consecutiveFails = 0; + state.cooldownUntil = 0; + } + } + + /** Report that a proxy request failed. May trigger cooldown. */ + reportFailure(proxy: ProxyValue): void { + const key = ProxyRotator.proxyKey(proxy); + const state = this.states.get(key); + if (state) { + state.failCount++; + state.consecutiveFails++; + state.lastFailed = performance.now(); + if (state.consecutiveFails >= this.maxFailures) { + state.cooldownUntil = performance.now() + this.cooldownMs; + } + // If current sticky proxy failed, force rotation + if ( + this.stickyCurrent !== null && + ProxyRotator.proxyKey(this.stickyCurrent) === key + ) { + this.stickyCurrent = null; + this.stickyCounter = 0; + } + } + } + + /** + * Execute a callback with an auto-selected proxy. + * Reports success on return, failure on throw. + */ + async withSession(fn: (proxy: ProxyValue) => Promise): Promise { + const proxy = this.next(); + try { + const result = await fn(proxy); + this.reportSuccess(proxy); + return result; + } catch (err) { + this.reportFailure(proxy); + throw err; + } + } + + /** Return usage statistics for all proxies. */ + stats(): ProxyStats[] { + const seen = new Set(); + const result: ProxyStats[] = []; + for (const p of this.proxies) { + const key = ProxyRotator.proxyKey(p); + if (seen.has(key)) continue; + seen.add(key); + const state = this.states.get(key)!; + result.push({ + proxy: maskProxy(key), + useCount: state.useCount, + failCount: state.failCount, + consecutiveFails: state.consecutiveFails, + available: isAvailable(state), + }); + } + return result; + } + + /** Reset all proxy states (counters, cooldowns). */ + reset(): void { + for (const state of this.states.values()) { + state.useCount = 0; + state.failCount = 0; + state.consecutiveFails = 0; + state.lastUsed = 0; + state.lastFailed = 0; + state.cooldownUntil = 0; + } + this.rrIndex = 0; + this.stickyCounter = 0; + this.stickyCurrent = null; + } + + /** Add a proxy to the pool at runtime. */ + add(proxy: ProxyValue): void { + ProxyRotator.validateProxy(proxy); + this.proxies.push(proxy); + const key = ProxyRotator.proxyKey(proxy); + if (!this.states.has(key)) { + this.states.set(key, createState(key)); + } + } + + /** + * Remove a proxy from the pool at runtime. + * @throws {Error} If the proxy is not in the pool or would leave it empty. + */ + remove(proxy: ProxyValue): void { + const key = ProxyRotator.proxyKey(proxy); + const filtered = this.proxies.filter( + (p) => ProxyRotator.proxyKey(p) !== key + ); + if (filtered.length === this.proxies.length) { + throw new Error(`Proxy not in pool: ${maskProxy(key)}`); + } + if (filtered.length === 0) { + throw new Error("Cannot remove last proxy — pool would be empty"); + } + // Safe to apply — both checks passed + this.proxies.length = 0; + this.proxies.push(...filtered); + this.states.delete(key); + // Clamp round-robin index to new pool size + if (this.rrIndex >= this.proxies.length) { + this.rrIndex = 0; + } + // Clear sticky if it was the removed proxy + if ( + this.stickyCurrent !== null && + ProxyRotator.proxyKey(this.stickyCurrent) === key + ) { + this.stickyCurrent = null; + this.stickyCounter = 0; + } + } + + /** Number of proxies currently available (not in cooldown). */ + get availableCount(): number { + let count = 0; + for (const state of this.states.values()) { + if (isAvailable(state)) count++; + } + return count; + } + + /** Total number of proxies in the pool. */ + get size(): number { + return this.proxies.length; + } + + toString(): string { + return `ProxyRotator(proxies=${this.proxies.length}, strategy=${this.strategy}, available=${this.availableCount})`; + } + + // ------------------------------------------------------------------ + // Internal selection logic + // ------------------------------------------------------------------ + + private getAvailable(): Array<{ index: number; proxy: ProxyValue }> { + const available: Array<{ index: number; proxy: ProxyValue }> = []; + for (let i = 0; i < this.proxies.length; i++) { + const key = ProxyRotator.proxyKey(this.proxies[i]); + const state = this.states.get(key)!; + if (isAvailable(state)) { + available.push({ index: i, proxy: this.proxies[i] }); + } + } + return available; + } + + private select(): ProxyValue { + const available = this.getAvailable(); + if (!available.length) { + throw new Error( + `All ${this.proxies.length} proxies are in cooldown. ` + + `Wait ${this.cooldownMs / 1000}s or call reset().` + ); + } + + switch (this.strategy) { + case "round_robin": + return this.selectRoundRobin(available); + case "random": + return this.selectRandom(available); + case "least_used": + return this.selectLeastUsed(available); + case "least_failures": + return this.selectLeastFailures(available); + default: + throw new Error(`Unknown strategy: ${this.strategy}`); + } + } + + private selectRoundRobin( + available: Array<{ index: number; proxy: ProxyValue }> + ): ProxyValue { + const n = this.proxies.length; + for (let i = 0; i < n; i++) { + const idx = this.rrIndex % n; + this.rrIndex = (this.rrIndex + 1) % n; + const proxy = this.proxies[idx]; + const key = ProxyRotator.proxyKey(proxy); + const state = this.states.get(key)!; + if (isAvailable(state)) { + return proxy; + } + } + return available[0].proxy; + } + + private selectRandom( + available: Array<{ index: number; proxy: ProxyValue }> + ): ProxyValue { + const idx = Math.floor(Math.random() * available.length); + return available[idx].proxy; + } + + private selectLeastUsed( + available: Array<{ index: number; proxy: ProxyValue }> + ): ProxyValue { + let best = available[0]; + let bestCount = this.states.get(ProxyRotator.proxyKey(best.proxy))!.useCount; + for (let i = 1; i < available.length; i++) { + const count = this.states.get( + ProxyRotator.proxyKey(available[i].proxy) + )!.useCount; + if (count < bestCount) { + best = available[i]; + bestCount = count; + } + } + return best.proxy; + } + + private selectLeastFailures( + available: Array<{ index: number; proxy: ProxyValue }> + ): ProxyValue { + let best = available[0]; + let bestCount = this.states.get(ProxyRotator.proxyKey(best.proxy))! + .failCount; + for (let i = 1; i < available.length; i++) { + const count = this.states.get( + ProxyRotator.proxyKey(available[i].proxy) + )!.failCount; + if (count < bestCount) { + best = available[i]; + bestCount = count; + } + } + return best.proxy; + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + /** + * Canonical string key for a proxy (for dedup/tracking). + * + * Uses '||' separator for dict proxies to avoid ambiguity with URL '@' characters. + */ + static proxyKey(proxy: ProxyValue): string { + if (typeof proxy === "object") { + const username = proxy.username ?? ""; + return username ? `${proxy.server}||${username}` : proxy.server; + } + return proxy; + } +} + +// --------------------------------------------------------------------------- +// Utility +// --------------------------------------------------------------------------- + +/** + * If the proxy is a ProxyRotator, call .next() to get the actual proxy. + * Otherwise return it unchanged. Used internally by launch wrappers. + */ +export function resolveProxyRotator( + proxy: ProxyValue | ProxyRotator | undefined | null +): string | { server: string; bypass?: string; username?: string; password?: string } | undefined { + if (proxy instanceof ProxyRotator) { + const next = proxy.next(); + // Ensure we return a plain string or object, never a ProxyRotator + return typeof next === "string" ? next : next; + } + return proxy ?? undefined; +} + +/** + * Mask credentials in a proxy URL for logging/stats. + * + * Handles both plain proxy URLs and internal dict-key format ('server||username'). + * Also handles bare proxy strings without a scheme (e.g. 'user:pass@host:port'). + */ +export function maskProxy(url: string): string { + // Internal dict-key format: "http://server:port||username" + if (url.includes("||")) { + const sep = url.indexOf("||"); + return `${url.slice(0, sep)}||***`; + } + try { + // Bare format: "user:pass@host:port" — no scheme. + let normalized = url; + const hadScheme = url.includes("://"); + if (!hadScheme && url.includes("@")) { + normalized = `http://${url}`; + } + const parsed = new URL(normalized); + if (parsed.username) { + const hostPort = `${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}`; + if (hadScheme) { + return `${parsed.protocol}//***:***@${hostPort}`; + } + // Bare format — return without scheme to match original style + return `***:***@${hostPort}`; + } + return url; + } catch { + return url; + } +} diff --git a/js/src/proxy.ts b/js/src/proxy.ts index 1f04b07..9c3c7cb 100644 --- a/js/src/proxy.ts +++ b/js/src/proxy.ts @@ -12,19 +12,32 @@ export interface ParsedProxy { * Parse a proxy URL, extracting credentials into separate fields. * * Handles: "http://user:pass@host:port" -> { server: "http://host:port", username: "user", password: "pass" } - * Also handles: no credentials, URL-encoded special chars, socks5://, missing port. + * Also handles: no credentials, URL-encoded special chars, socks5://, missing port, + * and bare proxy strings without a scheme (e.g. "user:pass@host:port" -> treated as http). */ export function parseProxyUrl(proxy: string): ParsedProxy { + // Bare format: "user:pass@host:port" — no scheme. + // new URL() needs a scheme to correctly parse credentials. + let normalized = proxy; + const hadScheme = proxy.includes("://"); + if (!hadScheme && proxy.includes("@")) { + normalized = `http://${proxy}`; + } + let url: URL; try { - url = new URL(proxy); + url = new URL(normalized); } catch { // Not a parseable URL (e.g. bare "host:port") — pass through as-is return { server: proxy }; } if (!url.username) { - return { server: proxy }; + // No credentials found — if we added a scheme for bare host:port, keep it. + if (!hadScheme && !proxy.includes("@")) { + return { server: proxy }; + } + return { server: normalized }; } // Rebuild server URL without credentials diff --git a/js/src/puppeteer.ts b/js/src/puppeteer.ts index 15a596e..44e63f7 100644 --- a/js/src/puppeteer.ts +++ b/js/src/puppeteer.ts @@ -8,6 +8,7 @@ import type { LaunchOptions } from "./types.js"; import { buildArgs } from "./args.js"; import { ensureBinary } from "./download.js"; import { parseProxyUrl } from "./proxy.js"; +import { resolveProxyRotator } from "./proxy-rotator.js"; /** * Launch stealth Chromium browser via Puppeteer. @@ -26,16 +27,18 @@ export async function launch(options: LaunchOptions = {}): Promise { const puppeteer = await import("puppeteer-core"); const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary()); - const resolved = await maybeResolveGeoip(options); - const args = buildArgs({ ...options, ...resolved }); + const resolvedProxy = resolveProxyRotator(options.proxy); + const opts = { ...options, proxy: resolvedProxy }; + const resolved = await maybeResolveGeoip(opts); + const args = buildArgs({ ...opts, ...resolved }); // Puppeteer handles proxy via CLI args, not a separate option. // Chromium's --proxy-server does NOT support inline credentials, // so we strip them and use page.authenticate() instead. let proxyAuth: { username: string; password: string } | undefined; - if (options.proxy) { - if (typeof options.proxy === "string") { - const { server, username, password } = parseProxyUrl(options.proxy); + if (resolvedProxy) { + if (typeof resolvedProxy === "string") { + const { server, username, password } = parseProxyUrl(resolvedProxy); args.push(`--proxy-server=${server}`); if (username) { proxyAuth = { username, password: password ?? "" }; @@ -43,14 +46,14 @@ export async function launch(options: LaunchOptions = {}): Promise { } 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); + const parsed = parseProxyUrl(resolvedProxy.server); args.push(`--proxy-server=${parsed.server}`); - if (options.proxy.bypass) { - args.push(`--proxy-bypass-list=${options.proxy.bypass}`); + if (resolvedProxy.bypass) { + args.push(`--proxy-bypass-list=${resolvedProxy.bypass}`); } // Explicit username/password fields take precedence over inline creds - const username = options.proxy.username ?? parsed.username; - const password = options.proxy.password ?? parsed.password; + const username = resolvedProxy.username ?? parsed.username; + const password = resolvedProxy.password ?? parsed.password; if (username) { proxyAuth = { username, password: password ?? "" }; } @@ -90,7 +93,10 @@ async function maybeResolveGeoip( if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale }; const { resolveProxyGeo } = await import("./geoip.js"); - const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server; + const proxy = options.proxy; + const proxyUrl = typeof proxy === "string" + ? proxy + : "server" in proxy ? proxy.server : undefined; if (!proxyUrl) return { timezone: options.timezone, locale: options.locale }; const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl); return { diff --git a/js/src/types.ts b/js/src/types.ts index a539c1d..5aec829 100644 --- a/js/src/types.ts +++ b/js/src/types.ts @@ -6,12 +6,13 @@ export interface LaunchOptions { /** Run in headless mode (default: true). */ headless?: boolean; /** - * Proxy server — URL string or Playwright proxy object. + * Proxy server — URL string, Playwright proxy object, or ProxyRotator. * String: 'http://user:pass@proxy:8080' (credentials auto-extracted). * Object: { server: "http://proxy:8080", bypass: ".google.com", ... } * — passed directly to Playwright. + * ProxyRotator: calls .next() automatically to get the next proxy. */ - proxy?: string | { server: string; bypass?: string; username?: string; password?: string }; + proxy?: string | { server: string; bypass?: string; username?: string; password?: string } | import('./proxy-rotator.js').ProxyRotator; /** Additional Chromium CLI arguments. */ args?: string[]; /** Include default stealth fingerprint args (default: true). Set false to use custom --fingerprint flags. */ diff --git a/js/tests/proxy-rotator.test.ts b/js/tests/proxy-rotator.test.ts new file mode 100644 index 0000000..27dc85c --- /dev/null +++ b/js/tests/proxy-rotator.test.ts @@ -0,0 +1,454 @@ +import { describe, it, expect } from "vitest"; +import { + ProxyRotator, + maskProxy, +} from "../src/proxy-rotator.js"; +import type { ProxyValue } from "../src/proxy-rotator.js"; + +const PROXIES: ProxyValue[] = [ + "http://user:pass@proxy1:8080", + "http://user:pass@proxy2:8080", + "http://user:pass@proxy3:8080", +]; + +// --------------------------------------------------------------------------- +// Construction +// --------------------------------------------------------------------------- + +describe("ProxyRotator construction", () => { + it("throws on empty list", () => { + expect(() => new ProxyRotator([])).toThrow("must not be empty"); + }); + + it("accepts single proxy", () => { + const r = new ProxyRotator(["http://proxy:8080"]); + expect(r.size).toBe(1); + expect(r.next()).toBe("http://proxy:8080"); + }); + + it("accepts dict proxies", () => { + const r = new ProxyRotator([ + { server: "http://proxy:8080", username: "u", password: "p" }, + ]); + const p = r.next(); + expect(typeof p).toBe("object"); + if (typeof p === "object") { + expect(p.server).toBe("http://proxy:8080"); + } + }); + + it("toString includes info", () => { + const r = new ProxyRotator(PROXIES); + const s = r.toString(); + expect(s).toContain("proxies=3"); + expect(s).toContain("round_robin"); + }); +}); + +// --------------------------------------------------------------------------- +// Round Robin +// --------------------------------------------------------------------------- + +describe("round_robin strategy", () => { + it("cycles through proxies", () => { + const r = new ProxyRotator(PROXIES, { strategy: "round_robin" }); + const results = Array.from({ length: 6 }, () => r.next()); + expect(results).toEqual([...PROXIES, ...PROXIES]); + }); + + it("skips cooled-down proxies", () => { + const r = new ProxyRotator(PROXIES, { + strategy: "round_robin", + maxFailures: 1, + cooldown: 60, + }); + const p = r.next(); + r.reportFailure(p); + const results = Array.from({ length: 4 }, () => r.next()); + expect(results).not.toContain(PROXIES[0]); + }); +}); + +// --------------------------------------------------------------------------- +// Random +// --------------------------------------------------------------------------- + +describe("random strategy", () => { + it("returns valid proxies", () => { + const r = new ProxyRotator(PROXIES, { strategy: "random" }); + for (let i = 0; i < 20; i++) { + expect(PROXIES).toContain(r.next()); + } + }); + + it("skips cooled-down proxies", () => { + const r = new ProxyRotator(PROXIES, { + strategy: "random", + maxFailures: 1, + cooldown: 60, + }); + r.reportFailure(PROXIES[0]); + r.reportFailure(PROXIES[1]); + for (let i = 0; i < 10; i++) { + expect(r.next()).toBe(PROXIES[2]); + } + }); +}); + +// --------------------------------------------------------------------------- +// Least Used +// --------------------------------------------------------------------------- + +describe("least_used strategy", () => { + it("distributes evenly", () => { + const r = new ProxyRotator(PROXIES, { strategy: "least_used" }); + for (let i = 0; i < 9; i++) r.next(); + const stats = r.stats(); + const counts = stats.map((s) => s.useCount); + expect(Math.max(...counts) - Math.min(...counts)).toBeLessThanOrEqual(1); + }); +}); + +// --------------------------------------------------------------------------- +// Least Failures +// --------------------------------------------------------------------------- + +describe("least_failures strategy", () => { + it("avoids failed proxies", () => { + const r = new ProxyRotator(PROXIES, { strategy: "least_failures" }); + r.reportFailure(PROXIES[0]); + r.reportFailure(PROXIES[0]); + const result = r.next(); + expect([PROXIES[1], PROXIES[2]]).toContain(result); + }); +}); + +// --------------------------------------------------------------------------- +// Health tracking +// --------------------------------------------------------------------------- + +describe("health tracking", () => { + it("success resets consecutive failures", () => { + const r = new ProxyRotator(PROXIES, { maxFailures: 3, cooldown: 60 }); + r.reportFailure(PROXIES[0]); + r.reportFailure(PROXIES[0]); + r.reportSuccess(PROXIES[0]); + expect(r.stats()[0].consecutiveFails).toBe(0); + expect(r.stats()[0].available).toBe(true); + }); + + it("triggers cooldown on max failures", () => { + const r = new ProxyRotator(PROXIES, { maxFailures: 2, cooldown: 60 }); + r.reportFailure(PROXIES[0]); + r.reportFailure(PROXIES[0]); + expect(r.stats()[0].available).toBe(false); + }); + + it("throws when all in cooldown", () => { + const r = new ProxyRotator( + ["http://p1:80", "http://p2:80"], + { maxFailures: 1, cooldown: 60 } + ); + r.reportFailure("http://p1:80"); + r.reportFailure("http://p2:80"); + expect(() => r.next()).toThrow("All"); + }); +}); + +// --------------------------------------------------------------------------- +// Sticky +// --------------------------------------------------------------------------- + +describe("sticky count", () => { + it("reuses proxy for stickyCount requests", () => { + const r = new ProxyRotator(PROXIES, { + strategy: "round_robin", + stickyCount: 3, + }); + const first = r.next(); + const second = r.next(); + const third = r.next(); + expect(first).toBe(second); + expect(second).toBe(third); + const fourth = r.next(); + expect(fourth).not.toBe(first); + }); +}); + +// --------------------------------------------------------------------------- +// withSession +// --------------------------------------------------------------------------- + +describe("withSession", () => { + it("reports success on normal return", async () => { + const r = new ProxyRotator(PROXIES); + await r.withSession(async (proxy) => { + expect(PROXIES).toContain(proxy); + return "ok"; + }); + expect(r.stats()[0].consecutiveFails).toBe(0); + }); + + it("reports failure on throw", async () => { + const r = new ProxyRotator(PROXIES); + await expect( + r.withSession(async () => { + throw new Error("boom"); + }) + ).rejects.toThrow("boom"); + const failedProxy = r.stats().find((s) => s.failCount > 0); + expect(failedProxy).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Dynamic pool +// --------------------------------------------------------------------------- + +describe("dynamic pool", () => { + it("adds proxy", () => { + const r = new ProxyRotator(PROXIES.slice(0, 2)); + expect(r.size).toBe(2); + r.add("http://new:8080"); + expect(r.size).toBe(3); + }); + + it("removes proxy", () => { + const r = new ProxyRotator(PROXIES); + r.remove(PROXIES[0]); + expect(r.size).toBe(2); + }); + + it("throws on remove nonexistent", () => { + const r = new ProxyRotator(PROXIES); + expect(() => r.remove("http://nonexistent:9999")).toThrow("not in pool"); + }); + + it("throws on remove last", () => { + const r = new ProxyRotator(["http://only:8080"]); + expect(() => r.remove("http://only:8080")).toThrow("pool would be empty"); + }); +}); + +// --------------------------------------------------------------------------- +// Stats and reset +// --------------------------------------------------------------------------- + +describe("stats and reset", () => { + it("returns correct structure", () => { + const r = new ProxyRotator(PROXIES); + r.next(); + const stats = r.stats(); + expect(stats).toHaveLength(3); + for (const s of stats) { + expect(s).toHaveProperty("proxy"); + expect(s).toHaveProperty("useCount"); + expect(s).toHaveProperty("failCount"); + expect(s).toHaveProperty("available"); + } + }); + + it("masks credentials in stats", () => { + const r = new ProxyRotator(PROXIES); + const stats = r.stats(); + for (const s of stats) { + expect(s.proxy).not.toContain("pass"); + } + }); + + it("reset clears all state", () => { + const r = new ProxyRotator(PROXIES); + for (let i = 0; i < 5; i++) r.next(); + r.reportFailure(PROXIES[0]); + r.reset(); + for (const s of r.stats()) { + expect(s.useCount).toBe(0); + expect(s.failCount).toBe(0); + } + }); +}); + +// --------------------------------------------------------------------------- +// Available count +// --------------------------------------------------------------------------- + +describe("availableCount", () => { + it("all available initially", () => { + const r = new ProxyRotator(PROXIES); + expect(r.availableCount).toBe(3); + }); + + it("decreases with cooldown", () => { + const r = new ProxyRotator(PROXIES, { maxFailures: 1, cooldown: 60 }); + r.reportFailure(PROXIES[0]); + expect(r.availableCount).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// current() method +// --------------------------------------------------------------------------- + +describe("current()", () => { + it("returns null initially", () => { + const r = new ProxyRotator(PROXIES); + expect(r.current()).toBeNull(); + }); + + it("returns last proxy after next()", () => { + const r = new ProxyRotator(PROXIES, { strategy: "round_robin" }); + const proxy = r.next(); + expect(r.current()).toBe(proxy); + }); + + it("useful for report after launch(proxy=rotator)", () => { + const r = new ProxyRotator(PROXIES, { strategy: "round_robin" }); + const proxy = r.next(); + const current = r.current()!; + expect(current).toBe(proxy); + r.reportSuccess(current); + expect(r.stats()[0].consecutiveFails).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// remove() edge cases +// --------------------------------------------------------------------------- + +describe("remove() edge cases", () => { + it("preserves state on last proxy error", () => { + const r = new ProxyRotator(["http://only:8080"]); + expect(() => r.remove("http://only:8080")).toThrow("pool would be empty"); + expect(r.size).toBe(1); + expect(r.next()).toBe("http://only:8080"); + }); + + it("clears sticky for removed proxy", () => { + const r = new ProxyRotator(PROXIES, { strategy: "round_robin", stickyCount: 5 }); + const first = r.next(); + r.remove(first); + const second = r.next(); + expect(second).not.toBe(first); + }); +}); + +// --------------------------------------------------------------------------- +// maskProxy — dict key format +// --------------------------------------------------------------------------- + +describe("maskProxy dict key format", () => { + it("masks username part of dict keys", () => { + const masked = maskProxy("http://proxy:8080||admin"); + expect(masked).not.toContain("admin"); + expect(masked).toContain("***"); + expect(masked).toBe("http://proxy:8080||***"); + }); + + it("preserves URL without dict key separator", () => { + expect(maskProxy("http://proxy:8080")).toBe("http://proxy:8080"); + }); +}); + +// --------------------------------------------------------------------------- +// maskProxy utility +// --------------------------------------------------------------------------- + +describe("maskProxy", () => { + it("masks credentials", () => { + const masked = maskProxy("http://user:pass@host:8080"); + expect(masked).not.toContain("pass"); + expect(masked).toContain("***"); + }); + + it("preserves URL without credentials", () => { + expect(maskProxy("http://host:8080")).toBe("http://host:8080"); + }); + + it("handles dict key format", () => { + const key = ProxyRotator.proxyKey({ + server: "http://p:80", + username: "u", + }); + expect(key).toBe("http://p:80||u"); + }); +}); + +// --------------------------------------------------------------------------- +// ProxyRotator.proxyKey static method +// --------------------------------------------------------------------------- + +describe("ProxyRotator.proxyKey", () => { + it("returns string as-is", () => { + expect(ProxyRotator.proxyKey("http://proxy:8080")).toBe( + "http://proxy:8080" + ); + }); + + it("builds key from dict with username", () => { + expect( + ProxyRotator.proxyKey({ server: "http://p:80", username: "u" }) + ).toBe("http://p:80||u"); + }); + + it("builds key from dict without username", () => { + expect(ProxyRotator.proxyKey({ server: "http://p:80" })).toBe( + "http://p:80" + ); + }); +}); + +// --------------------------------------------------------------------------- +// Bare proxy format (user:pass@host:port without scheme) +// --------------------------------------------------------------------------- + +describe("bare proxy format", () => { + it("maskProxy masks bare format credentials", () => { + const masked = maskProxy("user:pass@proxy1.example.com:5610"); + expect(masked).not.toContain("pass"); + expect(masked).not.toContain("user"); + expect(masked).toContain("***"); + expect(masked).toContain("proxy1.example.com:5610"); + }); + + it("maskProxy preserves bare host:port without credentials", () => { + expect(maskProxy("proxy1.example.com:5610")).toBe("proxy1.example.com:5610"); + }); + + it("rotator accepts bare proxy strings", () => { + const r = new ProxyRotator(["user:pass@proxy1:8080"]); + const proxy = r.next(); + expect(proxy).toBe("user:pass@proxy1:8080"); + }); + + it("stats masks bare proxy credentials", () => { + const r = new ProxyRotator(["user:secret@proxy1.example.com:5610"]); + r.next(); + const stats = r.stats(); + expect(stats[0].proxy).not.toContain("secret"); + expect(stats[0].proxy).not.toContain("user"); + }); +}); + +// --------------------------------------------------------------------------- +// SOCKS5 proxy format +// --------------------------------------------------------------------------- + +describe("socks5 format", () => { + it("maskProxy preserves socks5 without credentials", () => { + const masked = maskProxy("socks5://proxy:15610"); + expect(masked).toBe("socks5://proxy:15610"); + }); + + it("rotator accepts socks5 without auth", () => { + const r = new ProxyRotator(["socks5://proxy:15610"]); + const proxy = r.next(); + expect(typeof proxy).toBe("string"); + expect((proxy as string).startsWith("socks5://")).toBe(true); + }); + + it("socks5 with auth throws", () => { + expect(() => new ProxyRotator(["socks5://user:pass@proxy:15610"])).toThrow( + "SOCKS5 with authentication" + ); + }); +}); diff --git a/tests/test_proxy.py b/tests/test_proxy.py index 8d2781b..19b4c85 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -98,3 +98,36 @@ 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" + + +# --------------------------------------------------------------------------- +# Bare proxy format (user:pass@host:port without scheme) +# --------------------------------------------------------------------------- + + +class TestBareProxyFormat: + """_parse_proxy_url must handle bare 'user:pass@host:port' strings.""" + + def test_bare_proxy_with_credentials(self): + result = _parse_proxy_url("user:pass@proxy:8080") + assert result["username"] == "user" + assert result["password"] == "pass" + assert result["server"] == "http://proxy:8080" + + def test_bare_proxy_without_credentials(self): + result = _parse_proxy_url("proxy:8080") + assert result["server"] == "proxy:8080" + assert "username" not in result + + def test_bare_proxy_credentials_not_in_server(self): + """Server field must not contain embedded credentials.""" + result = _parse_proxy_url("user:pass@proxy1.example.com:5610") + assert "user" not in result["server"] + assert "pass" not in result["server"] + + def test_build_proxy_kwargs_bare_format(self): + result = _build_proxy_kwargs("user:pass@proxy:8080") + proxy = result["proxy"] + assert proxy["username"] == "user" + assert proxy["password"] == "pass" + assert "user" not in proxy["server"] diff --git a/tests/test_proxy_real.mjs b/tests/test_proxy_real.mjs new file mode 100644 index 0000000..76aa5aa --- /dev/null +++ b/tests/test_proxy_real.mjs @@ -0,0 +1,148 @@ +// tests/test_proxy_real.mjs +/** + * Real proxy rotation test (JS) — launches browsers with actual proxies. + * Run: node tests/test_proxy_real.mjs + */ +import { launch } from '../js/dist/index.js'; +import { ProxyRotator } from '../js/dist/proxy-rotator.js'; + +const PROXIES = [ + 'http://user:pass@proxy1.example.com:5610', + 'http://user:pass@proxy2.example.com:4586', + 'http://user:pass@proxy3.example.com:5906', +]; + +const results = []; + +function check(name, passed, detail = '') { + const status = passed ? 'PASS' : 'FAIL'; + let msg = ` [${status}] ${name}`; + if (detail) msg += ` — ${detail}`; + console.log(msg); + results.push({ name, status }); +} + +async function main() { + console.log('='.repeat(70)); + console.log(' PROXY ROTATION — REAL BROWSER TEST (JS)'); + console.log('='.repeat(70)); + + // ---- Test 1: Round-robin — each proxy returns different IP ---- + console.log('\n--- Test 1: Round-robin rotation ---'); + const rotator = new ProxyRotator(PROXIES, { strategy: 'round_robin' }); + const ips = []; + + for (let i = 0; i < 3; i++) { + const proxy = rotator.next(); + const browser = await launch({ headless: true, proxy }); + const page = await browser.newPage(); + await page.goto('https://api.ipify.org?format=json', { timeout: 15000 }); + const ip = await page.textContent('body'); + console.log(` Proxy ${i + 1}: ${ip}`); + ips.push(ip); + rotator.reportSuccess(proxy); + await browser.close(); + } + + const unique = new Set(ips); + check('round-robin unique IPs', unique.size === 3, `${unique.size} unique`); + + // ---- Test 2: Sticky — same IP for 2 requests ---- + console.log('\n--- Test 2: Sticky session (2 requests) ---'); + const sticky = new ProxyRotator(PROXIES.slice(0, 2), { + strategy: 'round_robin', + stickyCount: 2, + }); + const stickyIps = []; + + for (let i = 0; i < 4; i++) { + const proxy = sticky.next(); + const browser = await launch({ headless: true, proxy }); + const page = await browser.newPage(); + await page.goto('https://api.ipify.org?format=json', { timeout: 15000 }); + const ip = await page.textContent('body'); + console.log(` Request ${i + 1}: ${ip}`); + stickyIps.push(ip); + sticky.reportSuccess(proxy); + await browser.close(); + } + + check('sticky first pair', stickyIps[0] === stickyIps[1], `${stickyIps[0]} == ${stickyIps[1]}`); + check('sticky second pair', stickyIps[2] === stickyIps[3], `${stickyIps[2]} == ${stickyIps[3]}`); + check('sticky pairs differ', stickyIps[0] !== stickyIps[2], `${stickyIps[0]} != ${stickyIps[2]}`); + + // ---- Test 3: withSession — success tracking ---- + console.log('\n--- Test 3: withSession tracking ---'); + const tracker = new ProxyRotator(PROXIES, { strategy: 'least_failures' }); + + await tracker.withSession(async (proxy) => { + const browser = await launch({ headless: true, proxy }); + const page = await browser.newPage(); + await page.goto('https://api.ipify.org?format=json', { timeout: 15000 }); + console.log(` Session proxy: ${await page.textContent('body')}`); + await browser.close(); + }); + + const stats = tracker.stats(); + const totalFails = stats.reduce((sum, s) => sum + s.failCount, 0); + check('session no failures', totalFails === 0, `total fails: ${totalFails}`); + for (const s of stats) { + console.log(` ${s.proxy}: uses=${s.useCount}, fails=${s.failCount}`); + } + + // ---- Test 4: SOCKS5 with auth rejected ---- + console.log('\n--- Test 4: SOCKS5 auth validation ---'); + let socks5Rejected = false; + try { + new ProxyRotator(['socks5://user:pass@host:1080']); + } catch (e) { + socks5Rejected = e.message.includes('SOCKS5'); + } + check('socks5 with auth rejected', socks5Rejected); + + let socks5NoAuth = false; + try { + new ProxyRotator(['socks5://host:1080']); + socks5NoAuth = true; + } catch (_) {} + check('socks5 without auth accepted', socks5NoAuth); + + let socks5DictRejected = false; + try { + new ProxyRotator([{ server: 'socks5://host:1080', username: 'u', password: 'p' }]); + } catch (e) { + socks5DictRejected = e.message.includes('SOCKS5'); + } + check('socks5 dict with auth rejected', socks5DictRejected); + + let addRejected = false; + try { + const r = new ProxyRotator(['http://proxy:8080']); + r.add('socks5://user:pass@host:1080'); + } catch (e) { + addRejected = e.message.includes('SOCKS5'); + } + check('add socks5 with auth rejected', addRejected); + + // ---- Summary ---- + console.log('\n' + '='.repeat(70)); + console.log(' SUMMARY'); + console.log('='.repeat(70)); + + const passed = results.filter(r => r.status === 'PASS').length; + const failed = results.filter(r => r.status === 'FAIL').length; + + for (const r of results) { + const icon = r.status === 'PASS' ? 'OK' : 'XX'; + console.log(` [${icon}] ${r.name}`); + } + + console.log(`\n ${passed}/${results.length} passed, ${failed} failed`); + if (failed === 0) console.log(' *** ALL TESTS PASSED ***'); + console.log('='.repeat(70)); + + await new Promise(r => setTimeout(r, 500)); + process.exit(failed > 0 ? 1 : 0); +} + +main().catch(e => { console.error(e); process.exit(1); }); diff --git a/tests/test_proxy_real.py b/tests/test_proxy_real.py new file mode 100644 index 0000000..50569e2 --- /dev/null +++ b/tests/test_proxy_real.py @@ -0,0 +1,92 @@ +"""Real proxy rotation tests — launches browsers with actual proxies. + +Run: pytest tests/test_proxy_real.py -v -m slow +""" +import pytest +from cloakbrowser import ProxyRotator, launch + +PROXIES = [ + "http://user:pass@proxy1.example.com:5610", + "http://user:pass@proxy2.example.com:4586", + "http://user:pass@proxy3.example.com:5906", +] + + +@pytest.mark.slow +class TestRoundRobin: + def test_each_proxy_returns_unique_ip(self): + rotator = ProxyRotator(proxies=PROXIES, strategy="round_robin") + ips = [] + for i in range(3): + proxy = rotator.next() + browser = launch(headless=True, proxy=proxy) + page = browser.new_page() + page.goto("https://api.ipify.org?format=json", timeout=15000) + ip = page.text_content("body") + ips.append(ip) + rotator.report_success(proxy) + browser.close() + assert len(set(ips)) == 3, f"Expected 3 unique IPs, got {set(ips)}" + + +@pytest.mark.slow +class TestSticky: + def test_sticky_reuses_same_proxy(self): + sticky = ProxyRotator( + proxies=PROXIES[:2], + strategy="round_robin", + sticky_count=2, + ) + ips = [] + for _ in range(4): + proxy = sticky.next() + browser = launch(headless=True, proxy=proxy) + page = browser.new_page() + page.goto("https://api.ipify.org?format=json", timeout=15000) + ip = page.text_content("body") + ips.append(ip) + sticky.report_success(proxy) + browser.close() + assert ips[0] == ips[1], "Sticky: first 2 should match" + assert ips[2] == ips[3], "Sticky: last 2 should match" + assert ips[0] != ips[2], "Sticky: pairs should differ" + + +@pytest.mark.slow +class TestSession: + def test_session_tracks_success(self): + tracker = ProxyRotator(proxies=PROXIES, strategy="least_failures") + with tracker.session() as proxy: + browser = launch(headless=True, proxy=proxy) + page = browser.new_page() + page.goto("https://api.ipify.org?format=json", timeout=15000) + ip = page.text_content("body") + assert ip, "Should return IP" + browser.close() + stats = tracker.stats() + total_fails = sum(s["fail_count"] for s in stats) + assert total_fails == 0, "No failures expected" + + +class TestSocks5Validation: + def test_socks5_with_auth_raises(self): + with pytest.raises(ValueError, match="SOCKS5 with authentication"): + ProxyRotator(["socks5://user:pass@host:1080"]) + + def test_socks5_without_auth_accepted(self): + r = ProxyRotator(["socks5://host:1080"]) + assert len(r) == 1 + + def test_socks5_dict_with_auth_raises(self): + with pytest.raises(ValueError, match="SOCKS5 with authentication"): + ProxyRotator([{"server": "socks5://host:1080", "username": "u", "password": "p"}]) + + def test_add_socks5_with_auth_raises(self): + r = ProxyRotator(["http://proxy:8080"]) + with pytest.raises(ValueError, match="SOCKS5 with authentication"): + r.add("socks5://user:pass@host:1080") + + +if __name__ == "__main__": + import sys + sys.exit(pytest.main([__file__, "-v", "--tb=short", "-x"])) diff --git a/tests/test_proxy_rotator.py b/tests/test_proxy_rotator.py new file mode 100644 index 0000000..cd4aa9a --- /dev/null +++ b/tests/test_proxy_rotator.py @@ -0,0 +1,473 @@ +"""Unit tests for proxy_rotator.py — strategies, health tracking, thread safety.""" + +import threading +import time +from unittest.mock import patch + +import pytest + +from cloakbrowser.proxy_rotator import ProxyRotator, Strategy, _mask_proxy + + +PROXIES = [ + "http://user:pass@proxy1:8080", + "http://user:pass@proxy2:8080", + "http://user:pass@proxy3:8080", +] + + +# --------------------------------------------------------------------------- +# Construction +# --------------------------------------------------------------------------- + + +class TestConstruction: + def test_empty_raises(self): + with pytest.raises(ValueError, match="must not be empty"): + ProxyRotator([]) + + def test_single_proxy(self): + r = ProxyRotator(["http://proxy:8080"]) + assert len(r) == 1 + assert r.next() == "http://proxy:8080" + + def test_accepts_dict_proxies(self): + r = ProxyRotator([{"server": "http://proxy:8080", "username": "u", "password": "p"}]) + assert len(r) == 1 + p = r.next() + assert isinstance(p, dict) + assert p["server"] == "http://proxy:8080" + + def test_strategy_string(self): + r = ProxyRotator(PROXIES, strategy="random") + assert r._strategy == Strategy.RANDOM + + def test_strategy_enum(self): + r = ProxyRotator(PROXIES, strategy=Strategy.LEAST_USED) + assert r._strategy == Strategy.LEAST_USED + + def test_repr(self): + r = ProxyRotator(PROXIES) + s = repr(r) + assert "proxies=3" in s + assert "round_robin" in s + + +# --------------------------------------------------------------------------- +# Round Robin +# --------------------------------------------------------------------------- + + +class TestRoundRobin: + def test_cycles_through_proxies(self): + r = ProxyRotator(PROXIES, strategy="round_robin") + results = [r.next() for _ in range(6)] + assert results == PROXIES + PROXIES + + def test_skips_cooled_down(self): + r = ProxyRotator(PROXIES, strategy="round_robin", max_failures=1, cooldown=60) + p = r.next() + r.report_failure(p) + results = [r.next() for _ in range(4)] + assert PROXIES[0] not in results + + +# --------------------------------------------------------------------------- +# Random +# --------------------------------------------------------------------------- + + +class TestRandom: + def test_returns_valid_proxy(self): + r = ProxyRotator(PROXIES, strategy="random") + for _ in range(20): + assert r.next() in PROXIES + + def test_skips_cooled_down(self): + r = ProxyRotator(PROXIES, strategy="random", max_failures=1, cooldown=60) + r.report_failure(PROXIES[0]) + r.report_failure(PROXIES[1]) + for _ in range(10): + assert r.next() == PROXIES[2] + + +# --------------------------------------------------------------------------- +# Least Used +# --------------------------------------------------------------------------- + + +class TestLeastUsed: + def test_distributes_evenly(self): + r = ProxyRotator(PROXIES, strategy="least_used") + for _ in range(9): + r.next() + stats = {s["proxy"]: s["use_count"] for s in r.stats()} + counts = list(stats.values()) + assert max(counts) - min(counts) <= 1 + + +# --------------------------------------------------------------------------- +# Least Failures +# --------------------------------------------------------------------------- + + +class TestLeastFailures: + def test_avoids_failed_proxies(self): + r = ProxyRotator(PROXIES, strategy="least_failures") + r.report_failure(PROXIES[0]) + r.report_failure(PROXIES[0]) + result = r.next() + assert result in (PROXIES[1], PROXIES[2]) + + +# --------------------------------------------------------------------------- +# Health tracking +# --------------------------------------------------------------------------- + + +class TestHealthTracking: + def test_success_resets_consecutive(self): + r = ProxyRotator(PROXIES, strategy="round_robin", max_failures=3, cooldown=60) + proxy = PROXIES[0] + r.report_failure(proxy) + r.report_failure(proxy) + r.report_success(proxy) + key = r._proxy_key(proxy) + assert r._states[key].consecutive_fails == 0 + assert r._states[key].is_available + + def test_cooldown_triggers(self): + r = ProxyRotator(PROXIES, strategy="round_robin", max_failures=2, cooldown=60) + proxy = PROXIES[0] + r.report_failure(proxy) + r.report_failure(proxy) + key = r._proxy_key(proxy) + assert not r._states[key].is_available + + def test_all_in_cooldown_raises(self): + r = ProxyRotator(["http://p1:80", "http://p2:80"], max_failures=1, cooldown=60) + r.report_failure("http://p1:80") + r.report_failure("http://p2:80") + with pytest.raises(RuntimeError, match="All.*proxies are in cooldown"): + r.next() + + def test_cooldown_expires(self): + r = ProxyRotator(PROXIES, max_failures=1, cooldown=0.1) + proxy = PROXIES[0] + r.report_failure(proxy) + key = r._proxy_key(proxy) + assert not r._states[key].is_available + time.sleep(0.15) + assert r._states[key].is_available + + +# --------------------------------------------------------------------------- +# Sticky count +# --------------------------------------------------------------------------- + + +class TestSticky: + def test_sticky_reuses_proxy(self): + r = ProxyRotator(PROXIES, strategy="round_robin", sticky_count=3) + first = r.next() + second = r.next() + third = r.next() + assert first == second == third + fourth = r.next() + assert fourth != first + + def test_sticky_resets_on_failure(self): + r = ProxyRotator(PROXIES, strategy="round_robin", sticky_count=5) + first = r.next() + r.report_failure(first) + second = r.next() + assert r._sticky_counter == 1 + + +# --------------------------------------------------------------------------- +# Session context manager +# --------------------------------------------------------------------------- + + +class TestSession: + def test_success_path(self): + r = ProxyRotator(PROXIES) + with r.session() as proxy: + assert proxy in PROXIES + key = r._proxy_key(proxy) + assert r._states[key].consecutive_fails == 0 + + def test_failure_path(self): + r = ProxyRotator(PROXIES) + with pytest.raises(ValueError): + with r.session() as proxy: + raise ValueError("simulated error") + key = r._proxy_key(proxy) + assert r._states[key].fail_count == 1 + + +# --------------------------------------------------------------------------- +# Dynamic pool management +# --------------------------------------------------------------------------- + + +class TestDynamicPool: + def test_add_proxy(self): + r = ProxyRotator(PROXIES[:2]) + assert len(r) == 2 + r.add("http://new:8080") + assert len(r) == 3 + + def test_remove_proxy(self): + r = ProxyRotator(PROXIES) + r.remove(PROXIES[0]) + assert len(r) == 2 + + def test_remove_nonexistent_raises(self): + r = ProxyRotator(PROXIES) + with pytest.raises(ValueError, match="not in pool"): + r.remove("http://nonexistent:9999") + + def test_remove_last_raises(self): + r = ProxyRotator(["http://only:8080"]) + with pytest.raises(ValueError, match="pool would be empty"): + r.remove("http://only:8080") + + +# --------------------------------------------------------------------------- +# Stats and reset +# --------------------------------------------------------------------------- + + +class TestStatsAndReset: + def test_stats_structure(self): + r = ProxyRotator(PROXIES) + r.next() + stats = r.stats() + assert len(stats) == 3 + for s in stats: + assert "proxy" in s + assert "use_count" in s + assert "fail_count" in s + assert "available" in s + + def test_stats_masks_credentials(self): + r = ProxyRotator(PROXIES) + stats = r.stats() + for s in stats: + assert "pass" not in s["proxy"] + + def test_reset_clears_state(self): + r = ProxyRotator(PROXIES) + for _ in range(5): + r.next() + r.report_failure(PROXIES[0]) + r.reset() + stats = r.stats() + for s in stats: + assert s["use_count"] == 0 + assert s["fail_count"] == 0 + + +# --------------------------------------------------------------------------- +# Thread safety +# --------------------------------------------------------------------------- + + +class TestThreadSafety: + def test_concurrent_next(self): + r = ProxyRotator(PROXIES, strategy="round_robin") + results = [] + errors = [] + + def worker(): + try: + for _ in range(100): + p = r.next() + results.append(p) + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=worker) for _ in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors + assert len(results) == 1000 + assert all(p in PROXIES for p in results) + + +# --------------------------------------------------------------------------- +# Mask utility +# --------------------------------------------------------------------------- + + +class TestMaskProxy: + def test_masks_credentials(self): + assert "pass" not in _mask_proxy("http://user:pass@host:8080") + assert "***" in _mask_proxy("http://user:pass@host:8080") + + def test_no_credentials_unchanged(self): + assert _mask_proxy("http://host:8080") == "http://host:8080" + + def test_dict_key_format(self): + r = ProxyRotator([{"server": "http://p:80", "username": "u"}]) + key = r._proxy_key({"server": "http://p:80", "username": "u"}) + assert key == "http://p:80||u" + + +# --------------------------------------------------------------------------- +# Available count +# --------------------------------------------------------------------------- + + +class TestAvailableCount: + def test_all_available_initially(self): + r = ProxyRotator(PROXIES) + assert r.available_count == 3 + + def test_decreases_with_cooldown(self): + r = ProxyRotator(PROXIES, max_failures=1, cooldown=60) + r.report_failure(PROXIES[0]) + assert r.available_count == 2 + + +# --------------------------------------------------------------------------- +# current() method +# --------------------------------------------------------------------------- + + +class TestCurrent: + def test_current_returns_none_initially(self): + r = ProxyRotator(PROXIES) + assert r.current() is None + + def test_current_returns_last_proxy_after_next(self): + r = ProxyRotator(PROXIES, strategy="round_robin") + proxy = r.next() + assert r.current() == proxy + + def test_current_useful_for_report_after_launch(self): + r = ProxyRotator(PROXIES, strategy="round_robin") + proxy = r.next() + current = r.current() + assert current == proxy + r.report_success(current) + key = r._proxy_key(current) + assert r._states[key].consecutive_fails == 0 + + +# --------------------------------------------------------------------------- +# remove() edge cases +# --------------------------------------------------------------------------- + + +class TestRemoveEdgeCases: + def test_remove_preserves_state_on_last_proxy_error(self): + r = ProxyRotator(["http://only:8080"]) + with pytest.raises(ValueError, match="pool would be empty"): + r.remove("http://only:8080") + assert len(r) == 1 + assert r.next() == "http://only:8080" + + def test_remove_clamps_rr_index(self): + r = ProxyRotator(PROXIES, strategy="round_robin") + r.next() + r.next() + r.next() + r.remove(PROXIES[0]) + proxy = r.next() + assert proxy in (PROXIES[1], PROXIES[2]) + + def test_remove_clears_sticky_for_removed_proxy(self): + r = ProxyRotator(PROXIES, strategy="round_robin", sticky_count=5) + first = r.next() + r.remove(first) + second = r.next() + assert second != first + assert second in PROXIES[1:] + + +# --------------------------------------------------------------------------- +# Mask utility — dict key format +# --------------------------------------------------------------------------- + + +class TestMaskDictKey: + def test_mask_dict_key_hides_username(self): + masked = _mask_proxy("http://proxy:8080||admin") + assert "admin" not in masked + assert "***" in masked + assert "http://proxy:8080||***" == masked + + def test_mask_dict_key_no_username(self): + assert _mask_proxy("http://proxy:8080") == "http://proxy:8080" + + +# --------------------------------------------------------------------------- +# Bare proxy format (user:pass@host:port without scheme) +# --------------------------------------------------------------------------- + + +class TestBareProxyFormat: + def test_mask_bare_proxy_hides_credentials(self): + masked = _mask_proxy("user:pass@proxy1.example.com:5610") + assert "pass" not in masked + assert "user" not in masked + assert "***" in masked + assert "proxy1.example.com:5610" in masked + + def test_mask_bare_proxy_no_creds(self): + assert _mask_proxy("proxy1.example.com:5610") == "proxy1.example.com:5610" + + def test_rotator_accepts_bare_proxy(self): + r = ProxyRotator(["user:pass@proxy1:8080"]) + proxy = r.next() + assert proxy == "user:pass@proxy1:8080" + assert r.current() == proxy + + def test_rotator_mixed_bare_and_scheme(self): + r = ProxyRotator([ + "user:pass@proxy1:8080", + "http://user:pass@proxy2:8080", + "socks5://proxy3:15610", + ], strategy="round_robin") + results = [r.next() for _ in range(3)] + assert results[0] == "user:pass@proxy1:8080" + assert results[1] == "http://user:pass@proxy2:8080" + assert results[2] == "socks5://proxy3:15610" + + def test_stats_masks_bare_proxy_credentials(self): + r = ProxyRotator(["user:secret@proxy1.example.com:5610"]) + r.next() + stats = r.stats() + assert len(stats) == 1 + assert "secret" not in stats[0]["proxy"] + assert "user" not in stats[0]["proxy"] + + +# --------------------------------------------------------------------------- +# Socks5 proxy format +# --------------------------------------------------------------------------- + + +class TestSocks5Format: + """Verify SOCKS5 proxy support.""" + + def test_mask_socks5_no_credentials(self): + masked = _mask_proxy("socks5://proxy:15610") + assert masked == "socks5://proxy:15610" + + def test_rotator_accepts_socks5_no_auth(self): + r = ProxyRotator(["socks5://proxy:15610"]) + proxy = r.next() + assert proxy.startswith("socks5://") + r.report_success(proxy) + key = r._proxy_key(proxy) + assert r._states[key].consecutive_fails == 0 + + def test_socks5_with_auth_raises(self): + with pytest.raises(ValueError, match="SOCKS5 with authentication"): + ProxyRotator(["socks5://user:pass@proxy:15610"])