"""쿠팡 검색 어댑터. 쿠팡은 Akamai Bot Manager 의 JS 행동 챌린지를 걸어 curl_cffi 단독으로는 통과 못 한다. → Patchright(스텔스 Playwright) + 실제 Chrome 으로 챌린지를 통과한다. persistent context 로 브라우저를 재사용하므로 챌린지는 (쿠키 만료 전까지) 1회만 풀린다. 향후 최적화(하이브리드): 검증된 Akamai 쿠키를 curl_cffi 로 넘겨 대량 후속 요청을 브라우저 없이 처리 가능. 현재는 브라우저 재사용만으로도 후속 검색이 충분히 빠르다. """ import asyncio 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/") class CoupangAdapter(SearchAdapter): source = "coupang" def __init__(self, headless: bool = False, user_data_dir: str = "/tmp/lps_coupang_profile", rate_limiter: RateLimiter | None = None): self._headless = headless self._user_data_dir = user_data_dir self._rl = rate_limiter or RateLimiter() self._pw = None self._ctx = None self._lock = asyncio.Lock() self._ok = 0 self._blocked = 0 async def _ensure_browser(self): if self._ctx is not None: return self._pw = await async_playwright().start() self._ctx = await self._pw.chromium.launch_persistent_context( user_data_dir=self._user_data_dir, channel="chrome", headless=self._headless, no_viewport=True, ) 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