o2o-negosium-original/lps/tests/test_coupang_parser.py
민헌 8574df4c16 feat(lps): 배송 표기 원문 기록 — 순위는 상품가 유지, 배송은 근거로 남긴다
배송 주체가 다르면(쿠팡 로켓 / 판매자로켓 / 네이버 판매자) 배송비 숫자만으로는 비교가
무의미하다. 그래서 **순위는 지금처럼 상품가**로 두되, 배송 정보는 사후 판단이 가능하도록 남긴다.

실측 조사(섬유유연제·생수 2L, 두 소스):
  네이버  배송비무료 / 배송비3,000·3,900·4,500·5,000·8,800·9,000·10,000원 /
          내일배송 8.6.(목) 도착 · 오늘출발 · 빠른배송 / **배송비포함 혜택가 N원**(가격비교 카드)
  쿠팡    내일(목) 도착 보장 · 와우는 무료배송 ∙ 무료반품 ∙ 새벽도착 / 무료배송 ∙ 오늘출발 /
          모레(금) 도착 예정. 뱃지=logo_rocket_filter(로켓)·logo_rocket_merchant(판매자로켓)

- NormalizedProduct.shipping_label: 화면 문구 원문. 같은 '무료배송'이어도 주체·조건
  (와우회원·최소금액·새벽도착)이 다른데 숫자·분류로는 그게 사라진다
- 네이버: 배송비 문구 + 도착 정보를 잇는다. **_prices() 안에서 뽑는다** — 가격 노드에서
  배송비를 decompose 하기 전에 읽어야 해서(나중에 추가했다가 가격비교 카드에서 라벨이 통째로 빔)
- 쿠팡: 배송 문구가 유틸리티 클래스(fw-text-[14px])에 담겨 셀렉터로 못 집는다 → 텍스트 패턴으로
  조각을 모으고, 구분자 없이 붙은 상위 컨테이너("내일(목) 도착무료배송")는 조각 2개 이상을
  품은 것으로 판별해 버린다. selectolax 의 node.css("*") 가 자기 자신을 포함해
  '자손 매칭' 방식은 못 쓴다(실측)
- price_history.final_shipping_fee/type/label 추가(+마이그레이션). fee 는 0=무료,
  NULL=미확인(로켓 조건부) — 둘은 다른 뜻이라 기본값을 두지 않았다

교차 검증(파싱값 vs 카드 원문, 6개 규칙): 네이버 40건·쿠팡 40건 **불일치 0**.
테스트 3건 추가, 전체 231 passed.
2026-08-05 14:40:00 +09:00

116 lines
5.7 KiB
Python

# 쿠팡 파서 결정론적 단위 테스트(네트워크/브라우저 불필요).
# fixture 는 실제 렌더된 검색결과에서 카드 4개를 추출한 것(단위가격 '1개당' 케이스 포함).
import re
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
def test_trust_signals_are_captured():
"""쿠팡은 별점이 채워진 별 개수가 아니라 컨테이너 aria-label 에, 리뷰 수는 괄호 텍스트에 있다."""
ps = parse_search_html(FIXTURE.read_text())
rated = [p for p in ps if p.review_count]
assert rated
assert all(0 < p.rating <= 5 for p in rated)
assert max(p.review_count for p in rated) > 1000 # '41,448' 처럼 콤마가 섞여도 파싱된다
def test_shipping_label_joins_smallest_fragments():
"""쿠팡 배송 문구는 유틸리티 클래스에 담겨 셀렉터로 못 집는다 — 텍스트 조각을 합쳐 만든다.
부모는 구분자 없이 붙어 나오므로("내일(목) 도착무료배송") 버리고, 조각만 이어야 한다."""
ps = parse_search_html(FIXTURE.read_text())
labels = [p.shipping_label for p in ps if p.shipping_label]
assert labels, "배송 표기가 하나도 안 잡히면 문구 패턴이 드리프트한 것"
assert any("도착" in x and "무료배송" in x for x in labels)
# 조각이 뭉개지지 않았는지 — '도착'만 남으면 언제 오는지가 사라진다
assert any(re.search(r"(내일|모레|오늘)\(?\w*\)? 도착", x) for x in labels)