o2o-negosium-original/lps/services/search/coupang/adapter.py
민헌 c955a8f367 feat(lps): 프록시 전송오류 IP회전 + 시작 프리플라이트 + DECODO/컴포넌트별 비용
프록시(DECODO) 포트/IP 사망(407/ERR_TUNNEL/ERR_HTTP_RESPONSE_CODE_FAILURE)이
봇차단과 구분 없이 예외로 튕겨 같은 죽은 포트로 재시도만 하다 DEAD 되던 문제를 고친다.

- browser_base: is_proxy_error(순수함수) + search 루프에서 프록시 전송오류 시 IP 회전 재시도
  (max_proxy_retries=2). 봇감지 회전과 통합. uses_proxy 프로퍼티.
- 쿠팡 어댑터를 BrowserSearchAdapter 로 통합 — 중복 machinery 제거, 회전 로직 한 곳에서 공유
  (detect_block 순수함수는 유지, 테스트 호환).
- proxy.healthcheck(): 시작 프리플라이트 — 살아있는 포트 선점 + egress IP 로그(빠른 실패·가시성).
  worker_main 기동 시 호출.
- 비용: DecodoConfig.cost_per_gb 추가. metrics 에 proxy_bytes(네이버 직접 제외) + 컴포넌트별
  cost{ai_usd, proxy_usd, total_usd}. FE 원가 타일에 AI/DECODO 분해·프록시 바이트.
- 테스트: is_proxy_error 8종 + 비용 분해 1종.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 15:33:37 +09:00

47 lines
2.3 KiB
Python

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