diff --git a/lps/crud/bot_detection.py b/lps/crud/bot_detection.py index 804ff62..01175fd 100644 --- a/lps/crud/bot_detection.py +++ b/lps/crud/bot_detection.py @@ -48,6 +48,19 @@ class BotDetectionLog: finally: await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value) + async def recent_count_by_marker(self, markers: list[str], minutes: int = 60) -> int: + """특정 마커의 최근 감지 건수 — '회전으로 못 고치는' 차단만 따로 세어 알림에 쓴다. + (일반 차단은 IP 평판 문제라 자연 회복되지만, 구조적 차단은 사람이 설정을 고쳐야 한다)""" + if not markers: + return 0 + sql = text("""SELECT count(*) FROM bot_detection + WHERE created_at > now() - make_interval(mins => :m) AND marker = ANY(:mk)""") + s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value) + try: + return int((await s.execute(sql, {"m": minutes, "mk": list(markers)})).scalar() or 0) + finally: + await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value) + async def recent_count(self, minutes: int = 60) -> int: """최근 N분간 봇 감지(차단) 건수 — 차단율 급증 알림·모니터링용.""" sql = text("SELECT count(*) FROM bot_detection WHERE created_at > now() - make_interval(mins => :m)") diff --git a/lps/services/search/browser_base.py b/lps/services/search/browser_base.py index 123fa3e..6c5349e 100644 --- a/lps/services/search/browser_base.py +++ b/lps/services/search/browser_base.py @@ -64,6 +64,11 @@ class BrowserSearchAdapter(SearchAdapter): # 서브클래스 오버라이드 지점 block_markers: tuple = () + # 회전해도 소용없는 차단 마커. IP 를 바꿔도 같은 결과가 나오는 구조적 원인(게이트웨이 국가가 + # 틀렸다 등)이라, 여기 걸리면 **포트를 태우지 않고 재시도도 하지 않는다**. + # 태우면 안 되는 이유: 원인이 IP 가 아닌데 100개를 순서대로 쿨다운시키고, 같은 게이트웨이를 + # 쓰는 다른 소스(쿠팡)의 풀까지 말려버린다. + fatal_block_markers: tuple = () min_result_html: int = 10000 ready_selector: str = "body" ready_timeout_ms: int = 20000 @@ -298,10 +303,19 @@ class BrowserSearchAdapter(SearchAdapter): marker = detect_block(html, len(products), self.block_markers, self.min_result_html) blocked = marker is not None + fatal = blocked and marker in self.fatal_block_markers self._blocked += 1 if blocked: self._sess_blocked += 1 await self._report_detection(query, marker, len(html)) + if fatal: + # 회전해도 같은 결과다 — 태우지 않고 즉시 포기해 원인(설정)을 드러낸다. + self._note_result(False) + LOG.e_no_callstack( + f"[{self.source}] 구조적 차단 '{marker}' — IP 회전으로 회복 불가. " + f"게이트웨이/국가 설정을 확인하세요([DecodoConfig].kr_host 등)") + raise AdapterError(f"{self.source} 구조적 차단 ({marker}) — 설정 확인 필요", + source=self.source, blocked=True, fatal=True) if self.uses_proxy: self._proxy.mark_burned(self._current_port) # 불탄 포트 — 쿨다운 격리(로테이션이 건너뜀) diff --git a/lps/services/search/contract.py b/lps/services/search/contract.py index bd3076e..0e308cb 100644 --- a/lps/services/search/contract.py +++ b/lps/services/search/contract.py @@ -40,12 +40,18 @@ class AdapterHealth(BaseModel): class AdapterError(Exception): - """어댑터 수집 실패. blocked=True 면 안티봇 차단으로 판단(에스컬레이션/알림 트리거).""" + """어댑터 수집 실패. blocked=True 면 안티봇 차단으로 판단(에스컬레이션/알림 트리거). - def __init__(self, message: str, *, source: str, blocked: bool = False): + 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): diff --git a/lps/services/search/naver_shop/adapter.py b/lps/services/search/naver_shop/adapter.py index d8f2c6a..eb67ade 100644 --- a/lps/services/search/naver_shop/adapter.py +++ b/lps/services/search/naver_shop/adapter.py @@ -29,6 +29,10 @@ _BLOCK_MARKERS = ( "wtm_captcha", "비정상적인 접근", "일시적으로 제한", "자동입력 방지", "정상적인 서비스 이용", "nid.naver.com/nidlogin", ) +# 해외 IP 로 접근했을 때만 나오는 하드차단(실측 2,641B). IP 를 바꿔도 같은 게이트웨이면 +# 결과가 같으므로 회전·재시도가 무의미하다 — kr_host 설정을 고쳐야 한다. +_FATAL_MARKERS = ("비정상적인 접근",) + # 정상 결과 페이지는 1.3MB+ 다. 차단 페이지는 실측 48~65KB → 그 사이에 임계를 둔다. # (0건일 때만 적용되므로 '검색결과 없음'이 커도 오탐하지 않는다) _MIN_RESULT_HTML = 150_000 @@ -44,6 +48,7 @@ class NaverShopAdapter(BrowserSearchAdapter): # price_history 의 naver_lowest/name/url, MALL_BY_SOURCE, 프론트 그래프 계약이 그대로 산다. source = "naver" block_markers = _BLOCK_MARKERS + fatal_block_markers = _FATAL_MARKERS min_result_html = _MIN_RESULT_HTML ready_selector = SELECTORS.card ready_timeout_ms = 20000 diff --git a/lps/tests/test_browser_base.py b/lps/tests/test_browser_base.py index 102356c..06fac71 100644 --- a/lps/tests/test_browser_base.py +++ b/lps/tests/test_browser_base.py @@ -149,3 +149,47 @@ async def test_live_smoke(name): assert all(p.price > 0 and p.name for p in ps) finally: await ad.close() + + +# ── 구조적 차단(회전 무효) ─────────────────────────────────────────────── +class _FatalAdapter(_MockAdapter): + """해외 IP 하드차단처럼 '회전해도 같은 결과'인 마커를 가진 어댑터.""" + block_markers = ("BOTBLOCK", "HARDBLOCK") + fatal_block_markers = ("HARDBLOCK",) + + +async def test_fatal_block_does_not_rotate_or_burn(): + """구조적 차단은 IP 문제가 아니다 — 태우면 원인도 못 고친 채 풀만 말린다 + (같은 게이트웨이를 쓰는 다른 소스의 IP 까지 쿨다운에 묶인다).""" + page = _MockPage(["HARDBLOCK"]) + proxy = _MockProxy() + ad = _FatalAdapter(page, proxy=proxy, max_block_retries=3) + with pytest.raises(AdapterError) as e: + await ad.search("q") + assert e.value.fatal is True and e.value.blocked is True + assert proxy.burned == [], "구조적 차단은 포트를 태우지 않는다" + assert proxy.rotations == 0, "회전해도 소용없으므로 회전하지 않는다" + + +async def test_fatal_block_is_recorded_for_alerting(): + """감지 기록은 남겨야 한다 — 알림이 이 마커를 세서 '설정 고치라'고 알린다.""" + events = [] + + async def on_detect(ev): + events.append(ev) + + page = _MockPage(["HARDBLOCK"]) + ad = _FatalAdapter(page, proxy=_MockProxy(), on_detect=on_detect) + with pytest.raises(AdapterError): + await ad.search("q") + assert len(events) == 1 and events[0]["marker"] == "HARDBLOCK" + + +async def test_ordinary_block_still_rotates_and_burns(): + """일반 차단(IP 평판)은 기존대로 태우고 회전해 회복을 시도한다 — 두 경로가 갈렸는지 확인.""" + page = _MockPage(["BOTBLOCK", "ok"]) + proxy = _MockProxy() + ad = _FatalAdapter(page, proxy=proxy, max_block_retries=1) + ps = await ad.search("q") + assert len(ps) > 0 + assert proxy.burned == [10001] and proxy.rotations == 1 diff --git a/lps/worker_main.py b/lps/worker_main.py index 652d38f..db0c840 100644 --- a/lps/worker_main.py +++ b/lps/worker_main.py @@ -132,6 +132,14 @@ async def _warmup_worker(worker_adapters, tries: int = 3, attempt_timeout: float LOG.w(f"[warmup:{ad.source}] {tries}회 실패(첫 잡에서 재시도): {type(ex).__name__}") +def _fatal_markers(adapters) -> list[str]: + """어댑터들이 선언한 '회전 무효' 마커 목록 — 알림이 이 마커만 세도록 모은다.""" + out = [] + for ad in (adapters or []): + out += list(getattr(ad, "fatal_block_markers", ()) or ()) + return sorted(set(out)) + + async def _port_pool_status(adapters) -> tuple[dict, tuple[int, int] | None]: """포트 장부 현황 → (게이트웨이별 상세, (최소 가용, 전체)). @@ -176,6 +184,13 @@ async def run_ops_monitor(queue, bot_log, stop, interval: float = 30.0, adapters snap["pool_pct"] = pool["pct"] await alerts.check("dead", snap["dead_1h"] >= th.dead_1h, f"DEAD 1h={snap['dead_1h']}", snap) await alerts.check("blocks", snap["blocks_1h"] >= th.blocks_1h, f"차단 1h={snap['blocks_1h']}", snap) + # 구조적 차단(회전 무효) — 1건만 나와도 알린다. 방치하면 그 소스는 계속 0건이다. + fatal = _fatal_markers(adapters) + if fatal: + snap["fatal_blocks_1h"] = await bot_log.recent_count_by_marker(fatal, 60) + await alerts.check("fatal_block", snap["fatal_blocks_1h"] > 0, + f"구조적 차단 1h={snap['fatal_blocks_1h']} (마커 {fatal}) — " + f"IP 회전으로 회복 불가. 게이트웨이 국가 설정([DecodoConfig].kr_host) 확인", snap) await alerts.check("queue_lag", snap["oldest_pending_sec"] >= th.queue_lag_sec, f"큐지연={snap['oldest_pending_sec']}s", snap) await alerts.check("stuck", snap["stuck_running"] > 0, f"stuck={snap['stuck_running']}", snap) await alerts.check("db_pool", pool["pct"] >= th.pool_pct,