o2o-negosium-original/lps/services/search/contract.py
민헌 b278f58d9c feat(lps): 1단계 — 몰별 상태 보존, '없음'과 '못 봄'을 가른다
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>
2026-08-07 10:47:28 +09:00

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