From 8460a5d833eb58a8c9574f310c2eca5aebae6c44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Thu, 9 Jul 2026 13:59:43 +0900 Subject: [PATCH] =?UTF-8?q?fix(lps):=20=EC=BF=A0=ED=8C=A1=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8=20=EA=B0=90=EC=A7=80=20=EB=8B=A4=EC=A2=85=ED=99=94=20?= =?UTF-8?q?=E2=80=94=20Edge=20Deny=C2=B7=EA=B6=8C=ED=95=9C=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=C2=B7=EC=A7=A7=EC=9D=80HTML=20=ED=8F=B4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존엔 Akamai JS 챌린지 마커만 감지해, Edge Access Denied(errors.edgesuite.net)와 '사용권한이 제한된' 권한제한 페이지가 blocked=False 로 오판됐다. 그 결과 IP 회전· bot_detection 기록이 누락되고 같은 IP 로 재시도만 반복하다 DEAD 로 빠졌다. - 마커 3계열로 확장(Akamai / Edge Deny / 권한제한) - 짧은 HTML 폴백(_MIN_RESULT_HTML): 0건인데 <10KB 면 미지의 차단으로 간주 (정상 '검색결과 없음'은 전체 chrome 포함이라 큼) - 감지를 순수 함수 detect_block(html, product_count) 로 분리 — 단위 테스트 6종 검증: 신라면 '사용권한이 제한된'(3383B) 감지 → IP 회전 → 새 IP 59건 복구. 파서 배송 분류 테스트도 함께 추가(shared fixture). Co-Authored-By: Claude Fable 5 --- lps/services/search/coupang/adapter.py | 29 +++++++++-- lps/tests/test_coupang_parser.py | 67 ++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) 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