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.
139 lines
5.8 KiB
Python
139 lines
5.8 KiB
Python
"""네이버 모바일 쇼핑 카드 → NormalizedProduct (순수 함수, 저장 HTML 로 테스트).
|
|
|
|
최종 목적은 **정확한 상품의 최저가**다. 그래서 파싱은 '많이 긁기'가 아니라
|
|
'가격의 의미가 확실한 것만 남기기' 쪽으로 판단한다.
|
|
|
|
카드 종류에 따라 가격의 의미가 다르다 — 둘 다 담되 구분한다:
|
|
판매처 카드 "21,900원" + 몰명 → 그 판매처의 실제 판매가
|
|
가격비교 카드 "최저7,790원" + 판매처 N곳 → 카탈로그 최저가. 몰명이 없어 mall_name="네이버"
|
|
(옛 오픈API 의 lprice 와 같은 의미 — 그래서 버리지 않고 유지한다)
|
|
|
|
버리는 것과 이유:
|
|
광고/슈퍼적립/브랜드블록 카드 판매 오퍼가 아니라 노출 상품 — 가격이 멤버십·쿠폰 조건부
|
|
쿠폰할인가 조건부(1인 1회·선착순) — price 로 쓰면 실구매가보다 싸게 잡힘
|
|
|
|
가격 파싱 함정(전부 실측에서 실제로 밟은 것들):
|
|
"21,900원(100ml당 548원)" 단위가격을 먼저 떼지 않으면 548 을 집는다
|
|
"최저7,790원(1개입당 31원)배송비3,900원" 가격 노드가 배송비까지 감싼다 → 마지막 매치를 쓰면 3,900 이 가격이 된다
|
|
"정상가60,000원할인율38%36,840원" 정상가·할인율을 걷어내야 실판매가가 남는다
|
|
"최저7,790원" '최저'와 숫자 사이에 공백이 없다(\\s 를 요구하면 못 잡음)
|
|
"""
|
|
|
|
import re
|
|
|
|
from selectolax.parser import HTMLParser
|
|
|
|
from common.logger import LOG
|
|
from services.search.contract import NormalizedProduct
|
|
from services.search.naver_shop.selectors import SELECTORS as S
|
|
|
|
_NUM = re.compile(r"([\d,]{3,})\s*원")
|
|
_LOWEST = re.compile(r"최저\s*[\d]") # "최저7,790원" — 공백 없는 표기가 실제로 나온다
|
|
_CATALOG_MALL = "네이버" # 가격비교 카드의 판매처 표기(옛 오픈API 의 mallName 과 동일)
|
|
|
|
|
|
def parse_search_html(html: str, source: str = "naver") -> list[NormalizedProduct]:
|
|
"""검색 결과 HTML → 정규화 상품. 파싱 불가/의미 불명 카드는 조용히 버린다."""
|
|
tree = HTMLParser(html)
|
|
out: list[NormalizedProduct] = []
|
|
skipped = {"ad": 0, "no_price": 0, "no_title": 0}
|
|
catalog = 0
|
|
|
|
for card in tree.css(S.card):
|
|
cls = (card.attributes.get("class") or "").strip()
|
|
# adProduct_item_inner 도 'product_item_inner' 를 포함하므로 prefix 로 유기 카드만 남긴다
|
|
if not cls.startswith(S.card_class_prefix):
|
|
skipped["ad"] += 1
|
|
continue
|
|
if card.css_first(S.ad_marker) is not None:
|
|
skipped["ad"] += 1
|
|
continue
|
|
|
|
title = _text(card, S.title)
|
|
if not title:
|
|
skipped["no_title"] += 1
|
|
continue
|
|
|
|
price, price_raw, fee, ship_type = _prices(card)
|
|
if not price:
|
|
skipped["no_price"] += 1
|
|
continue
|
|
|
|
is_catalog = bool(_LOWEST.search(price_raw))
|
|
if is_catalog:
|
|
catalog += 1
|
|
out.append(NormalizedProduct(
|
|
source=source,
|
|
name=title,
|
|
price=price,
|
|
mall_name=_text(card, S.mall) or (_CATALOG_MALL if is_catalog else None),
|
|
detail_url=_href(card),
|
|
shipping_fee=fee,
|
|
shipping_type=ship_type,
|
|
))
|
|
|
|
if out or any(skipped.values()):
|
|
LOG.d(f"[naver_shop] 파싱 {len(out)}건(가격비교 {catalog}) · 제외 {skipped}")
|
|
return out
|
|
|
|
|
|
def _text(card, sel: str) -> str:
|
|
node = card.css_first(sel)
|
|
return re.sub(r"\s+", " ", node.text()).strip() if node else ""
|
|
|
|
|
|
def _prices(card) -> tuple[int | None, str, int | None, str | None]:
|
|
"""(상품가, 가격원문, 배송비, 배송유형)을 한 번에 뽑는다.
|
|
|
|
순서가 중요해서 한 함수에 가뒀다: 가격 노드에서 '가격이 아닌 금액'(단위가격·배송비·쿠폰가·
|
|
정상가)을 decompose 로 떼어내는데, decompose 는 트리에서 노드를 아예 지우므로
|
|
**배송비를 먼저 읽어야** 한다. 밖에서 호출 순서를 지키게 하면 언젠가 반드시 깨진다.
|
|
"""
|
|
fee, ship_type = _shipping(card)
|
|
|
|
node = card.css_first(S.price)
|
|
if node is None:
|
|
return None, "", fee, ship_type
|
|
for sel in (S.unit_price, S.delivery_fee, S.coupon_price, S.org_price):
|
|
for junk in node.css(sel):
|
|
junk.decompose()
|
|
raw = re.sub(r"\s+", " ", node.text()).strip()
|
|
return _to_won(raw), raw, fee, ship_type
|
|
|
|
|
|
def _to_won(text: str) -> int | None:
|
|
"""'할인율38% 36,840원' → 36840. '원' 앞 숫자에 앵커링하고 **첫** 매치를 쓴다.
|
|
|
|
첫 매치인 이유: 노드에 금액이 여러 개 남았다면 뒤쪽은 배송비·적립처럼 상품가가 아닌 것들이다
|
|
(할인율 '38%' 는 '원'이 안 붙어 애초에 매치되지 않는다).
|
|
"""
|
|
matches = _NUM.findall(text)
|
|
if not matches:
|
|
return None
|
|
try:
|
|
price = int(matches[0].replace(",", ""))
|
|
except ValueError:
|
|
return None
|
|
return price if price > 0 else None
|
|
|
|
|
|
def _shipping(card) -> tuple[int | None, str | None]:
|
|
"""'배송비무료' → (0, 'free') · '배송비3,000원' → (3000, 'paid') · 없으면 (None, None).
|
|
|
|
옛 오픈API 는 배송비 필드 자체가 없어 전 소스 None 이었다(lprice=배송비 제외 상품가).
|
|
크롤은 화면 그대로라 배송비를 얻는다 — 최저가 비교의 정확도가 올라가는 지점.
|
|
"""
|
|
text = _text(card, S.delivery_fee)
|
|
if not text:
|
|
return None, None
|
|
if "무료" in text:
|
|
return 0, "free"
|
|
won = _to_won(text)
|
|
return (won, "paid") if won else (None, None)
|
|
|
|
|
|
def _href(card) -> str | None:
|
|
node = card.css_first(S.link)
|
|
href = node.attributes.get("href") if node else None
|
|
return href or None
|