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:
parent
5c9567919f
commit
6c7ff5af51
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
5
lps/migrations/2026-08-05-price_history-trust.sql
Normal file
5
lps/migrations/2026-08-05-price_history-trust.sql
Normal 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;
|
||||
@ -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):
|
||||
|
||||
@ -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),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 계열에만 존재하지만, 유기 카드에 광고가 섞이는 경우까지 방어한다.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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' 처럼 콤마가 섞여도 파싱된다
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 # 미검증 오퍼임이 드러난다
|
||||
|
||||
@ -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), # 몰별 최저가 스냅샷(열린 스키마)
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user