기존 ops-monitor 는 임계 초과가 지속되면 30초마다 같은 웹훅을 반복 발송했고 (쿨다운 없음), 해소 여부도 알 수 없었다. 감시 항목도 큐 지표 4종뿐이었다. - common/alerts.py AlertManager 신설: 룰 키별 상태 관리 — 발화 1회 + 쿨다운(LPS_ALERT_COOLDOWN_MIN, 기본 30분)마다 리마인드, 해소 시 회복 알림 1회. sender/clock 주입으로 네트워크·대기 없이 단위 테스트. - 워커 ops-monitor 를 AlertManager 로 이관(기존 4룰 유지) + 신규 2룰: db_pool(풀 포화율 ≥ LPS_ALERT_POOL_PCT 90%) · source_fail:<src>(최근 30분 시도 ≥ LPS_ALERT_SOURCE_FAIL_30M(5) & 성공 0 — 쿼터 소진·셀렉터 드리프트·전면 차단 신호). - DBSessionManager.pool_status(): 전 엔진 합산 checked_out/capacity/pct. - SearchAdapter 에 시간 윈도우 성공/실패 카운터(recent_stats) — 누적 카운터로는 '최근 30분 성공 0건'을 볼 수 없어 추가. 쿠팡(브라우저)· 네이버(API) 성공/실패 지점에 배선. - API 자체 풀 모니터: lifespan 백그라운드 태스크(run_pool_monitor) — 대량 폴링으로 풀을 고갈시키는 주범이 API 자신일 수 있다. /v1/lps/ops 에 pool_checked_out/pool_capacity/pool_pct 노출(스모크 확인). - 테스트 9건 추가(발화·쿨다운·회복·룰 독립·윈도우 카운터·풀 현황), 전체 135 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
106 lines
4.7 KiB
Python
106 lines
4.7 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] = []
|
|
try:
|
|
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
|
|
except Exception:
|
|
self._note_result(False) # 장기 실패 알림용 윈도우 카운터(쿼터 소진·네트워크 포함)
|
|
raise
|
|
|
|
products = transform_items(collected, self.source)
|
|
self._ok += 1
|
|
self._note_result(True)
|
|
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
|