o2o-negosium-original/lps/services/search/proxy.py
민헌 ad26e6ce6f feat(lps): 다중 상품 병렬 검색(워커별 브라우저 세트) + 부하테스트 스크립트
WORKER_CONCURRENCY 를 늘려도 공유 브라우저 lock 때문에 직렬화되던 문제를 고쳐 진짜 병렬 검색.

- worker_main: _build_worker(i) 로 워커마다 자립 세트(브라우저 어댑터·AI·핸들러) 생성.
  프로필 분리(user_data_dir_w{i}, ProcessSingleton 충돌 회피) + 워커별 다른 프록시 포트
  (proxy.seed_offset 로 100포트를 균등 분할=다른 IP). naver/judge/keyword 도 워커별(공유상태 경합 제거).
  프리플라이트는 대표 프록시로 게이트 1회 확인.
- proxy.seed_offset(k): 워커 시작 포트 분산.
- loadtest.py: N개 상품 제출→폴링→처리량·지연(p50/p95)·AI/DECODO/총비용 집계.
  실측(동시성2, 4상품): 순차합 323s→벽시계 181s(~1.8x), 상품당 $0.0071, 1000건 ~$7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 16:28:08 +09:00

85 lines
3.7 KiB
Python

"""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 seed_offset(self, k: int):
"""워커별 시작 포트 분산용 — 동시 워커가 같은 포트(=같은 IP)를 쓰지 않도록 시작점을 벌린다."""
self._rotate_offset = k
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