diff --git a/lps/.env.example b/lps/.env.example index 150d9a2..f7b28bc 100644 --- a/lps/.env.example +++ b/lps/.env.example @@ -14,11 +14,12 @@ NAVER_CLIENT_SECRET="" # ── AI (상품 유사도 판정 / 검색어 생성) — OpenAI ────────────────────── OPENAI_API_KEY="" -# ── DECODO residential 프록시 (쿠팡 전용, sticky 세션+주기적 회전) ───── -# DECODO 대시보드의 residential 게이트웨이/자격증명. 4개 다 채워야 활성화(비면 프록시 미사용). -# username 은 base 값만(코드가 -session--sessionduration-<분> 을 자동으로 붙임). +# ── DECODO residential 프록시 (쿠팡 전용, 포트 기반 sticky+주기 회전) ── +# DECODO 대시보드 Residential > Proxy setup 값. HOST/USER/PASS + 포트범위 다 채우면 활성. +# IP 회전 = 포트 순환(각 포트가 sticky 세션). SESSION_MINUTES 는 대시보드 Sticky 지속시간과 맞춘다. DECODO_HOST="" # 예: gate.decodo.com -DECODO_PORT="" # 예: 7000 -DECODO_USERNAME="" -DECODO_PASSWORD="" -DECODO_SESSION_MINUTES="10" # 같은 IP 유지 시간(분) → 이 주기로 회전 +DECODO_USERNAME="" # 대시보드 USERNAME (예: sppd6a3ze3) +DECODO_PASSWORD="" # 대시보드 PASSWORD +DECODO_PORT_START="10001" # 엔드포인트 포트 시작 +DECODO_PORT_END="10010" # 엔드포인트 포트 끝 +DECODO_SESSION_MINUTES="10" # 대시보드 Sticky 지속시간(분)과 일치 → 이 주기로 포트/ IP 회전 diff --git a/lps/services/search/coupang/adapter.py b/lps/services/search/coupang/adapter.py index e3138b7..bc8eb36 100644 --- a/lps/services/search/coupang/adapter.py +++ b/lps/services/search/coupang/adapter.py @@ -22,16 +22,19 @@ from services.search.coupang.selectors import SELECTORS _SEARCH_URL = "https://www.coupang.com/np/search?q={q}&channel=user&listSize={n}" _BLOCK_MARKERS = ("sec-if-cpt-container", "Powered and protected", "/akam/") +# 대역폭 절감: 이미지/미디어/폰트/CSS 는 상품 데이터·Akamai(JS) 에 불필요 → 차단(프록시 per-GB 비용↓). +_BLOCKED_RESOURCES = {"image", "media", "font", "stylesheet"} class CoupangAdapter(SearchAdapter): source = "coupang" - def __init__(self, headless: bool = False, user_data_dir: str = "/tmp/lps_coupang_profile", rate_limiter: RateLimiter | None = None, proxy=None): + def __init__(self, headless: bool = False, user_data_dir: str = "/tmp/lps_coupang_profile", rate_limiter: RateLimiter | None = None, proxy=None, block_resources: bool = True): self._headless = headless self._user_data_dir = user_data_dir self._rl = rate_limiter or RateLimiter() self._proxy = proxy # DecodoProxy 등 (없으면 직접 연결) + self._block_resources = block_resources self._pw = None self._ctx = None self._launched_at = 0.0 @@ -39,6 +42,12 @@ class CoupangAdapter(SearchAdapter): self._ok = 0 self._blocked = 0 + async def _route(self, route): + if route.request.resource_type in _BLOCKED_RESOURCES: + await route.abort() + else: + await route.continue_() + def _recycle_due(self) -> bool: """프록시 sticky 세션창이 지났으면 브라우저를 재기동해 새 IP 를 받는다.""" if not (self._proxy and self._proxy.enabled): @@ -58,6 +67,8 @@ class CoupangAdapter(SearchAdapter): if self._proxy and self._proxy.enabled: kwargs["proxy"] = self._proxy.playwright_proxy() self._ctx = await self._pw.chromium.launch_persistent_context(**kwargs) + if self._block_resources: + await self._ctx.route("**/*", self._route) # 이미지/미디어/폰트/CSS 차단 self._launched_at = time.monotonic() async def _close_ctx(self): diff --git a/lps/services/search/proxy.py b/lps/services/search/proxy.py index 671642a..ff80678 100644 --- a/lps/services/search/proxy.py +++ b/lps/services/search/proxy.py @@ -1,47 +1,53 @@ -"""DECODO(구 Smartproxy) residential 프록시 제공자. +"""DECODO(구 Smartproxy) residential 프록시 제공자 — 쿠팡(Akamai) 전용. -쿠팡(Akamai) 전용. 핵심: **sticky 세션 + 주기적 회전**. -매 요청 IP 변경은 Akamai 가 '쿠키-IP 불일치'로 재챌린지/차단하므로 금물. -→ 일정 시간(session_minutes) 같은 IP 유지 후 회전한다. +Decodo residential 은 **포트 기반 sticky** 모델이다: + gate.decodo.com : 10001..N (각 포트 = 별도 sticky 세션, 대시보드에서 지속시간 지정: 예 10분) + username/password 는 고정. → **IP 회전 = 포트를 바꾸는 것.** -DECODO 는 username 에 세션 파라미터를 붙여 sticky 를 구현한다: - -session--sessionduration- -세션 id 를 '시간창(now // window)'으로 만들면 창 안에선 같은 IP, 창이 바뀌면 새 IP 가 된다. -자격증명은 .env(DECODO_*) 에서 로드(시크릿). +매 요청 IP 변경은 Akamai 가 '쿠키-IP 불일치'로 재챌린지하므로 금물. +→ 시간창(now // window)으로 포트를 고른다: 창 안에선 같은 포트=같은 IP, 창이 지나면 다음 포트=새 IP. +자격증명/엔드포인트는 .env(DECODO_*) 에서 로드(시크릿). """ import os import time +_UNSET = object() # 미지정(→env 사용) vs 명시적 빈값("" 등, 그대로 사용) 구분 + + +def _int(v, default=None): + try: + return int(v) + except (TypeError, ValueError): + return default + class DecodoProxy: - def __init__(self, host=None, port=None, username=None, password=None, session_minutes=None, sticky=True): - self.host = host or os.environ.get("DECODO_HOST") - self.port = port or os.environ.get("DECODO_PORT") - self.username = username or os.environ.get("DECODO_USERNAME") - self.password = password or os.environ.get("DECODO_PASSWORD") - self.session_minutes = int(session_minutes or os.environ.get("DECODO_SESSION_MINUTES") or 10) - self.sticky = sticky + def __init__(self, host=_UNSET, username=_UNSET, password=_UNSET, port_start=_UNSET, port_end=_UNSET, session_minutes=_UNSET): + env = os.environ.get + self.host = env("DECODO_HOST") if host is _UNSET else host + self.username = env("DECODO_USERNAME") if username is _UNSET else username + self.password = env("DECODO_PASSWORD") if password is _UNSET else password + self.port_start = _int(env("DECODO_PORT_START") if port_start is _UNSET else port_start) + self.port_end = _int(env("DECODO_PORT_END") if port_end is _UNSET else port_end) + self.session_minutes = _int(env("DECODO_SESSION_MINUTES") if session_minutes is _UNSET else session_minutes, 10) @property def enabled(self) -> bool: - return all([self.host, self.port, self.username, self.password]) + return all([self.host, self.username, self.password, self.port_start, self.port_end]) - def _session_id(self) -> int: - """시간창 기반 세션 id — 같은 창에선 동일, 창이 지나면 증가(→새 IP).""" - return int(time.time() // (self.session_minutes * 60)) - - def _session_username(self) -> str: - if not self.sticky: - return self.username - return f"{self.username}-session-{self._session_id()}-sessionduration-{self.session_minutes}" + def _port(self) -> int: + """시간창 기반 포트 선택 — 창 안에선 동일 포트(동일 sticky IP), 창이 지나면 다음 포트.""" + n = self.port_end - self.port_start + 1 + bucket = int(time.time() // (self.session_minutes * 60)) + return self.port_start + (bucket % n) 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._session_username(), + "server": f"http://{self.host}:{self._port()}", + "username": self.username, "password": self.password, } diff --git a/lps/tests/test_proxy.py b/lps/tests/test_proxy.py index 973d6cb..7295edc 100644 --- a/lps/tests/test_proxy.py +++ b/lps/tests/test_proxy.py @@ -1,40 +1,40 @@ -"""DECODO 프록시 제공자 테스트 (순수, 네트워크 불필요).""" +"""DECODO 프록시 제공자 테스트 (포트 기반 sticky, 순수·네트워크 불필요).""" from services.search.proxy import DecodoProxy def _p(**kw): - base = dict(host="gate.decodo.com", port="7000", username="user1", password="pw", session_minutes=10) + base = dict(host="gate.decodo.com", username="user1", password="pw", port_start=10001, port_end=10010, session_minutes=10) base.update(kw) return DecodoProxy(**base) def test_disabled_when_credentials_missing(): - assert DecodoProxy(host="", port="", username="", password="").enabled is False - assert DecodoProxy(host="h", port="7000", username="", password="pw").enabled is False - assert _p().playwright_proxy() is not None # 다 채우면 활성 - assert DecodoProxy(host="", port="", username="", password="").playwright_proxy() is None + assert DecodoProxy(host="", username="", password="", port_start=None, port_end=None).enabled is False + assert _p(password="").enabled is False + assert _p(port_end=None).enabled is False + assert _p().enabled is True -def test_playwright_proxy_shape_and_sticky_username(): +def test_playwright_proxy_shape(): cfg = _p().playwright_proxy() - assert cfg["server"] == "http://gate.decodo.com:7000" - assert cfg["password"] == "pw" - # sticky: base username + 세션/유지시간 파라미터 - assert cfg["username"].startswith("user1-session-") - assert cfg["username"].endswith("-sessionduration-10") + assert cfg["username"] == "user1" and cfg["password"] == "pw" # 고정 자격증명 + assert cfg["server"].startswith("http://gate.decodo.com:") + port = int(cfg["server"].rsplit(":", 1)[1]) + assert 10001 <= port <= 10010 # 포트 범위 안 -def test_non_sticky_uses_plain_username(): - cfg = _p(sticky=False).playwright_proxy() - assert cfg["username"] == "user1" +def test_disabled_returns_none(): + assert DecodoProxy(host="", username="", password="", port_start=None, port_end=None).playwright_proxy() is None -def test_session_id_rotates_by_time_window(): - p = _p(session_minutes=10) - sid = p._session_id() - assert isinstance(sid, int) - # 같은 창 안에서는 동일해야 함(연속 호출) - assert p._session_id() == sid - # 창 크기가 다르면(1분) 버킷 값도 달라짐(더 큰 수) — 시간창 분해 검증 - assert _p(session_minutes=1)._session_id() > sid +def test_port_selected_within_range_and_stable_in_window(): + p = _p() + port = p._port() + assert 10001 <= port <= 10010 + assert p._port() == port # 같은 시간창에서는 동일 포트(동일 sticky IP) + + +def test_single_port_range(): + p = _p(port_start=10001, port_end=10001) + assert p._port() == 10001 # 포트 1개면 항상 그 포트