diff --git a/lps/common/enums.py b/lps/common/enums.py index b275871..035ff90 100644 --- a/lps/common/enums.py +++ b/lps/common/enums.py @@ -69,3 +69,26 @@ class JobType(Enum): SEARCH = 1 # 최저가 검색(쿠팡=브라우저) — 무거움 OUTBOX = 2 # 외부 API 결과 전송(재시도 엔진 공유) — 가벼움 + + +class SourceState(Enum): + """한 상품을 **한 몰에서** 찾은 결과. 정의·표기 규칙은 docs/result-states.md 가 소스다. + + 가장 중요한 경계는 `확인함` 과 `못 봄` 사이다: + MATCHED·NO_MATCH·EMPTY 그 몰을 실제로 봤다 → "없다"고 말해도 되는 사실 + BLOCKED·ENV_BLOCKED·UNAVAILABLE 못 봤다 → "없다"고 말하면 거짓이 된다 + 이 경계를 잃으면 '차단당해 못 본 것'이 '그 몰엔 없음'으로 둔갑한다(실측 문제). + """ + + MATCHED = 1 # 수집·매칭 성공 — 가격 확보 + NO_MATCH = 2 # 수집은 됐으나 같은 상품이 없음(액세서리·다른 규격만) + EMPTY = 3 # 그 몰의 검색 결과 자체가 0건 + BLOCKED = 4 # 안티봇 차단 — IP 회전으로 회복 가능(자동) + ENV_BLOCKED = 5 # 회전해도 안 되는 차단(환경·게이트웨이 설정) — 사람이 고쳐야 함 + UNAVAILABLE = 6 # 전송 실패·가용 IP 없음 등 일시적 — 잠시 후 재시도로 회복 + SKIPPED = 7 # 그 소스를 아예 쓰지 않음(폴백 OFF 등) + + @property + def confirmed(self) -> bool: + """그 몰을 **실제로 확인했는지**. False 면 '없다'고 단정하면 안 된다.""" + return self in (SourceState.MATCHED, SourceState.NO_MATCH, SourceState.EMPTY) diff --git a/lps/docs/result-states.md b/lps/docs/result-states.md index fdda3ab..b277aa0 100644 --- a/lps/docs/result-states.md +++ b/lps/docs/result-states.md @@ -157,13 +157,31 @@ per_source[src] = {"error": f"{type(res).__name__}: {res}"} # ← blocked/fata - 부분 실패 시 네거티브 캐시 오염 방지 - 한 소스가 막혀도 살아있는 소스로 잡을 정상 종료 -**안 된 것** -1. `_search_round` 가 `AdapterError.blocked/fatal` 을 버린다 → 몰별 상태를 못 만든다 *(가장 근본)* +### 진행 상황 + +**1단계 — 몰별 상태 보존 ✅ 완료** (`feat/source-state`) + +- `common/enums.py` 에 `SourceState`(7상태) 추가. `.confirmed` 로 '봤다/못 봤다' 경계를 한곳에 둔다. +- `AdapterError.state` — 어댑터가 이미 알던 구분을 실어 보낸다. `state` 를 안 주면 + `blocked/fatal` 에서 유도하고, **모르면 `UNAVAILABLE`**(= 못 봤다)로 둔다. + `EMPTY` 를 기본값으로 하면 확인도 안 한 몰을 '없음'으로 단정하게 되기 때문이다. +- `browser_base` 의 raise 지점 5곳에 상태를 실었다. 갈림은 0건 종착 지점 하나다: + `blocked=False` → `EMPTY`(정말 없다) / `blocked=True` → `BLOCKED`(못 봤다). +- `_search_round` 가 문자열 대신 `{"state": ..., "count"|"error": ...}` 를 남긴다. + `EMPTY` 는 '정상 응답'으로 세므로, 쿠팡에 정말 없을 때 잡이 재시도로 낭비되지 않는다. +- `_finalize_states` — 수집만 된 소스를 AI 판정 뒤 `MATCHED`/`NO_MATCH` 로 확정한다. + +검증(9조합 실측): `empty` → `partial=False`(확정) / `blocked`·`env_blocked`·`unavailable` +→ `partial=True`(미확정). 테스트 12건 추가. + +**남은 것** + 2. `price_history` 에 몰별 상태·`partial` 을 담을 자리가 없다 → 두 화면 모두 못 읽는다 + (지금은 `job.result` 에만 있다) 3. lps-admin 이 몰별 상태·원인을 못 보여준다(잡 목록의 outcome 까지만) 4. negodata 가 `–`(없음)와 `확인 못함`(미확인)을 구분하지 못한다 -**1 → 2 → (3, 4)** 순서다. 1을 안 고치면 2가 담을 내용이 없고, 2가 없으면 3·4가 읽을 게 없다. -3과 4는 같은 데이터에서 각자 다르게 접는 것이므로 순서가 없다 — 병행 가능하다. +**2 → (3, 4)** 순서다. 2가 없으면 3·4가 읽을 게 없다. +3과 4는 같은 데이터를 각자 다르게 접는 것이므로 순서가 없다 — 병행 가능하다. > 이 문서는 **정의**다. 구현 전에 용어를 맞추기 위한 것이고, 실제 반영 여부는 위 4절이 소스다. diff --git a/lps/services/search/browser_base.py b/lps/services/search/browser_base.py index fb47528..11a0d5c 100644 --- a/lps/services/search/browser_base.py +++ b/lps/services/search/browser_base.py @@ -20,6 +20,7 @@ from abc import abstractmethod from patchright.async_api import async_playwright from common.logger import LOG +from common.enums import SourceState from config.server_configs import decodo_config, worker_config from services.search.contract import SearchAdapter, NormalizedProduct, AdapterError, AdapterHealth from services.search.rate_limiter import RateLimiter @@ -253,7 +254,7 @@ class BrowserSearchAdapter(SearchAdapter): self._note_result(False) raise AdapterError( f"{self.source} 가용 프록시 IP 없음(전부 임대/휴식/쿨다운) — 잠시 후 재시도", - source=self.source) + source=self.source, state=SourceState.UNAVAILABLE) kwargs["proxy"] = self._proxy.playwright_proxy() await self._begin_ip_session(self._proxy.current_port) else: @@ -409,7 +410,9 @@ class BrowserSearchAdapter(SearchAdapter): self._rotate_ip(f"프록시 전송오류({type(ex).__name__}) 재시도 {self.max_proxy_retries - proxy_retries}/{self.max_proxy_retries}", kind="proxy_error") continue self._note_result(False) - raise AdapterError(f"{self.source} 검색 실패: {ex}", source=self.source) from ex + # 페이지를 못 받았다 = 그 몰을 **못 봤다**. '없음'과 섞이면 안 된다. + raise AdapterError(f"{self.source} 검색 실패: {ex}", source=self.source, + state=SourceState.UNAVAILABLE) from ex # 실제 프록시 전송 바이트(CDP encodedDataLength) — 미지원 시 DOM 크기 폴백 self.last_bytes = self._net_bytes if self._cdp is not None else len(html.encode("utf-8")) @@ -436,7 +439,8 @@ class BrowserSearchAdapter(SearchAdapter): f"[{self.source}] 구조적 차단 '{marker}' — IP 회전으로 회복 불가. " f"게이트웨이/국가 설정을 확인하세요([DecodoConfig].kr_host 등)") raise AdapterError(f"{self.source} 구조적 차단 ({marker}) — 설정 확인 필요", - source=self.source, blocked=True, fatal=True) + source=self.source, blocked=True, fatal=True, + state=SourceState.ENV_BLOCKED) # 확신도에 따라 대응을 가른다. # 알려진 마커 사이트가 대놓고 막았다 → 태울 근거가 있다 # short_html 폴백 '0건인데 페이지가 짧다'는 정황일 뿐이다. 진짜 '검색결과 @@ -459,7 +463,8 @@ class BrowserSearchAdapter(SearchAdapter): raise AdapterError( f"{self.source} 환경 차단 (IP {len(self._fresh_ip_blocks)}개가 첫 요청부터 차단, " f"최근 마커={marker}) — IP 회전으로 회복 불가", - source=self.source, blocked=True, fatal=True) + source=self.source, blocked=True, fatal=True, + state=SourceState.ENV_BLOCKED) if known and self.uses_proxy: self._proxy.mark_burned(self._current_port) # 불탄 포트 — 쿨다운 격리(로테이션이 건너뜀) @@ -471,7 +476,13 @@ class BrowserSearchAdapter(SearchAdapter): if blocked: # 재시도 소진/비활성 — 불탄 포트로 다음 검색을 하지 않도록 회전만 예약하고 포기 self._rotate_ip("봇 감지 — 다음 검색은 새 IP", kind="block") self._note_result(False) - raise AdapterError(f"{self.source} 결과 없음/차단 (query={query!r}, blocked={blocked})", source=self.source, blocked=blocked) + # 여기가 '없음'과 '못 봄'이 갈리는 유일한 지점이다. + # blocked=False → 페이지는 정상인데 상품이 0건 = 그 몰에 **정말 없다**(EMPTY) + # blocked=True → 차단 페이지를 받은 것 = **못 봤다**(BLOCKED) + # 이 구분을 여기서 안 실어 보내면 위쪽에서는 영영 알 수 없다. + raise AdapterError(f"{self.source} 결과 없음/차단 (query={query!r}, blocked={blocked})", + source=self.source, blocked=blocked, + state=SourceState.BLOCKED if blocked else SourceState.EMPTY) async def _report_detection(self, query: str, marker: str, html_len: int): # 경과는 **IP 세션** 기준 — 'ip_req#N 을 몇 초 만에 쐈나'가 차단 진단의 축이다. diff --git a/lps/services/search/contract.py b/lps/services/search/contract.py index 3f37a5d..5086955 100644 --- a/lps/services/search/contract.py +++ b/lps/services/search/contract.py @@ -12,6 +12,8 @@ from typing import Optional from pydantic import BaseModel, Field +from common.enums import SourceState + class NormalizedProduct(BaseModel): """소스 무관 정규화 상품 스키마. 어댑터의 유일한 출력 계약.""" @@ -55,13 +57,25 @@ class AdapterError(Exception): 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): + 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): diff --git a/lps/tests/test_source_state.py b/lps/tests/test_source_state.py new file mode 100644 index 0000000..d8deffc --- /dev/null +++ b/lps/tests/test_source_state.py @@ -0,0 +1,107 @@ +"""몰(소스)별 상태 테스트 — '그 몰에 없었다'와 '그 몰을 못 봤다'를 가르는 계약. + +정의는 docs/result-states.md 가 소스다. 여기서 지키는 것은 하나다: +**확인한 것(matched/no_match/empty)과 못 본 것(blocked/env_blocked/unavailable)이 섞이지 않는다.** +섞이면 차단당해 못 본 몰이 화면에서 '그 몰엔 없음'으로 둔갑한다(실제로 그랬다). +""" + +import pytest + +from common.enums import SourceState +from services.search.contract import AdapterError, NormalizedProduct, SearchAdapter +from worker.handlers import _finalize_states, build_search_handler + + +# ── 상태 자체의 계약 ──────────────────────────────────────────────────── +def test_confirmed_separates_seen_from_unseen(): + """이 경계가 무너지면 나머지 로직이 전부 틀어진다.""" + assert [s for s in SourceState if s.confirmed] == [ + SourceState.MATCHED, SourceState.NO_MATCH, SourceState.EMPTY] + for s in (SourceState.BLOCKED, SourceState.ENV_BLOCKED, SourceState.UNAVAILABLE, SourceState.SKIPPED): + assert not s.confirmed, s + + +# ── AdapterError 가 상태를 싣는가 ─────────────────────────────────────── +@pytest.mark.parametrize("kw,expected", [ + (dict(), SourceState.UNAVAILABLE), # 아무것도 모르면 '못 봤다'가 안전 + (dict(blocked=True), SourceState.BLOCKED), + (dict(blocked=True, fatal=True), SourceState.ENV_BLOCKED), + (dict(state=SourceState.EMPTY), SourceState.EMPTY), +]) +def test_error_state_defaults(kw, expected): + """state 를 안 준 옛 호출부도 맞게 동작해야 한다 — 특히 기본값이 EMPTY 면 안 된다 + (확인도 안 한 몰을 '없음'으로 단정하게 된다).""" + assert AdapterError("x", source="s", **kw).state is expected + + +# ── 수집 뒤 매칭 결과로 확정 ──────────────────────────────────────────── +def test_collected_but_unmatched_becomes_no_match(): + per_source = {"naver": {"state": "matched"}, "coupang": {"state": "matched"}} + matched = [NormalizedProduct(source="naver", name="x", price=1)] + _finalize_states(per_source, matched) + assert per_source["naver"]["state"] == "matched" + assert per_source["coupang"]["state"] == "no_match" # 가져왔지만 같은 상품이 아니었다 + + +def test_finalize_never_overwrites_a_failure_state(): + """실패 상태는 이미 확정이다 — 매칭 결과로 덮으면 '못 봤다'가 사라진다.""" + per_source = {"coupang": {"state": "blocked"}, "naver": {"state": "empty"}} + _finalize_states(per_source, []) + assert per_source["coupang"]["state"] == "blocked" + assert per_source["naver"]["state"] == "empty" + + +# ── 핸들러 통합: 상태가 결과까지 실려 나가는가 ────────────────────────── +class _A(SearchAdapter): + def __init__(self, source, mode): self.source, self.mode = source, mode + + async def search(self, q, limit=40): + if self.mode == "hit": + return [NormalizedProduct(source=self.source, name="생수 2L", price=9000)] + if self.mode == "empty": + raise AdapterError("결과 없음", source=self.source, state=SourceState.EMPTY) + if self.mode == "blocked": + raise AdapterError("차단", source=self.source, blocked=True, state=SourceState.BLOCKED) + raise RuntimeError("우리 코드 버그") + + +def _job(): + return {"job_type": 1, "job_id": "J", "attempts": 1, "max_attempts": 3, + "payload": {"product_code": "P", "product_name": "생수"}} + + +async def _run(modes): + return await build_search_handler({s: _A(s, m) for s, m in modes.items()})(_job()) + + +async def test_empty_is_a_confirmed_answer_not_a_gap(): + """봤는데 없었으면 결과는 **확정**이다 — partial 로 흐리면 안 된다.""" + r = await _run({"naver": "hit", "coupang": "empty"}) + assert r["sources"]["coupang"]["state"] == "empty" + assert r["partial"] is False and r["sources_failed"] == [] + + +async def test_blocked_marks_the_result_incomplete(): + """못 본 몰이 있으면 그 결과는 최종이 아니다.""" + r = await _run({"naver": "hit", "coupang": "blocked"}) + assert r["sources"]["coupang"]["state"] == "blocked" + assert r["partial"] is True and r["sources_failed"] == ["coupang"] + + +async def test_unknown_exception_counts_as_unseen(): + """우리 코드 버그로 못 본 것도 '없음'이라 말하면 안 된다.""" + r = await _run({"naver": "hit", "coupang": "boom"}) + assert r["sources"]["coupang"]["state"] == "unavailable" + assert r["partial"] is True + + +async def test_all_empty_is_a_definitive_not_found(): + """전부 확인했고 없었다 → 확정적 not_found(네거티브 캐시에 넣어도 되는 상태).""" + r = await _run({"naver": "empty", "coupang": "empty"}) + assert r["outcome"] == "not_found" and r["partial"] is False + + +async def test_all_blocked_is_not_an_answer(): + """전부 못 봤으면 '없음'이 아니라 '모름'이다 — 재시도해야 한다.""" + with pytest.raises(RuntimeError): + await _run({"naver": "blocked", "coupang": "blocked"}) diff --git a/lps/worker/handlers.py b/lps/worker/handlers.py index 8d6098c..7d1f69b 100644 --- a/lps/worker/handlers.py +++ b/lps/worker/handlers.py @@ -16,7 +16,7 @@ import asyncio import time -from common.enums import JobType +from common.enums import JobType, SourceState from common.logger import LOG from services.metrics import SearchMetrics from services.search.contract import SearchAdapter, NormalizedProduct @@ -40,6 +40,20 @@ def _reap_abandoned(task: asyncio.Task): LOG.d(f"[fallback] 데드라인 초과 태스크 종료(무시): {type(ex).__name__}") +def _finalize_states(per_source: dict, matched: list[NormalizedProduct]) -> dict: + """수집 성공 소스의 상태를 **매칭 결과로 확정**한다(제자리 수정 후 반환). + + 수집 단계에선 '가져왔다'까지만 알 수 있다. '같은 상품이었나'는 AI 판정을 거쳐야 알므로 + 여기서 MATCHED / NO_MATCH 를 가른다. 실패 상태(BLOCKED·EMPTY 등)는 이미 확정이라 건드리지 않는다. + """ + hit = {p.source for p in matched} + collected = SourceState.MATCHED.name.lower() + for src, info in per_source.items(): + if info.get("state") == collected and src not in hit: + info["state"] = SourceState.NO_MATCH.name.lower() + return per_source + + def _price_snapshot(matched: list[NormalizedProduct]) -> dict: """매칭 목록에서 소스별 최저가 + 전체 최저가 스냅샷을 만든다(price_history 기록용).""" def lowest(src): @@ -119,11 +133,17 @@ def build_search_handler( products, per_source, ok_sources = [], {}, [] for src, res in zip(use, results): if isinstance(res, Exception): - per_source[src] = {"error": f"{type(res).__name__}: {res}"} - LOG.w(f"[{src}] 검색 실패: {type(res).__name__}: {res}") + # ⚠️ 예외를 문자열로만 남기면 '그 몰에 없었다'와 '그 몰을 못 봤다'가 같아진다. + # AdapterError 는 이미 state 로 그걸 알고 있으므로 **구조화해서 보존**한다. + # (AdapterError 가 아닌 예외 = 우리 코드 버그 → 못 본 것으로 본다) + state = getattr(res, "state", None) or SourceState.UNAVAILABLE + per_source[src] = {"state": state.name.lower(), "error": f"{type(res).__name__}: {res}"} + LOG.w(f"[{src}] 검색 실패({state.name}): {type(res).__name__}: {res}") + if state.confirmed: + ok_sources.append(src) # EMPTY = 봤는데 없던 것 → '정상 응답'으로 센다 else: products.extend(res) - per_source[src] = {"count": len(res)} + per_source[src] = {"state": SourceState.MATCHED.name.lower(), "count": len(res)} ok_sources.append(src) return products, per_source, ok_sources @@ -233,6 +253,7 @@ def build_search_handler( matched = [c for c, v in zip(candidates, verdicts) if v.is_match] stages.append({"stage": "ai_match", "in": len(candidates), "out": len(matched)}) candidates = matched + _finalize_states(per_source, candidates) # 수집만 된 소스를 matched/no_match 로 확정 last_stages, last_sources = stages, per_source last_ok, last_failed = ok_sources, failed_sources