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"""
  • +
    {name}
    +
    {name}
    +
    {price}
    {extra}
  • """ + + +def test_parse_shipping_paid_fee(): + # 명시 배송비('배송비 3,000원') → paid + 금액 + html = f"" + p = parse_search_html(html)[0] + assert (p.shipping_fee, p.shipping_type) == (3000, "paid") + + +def test_parse_shipping_name_free_text_no_false_positive(): + # 상품명에 '무료배송' 이 들어가도 배송 무료로 오인하지 않는다 + html = f"" + p = parse_search_html(html)[0] + assert (p.shipping_fee, p.shipping_type) == (None, None) + + +# --- 차단 감지(detect_block): 여러 flavor 를 모두 blocked 로 잡아야 IP 회전이 걸린다 --- + +def test_detect_block_none_when_products_exist(): + # 상품이 파싱됐으면 HTML 내용과 무관하게 차단 아님 + assert detect_block("Access Denied", product_count=3) is None + + +def test_detect_block_akamai_challenge(): + assert detect_block("
    " + "x" * 20000, 0) == "sec-if-cpt-container" + + +def test_detect_block_edge_access_denied(): + # 실제로 잡힌 303B edge deny + html = ('Access Denied

    Access Denied

    ' + "You don't have permission to access ... errors.edgesuite.net") + assert detect_block(html, 0) in ("errors.edgesuite.net", "You don't have permission to access") + + +def test_detect_block_permission_restricted_page(): + html = "쿠팡을 찾아주신 고객님, 입력하신 페이지주소는 사용권한이 제한된 페이지입니다." + assert detect_block(html, 0) in ("사용권한이 제한된", "쿠팡을 찾아주신 고객님") + + +def test_detect_block_short_html_fallback(): + # 마커 없어도 비정상적으로 짧은 0건 응답은 미지의 차단으로 폴백 + marker = detect_block("oops", 0) + assert marker is not None and marker.startswith("short_html(") + + +def test_detect_block_genuine_empty_large_page_not_blocked(): + # 정상 '검색결과 없음'(전체 chrome 포함, 큰 HTML)은 차단 아님 → not_found 로 흘러야 함 + big = "검색결과가 없습니다" + "x" * 20000 + "" + assert detect_block(big, 0) is None