o2o-negosium-original/lps/services/search/esm/adapter.py
민헌 e365e5c6e0 feat(lps): G마켓·옥션·11번가 크롤 어댑터 + 브라우저 어댑터 베이스
네이버 폴백용 오픈마켓 크롤러 3종. 4중 복제를 피해 BrowserSearchAdapter 베이스로
브라우저 수명·프록시 회전·차단감지+IP회전 재시도·감지기록을 공유하고, 사이트 차이는
훅(_search_url/_parse/ready_selector/block_markers)으로 분리.

- browser_base: patchright 공통 machinery(쿠팡 machinery 일반화)
- card_parser: 오픈마켓 공용 카드 파서(CardConfig 주입) + util(extract_price/clean_name/shipping)
- esm: G마켓·옥션(eBay 백엔드 공유, site 파라미터 1개로 커버, '잠시만' 챌린지 대기)
- st11: 11번가 PC(공식API 판매자계정 필요라 불가). 지연로딩 → scroll_steps, '.c-card-item' 토큰
- 학습 반영: 오픈마켓은 리소스차단 OFF(렌더/챌린지 깨짐), 스턱 챌린지='잠시만' 잔존 시 차단 간주→IP회전
- 파서 결정론 테스트 7종(실렌더 카드 3개씩 경량 fixture). 라이브 검증: 신선 IP로 3사 모두 파싱 성공

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:41:05 +09:00

39 lines
2.0 KiB
Python

"""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 = 22000 # '잠시만' 챌린지 통과(~9초) 여유
block_resources_default = False # 리소스 차단은 ESM 챌린지/렌더를 깨뜨림
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)