"""네이버 쇼핑 검색 어댑터. 공식 오픈 API(https://openapi.naver.com/v1/search/shop.json)라 크롤링/브라우저 불필요. 레퍼런스의 핵심 자산인 **키 로테이션**을 이식: 429/403(쿼터/차단) 시 다음 키로 순환 재시도. 키는 config.local.toml [NaverConfig].keys 에서 로드(여러 개면 로테이션). """ import httpx from common.logger import LOG from config.server_configs import naver_config 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) 쌍 목록. TOML [NaverConfig].keys 에서 로드(여러 개면 로테이션).""" return [(k.id, k.secret) for k in naver_config.keys if k.id and k.secret] 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 self.last_bytes = 0 # 직전 search 의 응답 바이트(계측용) uses_proxy = False # 네이버는 오픈API 직접 호출(프록시 미경유) — DECODO 대역폭 비용 없음 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 키 없음(config.local.toml [NaverConfig].keys)", source=self.source) self.last_bytes = 0 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: self.last_bytes += len(r.content) 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