o2o-negosium-original/lps/services/search/contract.py
민헌 8574df4c16 feat(lps): 배송 표기 원문 기록 — 순위는 상품가 유지, 배송은 근거로 남긴다
배송 주체가 다르면(쿠팡 로켓 / 판매자로켓 / 네이버 판매자) 배송비 숫자만으로는 비교가
무의미하다. 그래서 **순위는 지금처럼 상품가**로 두되, 배송 정보는 사후 판단이 가능하도록 남긴다.

실측 조사(섬유유연제·생수 2L, 두 소스):
  네이버  배송비무료 / 배송비3,000·3,900·4,500·5,000·8,800·9,000·10,000원 /
          내일배송 8.6.(목) 도착 · 오늘출발 · 빠른배송 / **배송비포함 혜택가 N원**(가격비교 카드)
  쿠팡    내일(목) 도착 보장 · 와우는 무료배송 ∙ 무료반품 ∙ 새벽도착 / 무료배송 ∙ 오늘출발 /
          모레(금) 도착 예정. 뱃지=logo_rocket_filter(로켓)·logo_rocket_merchant(판매자로켓)

- NormalizedProduct.shipping_label: 화면 문구 원문. 같은 '무료배송'이어도 주체·조건
  (와우회원·최소금액·새벽도착)이 다른데 숫자·분류로는 그게 사라진다
- 네이버: 배송비 문구 + 도착 정보를 잇는다. **_prices() 안에서 뽑는다** — 가격 노드에서
  배송비를 decompose 하기 전에 읽어야 해서(나중에 추가했다가 가격비교 카드에서 라벨이 통째로 빔)
- 쿠팡: 배송 문구가 유틸리티 클래스(fw-text-[14px])에 담겨 셀렉터로 못 집는다 → 텍스트 패턴으로
  조각을 모으고, 구분자 없이 붙은 상위 컨테이너("내일(목) 도착무료배송")는 조각 2개 이상을
  품은 것으로 판별해 버린다. selectolax 의 node.css("*") 가 자기 자신을 포함해
  '자손 매칭' 방식은 못 쓴다(실측)
- price_history.final_shipping_fee/type/label 추가(+마이그레이션). fee 는 0=무료,
  NULL=미확인(로켓 조건부) — 둘은 다른 뜻이라 기본값을 두지 않았다

교차 검증(파싱값 vs 카드 원문, 6개 규칙): 네이버 40건·쿠팡 40건 **불일치 0**.
테스트 3건 추가, 전체 231 passed.
2026-08-05 14:40:00 +09:00

101 lines
5.6 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="판매몰/스토어명")
# 배송 원문 표기. 금액만으로는 의미가 불완전하다 — 같은 '무료배송'이라도 주체(쿠팡 로켓/판매자)와
# 조건(와우회원·최소금액·새벽배송)이 다르고, 배송 주체가 바뀌면 금액 비교 자체가 무의미해진다.
# 그래서 숫자(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 로 접근 = 게이트웨이 설정이 틀림).
호출부는 회전·재시도를 멈추고 설정을 고쳐야 한다.
"""
def __init__(self, message: str, *, source: str, blocked: bool = False, fatal: bool = False):
super().__init__(message)
self.source = source
self.blocked = blocked
self.fatal = fatal
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