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