feat(lps): 네이버 어댑터 + 다중 소스 병합 검색

네이버 쇼핑 오픈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>
This commit is contained in:
민헌 2026-07-09 08:38:15 +09:00
parent 7491bbecd9
commit 9557c1b1af
6 changed files with 262 additions and 12 deletions

View File

@ -0,0 +1,104 @@
"""네이버 쇼핑 검색 어댑터.
공식 오픈 API(https://openapi.naver.com/v1/search/shop.json)라 크롤링/브라우저 불필요.
레퍼런스의 핵심 자산인 **키 로테이션**을 이식: 429/403(쿼터/차단) 시 다음 키로 순환 재시도.
키는 .env(NAVER_CLIENT_ID/SECRET, +_2.._10)에서 로드 → server_configs 가 load_dotenv 로 주입.
"""
import os
import httpx
from common.logger import LOG
from services.search.contract import SearchAdapter, NormalizedProduct, AdapterError, AdapterHealth
from services.search.rate_limiter import RateLimiter
from services.search.naver.transform import transform_items
_API = "https://openapi.naver.com/v1/search/shop.json"
_MAX_START = 1000 # 네이버 start 상한
_MAX_DISPLAY = 100
def load_naver_keys() -> list[tuple[str, str]]:
"""(client_id, client_secret) 쌍 목록. 기본(무접미) + _2.._10 로테이션 키."""
keys: list[tuple[str, str]] = []
cid, csec = os.environ.get("NAVER_CLIENT_ID"), os.environ.get("NAVER_CLIENT_SECRET")
if cid and csec:
keys.append((cid, csec))
for i in range(2, 11):
cid, csec = os.environ.get(f"NAVER_CLIENT_ID_{i}"), os.environ.get(f"NAVER_CLIENT_SECRET_{i}")
if cid and csec:
keys.append((cid, csec))
return keys
class NaverAdapter(SearchAdapter):
source = "naver"
def __init__(self, keys: list[tuple[str, str]] | None = None, rate_limiter: RateLimiter | None = None, timeout: float = 10.0):
self._keys = keys if keys is not None else load_naver_keys()
self._idx = 0
self._rl = rate_limiter or RateLimiter(0.1, 0.3) # 공식 API — 짧은 간격
self._timeout = timeout
self._ok = 0
self._blocked = 0
def _headers(self) -> dict:
cid, csec = self._keys[self._idx]
return {"X-Naver-Client-Id": cid, "X-Naver-Client-Secret": csec}
def _rotate(self):
self._idx = (self._idx + 1) % len(self._keys)
async def search(self, query: str, limit: int = 40) -> list[NormalizedProduct]:
if not self._keys:
raise AdapterError("네이버 API 키 없음(.env NAVER_CLIENT_ID/SECRET)", source=self.source)
collected: list[dict] = []
async with httpx.AsyncClient(timeout=self._timeout) as client:
start = 1
while len(collected) < limit and start <= _MAX_START:
display = min(_MAX_DISPLAY, limit - len(collected))
data = await self._request(client, {
"query": query, "display": display, "start": start,
"sort": "sim", "exclude": "used:rental:cbshop",
})
items = data.get("items", [])
if not items:
break
collected.extend(items)
start += display
if len(items) < display:
break
products = transform_items(collected, self.source)
self._ok += 1
LOG.d(f"[naver] query={query!r} → {len(products)}건 (limit {limit})")
return products[:limit]
async def _request(self, client: httpx.AsyncClient, params: dict) -> dict:
"""키 개수만큼 재시도. 429/403 이면 다음 키로 로테이션."""
last_status = None
for _ in range(max(1, len(self._keys))):
await self._rl.wait()
r = await client.get(_API, params=params, headers=self._headers())
if r.status_code == 200:
return r.json()
if r.status_code in (429, 403):
self._blocked += 1
last_status = r.status_code
LOG.w(f"[naver] {r.status_code} → 키 로테이션(idx {self._idx})")
self._rotate()
continue
raise AdapterError(f"네이버 API 오류 {r.status_code}: {r.text[:200]}", source=self.source)
raise AdapterError(f"네이버 API 쿼터/차단(모든 키 소진, last={last_status})", source=self.source, blocked=True)
async def health(self) -> AdapterHealth:
total = self._ok + self._blocked
rate = (self._ok / total) if total else 0.0
return AdapterHealth(source=self.source, ok=(self._blocked == 0 or rate > 0.5),
recent_success_rate=rate, blocked_rate=(self._blocked / total) if total else 0.0)
async def close(self):
"""httpx 클라이언트를 search 마다 생성/정리하므로 별도 정리 불필요(수명주기 통일용 no-op)."""
return

