o2o-negosium-original/lps/tests/test_naver_shop_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

201 lines
10 KiB
Python

"""네이버 모바일 쇼핑 파서 테스트 (저장 HTML — 네트워크 불필요).
fixtures/naver_msearch.html 은 2026-08-04 실측 응답에서 카드만 추린 축약본이다
(판매처 6 · 가격비교 6 · 광고 3 · 슈퍼적립 1 · 브랜드블록 2).
가격 함정 회귀 테스트를 합성 HTML 로 따로 둔다 — 픽스처는 네이버가 마크업을 바꾸면
같이 늙지만, 함정 자체(단위가격·배송비·정상가)는 계속 지켜야 하는 계약이라서다.
"""
from pathlib import Path
from services.search.naver_shop.adapter import detect_block
from services.search.naver_shop.parser import parse_search_html
_FIXTURE = Path(__file__).parent / "fixtures" / "naver_msearch.html"
def _fixture() -> str:
return _FIXTURE.read_text()
def _card(inner: str, cls: str = "product_item_inner__9nwv0") -> str:
return f'<html><body><div class="{cls}">{inner}</div></body></html>'
# ── 픽스처 기반 ──────────────────────────────────────────────────────────
def test_parses_organic_cards_and_drops_ads():
ps = parse_search_html(_fixture())
assert len(ps) == 12 # 광고 3 + 슈퍼적립 1 은 제외됨
assert all(p.source == "naver" for p in ps)
assert all(p.price > 0 and p.name for p in ps)
def test_seller_and_catalog_cards_are_both_kept():
ps = parse_search_html(_fixture())
catalog = [p for p in ps if p.mall_name == "네이버"] # "최저 N원" = 가격비교
seller = [p for p in ps if p.mall_name and p.mall_name != "네이버"]
assert len(catalog) == 6 and len(seller) == 6
# 판매처 카드는 실제 몰명이 들어온다(빈 문자열/None 아님)
assert all(len(p.mall_name) > 1 for p in seller)
def test_shipping_is_captured():
"""옛 오픈API 는 배송비 필드가 없어 전 소스 None 이었다 — 크롤의 이점이라 회귀로 막는다."""
ps = parse_search_html(_fixture())
assert all(p.shipping_fee is not None for p in ps)
assert {p.shipping_type for p in ps} <= {"free", "paid"}
assert any(p.shipping_type == "free" and p.shipping_fee == 0 for p in ps)
assert any(p.shipping_type == "paid" and p.shipping_fee > 0 for p in ps)
def test_price_is_never_the_shipping_fee():
"""실측에서 실제로 밟은 버그: 가격 노드가 배송비까지 감싸 3,900원이 상품가로 잡혔다."""
ps = parse_search_html(_fixture())
assert not [p for p in ps if p.shipping_fee and p.price == p.shipping_fee]
def test_detail_url_present():
ps = parse_search_html(_fixture())
assert all(p.detail_url and p.detail_url.startswith("http") for p in ps)
# ── 가격 함정 회귀 (합성) ────────────────────────────────────────────────
def test_unit_price_is_not_mistaken_for_price():
"""'21,900원(100ml당 548원)' → 21900. 단위가격을 먼저 떼지 않으면 548 을 집는다."""
html = _card("""
<div class="product_info_tit__UOCqq">테스트 섬유유연제 4L</div>
<span class="product_price__O3ZGH">
<span class="product_immediate_price__m3YQU">21,900<span class="product_unit__seYZk">원</span>
<span class="product_unit_price__uLsbL">(100ml당 548원)</span>
</span>
</span>
<span class="product_mall__gUvbk">테스트몰</span>
<a class="product_btn_link__AhZaM" href="https://x.test/1">상품 바로가기</a>""")
(p,) = parse_search_html(html)
assert p.price == 21900
def test_delivery_fee_inside_price_node_is_excluded():
"""가격비교 카드 구조: 가격 노드가 배송비를 품는다. 상품가 7,790 / 배송비 3,900 로 갈려야 한다."""
html = _card("""
<div class="product_info_tit__UOCqq">커클랜드 드라이시트, 250매, 1개</div>
<span class="product_price__O3ZGH">
<span class="product_immediate_price__m3YQU">최저7,790<span class="product_unit__seYZk">원</span>
<span class="product_unit_price__uLsbL">(1개입당 31원)</span>
<span class="product_delivery_fee__agcRS">배송비3,900원</span>
</span>
</span>
<a class="product_btn_link__AhZaM" href="https://x.test/2">상품 바로가기</a>""")
(p,) = parse_search_html(html)
assert p.price == 7790
assert p.shipping_fee == 3900 and p.shipping_type == "paid"
assert p.mall_name == "네이버" # 판매처 표기가 없는 가격비교 카드
def test_list_price_and_discount_rate_are_ignored():
"""'정상가60,000원 할인율38% 36,840원' → 36840."""
html = _card("""
<div class="product_info_tit__UOCqq">다우니 미스티크 1L 6개</div>
<span class="product_price__O3ZGH">
<del class="product_org_price__Rc5Dx">정상가60,000원</del>
<span class="product_immediate_price__m3YQU">
<em class="product_rate__41yIl">38%</em>36,840<span class="product_unit__seYZk">원</span>
</span>
</span>
<span class="product_mall__gUvbk">공식몰</span>
<a class="product_btn_link__AhZaM" href="https://x.test/3">상품 바로가기</a>""")
(p,) = parse_search_html(html)
assert p.price == 36840
def test_coupon_price_is_not_used_as_price():
"""쿠폰할인가는 조건부(1인 1회·선착순)라 실구매가 하한으로 쓰면 최저가가 왜곡된다."""
html = _card("""
<div class="product_info_tit__UOCqq">테스트 상품</div>
<span class="product_price__O3ZGH">
<span class="product_immediate_price__m3YQU">21,900<span class="product_unit__seYZk">원</span></span>
<span class="product_discount_price__lL8iq">21,680원</span>
<span class="product_discount_title__arcCm">쿠폰할인가</span>
</span>
<span class="product_mall__gUvbk">테스트몰</span>
<a class="product_btn_link__AhZaM" href="https://x.test/4">상품 바로가기</a>""")
(p,) = parse_search_html(html)
assert p.price == 21900
def test_ad_card_family_is_dropped():
html = _card("""
<div class="adProduct_info_tit__x">광고 상품</div>
<span class="adProduct_price__DN4Al"><span class="adProduct_immediate_price__q">9,900원</span></span>
<a class="adProduct_link_ad__SqeqU">광고</a>""", cls="adProduct_item_inner__abc")
assert parse_search_html(html) == []
# ── 차단 판정 ────────────────────────────────────────────────────────────
def test_detect_block_flags_captcha_and_short_html():
assert detect_block("<html>wtm_captcha</html>", 0) == "wtm_captcha"
assert detect_block("<html>비정상적인 접근입니다</html>", 0) == "비정상적인 접근"
assert detect_block("<html>" + "x" * 5000 + "</html>", 0).startswith("short_html")
# 결과가 있으면 차단이 아니다 — 짧아도 통과
assert detect_block("<html>x</html>", 3) is None
def test_detect_block_passes_real_page():
assert detect_block(_fixture(), 12) is None
# ── 신뢰 신호 ────────────────────────────────────────────────────────────
def test_trust_signals_are_captured():
"""최저가는 '살 수 있는 가장 싼 값'이어야 한다 — 평점·리뷰가 그 판단 근거다."""
ps = parse_search_html(_fixture())
rated = [p for p in ps if p.review_count]
assert rated, "리뷰가 있는 오퍼가 하나도 안 잡히면 셀렉터가 드리프트한 것"
assert all(0 < p.rating <= 5 for p in rated)
def test_missing_trust_stays_none_not_zero():
"""리뷰 정보가 없는 것과 '리뷰 0개'는 다른 뜻이다 — 0 으로 채우면 구분이 사라진다."""
ps = parse_search_html(_fixture())
ghost = [p for p in ps if p.review_count is None]
assert ghost, "픽스처에 미검증 오퍼가 있어야 이 계약을 지킬 수 있다"
assert all(p.rating is None for p in ghost)
def test_korean_abbreviated_review_count():
"""'1.7만' 을 그대로 int() 하면 1 이 된다 — 만/천 단위를 풀어야 한다."""
html = _card("""
<div class="product_info_tit__UOCqq">테스트 상품</div>
<span class="product_price__O3ZGH">
<span class="product_immediate_price__m3YQU">10,000<span class="product_unit__seYZk">원</span></span>
</span>
<div class="product_info_count__J6ElA">
<span class="product_grade__eU8gY"><span class="blind">평점</span><strong>4.88</strong><em>1.7만</em></span>
</div>
<a class="product_btn_link__AhZaM" href="https://x.test/9">상품 바로가기</a>""")
(p,) = parse_search_html(html)
assert p.rating == 4.88 and p.review_count == 17000
def test_shipping_label_keeps_the_screen_wording():
"""배송비 숫자만으론 '언제·어떤 조건'이 사라진다 — 화면 문구를 그대로 남겨야 근거가 된다."""
ps = parse_search_html(_fixture())
assert all(p.shipping_label for p in ps)
assert any("배송비무료" in p.shipping_label for p in ps)
assert any("도착" in p.shipping_label for p in ps)
def test_shipping_label_survives_price_node_decompose():
"""가격 노드에서 배송비를 decompose 하기 **전에** 라벨을 읽어야 한다.
(실제로 나중에 추가했다가 가격비교 카드에서 라벨이 통째로 비었다)"""
html = _card("""
<div class="product_info_tit__UOCqq">커클랜드 드라이시트, 250매, 1개</div>
<span class="product_price__O3ZGH">
<span class="product_immediate_price__m3YQU">최저7,790<span class="product_unit__seYZk">원</span>
<span class="product_delivery_fee__agcRS">배송비3,900원</span>
</span>
</span>
<a class="product_btn_link__AhZaM" href="https://x.test/2">상품 바로가기</a>""")
(p,) = parse_search_html(html)
assert p.shipping_label == "배송비3,900원" and p.shipping_fee == 3900