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.
This commit is contained in:
민헌 2026-08-05 14:00:12 +09:00
parent 5c9567919f
commit 6c7ff5af51
13 changed files with 142 additions and 4 deletions

View File

@ -1,4 +1,4 @@
from sqlalchemy import Boolean, Column, Index, Integer, SmallInteger, String, Text, DateTime
from sqlalchemy import Boolean, Column, Index, Integer, Numeric, SmallInteger, String, Text, DateTime
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import declarative_base
from sqlalchemy.sql import text
@ -93,6 +93,10 @@ class price_history(MAIN_BASE):
coupang_url = Column(Text, nullable=True)
final_lowest = Column(Integer, nullable=True) # 전체 최저가(Y축 핵심)
final_source = Column(String(20), nullable=True) # 최종 최저가 소스
# 최저가 오퍼의 신뢰 신호 — '이 가격에 실제로 살 수 있나'를 사후 판단·분석하기 위함.
# 둘 다 NULL = 리뷰·평점이 없는 오퍼(재고 없는 미끼가격일 수 있음). 0 과 NULL 은 다른 뜻이다.
final_rating = Column(Numeric(3, 2), nullable=True) # 평점(5점 만점)
final_review_count = Column(Integer, nullable=True) # 리뷰 수
# 몰별 최저가 스냅샷(열린 스키마) — [{mall, source, price, shipping_fee, shipping_type, url}, ...].
# 몰이 늘어도 컬럼 추가/마이그레이션 없이 담는다(G마켓·옥션·11번가 등). naver/coupang 3선은 위 컬럼 유지.
by_mall = Column(JSONB, nullable=True)

View File

