o2o-negosium-original/lps/services/search/coupang/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

155 lines
6.8 KiB
Python

"""쿠팡 검색결과 HTML → NormalizedProduct[] (순수 함수, selectolax).
브라우저/네트워크와 무관. 저장된 HTML 로 결정론적 단위 테스트가 가능하다.
"""
import re
from selectolax.parser import HTMLParser
from services.search.contract import NormalizedProduct
from services.search.coupang.selectors import SELECTORS as S
BASE = "https://www.coupang.com"
# '원' 바로 앞의 숫자만 가격으로 인식('(1개당 44,400원)'의 앞 '1' 오인 방지).
_WON = re.compile(r"([\d,]+)\s*원")
# 카드 내 명시 배송비(예: '배송비 3,000원').
_SHIP_FEE = re.compile(r"배송비\s*([\d,]+)\s*원")
# 리뷰 수는 별점 옆 괄호에 들어온다 — "(41,448)".
_REVIEW = re.compile(r"\(([\d,]+)\)")
# 배송 표기 조각. 쿠팡은 이 문구가 **유틸리티 클래스**(fw-text-[14px] 등)에 담겨 셀렉터로 못 집는다
# → 텍스트 패턴으로 최말단 노드를 고른다. 실측 문구:
# "무료배송 ∙ 무료반품 ∙ 새벽도착" · "무료배송 ∙ 오늘출발" · "내일(목) 도착 보장" · "모레(금) 도착 예정"
_SHIP_LABEL = re.compile(r"무료배송|무료반품|배송비\s*[\d,]+원|도착|출발")
def _shipping_label(card, name: str | None) -> str | None:
"""배송 표기 원문. 같은 '무료배송'이어도 주체(로켓/판매자로켓)와 조건(와우회원·새벽도착)이
다르므로 금액·분류와 별개로 화면 문구를 그대로 남긴다.
부모 노드는 자식 텍스트가 구분자 없이 붙어 나오므로("내일(목) 도착무료배송"), 부모를 버리고
가장 작은 조각만 남긴다. 부모/자식 판정은 **텍스트 포함 관계**로 한다 —
selectolax 의 node.css("*") 는 자기 자신을 포함해서 '자손이 매칭되나'로는 가릴 수 없다(실측).
"""
found: list[str] = []
for node in card.css("*"):
text = re.sub(r"\s+", " ", node.text() or "").strip()
if not text or len(text) > 45 or not _SHIP_LABEL.search(text):
continue
if name and name in text:
continue # 상품명이 섞인 상위 컨테이너
if text not in found:
found.append(text)
# ① 구분자 없이 여러 조각이 붙은 상위 컨테이너를 버린다("내일(목) 도착무료배송").
# 조각을 **2개 이상** 품고 있으면 연결된 것으로 본다(1개만 품으면 '내일(목) 도착'처럼
# 그 자체가 읽을 만한 단위다 — 이걸 버리면 '도착'만 남는다).
merged = [t for t in found if sum(1 for o in found if o != t and o in t) >= 2]
parts = [t for t in found if t not in merged]
# ② 남은 것 중 다른 조각에 완전히 포함되는 짧은 조각은 중복이므로 버린다("도착" ⊂ "내일(목) 도착").
parts = [t for t in parts if not any(o != t and t in o for o in parts)]
return " · ".join(parts[:3]) or None
def _trust(card) -> dict:
"""평점·리뷰 수. 리뷰가 없으면 이 노드 자체가 없다 — None 을 유지해 '미검증 오퍼'로 남긴다
(0 으로 채우면 '리뷰 0개'와 '리뷰 정보 없음'이 구분되지 않는다)."""
area = card.css_first(S.rating_area)
if area is None:
return {"rating": None, "review_count": None}
rating = None
holder = area.css_first(f"[{S.rating_value_attr}]")
if holder is not None:
try:
rating = float(holder.attributes.get(S.rating_value_attr) or "")
except ValueError:
rating = None
m = _REVIEW.search(area.text() or "")
return {"rating": rating, "review_count": int(m.group(1).replace(",", "")) if m else None}
def _sale_price(price_area) -> int | None:
"""판매가 추출. 정가(del)·단위가격('~당', 괄호)·할인율(%)은 제외하고
본문 판매가 노드(문서 순서상 먼저 오는 '원' 값)를 취한다."""
if price_area is None:
return None
for node in price_area.css("span, div, strong"):
if node.tag == "del":
continue
text = node.text(strip=True) or ""
if "원" not in text or "당" in text or text.startswith("("):
continue # 단위가격/부가문구 제외
m = _WON.search(text)
if m:
return int(m.group(1).replace(",", ""))
return None
def _shipping(card, name: str | None) -> tuple[int | None, str | None]:
"""카드에서 (배송비, 배송유형) 추출.
유형은 로켓 뱃지(img src)로, 금액은 '무료배송'/'배송비 X원' 텍스트로 판별한다.
로켓 계열은 조건부 무료(와우/최소금액)라 명시 텍스트 없으면 배송비 None 유지.
상품명에 '무료배송' 이 들어간 오탐을 막기 위해 이름 텍스트는 제거 후 매칭."""
badge_type = None
for img in card.css(S.rocket_badge):
src = img.attributes.get("src") or ""
badge_type = "rocket_merchant" if S.rocket_merchant_marker in src else "rocket"
if badge_type == "rocket":
break # 로켓배송 뱃지가 가장 강한 신호
text = card.text(separator=" ") or ""
if name:
text = text.replace(name, " ")
if "무료배송" in text:
return 0, badge_type or "free"
m = _SHIP_FEE.search(text)
if m:
return int(m.group(1).replace(",", "")), badge_type or "paid"
return None, badge_type
def parse_search_html(html: str, source: str = "coupang") -> list[NormalizedProduct]:
tree = HTMLParser(html)
products: list[NormalizedProduct] = []
for card in tree.css(S.card):
name_el = card.css_first(S.name)
name = name_el.text(strip=True) if name_el else None
img = card.css_first(S.image)
if not name and img:
name = img.attributes.get("alt")
image_url = img.attributes.get("src") if img else None
price_area = card.css_first(S.price_area)
price = _sale_price(price_area)
# 이름/가격이 없으면 유효 상품이 아니므로 스킵(광고 슬롯 등)
if not name or price is None:
continue
a = card.css_first(S.link)
href = a.attributes.get("href") if a else None
detail_url = (BASE + href) if href and href.startswith("/") else href
shipping_fee, shipping_type = _shipping(card, name)
products.append(
NormalizedProduct(
source=source,
name=name,
price=price,
image_url=image_url,
detail_url=detail_url,
mall_name="쿠팡",
external_id=card.attributes.get(S.data_id_attr),
shipping_fee=shipping_fee,
shipping_type=shipping_type,
shipping_label=_shipping_label(card, name),
**_trust(card),
)
)
return products