네이버 쇼핑 오픈API 어댑터(크롤링 불필요) + 핸들러를 다중 소스로 확장.
쿠팡(브라우저)+네이버(API)를 동시 검색·병합해 교차 최저가를 뽑는다.
- search/naver/adapter: httpx + 오픈API + 키 로테이션(429/403 순환), .env 키 로드
- search/naver/transform: 순수 변환(태그/엔티티 정리, lprice). 가격비교(catalog) lprice 는
'여러 판매자 중 최저가'라 최저가 솔루션엔 핵심 → 유지
- handler: asyncio.gather 동시 검색 + 소스별 실패 격리(일부 죽어도 결과) + 전체 실패 시 잡 실패
- worker_main: adapters={coupang, naver}
- tests: 네이버 변환 + 병합/실패격리/전체실패 4건 → 전체 31/31
- 라이브: 쿠팡30+네이버30 병합 top-N 최저가(소스 라벨 포함)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
50 lines
1.6 KiB
Python
50 lines
1.6 KiB
Python
"""네이버 쇼핑 API 응답 items → NormalizedProduct (순수 함수).
|
|
|
|
네트워크 무관 — 저장된/모의 JSON 으로 결정론적 테스트 가능.
|
|
title 의 <b> 강조 태그·HTML 엔티티를 제거하고, 가격비교(catalog) 페이지는 제외한다.
|
|
"""
|
|
|
|
import html
|
|
import re
|
|
|
|
from services.search.contract import NormalizedProduct
|
|
|
|
_TAG = re.compile(r"<[^>]+>")
|
|
|
|
|
|
def _clean(text: str) -> str:
|
|
return html.unescape(_TAG.sub("", text or "")).strip()
|
|
|
|
|
|
def transform_items(items: list[dict], source: str = "naver") -> list[NormalizedProduct]:
|
|
products: list[NormalizedProduct] = []
|
|
for it in items:
|
|
link = it.get("link", "") or ""
|
|
# 가격비교(catalog, productType=1) 페이지의 lprice 는 '여러 판매자 중 최저가'라
|
|
# 최저가 솔루션에는 오히려 핵심 신호 → 제외하지 않고 그대로 취한다.
|
|
|
|
try:
|
|
price = int(it.get("lprice")) # lprice = 최저가
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if price <= 0:
|
|
continue
|
|
|
|
name = _clean(it.get("title"))
|
|
if not name:
|
|
continue
|
|
|
|
products.append(
|
|
NormalizedProduct(
|
|
source=source,
|
|
name=name,
|
|
price=price,
|
|
image_url=it.get("image") or None,
|
|
detail_url=link or None,
|
|
mall_name=it.get("mallName") or None,
|
|
manufacturer=(it.get("maker") or it.get("brand")) or None,
|
|
external_id=str(it["productId"]) if it.get("productId") else None,
|
|
)
|
|
)
|
|
return products
|