대시보드 실측 반영 — Decodo residential 은 포트 기반 sticky(gate:10001..N, 고정 user/pass, 각 포트=sticky 세션). IP 회전 = 포트 순환. - proxy.DecodoProxy: 시간창 기반 포트 선택(창 안 동일 IP, 창 지나면 다음 포트=새 IP). _UNSET sentinel 로 '미지정(env)' vs '명시적 빈값' 구분(테스트 결정성) - coupang/adapter: 이미지/미디어/폰트/CSS 차단(block_resources) — per-GB 대역폭 대폭 절감, Akamai(JS)·상품파싱엔 무영향. 라이브 검증: 차단 ON 에도 Akamai 통과+파싱 정상 - .env.example: DECODO_HOST/USERNAME/PASSWORD/PORT_START/PORT_END/SESSION_MINUTES - tests: 포트범위/시간창 안정성/활성조건 5건 → 전체 44/44 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
126 lines
5.5 KiB
Python
126 lines
5.5 KiB
Python
"""쿠팡 검색 어댑터.
|
|
|
|
쿠팡은 Akamai Bot Manager 의 JS 행동 챌린지를 걸어 curl_cffi 단독으로는 통과 못 한다.
|
|
→ Patchright(스텔스 Playwright) + 실제 Chrome 으로 챌린지를 통과한다.
|
|
persistent context 로 브라우저를 재사용하므로 챌린지는 (쿠키 만료 전까지) 1회만 풀린다.
|
|
|
|
향후 최적화(하이브리드): 검증된 Akamai 쿠키를 curl_cffi 로 넘겨 대량 후속 요청을
|
|
브라우저 없이 처리 가능. 현재는 브라우저 재사용만으로도 후속 검색이 충분히 빠르다.
|
|
"""
|
|
|
|
import asyncio
|
|
import time
|
|
from urllib.parse import quote
|
|
|
|
from patchright.async_api import async_playwright
|
|
|
|
from common.logger import LOG
|
|
from services.search.contract import SearchAdapter, NormalizedProduct, AdapterError, AdapterHealth
|
|
from services.search.rate_limiter import RateLimiter
|
|
from services.search.coupang.parser import parse_search_html
|
|
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, 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
|
|
self._lock = asyncio.Lock()
|
|
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):
|
|
return False
|
|
return (time.monotonic() - self._launched_at) > self._proxy.session_minutes * 60
|
|
|
|
async def _ensure_browser(self):
|
|
if self._ctx is not None:
|
|
if self._recycle_due():
|
|
LOG.d("[coupang] 프록시 세션창 경과 → 브라우저 재기동(IP 회전)")
|
|
await self._close_ctx()
|
|
else:
|
|
return
|
|
if self._pw is None:
|
|
self._pw = await async_playwright().start()
|
|
kwargs = dict(user_data_dir=self._user_data_dir, channel="chrome", headless=self._headless, no_viewport=True)
|
|
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):
|
|
if self._ctx is not None:
|
|
try:
|
|
await self._ctx.close()
|
|
finally:
|
|
self._ctx = None
|
|
|
|
async def search(self, query: str, limit: int = 40) -> list[NormalizedProduct]:
|
|
async with self._lock: # 인스턴스 내 검색은 직렬화(브라우저 컨텍스트 공유)
|
|
await self._rl.wait()
|
|
await self._ensure_browser()
|
|
page = self._ctx.pages[0] if self._ctx.pages else await self._ctx.new_page()
|
|
url = _SEARCH_URL.format(q=quote(query), n=limit)
|
|
try:
|
|
await page.goto(url, wait_until="domcontentloaded", timeout=40000)
|
|
# Akamai 센서 실행 + 상품 렌더 대기
|
|
try:
|
|
await page.wait_for_selector(SELECTORS.card, timeout=20000)
|
|
except Exception:
|
|
pass
|
|
html = await page.content()
|
|
except Exception as ex:
|
|
raise AdapterError(f"쿠팡 검색 실패: {ex}", source=self.source) from ex
|
|
|
|
products = parse_search_html(html, source=self.source)
|
|
if not products:
|
|
blocked = any(m in html for m in _BLOCK_MARKERS)
|
|
self._blocked += 1
|
|
LOG.w(f"[coupang] 결과 0건 (blocked={blocked}) query={query!r} len={len(html)}")
|
|
raise AdapterError(f"쿠팡 결과 없음/차단 (query={query!r})", source=self.source, blocked=blocked)
|
|
|
|
self._ok += 1
|
|
LOG.d(f"[coupang] query={query!r} → {len(products)}건 (limit {limit})")
|
|
return products[:limit]
|
|
|
|
async def health(self) -> AdapterHealth:
|
|
total = self._ok + self._blocked
|
|
rate = (self._ok / total) if total else 0.0
|
|
return AdapterHealth(
|
|
source=self.source,
|
|
ok=self._blocked == 0 or rate > 0.5,
|
|
recent_success_rate=rate,
|
|
blocked_rate=(self._blocked / total) if total else 0.0,
|
|
)
|
|
|
|
async def close(self):
|
|
if self._ctx is not None:
|
|
await self._ctx.close()
|
|
self._ctx = None
|
|
if self._pw is not None:
|
|
await self._pw.stop()
|
|
self._pw = None
|