shop.json 이 2026-07-31 종료(404 SE05)되고 NCP API HUB 에도 승계되지 않아
가격을 얻을 공식 경로가 사라졌다 → 쿠팡과 같은 스택(patchright+실제 Chrome)으로 크롤 전환.
경로: msearch.shopping.naver.com (PC 는 405/418 로 막힘). 7/9 스파이크 때 모바일은
로그인 리다이렉트였는데 그 사이 열렸다.
통과 조건 3개 — 하나라도 빠지면 WTM 캡차(실측):
- **한국 IP**: 해외 residential 은 즉시 하드차단(2.6KB) → kr.decodo.com 게이트웨이
([DecodoConfig].kr_host, DecodoProxy(host=...) 로 주입. 쿠팡은 기존 월드와이드 유지)
- **ko-KR 로케일/시간대**: KR IP + en-US 조합을 봇으로 본다
(BrowserSearchAdapter.context_options 훅 추가)
- **리소스 차단 금지**: route 를 걸면 즉시 캡차. image/media/font 만 막아도 동일 →
'무엇을 막느냐'가 아니라 요청 가로채기 자체가 탐지 신호. 대신 검색당 ~3MB(~$0.009)
파서는 '정확한 상품의 최저가'를 기준으로 취사선택한다:
- 광고/슈퍼적립/브랜드블록 카드 제외(멤버십·쿠폰 조건부 가격)
- 쿠폰할인가를 price 로 쓰지 않음(조건부라 실구매가보다 싸게 잡힘)
- 가격비교('최저 N원') 카드는 유지하고 mall_name="네이버"(옛 lprice 와 같은 의미)
- **배송비 확보** — 옛 오픈API 는 필드 자체가 없어 전 소스 None 이었다
- 가격 함정 3종 회귀 테스트: 단위가격(548원)·가격노드 안의 배송비(3,900원)·정상가/할인율
source 는 "naver" 유지 — price_history.naver_lowest·MALL_BY_SOURCE·프론트 그래프 계약이
구현(API→크롤) 교체와 무관하게 살아야 한다.
테스트 12건 추가(축약 픽스처 + 합성 함정) · 전체 182 passed.
146 lines
7.1 KiB
Python
146 lines
7.1 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
|