o2o-negosium-original/lps/services/search/coupang/adapter.py
민헌 e27b73c018 feat(lps): 쿠팡 검색 어댑터 — Akamai 우회(Patchright) + 정규화 파서
소스 어댑터 패턴으로 크롤링을 시작한다. 쿠팡은 Akamai Bot Manager 의
JS 행동 챌린지라 curl_cffi 단독 불가 → Patchright(스텔스 Playwright) +
실제 Chrome(channel=chrome)으로 챌린지를 통과하고 selectolax 로 파싱한다.

- contract: SearchAdapter(ABC)·NormalizedProduct·AdapterHealth·AdapterError
- coupang: adapter(브라우저 재사용, 챌린지 1회)·parser(순수)·selectors(외부화, webpack 해시 prefix 매칭)
- 공용: rate_limiter(2~8s 랜덤)·proxy(무프록시 off 인터페이스)·util.parse_price
- 가격 파싱: 단위가격 '(1개당 44,400원)'의 앞 '1' 오인 방지 — '원' 앞 숫자 앵커링
- deps: curl_cffi·selectolax·patchright(nodriver 는 py3.14 버그로 대체)
- tests: 파서 회귀(소형 fixture) — 라이브 2회 검색 + 파서 3/3 통과

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:59:48 +09:00

95 lines
3.9 KiB
Python

"""쿠팡 검색 어댑터.
쿠팡은 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