o2o-negosium-original/lps/tests/test_coupang_parser.py
민헌 8460a5d833 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>
2026-07-09 13:59:43 +09:00

95 lines
4.5 KiB
Python

# 쿠팡 파서 결정론적 단위 테스트(네트워크/브라우저 불필요).
# fixture 는 실제 렌더된 검색결과에서 카드 4개를 추출한 것(단위가격 '1개당' 케이스 포함).
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"
def test_parse_extracts_valid_products():
items = parse_search_html(FIXTURE.read_text())
assert len(items) >= 3
for p in items:
assert p.source == "coupang"
assert p.name and len(p.name) > 2
# 단위가격('1개당 44,400원')의 '1' 을 가격으로 오인하던 회귀 방지
assert p.price >= 100, f"이상 저가: {p.price} ({p.name})"
assert p.detail_url and p.detail_url.startswith("https://www.coupang.com/vp/products/")
assert p.external_id # vendorItemId(data-id)
def test_parse_price_is_sale_not_unit_price():
# fixture 첫 카드의 판매가는 44,400원(단위가격도 '1개당 44,400원'이라 값은 같지만
# 파서가 정가(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