o2o-negosium-original/lps/services/search/naver_shop/parser.py
민헌 6c7ff5af51 feat(lps): 최저가 오퍼의 신뢰 신호(평점·리뷰) 수집 — 네이버·쿠팡 공통
'가장 싼 값'과 '실제로 살 수 있는 가장 싼 값'은 다르다. 리뷰·평점이 전혀 없는 오퍼는
재고 없는 미끼가격일 수 있고, 그걸 최저가로 보고하면 사용자는 그 가격에 살 수 없다 —
조금 비싼 정답보다 나쁘다. 판단 근거를 수집해 둔다.

- NormalizedProduct.rating / review_count 추가. 두 소스 모두 카드에 노출하는 값만 담아
  교차 비교가 되게 했다. **없으면 None 유지** — '리뷰 0개'와 '리뷰 정보 없음'은 다른 뜻이다
- 네이버: product_grade 의 <strong>평점</strong><em>리뷰수</em>. 텍스트를 통째로 정규식
  돌리면 '평점4.7473' 이 4.74/73 인지 4.7/473 인지 못 가르므로 노드로 분리해 읽는다.
  '1.7만' 같은 축약은 parse_ko_count 로 푼다(그대로 int() 하면 1 이 된다)
- 쿠팡: 별점은 채워진 별 개수가 아니라 컨테이너 aria-label 에, 리뷰 수는 괄호 텍스트에 있다
- price_history.final_rating/final_review_count 추가(+마이그레이션) → "리뷰 0인 최저가가
  몇 %인가"를 SQL 로 물을 수 있다. NULL 과 0 을 구분해야 해서 기본값을 두지 않았다

정렬 점검(사용자 제기): 두 소스 다 정렬 파라미터 없이 **랭킹/추천순**이다(픽스처 가격이
오름차순이 아님으로 확인). 가격순(sort=price_asc)은 차단 없이 동작하고 실측상 더 싼 후보를
찾지만(15,400→10,900), 리뷰·평점 없는 유령상품을 위로 끌어올려 미채택 — 추천순 유지.
신뢰 신호가 쌓이면 "리뷰 N 이상" 가드를 걸고 가격순을 켜는 선택지가 열린다.

e2e: TR-1/TR-2 최저가에 평점 4.89·리뷰 7,314/102,000 이 함께 기록됨. 테스트 5건 추가, 228 passed.
2026-08-05 14:00:12 +09:00

158 lines
6.5 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.util import parse_ko_count
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,
**_trust(card),
))
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 _trust(card) -> dict:
"""평점·리뷰 수. 없으면 None — '신규/미검증 오퍼'라는 정보 자체가 의미 있으므로 0 으로 채우지 않는다."""
node = card.css_first(S.grade)
if node is None:
return {"rating": None, "review_count": None}
rating_node = node.css_first(S.grade_rating)
review_node = node.css_first(S.grade_review)
rating = None
if rating_node is not None:
try:
rating = float(rating_node.text().strip())
except ValueError:
rating = None
return {"rating": rating,
"review_count": parse_ko_count(review_node.text()) if review_node is not None else 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