"""네이버 모바일 쇼핑 카드 → 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, ship_label = _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, shipping_label=ship_label, **_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, str | None]: """(상품가, 가격원문, 배송비, 배송유형, 배송표기)을 한 번에 뽑는다. 순서가 중요해서 한 함수에 가뒀다: 가격 노드에서 '가격이 아닌 금액'(단위가격·배송비·쿠폰가· 정상가)을 decompose 로 떼어내는데, decompose 는 트리에서 노드를 아예 지우므로 **배송 정보를 먼저 읽어야** 한다. 밖에서 호출 순서를 지키게 하면 언젠가 반드시 깨진다 (실제로 배송 표기를 나중에 추가했다가 가격비교 카드에서 라벨이 통째로 비었다). """ fee, ship_type = _shipping(card) ship_label = _shipping_label(card) node = card.css_first(S.price) if node is None: return None, "", fee, ship_type, ship_label 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, ship_label 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 _shipping_label(card) -> str | None: """배송 표기 원문 — 금액 문구 + 도착 정보를 합친다('배송비3,000원 · 내일배송 8.6.(목) 도착'). 금액만 남기면 '언제 오는지·조건이 뭔지'가 사라져 나중에 비교 근거로 못 쓴다.""" parts = [t for t in (_text(card, S.delivery_fee), _text(card, S.delivery_speed)) if t] return " · ".join(parts) or 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