"""쿠팡 검색 어댑터. 쿠팡은 Akamai Bot Manager 의 JS 행동 챌린지를 걸어 curl_cffi 단독으로는 통과 못 한다. → Patchright(스텔스 Playwright) + 실제 Chrome 으로 챌린지를 통과한다(BrowserSearchAdapter 공유). persistent context 로 브라우저를 재사용하므로 챌린지는 (쿠키 만료 전까지) 1회만 풀린다. 쿠팡 차단은 여러 flavor 다 — Akamai JS 챌린지 / Edge Access Denied / 권한제한 페이지. detect_block 은 순수 함수로 분리해 단위 테스트한다(browser_base.detect_block 에 쿠팡 마커 주입). """ from urllib.parse import quote from services.search.browser_base import BrowserSearchAdapter, detect_block as _detect_block from services.search.contract import NormalizedProduct 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}" # 차단 마커 3계열: Akamai JS 챌린지 / Edge Access Denied(수백 B) / 권한제한 페이지. _BLOCK_MARKERS = ( "sec-if-cpt-container", "Powered and protected", "/akam/", "errors.edgesuite.net", "You don't have permission to access", "사용권한이 제한된", "쿠팡을 찾아주신 고객님", ) # 정상 검색결과·'검색결과 없음'은 수십 KB+. 이보다 짧은데 0건이면 미지의 차단으로 간주(회전 트리거). _MIN_RESULT_HTML = 10000 def detect_block(html: str, product_count: int) -> str | None: """쿠팡 0건 응답의 차단 여부 판정(순수 함수 — 브라우저 무관, 단위 테스트 가능).""" return _detect_block(html, product_count, _BLOCK_MARKERS, _MIN_RESULT_HTML) class CoupangAdapter(BrowserSearchAdapter): source = "coupang" block_markers = _BLOCK_MARKERS min_result_html = _MIN_RESULT_HTML ready_selector = SELECTORS.card ready_timeout_ms = 20000 block_resources_default = True # 이미지/미디어/폰트/CSS 차단 → 대역폭↓(Akamai·상품데이터엔 불필요) def _search_url(self, query: str, limit: int) -> str: return _SEARCH_URL.format(q=quote(query), n=limit) def _parse(self, html: str) -> list[NormalizedProduct]: return parse_search_html(html, source=self.source)