diff --git a/lps/common/database/model/models.py b/lps/common/database/model/models.py index 5f440d1..e2b22f0 100644 --- a/lps/common/database/model/models.py +++ b/lps/common/database/model/models.py @@ -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) diff --git a/lps/crud/price_history.py b/lps/crud/price_history.py index d4c74c9..71f9c07 100644 --- a/lps/crud/price_history.py +++ b/lps/crud/price_history.py @@ -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") diff --git a/lps/migrations/2026-08-05-price_history-trust.sql b/lps/migrations/2026-08-05-price_history-trust.sql new file mode 100644 index 0000000..15af2ab --- /dev/null +++ b/lps/migrations/2026-08-05-price_history-trust.sql @@ -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; diff --git a/lps/services/search/contract.py b/lps/services/search/contract.py index 0e308cb..acaaac5 100644 --- a/lps/services/search/contract.py +++ b/lps/services/search/contract.py @@ -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): diff --git a/lps/services/search/coupang/parser.py b/lps/services/search/coupang/parser.py index 30d20f0..0d3e09c 100644 --- a/lps/services/search/coupang/parser.py +++ b/lps/services/search/coupang/parser.py @@ -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), ) ) diff --git a/lps/services/search/coupang/selectors.py b/lps/services/search/coupang/selectors.py index 8552729..7ab9157 100644 --- a/lps/services/search/coupang/selectors.py +++ b/lps/services/search/coupang/selectors.py @@ -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() diff --git a/lps/services/search/naver_shop/parser.py b/lps/services/search/naver_shop/parser.py index c2e3ede..468a62d 100644 --- a/lps/services/search/naver_shop/parser.py +++ b/lps/services/search/naver_shop/parser.py @@ -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 diff --git a/lps/services/search/naver_shop/selectors.py b/lps/services/search/naver_shop/selectors.py index 4850b3a..51a49c4 100644 --- a/lps/services/search/naver_shop/selectors.py +++ b/lps/services/search/naver_shop/selectors.py @@ -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]" + # 신뢰 신호 — 평점/리뷰는 같은 노드 안에 4.881.7만 로 들어온다. + # 텍스트를 통째로 정규식 돌리면 '평점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 계열에만 존재하지만, 유기 카드에 광고가 섞이는 경우까지 방어한다. diff --git a/lps/services/search/util.py b/lps/services/search/util.py index 2d7889f..6022f26 100644 --- a/lps/services/search/util.py +++ b/lps/services/search/util.py @@ -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) diff --git a/lps/tests/test_coupang_parser.py b/lps/tests/test_coupang_parser.py index 2806a3a..6b58883 100644 --- a/lps/tests/test_coupang_parser.py +++ b/lps/tests/test_coupang_parser.py @@ -92,3 +92,12 @@ def test_detect_block_genuine_empty_large_page_not_blocked(): # 정상 '검색결과 없음'(전체 chrome 포함, 큰 HTML)은 차단 아님 → not_found 로 흘러야 함 big = "검색결과가 없습니다" + "x" * 20000 + "" 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' 처럼 콤마가 섞여도 파싱된다 diff --git a/lps/tests/test_naver_shop_parser.py b/lps/tests/test_naver_shop_parser.py index bbbe144..64cd008 100644 --- a/lps/tests/test_naver_shop_parser.py +++ b/lps/tests/test_naver_shop_parser.py @@ -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(""" +
테스트 상품
+ + 10,000원 + +
+ 평점4.881.7만 +
+ 상품 바로가기""") + (p,) = parse_search_html(html) + assert p.rating == 4.88 and p.review_count == 17000 diff --git a/lps/tests/test_price_history.py b/lps/tests/test_price_history.py index 70eccf4..bc6b032 100644 --- a/lps/tests/test_price_history.py +++ b/lps/tests/test_price_history.py @@ -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 # 미검증 오퍼임이 드러난다 diff --git a/lps/worker/handlers.py b/lps/worker/handlers.py index 523fd27..0f242c8 100644 --- a/lps/worker/handlers.py +++ b/lps/worker/handlers.py @@ -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), # 몰별 최저가 스냅샷(열린 스키마) }