feat(lps): 회전으로 못 고치는 차단을 따로 구분 — 태우지 않고 즉시 실패 + 설정 알림

네이버 차단은 두 종류인데 지금까지 똑같이 '회전 후 재시도'로 처리했다(실측):
  비정상적인 접근  2.6KB  해외 IP — 게이트웨이 국가가 틀림. IP 를 바꿔도 결과 동일
  wtm_captcha    47~63KB IP 평판·세션 — 회전으로 회복 가능

전자를 회전시키면 100포트를 순서대로 태우기만 하고, 더 나쁘게는 **같은 게이트웨이를 쓰는
쿠팡의 풀까지 30분씩 말린다**(kr_host 를 비우면 네이버가 gate 로 폴백하므로 실제로 일어난다).

- AdapterError.fatal: 재시도해도 안 되는 구조적 실패 표시
- BrowserSearchAdapter.fatal_block_markers: 걸리면 포트를 태우지 않고 회전도 없이 즉시 실패.
  감지 기록(bot_detection)은 남긴다 — 알림이 그걸 센다
- NaverShopAdapter.fatal_block_markers = ("비정상적인 접근",)
- ops 알림 'fatal_block': 해당 마커가 1h 내 1건만 나와도 발화(자연 회복이 없어 방치하면
  그 소스는 계속 0건이다). BotDetectionLog.recent_count_by_marker 추가

라이브 검증: 일부러 해외 게이트웨이로 네이버 검색 → fatal=True 로 즉시 실패,
쿨다운 증가 0(태우지 않음), ERROR 로그에 원인·조치(kr_host 확인) 명시.
테스트 3건 추가(태우지 않음·회전 없음 / 기록은 남김 / 일반 차단은 기존대로), 전체 220 passed.
This commit is contained in:
민헌 2026-08-05 13:22:37 +09:00
parent 3b345929d4
commit 18fe18dd39
6 changed files with 99 additions and 2 deletions

View File

@ -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)")

View File

@ -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) # 불탄 포트 — 쿨다운 격리(로테이션이 건너뜀)

View File

@ -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):

View File

@ -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

View File

@ -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(["<html>HARDBLOCK</html>"])
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(["<html>HARDBLOCK</html>"])
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(["<html>BOTBLOCK</html>", "<PRODUCT>ok</PRODUCT>"])
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

View File

@ -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,