"""ESM(G마켓·옥션) 검색 어댑터 — 네이버 폴백용(네이버에 해당 몰이 없을 때만 크롤). 두 사이트는 eBay Korea 백엔드 공유 + '잠시만 기다리십시오…' JS 챌린지(수 초 후 자동 통과)가 동일해, BrowserSearchAdapter 위에 site 파라미터 하나로 커버한다(카드/셀렉터만 분기). 챌린지는 ready_selector(카드) 가 렌더될 때까지 대기하면 자연히 통과한다. """ from urllib.parse import quote from services.search.browser_base import BrowserSearchAdapter from services.search.contract import NormalizedProduct from services.search.card_parser import parse_cards from services.search.esm.selectors import SITES # ESM 차단 마커. '잠시만 기다리십시오' 챌린지는 렌더 완료 후에도(parse=0) 남아 있으면 = 이 IP 가 # 챌린지를 못 푼 것 → 차단으로 간주해 IP 회전 재시도를 유도한다(정상 시엔 카드가 떠 parse>0 이라 여기 안 옴). _BLOCK_MARKERS = ("errors.edgesuite.net", "You don't have permission to access", "비정상적인 접근", "잠시만 기다리") class EsmAdapter(BrowserSearchAdapter): block_markers = _BLOCK_MARKERS min_result_html = 8000 # 챌린지 페이지(~21KB)는 크므로 short 폴백은 명백한 에러만 ready_timeout_ms = 12000 # 챌린지 통과(정상 ~9초). 폴백은 데드라인이 상한이라 과도한 대기 회피 block_resources_default = True # 라우팅 on — 단, 아래 집합만(CSS/JS 유지) blocked_resource_types = {"image", "media", "font"} # 대역폭 대부분(이미지)만 차단, 챌린지/렌더용 CSS/JS는 유지 def __init__(self, site: str, **kwargs): if site not in SITES: raise ValueError(f"unknown ESM site: {site}") self._site = SITES[site] self.source = site # base __init__ 의 user_data_dir 기본값에 쓰임 self.ready_selector = self._site.ready_selector super().__init__(**kwargs) def _search_url(self, query: str, limit: int) -> str: return self._site.search_url.format(q=quote(query)) def _parse(self, html: str) -> list[NormalizedProduct]: return parse_cards(html, self._site.cards)