'가장 싼 값'과 '실제로 살 수 있는 가장 싼 값'은 다르다. 리뷰·평점이 전혀 없는 오퍼는 재고 없는 미끼가격일 수 있고, 그걸 최저가로 보고하면 사용자는 그 가격에 살 수 없다 — 조금 비싼 정답보다 나쁘다. 판단 근거를 수집해 둔다. - 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.
123 lines
4.5 KiB
Python
123 lines
4.5 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,]+)\)")
|
|
|
|
|
|
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,
|
|
**_trust(card),
|
|
)
|
|
)
|
|
|
|
return products
|