"""소스 어댑터 공통 계약. 크롤링/검색 소스(네이버·쿠팡 …)는 각자 수집 방식과 안티봇 대응을 캡슐화하고, 코어 파이프라인(필터·이상치·AI)은 정규화된 NormalizedProduct 만 본다. 새 소스는 SearchAdapter 를 구현하기만 하면 코어 변경 없이 붙는다(트레드밀 격리). """ import time from abc import ABC, abstractmethod from collections import deque from typing import Optional from pydantic import BaseModel, Field class NormalizedProduct(BaseModel): """소스 무관 정규화 상품 스키마. 어댑터의 유일한 출력 계약.""" source: str = Field(description="수집 소스 (naver|coupang)") name: str = Field(description="상품명") price: int = Field(description="판매가(원, 정수). 파싱 실패분은 어댑터에서 제외") model: Optional[str] = Field(None, description="모델명(있으면)") manufacturer: Optional[str] = Field(None, description="제조사(있으면)") image_url: Optional[str] = Field(None, description="썸네일 URL") detail_url: Optional[str] = Field(None, description="상품 상세 URL") shipping_fee: Optional[int] = Field(None, description="배송비(원). 무료=0, 미확인=None") shipping_type: Optional[str] = Field(None, description="배송 유형: free(명시 무료)|paid(유료)|rocket(로켓배송, 조건부 무료)|rocket_merchant(판매자로켓)|None(미확인). 네이버는 lprice 가 배송비 제외 상품가라 항상 None") mall_name: Optional[str] = Field(None, description="판매몰/스토어명") external_id: Optional[str] = Field(None, description="소스 내 상품 식별자") class AdapterHealth(BaseModel): """어댑터 건강도. 성공률 급락 = 레이아웃 변경/차단 신호 → 알림 훅.""" source: str ok: bool = Field(description="현재 정상 동작 여부") recent_success_rate: float = Field(0.0, description="최근 요청 성공률(0~1)") blocked_rate: float = Field(0.0, description="최근 차단(봇탐지) 비율(0~1)") note: str = "" class AdapterError(Exception): """어댑터 수집 실패. blocked=True 면 안티봇 차단으로 판단(에스컬레이션/알림 트리거).""" def __init__(self, message: str, *, source: str, blocked: bool = False): super().__init__(message) self.source = source self.blocked = blocked class SearchAdapter(ABC): """검색 소스 어댑터. 소스별 수집/에스컬레이션/안티봇을 내부에 캡슐화한다.""" source: str @abstractmethod async def search(self, query: str, limit: int = 40) -> list[NormalizedProduct]: """query 로 검색해 정규화 상품 리스트를 반환. 차단 시 AdapterError(blocked=True).""" raise NotImplementedError async def health(self) -> AdapterHealth: """기본 건강도. 어댑터가 관측 지표를 축적하면 override.""" return AdapterHealth(source=self.source, ok=True) # ---- 시간 윈도우 성공/실패 카운터(장기 실패 알림용) ------------------ # 기존 _ok/_blocked 는 기동 후 누적이라 '최근 30분 성공 0건' 같은 장기 실패를 못 본다. # 어댑터의 search 성공/실패 지점에서 _note_result 를 부르면 ops-monitor 가 recent_stats 로 읽는다. # (lazy init — 서브클래스가 super().__init__ 을 부르지 않아도 동작) def _note_result(self, ok: bool): ev = getattr(self, "_win_events", None) if ev is None: ev = self._win_events = deque(maxlen=512) ev.append((time.monotonic(), ok)) def recent_stats(self, window_sec: float = 1800.0) -> tuple[int, int]: """최근 window_sec 내 (시도 수, 성공 수).""" now = time.monotonic() tries = ok = 0 for t, s in getattr(self, "_win_events", ()): if now - t <= window_sec: tries += 1 ok += s return tries, ok