View File

@ -0,0 +1,49 @@
"""네이버 쇼핑 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

View File

@ -0,0 +1,19 @@
"""네이버 응답 변환 테스트 (순수, 네트워크 불필요)."""
from services.search.naver.transform import transform_items
def test_transform_strips_tags_and_keeps_catalog():
items = [
{"title": "로지텍 <b>무선</b> 마우스 &amp; 키보드", "link": "https://search.shopping.naver.com/catalog/1",
"image": "img1", "lprice": "13500", "mallName": "네이버", "maker": "로지텍", "productId": "1"},
{"title": "0원상품", "link": "https://x", "lprice": "0"}, # 가격 0 → 제외
{"title": "가격없음", "link": "https://y", "lprice": "abc"}, # 파싱 실패 → 제외
]
out = transform_items(items)
assert len(out) == 1
p = out[0]
assert p.name == "로지텍 무선 마우스 & 키보드" # <b> 제거 + 엔티티 복원
assert p.price == 13500 and p.source == "naver"
assert p.mall_name == "네이버" and p.manufacturer == "로지텍" and p.external_id == "1"
assert "catalog/1" in p.detail_url # 가격비교(catalog) 최저가 유지

View File

@ -0,0 +1,58 @@
"""다중 소스 검색 핸들러 테스트 — 병합·소스별 실패 격리·전체 실패 시 잡 실패 (fake 어댑터)."""
import pytest
from common.enums import JobType
from services.search.contract import NormalizedProduct, AdapterError
from worker.handlers import build_search_handler
class FakeAdapter:
def __init__(self, source, products=None, fail=False):
self.source = source
self._products = products or []
self._fail = fail
async def search(self, query, limit=40):
if self._fail:
raise AdapterError("boom", source=self.source, blocked=True)
return self._products
def _np(source, price):
return NormalizedProduct(source=source, name=f"{source}-{price}", price=price)
def _job():
return {"job_type": JobType.SEARCH.value, "attempts": 1, "payload": {"product_name": "x"}}
async def test_merges_and_ranks_across_sources():
adapters = {
"coupang": FakeAdapter("coupang", [_np("coupang", 3000), _np("coupang", 1000)]),
"naver": FakeAdapter("naver", [_np("naver", 2000), _np("naver", 500)]),
}
r = await build_search_handler(adapters, top_n=3)(_job())
assert r["lowest"]["price"] == 500 and r["lowest"]["source"] == "naver"
assert [p["price"] for p in r["top"]] == [500, 1000, 2000]
assert r["sources"]["coupang"]["count"] == 2 and r["sources"]["naver"]["count"] == 2
async def test_isolates_single_source_failure():
adapters = {
"coupang": FakeAdapter("coupang", fail=True),
"naver": FakeAdapter("naver", [_np("naver", 900)]),
}
r = await build_search_handler(adapters)(_job())
assert "error" in r["sources"]["coupang"] # 실패 격리
assert r["sources"]["naver"]["count"] == 1
assert r["lowest"]["price"] == 900 # 성공 소스로 결과 산출
async def test_all_sources_fail_raises():
adapters = {
"coupang": FakeAdapter("coupang", fail=True),
"naver": FakeAdapter("naver", fail=True),
}
with pytest.raises(RuntimeError):
await build_search_handler(adapters)(_job())

View File

@ -1,17 +1,22 @@
"""잡 핸들러 — job_type 별 처리. 현재는 SEARCH(검색)만.
검색 핸들러: 소스 어댑터로 검색 → 코어 파이프라인(필터·이상치·top-N 최저가) → 결과.
검색 핸들러: 여러 소스 어댑터를 동시 검색 → 병합 → 코어 파이프라인(필터·이상치·top-N 최저가).
소스별 실패는 격리한다(한 소스가 죽어도 나머지로 결과 산출). 모든 소스 실패 시에만 잡 실패(재시도).
AI 유사도 판정은 파이프라인 슬롯에 키 준비 시 결합한다.
"""
import asyncio
from common.enums import JobType
from common.logger import LOG
from services.search.contract import SearchAdapter
from services.search.util import parse_price
from services.pipeline.core import run_price_pipeline
def build_search_handler(adapters: dict[str, SearchAdapter], default_source: str = "coupang", limit: int = 40, top_n: int = 5):
"""검색 핸들러 생성. adapters = {source: SearchAdapter}."""
def build_search_handler(adapters: dict[str, SearchAdapter], sources: list[str] | None = None, limit: int = 40, top_n: int = 5):
"""검색 핸들러 생성. adapters = {source: SearchAdapter}. sources 미지정 시 전체 사용."""
use = list(sources) if sources else list(adapters.keys())
async def handler(job: dict) -> dict:
if job["job_type"] != JobType.SEARCH.value:
@ -21,17 +26,30 @@ def build_search_handler(adapters: dict[str, SearchAdapter], default_source: str
query = (payload.get("product_name") or "").strip()
if not query:
raise ValueError("empty product_name")
adapter = adapters.get(default_source)
if adapter is None:
raise ValueError(f"no adapter for source: {default_source}")
products = await adapter.search(query, limit=limit)
# 요청 현재가(있으면)를 가격 밴드 기준으로 사용 → 엉뚱한 저가/고가 targeted 컷
base_price = parse_price(payload.get("price"))
# 소스 동시 검색 (실패는 예외로 수거해 격리)
results = await asyncio.gather(
*[adapters[s].search(query, limit=limit) for s in use],
return_exceptions=True,
)
products = []
per_source: dict[str, dict] = {}
for src, res in zip(use, results):
if isinstance(res, Exception):
LOG.w(f"[{src}] 검색 실패: {type(res).__name__}: {res}")
per_source[src] = {"error": f"{type(res).__name__}: {res}"}
else:
products.extend(res)
per_source[src] = {"count": len(res)}
if not products and all("error" in v for v in per_source.values()):
raise RuntimeError(f"모든 소스 검색 실패: {per_source}") # 잡 실패 → 재시도
result = run_price_pipeline(products, base_price=base_price, top_n=top_n)
result["source"] = adapter.source
result["query"] = query
result["sources"] = per_source # 소스별 건수/에러 (관측)
return result
return handler

View File

@ -12,6 +12,7 @@ from common.logger import LOG
from config.server_configs import web_server_config
from crud.job_crud import JobQueue
from services.search.coupang.adapter import CoupangAdapter
from services.search.naver.adapter import NaverAdapter
from worker.handlers import build_search_handler
from worker.notify import JobListener
from worker.runner import Worker, run_reaper
@ -21,7 +22,8 @@ LOG.SetPrefix(f"{web_server_config.server_name}-worker")
async def main(concurrency: int = 1):
queue = JobQueue()
adapters = {"coupang": CoupangAdapter(headless=False)} # 워커들이 공유(내부 직렬화)
# 쿠팡(브라우저, 무거움) + 네이버(오픈API, 가벼움) 동시 검색 → 병합 최저가
adapters = {"coupang": CoupangAdapter(headless=False), "naver": NaverAdapter()}
handler = build_search_handler(adapters)
stop = asyncio.Event()