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

77 lines
2.7 KiB
Python

"""검색 어댑터 공용 순수 유틸(부작용 없음). 레퍼런스의 parse_price 를 정리 이식."""
import re
_NUM = re.compile(r"[\d,]+")
_WON = re.compile(r"([\d,]+)\s*원") # '원' 바로 앞 숫자(단위가격/퍼센트 오인 방지)
_NUM3 = re.compile(r"([\d,]{3,})") # 3자리+ 숫자 토큰(가격 후보)
_SHIP_FEE = re.compile(r"배송비\s*([\d,]+)\s*원")
_LABELS = ("상품명", "브랜드명", "판매가", "할인가") # 오픈마켓 카드의 a11y 라벨 프리픽스
def parse_price(value) -> int | None:
"""'44,900원', 44900, '44.900' 등 다형 입력 → int. 실패 시 None."""
if value is None:
return None
if isinstance(value, (int, float)):
return int(value)
m = _NUM.search(str(value))
if not m:
return None
digits = m.group(0).replace(",", "")
return int(digits) if digits.isdigit() else None
def extract_price(text: str) -> int | None:
"""가격 노드 텍스트 → int. '원' 앵커 우선, 없으면 첫 3자리+ 숫자.
('1%할인가44,540원' → 44540, '44,540' → 44540). 실패 시 None."""
if not text:
return None
m = _WON.search(text)
if m:
return int(m.group(1).replace(",", ""))
m = _NUM3.search(text)
return int(m.group(1).replace(",", "")) if m else None
def clean_name(text: str) -> str:
"""오픈마켓 카드 이름에서 a11y 라벨('상품명'/'브랜드명' 등) 프리픽스를 제거."""
s = (text or "").strip()
for lab in _LABELS:
s = s.replace(lab, " ")
return re.sub(r"\s+", " ", s).strip()
def shipping_from_text(text: str, name: str | None = None) -> tuple[int | None, str | None]:
"""카드 텍스트에서 (배송비, 배송유형) 추출 — 오픈마켓 공용(로켓 없음).
'무료배송'→(0,'free'), '배송비 X원'→(X,'paid'), 그 외 (None, None).
상품명에 '무료배송'이 들어간 오탐 방지를 위해 이름 제거 후 매칭."""
t = text or ""
if name:
t = t.replace(name, " ")
if "무료배송" in t:
return 0, "free"
m = _SHIP_FEE.search(t)
if m:
return int(m.group(1).replace(",", "")), "paid"
return None, None
def parse_ko_count(text: str) -> int | None:
"""'1.7만'→17000 · '3,705'→3705 · '1.2천'→1200 · ''→None.
네이버는 리뷰·구매 수를 만/천 단위로 줄여 쓴다. 그대로 int() 하면 1.7만이 1이 된다.
"""
if not text:
return None
t = text.strip().replace(",", "")
m = re.match(r"^([\d.]+)\s*([만천])?$", t)
if not m:
return None
try:
n = float(m.group(1))
except ValueError:
return None
unit = {"만": 10000, "천": 1000}.get(m.group(2) or "", 1)
return int(n * unit)