feat(lps): 배송 표기 원문 기록 — 순위는 상품가 유지, 배송은 근거로 남긴다
배송 주체가 다르면(쿠팡 로켓 / 판매자로켓 / 네이버 판매자) 배송비 숫자만으로는 비교가
무의미하다. 그래서 **순위는 지금처럼 상품가**로 두되, 배송 정보는 사후 판단이 가능하도록 남긴다.
실측 조사(섬유유연제·생수 2L, 두 소스):
네이버 배송비무료 / 배송비3,000·3,900·4,500·5,000·8,800·9,000·10,000원 /
내일배송 8.6.(목) 도착 · 오늘출발 · 빠른배송 / **배송비포함 혜택가 N원**(가격비교 카드)
쿠팡 내일(목) 도착 보장 · 와우는 무료배송 ∙ 무료반품 ∙ 새벽도착 / 무료배송 ∙ 오늘출발 /
모레(금) 도착 예정. 뱃지=logo_rocket_filter(로켓)·logo_rocket_merchant(판매자로켓)
- NormalizedProduct.shipping_label: 화면 문구 원문. 같은 '무료배송'이어도 주체·조건
(와우회원·최소금액·새벽도착)이 다른데 숫자·분류로는 그게 사라진다
- 네이버: 배송비 문구 + 도착 정보를 잇는다. **_prices() 안에서 뽑는다** — 가격 노드에서
배송비를 decompose 하기 전에 읽어야 해서(나중에 추가했다가 가격비교 카드에서 라벨이 통째로 빔)
- 쿠팡: 배송 문구가 유틸리티 클래스(fw-text-[14px])에 담겨 셀렉터로 못 집는다 → 텍스트 패턴으로
조각을 모으고, 구분자 없이 붙은 상위 컨테이너("내일(목) 도착무료배송")는 조각 2개 이상을
품은 것으로 판별해 버린다. selectolax 의 node.css("*") 가 자기 자신을 포함해
'자손 매칭' 방식은 못 쓴다(실측)
- price_history.final_shipping_fee/type/label 추가(+마이그레이션). fee 는 0=무료,
NULL=미확인(로켓 조건부) — 둘은 다른 뜻이라 기본값을 두지 않았다
교차 검증(파싱값 vs 카드 원문, 6개 규칙): 네이버 40건·쿠팡 40건 **불일치 0**.
테스트 3건 추가, 전체 231 passed.
This commit is contained in:
parent
6c7ff5af51
commit
8574df4c16
@ -97,6 +97,11 @@ class price_history(MAIN_BASE):
|
|||||||
# 둘 다 NULL = 리뷰·평점이 없는 오퍼(재고 없는 미끼가격일 수 있음). 0 과 NULL 은 다른 뜻이다.
|
# 둘 다 NULL = 리뷰·평점이 없는 오퍼(재고 없는 미끼가격일 수 있음). 0 과 NULL 은 다른 뜻이다.
|
||||||
final_rating = Column(Numeric(3, 2), nullable=True) # 평점(5점 만점)
|
final_rating = Column(Numeric(3, 2), nullable=True) # 평점(5점 만점)
|
||||||
final_review_count = Column(Integer, nullable=True) # 리뷰 수
|
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}, ...].
|
# 몰별 최저가 스냅샷(열린 스키마) — [{mall, source, price, shipping_fee, shipping_type, url}, ...].
|
||||||
# 몰이 늘어도 컬럼 추가/마이그레이션 없이 담는다(G마켓·옥션·11번가 등). naver/coupang 3선은 위 컬럼 유지.
|
# 몰이 늘어도 컬럼 추가/마이그레이션 없이 담는다(G마켓·옥션·11번가 등). naver/coupang 3선은 위 컬럼 유지.
|
||||||
by_mall = Column(JSONB, nullable=True)
|
by_mall = Column(JSONB, nullable=True)
|
||||||
|
|||||||
@ -12,6 +12,7 @@ _FIELDS = (
|
|||||||
"naver_lowest", "naver_name", "naver_url",
|
"naver_lowest", "naver_name", "naver_url",
|
||||||
"coupang_lowest", "coupang_name", "coupang_url",
|
"coupang_lowest", "coupang_name", "coupang_url",
|
||||||
"final_lowest", "final_source", "final_rating", "final_review_count",
|
"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,
|
(product_code, job_id, outcome, matched_count,
|
||||||
naver_lowest, naver_name, naver_url,
|
naver_lowest, naver_name, naver_url,
|
||||||
coupang_lowest, coupang_name, coupang_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
|
VALUES
|
||||||
(:product_code, :job_id, :outcome, :matched_count,
|
(:product_code, :job_id, :outcome, :matched_count,
|
||||||
:naver_lowest, :naver_name, :naver_url,
|
:naver_lowest, :naver_name, :naver_url,
|
||||||
:coupang_lowest, :coupang_name, :coupang_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}
|
params = {k: event.get(k) for k in _FIELDS}
|
||||||
by_mall = event.get("by_mall")
|
by_mall = event.get("by_mall")
|
||||||
|
|||||||
7
lps/migrations/2026-08-05-price_history-shipping.sql
Normal file
7
lps/migrations/2026-08-05-price_history-shipping.sql
Normal file
@ -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);
|
||||||
@ -26,6 +26,10 @@ class NormalizedProduct(BaseModel):
|
|||||||
shipping_fee: Optional[int] = Field(None, description="배송비(원). 무료=0, 미확인=None")
|
shipping_fee: Optional[int] = Field(None, description="배송비(원). 무료=0, 미확인=None")
|
||||||
shipping_type: Optional[str] = Field(None, description="배송 유형: free(명시 무료)|paid(유료)|rocket(로켓배송, 조건부 무료)|rocket_merchant(판매자로켓)|None(미확인). 네이버는 lprice 가 배송비 제외 상품가라 항상 None")
|
shipping_type: Optional[str] = Field(None, description="배송 유형: free(명시 무료)|paid(유료)|rocket(로켓배송, 조건부 무료)|rocket_merchant(판매자로켓)|None(미확인). 네이버는 lprice 가 배송비 제외 상품가라 항상 None")
|
||||||
mall_name: Optional[str] = Field(None, description="판매몰/스토어명")
|
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="소스 내 상품 식별자")
|
external_id: Optional[str] = Field(None, description="소스 내 상품 식별자")
|
||||||
# ── 신뢰 신호 ──────────────────────────────────────────────────────
|
# ── 신뢰 신호 ──────────────────────────────────────────────────────
|
||||||
# 최저가는 '가장 싼 값'이 아니라 '실제로 살 수 있는 가장 싼 값'이어야 한다. 리뷰·평점이
|
# 최저가는 '가장 싼 값'이 아니라 '실제로 살 수 있는 가장 싼 값'이어야 한다. 리뷰·평점이
|
||||||
|
|||||||
@ -18,6 +18,37 @@ _WON = re.compile(r"([\d,]+)\s*원")
|
|||||||
_SHIP_FEE = re.compile(r"배송비\s*([\d,]+)\s*원")
|
_SHIP_FEE = re.compile(r"배송비\s*([\d,]+)\s*원")
|
||||||
# 리뷰 수는 별점 옆 괄호에 들어온다 — "(41,448)".
|
# 리뷰 수는 별점 옆 괄호에 들어온다 — "(41,448)".
|
||||||
_REVIEW = re.compile(r"\(([\d,]+)\)")
|
_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:
|
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),
|
external_id=card.attributes.get(S.data_id_attr),
|
||||||
shipping_fee=shipping_fee,
|
shipping_fee=shipping_fee,
|
||||||
shipping_type=shipping_type,
|
shipping_type=shipping_type,
|
||||||
|
shipping_label=_shipping_label(card, name),
|
||||||
**_trust(card),
|
**_trust(card),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -55,7 +55,7 @@ def parse_search_html(html: str, source: str = "naver") -> list[NormalizedProduc
|
|||||||
skipped["no_title"] += 1
|
skipped["no_title"] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
price, price_raw, fee, ship_type = _prices(card)
|
price, price_raw, fee, ship_type, ship_label = _prices(card)
|
||||||
if not price:
|
if not price:
|
||||||
skipped["no_price"] += 1
|
skipped["no_price"] += 1
|
||||||
continue
|
continue
|
||||||
@ -71,6 +71,7 @@ def parse_search_html(html: str, source: str = "naver") -> list[NormalizedProduc
|
|||||||
detail_url=_href(card),
|
detail_url=_href(card),
|
||||||
shipping_fee=fee,
|
shipping_fee=fee,
|
||||||
shipping_type=ship_type,
|
shipping_type=ship_type,
|
||||||
|
shipping_label=ship_label,
|
||||||
**_trust(card),
|
**_trust(card),
|
||||||
))
|
))
|
||||||
|
|
||||||
@ -84,23 +85,25 @@ def _text(card, sel: str) -> str:
|
|||||||
return re.sub(r"\s+", " ", node.text()).strip() if node else ""
|
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 는 트리에서 노드를 아예 지우므로
|
정상가)을 decompose 로 떼어내는데, decompose 는 트리에서 노드를 아예 지우므로
|
||||||
**배송비를 먼저 읽어야** 한다. 밖에서 호출 순서를 지키게 하면 언젠가 반드시 깨진다.
|
**배송 정보를 먼저 읽어야** 한다. 밖에서 호출 순서를 지키게 하면 언젠가 반드시 깨진다
|
||||||
|
(실제로 배송 표기를 나중에 추가했다가 가격비교 카드에서 라벨이 통째로 비었다).
|
||||||
"""
|
"""
|
||||||
fee, ship_type = _shipping(card)
|
fee, ship_type = _shipping(card)
|
||||||
|
ship_label = _shipping_label(card)
|
||||||
|
|
||||||
node = card.css_first(S.price)
|
node = card.css_first(S.price)
|
||||||
if node is None:
|
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 sel in (S.unit_price, S.delivery_fee, S.coupon_price, S.org_price):
|
||||||
for junk in node.css(sel):
|
for junk in node.css(sel):
|
||||||
junk.decompose()
|
junk.decompose()
|
||||||
raw = re.sub(r"\s+", " ", node.text()).strip()
|
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:
|
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)
|
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:
|
def _trust(card) -> dict:
|
||||||
"""평점·리뷰 수. 없으면 None — '신규/미검증 오퍼'라는 정보 자체가 의미 있으므로 0 으로 채우지 않는다."""
|
"""평점·리뷰 수. 없으면 None — '신규/미검증 오퍼'라는 정보 자체가 의미 있으므로 0 으로 채우지 않는다."""
|
||||||
node = card.css_first(S.grade)
|
node = card.css_first(S.grade)
|
||||||
|
|||||||
@ -27,6 +27,7 @@ class Selectors:
|
|||||||
org_price: str = "[class*=product_org_price]" # "정상가 60,000원" — 가격 아님
|
org_price: str = "[class*=product_org_price]" # "정상가 60,000원" — 가격 아님
|
||||||
coupon_price: str = "[class*=product_discount_price]" # 쿠폰할인가(조건부) — price 로 쓰지 않는다
|
coupon_price: str = "[class*=product_discount_price]" # 쿠폰할인가(조건부) — price 로 쓰지 않는다
|
||||||
delivery_fee: str = "[class*=product_delivery_fee]" # "배송비무료" | "배송비3,000원"
|
delivery_fee: str = "[class*=product_delivery_fee]" # "배송비무료" | "배송비3,000원"
|
||||||
|
delivery_speed: str = "[class*=nDeliveryInfo]" # "내일배송 8.6.(목) 도착" | "오늘출발"
|
||||||
mall: str = "[class*=product_mall]"
|
mall: str = "[class*=product_mall]"
|
||||||
# 신뢰 신호 — 평점/리뷰는 같은 노드 안에 <strong>4.88</strong><em>1.7만</em> 로 들어온다.
|
# 신뢰 신호 — 평점/리뷰는 같은 노드 안에 <strong>4.88</strong><em>1.7만</em> 로 들어온다.
|
||||||
# 텍스트를 통째로 정규식 돌리면 '평점4.7473' 처럼 붙어 나와 4.74/73 인지 4.7/473 인지 못 가른다.
|
# 텍스트를 통째로 정규식 돌리면 '평점4.7473' 처럼 붙어 나와 4.74/73 인지 4.7/473 인지 못 가른다.
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
# 쿠팡 파서 결정론적 단위 테스트(네트워크/브라우저 불필요).
|
# 쿠팡 파서 결정론적 단위 테스트(네트워크/브라우저 불필요).
|
||||||
# fixture 는 실제 렌더된 검색결과에서 카드 4개를 추출한 것(단위가격 '1개당' 케이스 포함).
|
# fixture 는 실제 렌더된 검색결과에서 카드 4개를 추출한 것(단위가격 '1개당' 케이스 포함).
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from services.search.coupang.parser import parse_search_html
|
from services.search.coupang.parser import parse_search_html
|
||||||
@ -101,3 +102,14 @@ def test_trust_signals_are_captured():
|
|||||||
assert rated
|
assert rated
|
||||||
assert all(0 < p.rating <= 5 for p in rated)
|
assert all(0 < p.rating <= 5 for p in rated)
|
||||||
assert max(p.review_count for p in rated) > 1000 # '41,448' 처럼 콤마가 섞여도 파싱된다
|
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)
|
||||||
|
|||||||
@ -175,3 +175,26 @@ def test_korean_abbreviated_review_count():
|
|||||||
<a class="product_btn_link__AhZaM" href="https://x.test/9">상품 바로가기</a>""")
|
<a class="product_btn_link__AhZaM" href="https://x.test/9">상품 바로가기</a>""")
|
||||||
(p,) = parse_search_html(html)
|
(p,) = parse_search_html(html)
|
||||||
assert p.rating == 4.88 and p.review_count == 17000
|
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("""
|
||||||
|
<div class="product_info_tit__UOCqq">커클랜드 드라이시트, 250매, 1개</div>
|
||||||
|
<span class="product_price__O3ZGH">
|
||||||
|
<span class="product_immediate_price__m3YQU">최저7,790<span class="product_unit__seYZk">원</span>
|
||||||
|
<span class="product_delivery_fee__agcRS">배송비3,900원</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<a class="product_btn_link__AhZaM" href="https://x.test/2">상품 바로가기</a>""")
|
||||||
|
(p,) = parse_search_html(html)
|
||||||
|
assert p.shipping_label == "배송비3,900원" and p.shipping_fee == 3900
|
||||||
|
|||||||
@ -56,6 +56,10 @@ def _price_snapshot(matched: list[NormalizedProduct]) -> dict:
|
|||||||
"final_lowest": f.price if f else None, "final_source": f.source if f 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,
|
"final_rating": f.rating if f else None, "final_review_count": f.review_count if f else None,
|
||||||
|
# 배송은 순위에 쓰지 않지만(주체가 다르면 비교 무의미) 기록은 남긴다 — 사후 분석용.
|
||||||
|
"final_shipping_fee": f.shipping_fee if f else None,
|
||||||
|
"final_shipping_type": f.shipping_type if f else None,
|
||||||
|
"final_shipping_label": f.shipping_label if f else None,
|
||||||
"by_mall": summarize_by_mall(matched), # 몰별 최저가 스냅샷(열린 스키마)
|
"by_mall": summarize_by_mall(matched), # 몰별 최저가 스냅샷(열린 스키마)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user