o2o-negosium-original/lps/services/search/naver/adapter.py
민헌 c955a8f367 feat(lps): 프록시 전송오류 IP회전 + 시작 프리플라이트 + DECODO/컴포넌트별 비용
프록시(DECODO) 포트/IP 사망(407/ERR_TUNNEL/ERR_HTTP_RESPONSE_CODE_FAILURE)이
봇차단과 구분 없이 예외로 튕겨 같은 죽은 포트로 재시도만 하다 DEAD 되던 문제를 고친다.

- browser_base: is_proxy_error(순수함수) + search 루프에서 프록시 전송오류 시 IP 회전 재시도
  (max_proxy_retries=2). 봇감지 회전과 통합. uses_proxy 프로퍼티.
- 쿠팡 어댑터를 BrowserSearchAdapter 로 통합 — 중복 machinery 제거, 회전 로직 한 곳에서 공유
  (detect_block 순수함수는 유지, 테스트 호환).
- proxy.healthcheck(): 시작 프리플라이트 — 살아있는 포트 선점 + egress IP 로그(빠른 실패·가시성).
  worker_main 기동 시 호출.
- 비용: DecodoConfig.cost_per_gb 추가. metrics 에 proxy_bytes(네이버 직접 제외) + 컴포넌트별
  cost{ai_usd, proxy_usd, total_usd}. FE 원가 타일에 AI/DECODO 분해·프록시 바이트.
- 테스트: is_proxy_error 8종 + 비용 분해 1종.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 15:33:37 +09:00

101 lines
4.5 KiB
Python

"""네이버 쇼핑 검색 어댑터.
공식 오픈 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