diff --git a/lps/services/search/coupang/adapter.py b/lps/services/search/coupang/adapter.py index 030fba4..628d89b 100644 --- a/lps/services/search/coupang/adapter.py +++ b/lps/services/search/coupang/adapter.py @@ -21,11 +21,34 @@ 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/") +# 쿠팡 차단은 여러 flavor 다 — 하나만 잡으면 나머지는 blocked=False 로 오판(→회전·기록 누락). +# 1) Akamai JS 챌린지: sec-if-cpt-container / /akam/ / Powered and protected +# 2) Edge Access Denied(수백 B): errors.edgesuite.net / You don't have permission to access +# 3) 권한 제한 페이지: '사용권한이 제한된' / '쿠팡을 찾아주신 고객님' +_BLOCK_MARKERS = ( + "sec-if-cpt-container", "Powered and protected", "/akam/", + "errors.edgesuite.net", "You don't have permission to access", + "사용권한이 제한된", "쿠팡을 찾아주신 고객님", +) +# 정상 검색결과·'검색결과 없음' 페이지는 전체 chrome 포함이라 수십 KB+ 다. +# 이보다 짧은데 0건이면 미지의 차단/에러 페이지로 간주(마커 없어도 회전 트리거). +_MIN_RESULT_HTML = 10000 # 대역폭 절감: 이미지/미디어/폰트/CSS 는 상품 데이터·Akamai(JS) 에 불필요 → 차단(프록시 per-GB 비용↓). _BLOCKED_RESOURCES = {"image", "media", "font", "stylesheet"} +def detect_block(html: str, product_count: int) -> str | None: + """0건 응답의 차단 여부 판정(순수 함수 — 브라우저 무관, 단위 테스트 가능). + 반환: 차단 마커 문자열(차단) 또는 None(정상 빈결과). 상품이 있으면 항상 None. + 알려진 마커 우선, 없으면 비정상적으로 짧은 HTML 을 미지의 차단으로 폴백 처리.""" + if product_count > 0: + return None + marker = next((m for m in _BLOCK_MARKERS if m in html), None) + if marker is None and len(html) < _MIN_RESULT_HTML: + marker = f"short_html({len(html)}B)" + return marker + + class CoupangAdapter(SearchAdapter): source = "coupang" @@ -113,8 +136,8 @@ class CoupangAdapter(SearchAdapter): LOG.d(f"[coupang] query={query!r} → {len(products)}건 (limit {limit}, ip_req#{self._ip_requests})") return products[:limit] - # 0건 — 봇 감지 여부 판정 - marker = next((m for m in _BLOCK_MARKERS if m in html), None) + # 0건 — 차단 여부 판정(알려진 마커 + 짧은 HTML 폴백) + marker = detect_block(html, len(products)) blocked = marker is not None self._blocked += 1 if blocked: diff --git a/lps/tests/test_coupang_parser.py b/lps/tests/test_coupang_parser.py index 89189a2..2806a3a 100644 --- a/lps/tests/test_coupang_parser.py +++ b/lps/tests/test_coupang_parser.py @@ -3,6 +3,7 @@ from pathlib import Path from services.search.coupang.parser import parse_search_html +from services.search.coupang.adapter import detect_block FIXTURE = Path(__file__).parent / "fixtures" / "coupang_search.html" @@ -25,3 +26,69 @@ def test_parse_price_is_sale_not_unit_price(): # 파서가 정가(del)나 '%'가 아닌 판매가를 정확히 집는지 확인) items = parse_search_html(FIXTURE.read_text()) assert items[0].price == 44400 + + +def test_parse_shipping_from_fixture(): + # fixture 4카드: [0,1]=로켓뱃지+무료배송, [2]=무료배송 텍스트만, [3]=판매자로켓 뱃지만 + items = parse_search_html(FIXTURE.read_text()) + assert (items[0].shipping_fee, items[0].shipping_type) == (0, "rocket") + assert (items[1].shipping_fee, items[1].shipping_type) == (0, "rocket") + assert (items[2].shipping_fee, items[2].shipping_type) == (0, "free") + # 판매자로켓은 조건부 무료 → 명시 텍스트 없으면 배송비 미확인(None) + assert (items[3].shipping_fee, items[3].shipping_type) == (None, "rocket_merchant") + + +def _card(name: str, price: str, extra: str = "") -> str: + return f"""
