프록시(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>
47 lines
2.3 KiB
Python
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)
|