diff --git a/lps/docs/api.md b/lps/docs/api.md index fb268f0..f497f5c 100644 --- a/lps/docs/api.md +++ b/lps/docs/api.md @@ -77,7 +77,8 @@ curl -X POST localhost:9600/v1/lps/search -H 'Content-Type: application/json' \ "output": { "outcome": "found", // found / not_found "query": "맥심 커피", - "lowest": { "price": 25200, "source": "coupang", "name": "맥심모카골드 ...", "detail_url": "..." }, + "lowest": { "price": 25200, "source": "coupang", "name": "맥심모카골드 ...", "detail_url": "...", + "shipping_fee": 0, "shipping_type": "rocket" }, "top": [ /* 최저가 상위 N개 */ ], "sources": { "naver": {"count": 40}, "coupang": {"count": 40} }, "stages": [ {"stage":"outlier","in":80,"out":76}, {"stage":"ai_match","in":76,"out":1}, {"stage":"top_n","in":1,"out":1} ] @@ -87,6 +88,14 @@ curl -X POST localhost:9600/v1/lps/search -H 'Content-Type: application/json' \ - `output.stages` = 각 단계에서 몇 건이 걸러졌는지(디버깅·품질 확인용). - 없는 job_id/잘못된 형식 → `result.desc = "LPS_JOB_NOT_FOUND"`. +> **⚠️ 가격의 의미 (배송비)** +> - `price` 는 **상품가**입니다. 배송비 포함 여부는 `shipping_fee`/`shipping_type` 으로 판단합니다. +> - **쿠팡**: 검색 화면의 배송 신호를 파싱해 채웁니다 — +> `shipping_type`: `rocket`(로켓배송, 와우 무료/일반 19,800원↑ 무료) · `rocket_merchant`(판매자로켓) · `free`(명시 무료) · `paid`(유료, `shipping_fee`에 금액) · `null`(미확인) +> - **네이버**: 오픈API `lprice` 는 **배송비 제외** 상품가라 둘 다 항상 `null` 입니다. +> 가격비교(카탈로그) 화면의 기본 표시는 "**배송비포함** 최저가"라서 **API 값과 다르게 보이는 것이 정상**입니다 +> (예: API 5,880원 vs 화면 7,520원). 카탈로그 페이지 자동 수집은 네이버 캡차로 차단되어 미지원. + ```bash curl localhost:9600/v1/lps/jobs/c885... ``` diff --git a/lps/services/search/contract.py b/lps/services/search/contract.py index 1fa7d64..69eec9b 100644 --- a/lps/services/search/contract.py +++ b/lps/services/search/contract.py @@ -22,6 +22,7 @@ class NormalizedProduct(BaseModel): image_url: Optional[str] = Field(None, description="썸네일 URL") detail_url: Optional[str] = Field(None, description="상품 상세 URL") 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="판매몰/스토어명") external_id: Optional[str] = Field(None, description="소스 내 상품 식별자") diff --git a/lps/services/search/coupang/parser.py b/lps/services/search/coupang/parser.py index 4ada7ab..30d20f0 100644 --- a/lps/services/search/coupang/parser.py +++ b/lps/services/search/coupang/parser.py @@ -14,6 +14,8 @@ BASE = "https://www.coupang.com" # '원' 바로 앞의 숫자만 가격으로 인식('(1개당 44,400원)'의 앞 '1' 오인 방지). _WON = re.compile(r"([\d,]+)\s*원") +# 카드 내 명시 배송비(예: '배송비 3,000원'). +_SHIP_FEE = re.compile(r"배송비\s*([\d,]+)\s*원") def _sale_price(price_area) -> int | None: @@ -33,6 +35,30 @@ def _sale_price(price_area) -> int | None: return None +def _shipping(card, name: str | None) -> tuple[int | None, str | None]: + """카드에서 (배송비, 배송유형) 추출. + 유형은 로켓 뱃지(img src)로, 금액은 '무료배송'/'배송비 X원' 텍스트로 판별한다. + 로켓 계열은 조건부 무료(와우/최소금액)라 명시 텍스트 없으면 배송비 None 유지. + 상품명에 '무료배송' 이 들어간 오탐을 막기 위해 이름 텍스트는 제거 후 매칭.""" + badge_type = None + for img in card.css(S.rocket_badge): + src = img.attributes.get("src") or "" + badge_type = "rocket_merchant" if S.rocket_merchant_marker in src else "rocket" + if badge_type == "rocket": + break # 로켓배송 뱃지가 가장 강한 신호 + + text = card.text(separator=" ") or "" + if name: + text = text.replace(name, " ") + + if "무료배송" in text: + return 0, badge_type or "free" + m = _SHIP_FEE.search(text) + if m: + return int(m.group(1).replace(",", "")), badge_type or "paid" + return None, badge_type + + def parse_search_html(html: str, source: str = "coupang") -> list[NormalizedProduct]: tree = HTMLParser(html) products: list[NormalizedProduct] = [] @@ -57,6 +83,8 @@ def parse_search_html(html: str, source: str = "coupang") -> list[NormalizedProd href = a.attributes.get("href") if a else None detail_url = (BASE + href) if href and href.startswith("/") else href + shipping_fee, shipping_type = _shipping(card, name) + products.append( NormalizedProduct( source=source, @@ -66,6 +94,8 @@ def parse_search_html(html: str, source: str = "coupang") -> list[NormalizedProd detail_url=detail_url, mall_name="쿠팡", external_id=card.attributes.get(S.data_id_attr), + shipping_fee=shipping_fee, + shipping_type=shipping_type, ) ) diff --git a/lps/services/search/coupang/selectors.py b/lps/services/search/coupang/selectors.py index 3aa6cff..8552729 100644 --- a/lps/services/search/coupang/selectors.py +++ b/lps/services/search/coupang/selectors.py @@ -17,6 +17,9 @@ class CoupangSelectors: link: str = "a[href]" image: str = "figure img" data_id_attr: str = "data-id" # li 의 vendorItemId + # 배송 뱃지: 로고 이미지 src 로 판별(배송 텍스트는 해시 없는 인라인 스타일 span 이라 텍스트 매칭). + rocket_badge: str = "img[src*=rocket]" + rocket_merchant_marker: str = "rocket_merchant" # src 에 포함 시 판매자로켓, 그 외 rocket* 는 로켓배송 SELECTORS = CoupangSelectors() diff --git a/lps/services/search/naver/transform.py b/lps/services/search/naver/transform.py index e54b325..07b4380 100644 --- a/lps/services/search/naver/transform.py +++ b/lps/services/search/naver/transform.py @@ -22,6 +22,9 @@ def transform_items(items: list[dict], source: str = "naver") -> list[Normalized link = it.get("link", "") or "" # 가격비교(catalog, productType=1) 페이지의 lprice 는 '여러 판매자 중 최저가'라 # 최저가 솔루션에는 오히려 핵심 신호 → 제외하지 않고 그대로 취한다. + # 단, lprice 는 **배송비 제외** 상품가(API 에 배송비 필드 없음) — 카탈로그 화면의 + # '배송비포함 최저가'와 다를 수 있다. 카탈로그 크롤링은 WTM 캡차로 차단됨(2026-07 스파이크) + # → shipping_fee/shipping_type 은 None(미확인)으로 남긴다. try: price = int(it.get("lprice")) # lprice = 최저가