"""DECODO(구 Smartproxy) residential 프록시 제공자 — 쿠팡(Akamai) 전용. Decodo residential 은 **포트 기반 sticky** 모델이다: gate.decodo.com : 10001..N (각 포트 = 별도 sticky 세션, 대시보드에서 지속시간 지정: 예 10분) username/password 는 고정. → **IP 회전 = 포트를 바꾸는 것.** 매 요청 IP 변경은 Akamai 가 '쿠키-IP 불일치'로 재챌린지하므로 금물. → 시간창(now // window)으로 포트를 고른다: 창 안에선 같은 포트=같은 IP, 창이 지나면 다음 포트=새 IP. 자격증명/엔드포인트는 config.local.toml [DecodoConfig] 에서 로드(시크릿). """ import time from urllib.parse import quote import httpx from common.logger import LOG from config.server_configs import decodo_config class DecodoProxy: def __init__(self, cfg=None): cfg = cfg if cfg is not None else decodo_config self.host = cfg.host self.username = cfg.username self.password = cfg.password self.port_start = cfg.port_start self.port_end = cfg.port_end self.session_minutes = cfg.session_minutes or 10 self._rotate_offset = 0 # 봇 감지 등으로 '즉시 회전'이 필요할 때 증가 @property def enabled(self) -> bool: return all([self.host, self.username, self.password, self.port_start, self.port_end]) def rotate(self): """시간창과 무관하게 즉시 다음 포트(=새 IP)로 회전. 봇 감지 시 호출.""" self._rotate_offset += 1 def _port(self) -> int: """시간창 + 수동 오프셋 기반 포트 선택. 창 안에선 동일 IP, rotate()나 창 변화 시 다음 IP.""" n = self.port_end - self.port_start + 1 bucket = int(time.time() // (self.session_minutes * 60)) return self.port_start + ((bucket + self._rotate_offset) % n) @property def current_port(self): return self._port() if self.enabled else None def playwright_proxy(self) -> dict | None: """Playwright launch(proxy=...) 용 설정. 비활성 시 None(프록시 미사용).""" if not self.enabled: return None return { "server": f"http://{self.host}:{self._port()}", "username": self.username, "password": self.password, } def _proxy_url(self, port: int) -> str: return f"http://{quote(self.username)}:{quote(self.password)}@{self.host}:{port}" async def healthcheck(self, timeout: float = 6.0) -> tuple[str | None, int | None]: """시작 프리플라이트: 현재 포트로 egress IP 확인, 실패하면 회전하며 살아있는 포트를 찾는다. 반환: (egress_ip, port) 성공 / (None, None) 전 포트 실패. residential IP 는 실행 중에도 죽으므로 이건 '빠른 실패+가시성'용이고, 실제 회복은 런타임 IP 회전이 담당한다.""" if not self.enabled: return None, None n = self.port_end - self.port_start + 1 for _ in range(n): port = self._port() try: async with httpx.AsyncClient(proxy=self._proxy_url(port), timeout=timeout) as c: r = await c.get("https://ip.decodo.com/ip") if r.status_code == 200: return r.text.strip(), port except Exception as ex: LOG.d(f"[proxy] 포트 {port} 헬스체크 실패: {type(ex).__name__} → 회전") self.rotate() return None, None