feat(lps): 배송비 유형 분류 — 쿠팡 배송신호 파싱 + 네이버 배송비제외 명시
NormalizedProduct 에 shipping_type(rocket|rocket_merchant|free|paid|None) 추가. 쿠팡 파서: 로켓 뱃지(img src)로 유형, '무료배송'/'배송비 X원' 텍스트로 금액 판별 (상품명 '무료배송' 오탐은 이름 제거 후 매칭). 네이버 lprice 는 배송비 제외 상품가라 배송 필드 None — 카탈로그 '배송비포함 최저가'와 다른 이유를 docs/api.md 에 명시. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
313d882df4
commit
61bc719e28
@ -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...
|
||||
```
|
||||
|
||||
@ -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="소스 내 상품 식별자")
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 = 최저가
|
||||
|
||||
Loading…
Reference in New Issue
Block a user