diff --git a/lps/common/database/model/models.py b/lps/common/database/model/models.py index e2b22f0..f458e9b 100644 --- a/lps/common/database/model/models.py +++ b/lps/common/database/model/models.py @@ -97,6 +97,11 @@ class price_history(MAIN_BASE): # 둘 다 NULL = 리뷰·평점이 없는 오퍼(재고 없는 미끼가격일 수 있음). 0 과 NULL 은 다른 뜻이다. final_rating = Column(Numeric(3, 2), nullable=True) # 평점(5점 만점) final_review_count = Column(Integer, nullable=True) # 리뷰 수 + # 최저가 오퍼의 배송 정보. 순위는 상품가로 매기지만(배송 주체가 다르면 금액 비교가 무의미), + # **기록은 남긴다** — 나중에 '배송비까지 더하면 순위가 뒤집히나'를 데이터로 물을 수 있어야 한다. + final_shipping_fee = Column(Integer, nullable=True) # 0=무료, NULL=미확인(조건부) + final_shipping_type = Column(String(20), nullable=True) # free/paid/rocket/rocket_merchant + final_shipping_label = Column(String(120), 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 71f9c07..869455c 100644 --- a/lps/crud/price_history.py +++ b/lps/crud/price_history.py @@ -12,6 +12,7 @@ _FIELDS = ( "naver_lowest", "naver_name", "naver_url", "coupang_lowest", "coupang_name", "coupang_url", "final_lowest", "final_source", "final_rating", "final_review_count", + "final_shipping_fee", "final_shipping_type", "final_shipping_label", ) @@ -25,12 +26,14 @@ 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, final_rating, final_review_count, by_mall) + final_lowest, final_source, final_rating, final_review_count, + final_shipping_fee, final_shipping_type, final_shipping_label, 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, :final_rating, :final_review_count, CAST(:by_mall AS jsonb)) + :final_lowest, :final_source, :final_rating, :final_review_count, + :final_shipping_fee, :final_shipping_type, :final_shipping_label, 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-shipping.sql b/lps/migrations/2026-08-05-price_history-shipping.sql new file mode 100644 index 0000000..68da0fd --- /dev/null +++ b/lps/migrations/2026-08-05-price_history-shipping.sql @@ -0,0 +1,7 @@ +-- 최저가 오퍼의 배송 정보. 순위는 상품가로 매긴다(배송 주체가 로켓/판매자/네이버 판매자로 갈리면 +-- 배송비 숫자만으로는 비교가 무의미) — 그래도 **기록은 남겨야** 나중에 '배송비를 더하면 순위가 +-- 뒤집히는 비율이 얼마인가'를 데이터로 판단할 수 있다. +-- fee 는 0=무료 / NULL=미확인(로켓처럼 조건부 무료라 화면에 금액이 없는 경우) — 둘은 다른 뜻이다. +ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_shipping_fee integer; +ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_shipping_type varchar(20); +ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_shipping_label varchar(120); diff --git a/lps/services/search/contract.py b/lps/services/search/contract.py index acaaac5..3f37a5d 100644 --- a/lps/services/search/contract.py +++ b/lps/services/search/contract.py @@ -26,6 +26,10 @@ class NormalizedProduct(BaseModel): shipping_fee: Optional[int] = Field(None, description="배송비(원). 무료=0, 미확인=None") shipping_type: Optional[str] = Field(None, description="배송 유형: free(명시 무료)|paid(유료)|rocket(로켓배송, 조건부 무료)|rocket_merchant(판매자로켓)|None(미확인). 네이버는 lprice 가 배송비 제외 상품가라 항상 None") mall_name: Optional[str] = Field(None, description="판매몰/스토어명") + # 배송 원문 표기. 금액만으로는 의미가 불완전하다 — 같은 '무료배송'이라도 주체(쿠팡 로켓/판매자)와 + # 조건(와우회원·최소금액·새벽배송)이 다르고, 배송 주체가 바뀌면 금액 비교 자체가 무의미해진다. + # 그래서 숫자(shipping_fee)·분류(shipping_type)와 별개로 **화면 문구를 그대로** 남긴다. + shipping_label: Optional[str] = Field(None, description="배송 표기 원문(예: '무료배송 ∙ 무료반품 ∙ 새벽도착', '배송비3,000원 · 내일배송 8.6.(목) 도착')") external_id: Optional[str] = Field(None, description="소스 내 상품 식별자") # ── 신뢰 신호 ────────────────────────────────────────────────────── # 최저가는 '가장 싼 값'이 아니라 '실제로 살 수 있는 가장 싼 값'이어야 한다. 리뷰·평점이 diff --git a/lps/services/search/coupang/parser.py b/lps/services/search/coupang/parser.py index 0d3e09c..a7e04af 100644 --- a/lps/services/search/coupang/parser.py +++ b/lps/services/search/coupang/parser.py @@ -18,6 +18,37 @@ _WON = re.compile(r"([\d,]+)\s*원") _SHIP_FEE = re.compile(r"배송비\s*([\d,]+)\s*원") # 리뷰 수는 별점 옆 괄호에 들어온다 — "(41,448)". _REVIEW = re.compile(r"\(([\d,]+)\)") +# 배송 표기 조각. 쿠팡은 이 문구가 **유틸리티 클래스**(fw-text-[14px] 등)에 담겨 셀렉터로 못 집는다 +# → 텍스트 패턴으로 최말단 노드를 고른다. 실측 문구: +# "무료배송 ∙ 무료반품 ∙ 새벽도착" · "무료배송 ∙ 오늘출발" · "내일(목) 도착 보장" · "모레(금) 도착 예정" +_SHIP_LABEL = re.compile(r"무료배송|무료반품|배송비\s*[\d,]+원|도착|출발") + + +def _shipping_label(card, name: str | None) -> str | None: + """배송 표기 원문. 같은 '무료배송'이어도 주체(로켓/판매자로켓)와 조건(와우회원·새벽도착)이 + 다르므로 금액·분류와 별개로 화면 문구를 그대로 남긴다. + + 부모 노드는 자식 텍스트가 구분자 없이 붙어 나오므로("내일(목) 도착무료배송"), 부모를 버리고 + 가장 작은 조각만 남긴다. 부모/자식 판정은 **텍스트 포함 관계**로 한다 — + selectolax 의 node.css("*") 는 자기 자신을 포함해서 '자손이 매칭되나'로는 가릴 수 없다(실측). + """ + found: list[str] = [] + for node in card.css("*"): + text = re.sub(r"\s+", " ", node.text() or "").strip() + if not text or len(text) > 45 or not _SHIP_LABEL.search(text): + continue + if name and name in text: + continue # 상품명이 섞인 상위 컨테이너 + if text not in found: + found.append(text) + # ① 구분자 없이 여러 조각이 붙은 상위 컨테이너를 버린다("내일(목) 도착무료배송"). + # 조각을 **2개 이상** 품고 있으면 연결된 것으로 본다(1개만 품으면 '내일(목) 도착'처럼 + # 그 자체가 읽을 만한 단위다 — 이걸 버리면 '도착'만 남는다). + merged = [t for t in found if sum(1 for o in found if o != t and o in t) >= 2] + parts = [t for t in found if t not in merged] + # ② 남은 것 중 다른 조각에 완전히 포함되는 짧은 조각은 중복이므로 버린다("도착" ⊂ "내일(목) 도착"). + parts = [t for t in parts if not any(o != t and t in o for o in parts)] + return " · ".join(parts[:3]) or None def _trust(card) -> dict: @@ -115,6 +146,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, + shipping_label=_shipping_label(card, name), **_trust(card), ) ) diff --git a/lps/services/search/naver_shop/parser.py b/lps/services/search/naver_shop/parser.py index 468a62d..a8188db 100644 --- a/lps/services/search/naver_shop/parser.py +++ b/lps/services/search/naver_shop/parser.py @@ -55,7 +55,7 @@ def parse_search_html(html: str, source: str = "naver") -> list[NormalizedProduc skipped["no_title"] += 1 continue - price, price_raw, fee, ship_type = _prices(card) + price, price_raw, fee, ship_type, ship_label = _prices(card) if not price: skipped["no_price"] += 1 continue @@ -71,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, + shipping_label=ship_label, **_trust(card), )) @@ -84,23 +85,25 @@ def _text(card, sel: str) -> str: return re.sub(r"\s+", " ", node.text()).strip() if node else "" -def _prices(card) -> tuple[int | None, str, int | None, str | None]: - """(상품가, 가격원문, 배송비, 배송유형)을 한 번에 뽑는다. +def _prices(card) -> tuple[int | None, str, int | None, str | None, str | None]: + """(상품가, 가격원문, 배송비, 배송유형, 배송표기)을 한 번에 뽑는다. 순서가 중요해서 한 함수에 가뒀다: 가격 노드에서 '가격이 아닌 금액'(단위가격·배송비·쿠폰가· 정상가)을 decompose 로 떼어내는데, decompose 는 트리에서 노드를 아예 지우므로 - **배송비를 먼저 읽어야** 한다. 밖에서 호출 순서를 지키게 하면 언젠가 반드시 깨진다. + **배송 정보를 먼저 읽어야** 한다. 밖에서 호출 순서를 지키게 하면 언젠가 반드시 깨진다 + (실제로 배송 표기를 나중에 추가했다가 가격비교 카드에서 라벨이 통째로 비었다). """ fee, ship_type = _shipping(card) + ship_label = _shipping_label(card) node = card.css_first(S.price) if node is None: - return None, "", fee, ship_type + return None, "", fee, ship_type, ship_label for sel in (S.unit_price, S.delivery_fee, S.coupon_price, S.org_price): for junk in node.css(sel): junk.decompose() raw = re.sub(r"\s+", " ", node.text()).strip() - return _to_won(raw), raw, fee, ship_type + return _to_won(raw), raw, fee, ship_type, ship_label def _to_won(text: str) -> int | None: @@ -134,6 +137,13 @@ def _shipping(card) -> tuple[int | None, str | None]: return (won, "paid") if won else (None, None) +def _shipping_label(card) -> str | None: + """배송 표기 원문 — 금액 문구 + 도착 정보를 합친다('배송비3,000원 · 내일배송 8.6.(목) 도착'). + 금액만 남기면 '언제 오는지·조건이 뭔지'가 사라져 나중에 비교 근거로 못 쓴다.""" + parts = [t for t in (_text(card, S.delivery_fee), _text(card, S.delivery_speed)) if t] + return " · ".join(parts) or None + + def _trust(card) -> dict: """평점·리뷰 수. 없으면 None — '신규/미검증 오퍼'라는 정보 자체가 의미 있으므로 0 으로 채우지 않는다.""" node = card.css_first(S.grade) diff --git a/lps/services/search/naver_shop/selectors.py b/lps/services/search/naver_shop/selectors.py index 51a49c4..5eaa972 100644 --- a/lps/services/search/naver_shop/selectors.py +++ b/lps/services/search/naver_shop/selectors.py @@ -27,6 +27,7 @@ class Selectors: org_price: str = "[class*=product_org_price]" # "정상가 60,000원" — 가격 아님 coupon_price: str = "[class*=product_discount_price]" # 쿠폰할인가(조건부) — price 로 쓰지 않는다 delivery_fee: str = "[class*=product_delivery_fee]" # "배송비무료" | "배송비3,000원" + delivery_speed: str = "[class*=nDeliveryInfo]" # "내일배송 8.6.(목) 도착" | "오늘출발" mall: str = "[class*=product_mall]" # 신뢰 신호 — 평점/리뷰는 같은 노드 안에 4.881.7만 로 들어온다. # 텍스트를 통째로 정규식 돌리면 '평점4.7473' 처럼 붙어 나와 4.74/73 인지 4.7/473 인지 못 가른다. diff --git a/lps/tests/test_coupang_parser.py b/lps/tests/test_coupang_parser.py index 6b58883..6dfeef1 100644 --- a/lps/tests/test_coupang_parser.py +++ b/lps/tests/test_coupang_parser.py @@ -1,5 +1,6 @@ # 쿠팡 파서 결정론적 단위 테스트(네트워크/브라우저 불필요). # fixture 는 실제 렌더된 검색결과에서 카드 4개를 추출한 것(단위가격 '1개당' 케이스 포함). +import re from pathlib import Path from services.search.coupang.parser import parse_search_html @@ -101,3 +102,14 @@ def test_trust_signals_are_captured(): assert rated assert all(0 < p.rating <= 5 for p in rated) assert max(p.review_count for p in rated) > 1000 # '41,448' 처럼 콤마가 섞여도 파싱된다 + + +def test_shipping_label_joins_smallest_fragments(): + """쿠팡 배송 문구는 유틸리티 클래스에 담겨 셀렉터로 못 집는다 — 텍스트 조각을 합쳐 만든다. + 부모는 구분자 없이 붙어 나오므로("내일(목) 도착무료배송") 버리고, 조각만 이어야 한다.""" + ps = parse_search_html(FIXTURE.read_text()) + labels = [p.shipping_label for p in ps if p.shipping_label] + assert labels, "배송 표기가 하나도 안 잡히면 문구 패턴이 드리프트한 것" + assert any("도착" in x and "무료배송" in x for x in labels) + # 조각이 뭉개지지 않았는지 — '도착'만 남으면 언제 오는지가 사라진다 + assert any(re.search(r"(내일|모레|오늘)\(?\w*\)? 도착", x) for x in labels) diff --git a/lps/tests/test_naver_shop_parser.py b/lps/tests/test_naver_shop_parser.py index 64cd008..5568462 100644 --- a/lps/tests/test_naver_shop_parser.py +++ b/lps/tests/test_naver_shop_parser.py @@ -175,3 +175,26 @@ def test_korean_abbreviated_review_count(): 상품 바로가기""") (p,) = parse_search_html(html) assert p.rating == 4.88 and p.review_count == 17000 + + +def test_shipping_label_keeps_the_screen_wording(): + """배송비 숫자만으론 '언제·어떤 조건'이 사라진다 — 화면 문구를 그대로 남겨야 근거가 된다.""" + ps = parse_search_html(_fixture()) + assert all(p.shipping_label for p in ps) + assert any("배송비무료" in p.shipping_label for p in ps) + assert any("도착" in p.shipping_label for p in ps) + + +def test_shipping_label_survives_price_node_decompose(): + """가격 노드에서 배송비를 decompose 하기 **전에** 라벨을 읽어야 한다. + (실제로 나중에 추가했다가 가격비교 카드에서 라벨이 통째로 비었다)""" + html = _card(""" +