fix(lps): 쿠팡 차단 감지 다종화 — Edge Deny·권한제한·짧은HTML 폴백
기존엔 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 <noreply@anthropic.com>
This commit is contained in:
parent
61bc719e28
commit
8460a5d833
@ -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:
|
||||
|
||||
@ -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"""<li class="ProductUnit_productUnit__x1" data-id="1">
|
||||
<a href="/vp/products/1"><figure><img alt="{name}" src="i.jpg"/></figure>
|
||||
<div class="ProductUnit_productNameV2__x1">{name}</div>
|
||||
<div class="PriceArea_priceArea__x1"><strong>{price}</strong></div>{extra}</a></li>"""
|
||||
|
||||
|
||||
def test_parse_shipping_paid_fee():
|
||||
# 명시 배송비('배송비 3,000원') → paid + 금액
|
||||
html = f"<ul>{_card('AAA 건전지 20개입', '5,880원', '<span>배송비 3,000원</span>')}</ul>"
|
||||
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"<ul>{_card('무료배송 텀블러 굿즈', '12,000원')}</ul>"
|
||||
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("<div id='sec-if-cpt-container'>" + "x" * 20000, 0) == "sec-if-cpt-container"
|
||||
|
||||
|
||||
def test_detect_block_edge_access_denied():
|
||||
# 실제로 잡힌 303B edge deny
|
||||
html = ('<html><head><title>Access Denied</title></head><body><h1>Access Denied</h1>'
|
||||
"You don't have permission to access ... errors.edgesuite.net</body></html>")
|
||||
assert detect_block(html, 0) in ("errors.edgesuite.net", "You don't have permission to access")
|
||||
|
||||
|
||||
def test_detect_block_permission_restricted_page():
|
||||
html = "<html><body>쿠팡을 찾아주신 고객님, 입력하신 페이지주소는 사용권한이 제한된 페이지입니다.</body></html>"
|
||||
assert detect_block(html, 0) in ("사용권한이 제한된", "쿠팡을 찾아주신 고객님")
|
||||
|
||||
|
||||
def test_detect_block_short_html_fallback():
|
||||
# 마커 없어도 비정상적으로 짧은 0건 응답은 미지의 차단으로 폴백
|
||||
marker = detect_block("<html><body>oops</body></html>", 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 = "<html><body>검색결과가 없습니다" + "x" * 20000 + "</body></html>"
|
||||
assert detect_block(big, 0) is None
|
||||
|
||||
Loading…
Reference in New Issue
Block a user