기존 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>
85 lines
3.9 KiB
Python
85 lines
3.9 KiB
Python
"""소스 어댑터 공통 계약.
|
|
|
|
크롤링/검색 소스(네이버·쿠팡 …)는 각자 수집 방식과 안티봇 대응을 캡슐화하고,
|
|
코어 파이프라인(필터·이상치·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
|