docs/result-states.md 의 1단계. 어댑터는 이미 구분을 알고 있는데 핸들러가 그 정보를 버리고
있었다(per_source[src] = {"error": 문자열}). 그래서 차단당해 못 본 몰이 화면에서 '그 몰엔 없음'
으로 둔갑했다. 새로 알아낼 정보는 없고, 흘리던 걸 잡아두기만 하면 된다.
- common/enums.py: SourceState 7상태 추가(MATCHED/NO_MATCH/EMPTY/BLOCKED/ENV_BLOCKED/
UNAVAILABLE/SKIPPED). `.confirmed` 프로퍼티로 **'봤다 vs 못 봤다' 경계를 한곳에** 둔다 —
이 경계가 무너지면 나머지 판단이 전부 틀어지므로 흩어놓지 않는다.
- AdapterError.state: 어댑터가 아는 구분을 실어 보낸다. blocked/fatal 은 '어떻게 대응할까'
(회전·재시도)를 위한 값이고 state 는 '사용자에게 뭐라 말할까'를 위한 값이라 쓰임이 다르다.
특히 blocked=False 하나에 결과0건(EMPTY)과 전송실패(UNAVAILABLE)가 섞여 있어 state 없이는
갈라낼 수 없었다. **기본값은 UNAVAILABLE** — 모르면 '못 봤다'가 안전하다(EMPTY 로 두면
확인도 안 한 몰을 '없음'으로 단정한다).
- browser_base: raise 지점 5곳에 상태 부여. 핵심 갈림은 0건 종착 한 곳 —
blocked=False → EMPTY(정말 없다) / blocked=True → BLOCKED(못 봤다).
- _search_round: {"state": ..., "count"|"error": ...} 로 구조화. EMPTY 는 '정상 응답'으로 세어
(confirmed) 쿠팡에 정말 없을 때 잡이 재시도로 낭비되지 않게 한다.
- _finalize_states: 수집만 된 소스를 AI 판정 뒤 MATCHED/NO_MATCH 로 확정한다. 실패 상태는
이미 확정이라 덮지 않는다.
검증(9조합 실측): empty → partial=False(확정) / blocked·env_blocked·unavailable → partial=True.
테스트 12건 추가, 전체 286 passed. 진행 상황은 docs/result-states.md 4절에 기록.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
115 lines
6.7 KiB
Python
115 lines
6.7 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
|
|
|
|
from common.enums import SourceState
|
|
|
|
|
|
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="판매몰/스토어명")
|
|
# 배송 원문 표기. 금액만으로는 의미가 불완전하다 — 같은 '무료배송'이라도 주체(쿠팡 로켓/판매자)와
|
|
# 조건(와우회원·최소금액·새벽배송)이 다르고, 배송 주체가 바뀌면 금액 비교 자체가 무의미해진다.
|
|
# 그래서 숫자(shipping_fee)·분류(shipping_type)와 별개로 **화면 문구를 그대로** 남긴다.
|
|
shipping_label: Optional[str] = Field(None, description="배송 표기 원문(예: '무료배송 ∙ 무료반품 ∙ 새벽도착', '배송비3,000원 · 내일배송 8.6.(목) 도착')")
|
|
external_id: Optional[str] = Field(None, description="소스 내 상품 식별자")
|
|
# ── 신뢰 신호 ──────────────────────────────────────────────────────
|
|
# 최저가는 '가장 싼 값'이 아니라 '실제로 살 수 있는 가장 싼 값'이어야 한다. 리뷰·평점이
|
|
# 전혀 없는 오퍼는 재고 없는 미끼가격일 수 있어, 그걸 최저가로 보고하면 사용자는 그 가격에
|
|
# 살 수 없다. 두 소스 모두 카드에 노출하는 값만 담는다(교차 비교가 되어야 하므로).
|
|
rating: Optional[float] = Field(None, description="평점(5점 만점). 없으면 None=신규/미검증 오퍼 신호")
|
|
review_count: Optional[int] = Field(None, description="리뷰 수. 0/None 이면 거래 이력이 없다는 뜻")
|
|
|
|
|
|
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 면 안티봇 차단으로 판단(에스컬레이션/알림 트리거).
|
|
|
|
fatal=True 는 **재시도해도 절대 안 되는 차단**이다 — IP 를 바꿔도 같은 결과가 나오는
|
|
구조적 원인(예: 네이버 msearch 에 해외 IP 로 접근 = 게이트웨이 설정이 틀림).
|
|
호출부는 회전·재시도를 멈추고 설정을 고쳐야 한다.
|
|
|
|
⚠️ `state` 는 **'그 몰을 봤는가'** 를 담는다. blocked/fatal 은 '어떻게 대응할까'(회전·재시도)를
|
|
위한 값이고, state 는 '사용자에게 뭐라고 말할까'를 위한 값이라 쓰임이 다르다.
|
|
특히 blocked=False 하나에 두 가지가 섞여 있어 state 없이는 갈라낼 수 없다:
|
|
결과 0건(EMPTY) 그 몰을 봤고 정말 없었다 → "없음"이라 말해도 된다
|
|
전송 실패(UNAVAILABLE) 그 몰을 못 봤다 → "없음"이라 말하면 거짓
|
|
"""
|
|
|
|
def __init__(self, message: str, *, source: str, blocked: bool = False, fatal: bool = False,
|
|
state: SourceState | None = None):
|
|
super().__init__(message)
|
|
self.source = source
|
|
self.blocked = blocked
|
|
self.fatal = fatal
|
|
# state 를 안 준 옛 호출부도 맞게 동작하도록 blocked/fatal 에서 유도한다.
|
|
# (모르면 UNAVAILABLE — '못 봤다' 쪽이 안전한 기본값이다. EMPTY 로 잘못 넘기면
|
|
# 확인도 안 한 몰을 '없음'으로 단정하게 된다)
|
|
self.state = state or (SourceState.ENV_BLOCKED if fatal else
|
|
SourceState.BLOCKED if blocked else SourceState.UNAVAILABLE)
|
|
|
|
|
|
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
|