"""Proxy session management. Supports static list (PROXY_LIST env) with optional Lambda-backed rotation. ProxyManager is used internally by scrape tools — not exposed as an MCP tool. """ from __future__ import annotations import json import random from dataclasses import dataclass, field from typing import Any, Literal from urllib.parse import urlparse from marketing_intelligence.core.config import settings @dataclass class ProxySession: proxy_url: str provider: str country: str | None = None sticky_id: str | None = None meta: dict = field(default_factory=dict) def to_playwright_dict(self) -> dict: """Convert proxy_url to Playwright/Camoufox proxy config dict.""" parsed = urlparse(self.proxy_url) result: dict = {"server": f"{parsed.scheme}://{parsed.hostname}:{parsed.port}"} if parsed.username: result["username"] = parsed.username if parsed.password: result["password"] = parsed.password return result class ProxyManager: """Static proxy list + optional Lambda-backed rotation. Usage: manager = ProxyManager() session = manager.acquire(country="US") proxy_dict = session.to_playwright_dict() # ... use proxy_dict in browser ... manager.release(session) Or on block_signal: new_session = manager.rotate_on_signal(session, "challenge") """ def __init__(self, proxies: list[str] | None = None, provider: str | None = None): self.provider = provider or settings.proxy_provider raw: list[str] = proxies if proxies is not None else list(settings.proxy_list) self._proxies: list[str] = raw self._in_use: set[str] = set() self._lambda_client = self._build_lambda_client() def _build_lambda_client(self): if not settings.proxy_lambda_name: return None try: import boto3 return boto3.client("lambda", region_name=settings.aws_region) except ImportError: return None def _invoke_rotator(self, action: str = "health_check") -> dict[str, Any]: if not self._lambda_client: return {"status": "no_lambda"} try: payload = json.dumps({"action": action}).encode() resp = self._lambda_client.invoke( FunctionName=settings.proxy_lambda_name, InvocationType="RequestResponse", Payload=payload, ) body = json.loads(resp["Payload"].read()) return body if isinstance(body, dict) else {"raw": body} except Exception as e: return {"status": "error", "error": str(e)} def acquire( self, *, country: str | None = None, sticky: bool = True, sticky_minutes: int = 10, ) -> ProxySession: """Return a proxy session — Lambda-backed if configured, else static list.""" if self._lambda_client: result = self._invoke_rotator("get_proxy") if result.get("status") == "healthy" and result.get("proxy_host"): host = result["proxy_host"] port = result.get("port", 22225) proxy_url = f"http://PROXY_FROM_SECRET@{host}:{port}" return ProxySession( proxy_url=proxy_url, provider=self.provider, country=country, sticky_id=result.get("sticky_id"), meta={"sticky_minutes": sticky_minutes, "source": "lambda"}, ) if not self._proxies: raise RuntimeError( "No proxies configured. Set PROXY_LIST or PROXY_LAMBDA_NAME." ) candidates = [ p for p in self._proxies if p not in self._in_use ] or self._proxies if country: cc = country.lower() filtered = [p for p in candidates if self._country_of(p) == cc] if filtered: candidates = filtered proxy_url = random.choice(candidates) self._in_use.add(proxy_url) return ProxySession( proxy_url=proxy_url, provider=self.provider, country=country, sticky_id=f"sticky-{random.randint(100000, 999999)}" if sticky else None, meta={"sticky_minutes": sticky_minutes, "source": "static"}, ) @staticmethod def _country_of(proxy_url: str) -> str | None: """Extract 2-letter country code from Decodo-style hostnames like gb.decodo.com.""" try: host = urlparse(proxy_url).hostname or "" part = host.split(".")[0] return part if len(part) == 2 else None except Exception: return None def available_countries(self) -> list[str]: """Return sorted list of country codes present in the proxy list.""" codes = {self._country_of(p) for p in self._proxies} return sorted(c for c in codes if c) def release(self, session: ProxySession) -> None: self._in_use.discard(session.proxy_url) def rotate_on_signal( self, session: ProxySession, signal: Literal["challenge", "rate_limit", "success"], ) -> ProxySession | None: """Rotate proxy on challenge/rate_limit signals; no-op on success.""" if signal in ("challenge", "rate_limit"): self.release(session) if self._lambda_client: self._invoke_rotator("rotate") return self.acquire(country=session.country) return None def get_status(self) -> dict[str, Any]: return self._invoke_rotator("health_check")