@ -11,7 +11,7 @@ _FIELDS = (
"product_code", "job_id", "outcome", "matched_count",
"naver_lowest", "naver_name", "naver_url",
"coupang_lowest", "coupang_name", "coupang_url",
"final_lowest", "final_source",
"final_lowest", "final_source", "final_rating", "final_review_count",
)
@ -25,12 +25,12 @@ class PriceHistory:
(product_code, job_id, outcome, matched_count,
naver_lowest, naver_name, naver_url,
coupang_lowest, coupang_name, coupang_url,
final_lowest, final_source, by_mall)
final_lowest, final_source, final_rating, final_review_count, by_mall)
VALUES
(:product_code, :job_id, :outcome, :matched_count,
:naver_lowest, :naver_name, :naver_url,
:coupang_lowest, :coupang_name, :coupang_url,
:final_lowest, :final_source, CAST(:by_mall AS jsonb))
:final_lowest, :final_source, :final_rating, :final_review_count, CAST(:by_mall AS jsonb))
""")
params = {k: event.get(k) for k in _FIELDS}
by_mall = event.get("by_mall")

View File

@ -0,0 +1,5 @@
-- 최저가 오퍼의 신뢰 신호 — '가장 싼 값'이 아니라 '실제로 살 수 있는 가장 싼 값'인지 판단·분석용.
-- 리뷰·평점이 전혀 없는 오퍼는 재고 없는 미끼가격일 수 있고, 그걸 최저가로 보고하면
-- 사용자는 그 가격에 살 수 없다. NULL(정보 없음)과 0(리뷰 0개)은 다른 뜻이라 기본값을 두지 않는다.
ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_rating numeric(3,2);
ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_review_count integer;

View File

@ -27,6 +27,12 @@ class NormalizedProduct(BaseModel):
shipping_type: Optional[str] = Field(None, description="배송 유형: free(명시 무료)|paid(유료)|rocket(로켓배송, 조건부 무료)|rocket_merchant(판매자로켓)|None(미확인). 네이버는 lprice 가 배송비 제외 상품가라 항상 None")
mall_name: Optional[str] = Field(None, description="판매몰/스토어명")
external_id: Optional[str] = Field(None, description="소스 내 상품 식별자")
# ── 신뢰 신호 ──────────────────────────────────────────────────────
# 최저가는 '가장 싼 값'이 아니라 '실제로 살 수 있는 가장 싼 값'이어야 한다. 리뷰·평점이
# 전혀 없는 오퍼는 재고 없는 미끼가격일 수 있어, 그걸 최저가로 보고하면 사용자는 그 가격에
# 살 수 없다. 두 소스 모두 카드에 노출하는 값만 담는다(교차 비교가 되어야 하므로).
rating: Optional[float] = Field(None, description="평점(5점 만점). 없으면 None=신규/미검증 오퍼 신호")
review_count: Optional[int] = Field(None, description="리뷰 수. 0/None 이면 거래 이력이 없다는 뜻")
class AdapterHealth(BaseModel):

View File

@ -16,6 +16,25 @@ BASE = "https://www.coupang.com"
_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:
@ -96,6 +115,7 @@ def parse_search_html(html: str, source: str = "coupang") -> list[NormalizedProd
external_id=card.attributes.get(S.data_id_attr),
shipping_fee=shipping_fee,
shipping_type=shipping_type,
**_trust(card),
)
)

View File

@ -20,6 +20,10 @@ class CoupangSelectors:
# 배송 뱃지: 로고 이미지 src 로 판별(배송 텍스트는 해시 없는 인라인 스타일 span 이라 텍스트 매칭).
rocket_badge: str = "img[src*=rocket]"
rocket_merchant_marker: str = "rocket_merchant" # src 에 포함 시 판매자로켓, 그 외 rocket* 는 로켓배송
# 신뢰 신호 — 별점은 채워진 별 개수가 아니라 **컨테이너의 aria-label**(예: "5")에 들어 있고,
# 리뷰 수는 그 옆 괄호 텍스트("(41,448)")다.
rating_area: str = "[class*=ProductRating_productRating]"
rating_value_attr: str = "aria-label"
SELECTORS = CoupangSelectors()

View File

@ -25,6 +25,7 @@ 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*원")
@ -70,6 +71,7 @@ def parse_search_html(html: str, source: str = "naver") -> list[NormalizedProduc
detail_url=_href(card),
shipping_fee=fee,
shipping_type=ship_type,
**_trust(card),
))
if out or any(skipped.values()):
@ -132,6 +134,23 @@ def _shipping(card) -> tuple[int | None, str | None]:
return (won, "paid") if won else (None, 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

View File

@ -28,6 +28,11 @@ class Selectors:
coupon_price: str = "[class*=product_discount_price]" # 쿠폰할인가(조건부) — price 로 쓰지 않는다
delivery_fee: str = "[class*=product_delivery_fee]" # "배송비무료" | "배송비3,000원"
mall: str = "[class*=product_mall]"
# 신뢰 신호 — 평점/리뷰는 같은 노드 안에 <strong>4.88</strong><em>1.7만</em> 로 들어온다.
# 텍스트를 통째로 정규식 돌리면 '평점4.7473' 처럼 붙어 나와 4.74/73 인지 4.7/473 인지 못 가른다.
grade: str = "[class*=product_grade]"
grade_rating: str = "strong"
grade_review: str = "em"
link: str = "[class*=product_btn_link]"
# 광고 배지. adProduct 계열에만 존재하지만, 유기 카드에 광고가 섞이는 경우까지 방어한다.

View File

@ -55,3 +55,22 @@ def shipping_from_text(text: str, name: str | None = None) -> tuple[int | None,
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)

View File

@ -92,3 +92,12 @@ def test_detect_block_genuine_empty_large_page_not_blocked():
# 정상 '검색결과 없음'(전체 chrome 포함, 큰 HTML)은 차단 아님 → not_found 로 흘러야 함
big = "<html><body>검색결과가 없습니다" + "x" * 20000 + "</body></html>"
assert detect_block(big, 0) is None
def test_trust_signals_are_captured():
"""쿠팡은 별점이 채워진 별 개수가 아니라 컨테이너 aria-label 에, 리뷰 수는 괄호 텍스트에 있다."""
ps = parse_search_html(FIXTURE.read_text())
rated = [p for p in ps if p.review_count]
assert rated
assert all(0 < p.rating <= 5 for p in rated)
assert max(p.review_count for p in rated) > 1000 # '41,448' 처럼 콤마가 섞여도 파싱된다

View File

@ -143,3 +143,35 @@ def test_detect_block_flags_captcha_and_short_html():
def test_detect_block_passes_real_page():
assert detect_block(_fixture(), 12) is None
# ── 신뢰 신호 ────────────────────────────────────────────────────────────
def test_trust_signals_are_captured():
"""최저가는 '살 수 있는 가장 싼 값'이어야 한다 — 평점·리뷰가 그 판단 근거다."""
ps = parse_search_html(_fixture())
rated = [p for p in ps if p.review_count]
assert rated, "리뷰가 있는 오퍼가 하나도 안 잡히면 셀렉터가 드리프트한 것"
assert all(0 < p.rating <= 5 for p in rated)
def test_missing_trust_stays_none_not_zero():
"""리뷰 정보가 없는 것과 '리뷰 0개'는 다른 뜻이다 — 0 으로 채우면 구분이 사라진다."""
ps = parse_search_html(_fixture())
ghost = [p for p in ps if p.review_count is None]
assert ghost, "픽스처에 미검증 오퍼가 있어야 이 계약을 지킬 수 있다"
assert all(p.rating is None for p in ghost)
def test_korean_abbreviated_review_count():
"""'1.7만' 을 그대로 int() 하면 1 이 된다 — 만/천 단위를 풀어야 한다."""
html = _card("""
<div class="product_info_tit__UOCqq">테스트 상품</div>
<span class="product_price__O3ZGH">
<span class="product_immediate_price__m3YQU">10,000<span class="product_unit__seYZk">원</span></span>
</span>
<div class="product_info_count__J6ElA">
<span class="product_grade__eU8gY"><span class="blind">평점</span><strong>4.88</strong><em>1.7만</em></span>
</div>
<a class="product_btn_link__AhZaM" href="https://x.test/9">상품 바로가기</a>""")
(p,) = parse_search_html(html)
assert p.rating == 4.88 and p.review_count == 17000

View File

@ -100,3 +100,16 @@ async def test_handler_records_on_negative_cache_hit():
e = rec.events[0]
assert e["product_code"] == "PC1" and e["outcome"] == "not_found"
assert e["final_lowest"] is None and e["matched_count"] == 0 # 검색을 안 했으니 가격도 없다
async def test_snapshot_carries_trust_of_the_lowest_offer():
"""최저가 오퍼의 평점·리뷰가 이력에 함께 남아야 '살 수 있는 가격이었나'를 사후에 물을 수 있다."""
rec = _Rec()
cheap_ghost = _np("naver", 900) # 가장 싸지만 리뷰·평점 없음
trusted = _np("coupang", 1800)
trusted.rating, trusted.review_count = 4.8, 1200
adapters = {"naver": _FakeAdapter("naver", [cheap_ghost]), "coupang": _FakeAdapter("coupang", [trusted])}
await build_search_handler(adapters, history=rec)(_job())
e = rec.events[0]
assert e["final_lowest"] == 900 and e["final_source"] == "naver"
assert e["final_rating"] is None and e["final_review_count"] is None # 미검증 오퍼임이 드러난다

View File

@ -54,6 +54,8 @@ def _price_snapshot(matched: list[NormalizedProduct]) -> dict:
"naver_lowest": n.price if n else None, "naver_name": n.name if n else None, "naver_url": n.detail_url if n else None,
"coupang_lowest": c.price if c else None, "coupang_name": c.name if c else None, "coupang_url": c.detail_url if c else None,
"final_lowest": f.price if f else None, "final_source": f.source if f else None,
# 최저가 오퍼의 신뢰 신호 — 리뷰·평점이 없으면 '살 수 없는 가격'일 수 있다(유령상품).
"final_rating": f.rating if f else None, "final_review_count": f.review_count if f else None,
"by_mall": summarize_by_mall(matched), # 몰별 최저가 스냅샷(열린 스키마)
}