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:
민헌 2026-07-09 13:59:28 +09:00
parent 313d882df4
commit 61bc719e28
5 changed files with 47 additions and 1 deletions

View File

@ -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...
```

View File

@ -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="소스 내 상품 식별자")

View File

@ -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,
)
)

View File

@ -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()

View File

@ -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 = 최저가