o2o-negosium-original/lps/crud/bot_detection.py
민헌 18fe18dd39 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.
2026-08-05 13:22:37 +09:00

72 lines
3.6 KiB
Python

"""봇 감지 이력 기록 CRUD — '몇 번째 요청/어떤 포트에서 감지됐나'를 축적(패턴 분석용)."""
from sqlalchemy import text
from common.database.db_session_manager import DB_SESSION_MNG
from common.enums import DBType, DBWRType
class BotDetectionLog:
DB = DBType.MAIN.value
async def record(self, event: dict):
"""감지 이벤트 1건 저장. 로깅 실패가 검색을 막지 않도록 호출부에서 예외를 삼킨다."""
sql = text("""
INSERT INTO bot_detection (source, query, ip_request_no, proxy_port, elapsed_sec, marker, headless, html_len)
VALUES (:source, :query, :ip_request_no, :proxy_port, :elapsed_sec, :marker, :headless, :html_len)
""")
params = {k: event.get(k) for k in
("source", "query", "ip_request_no", "proxy_port", "elapsed_sec", "marker", "headless", "html_len")}
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_WRITE.value)
try:
await s.execute(sql, params)
await s.commit()
except Exception:
await s.rollback()
raise
finally:
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_WRITE.value)
async def admin_stats(self, hours: int = 168) -> dict:
"""관리자 FE 용 차단 통계 — 시간대별 건수 + 최근 감지 목록."""
p = {"h": hours}
hourly = text("""
SELECT date_trunc('hour', created_at) AS bucket, count(*) AS n FROM bot_detection
WHERE created_at > now() - make_interval(hours => :h) GROUP BY 1 ORDER BY 1
""")
recent = text("""
SELECT source, query, ip_request_no, proxy_port, elapsed_sec, marker, html_len, created_at
FROM bot_detection WHERE created_at > now() - make_interval(hours => :h)
ORDER BY created_at DESC LIMIT 50
""")
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value)
try:
return {
"hourly": [{"bucket": r[0].isoformat(), "count": int(r[1])} for r in (await s.execute(hourly, p)).all()],
"items": [dict(r) for r in (await s.execute(recent, p)).mappings().all()],
}
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)")
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value)
try:
return int((await s.execute(sql, {"m": minutes})).scalar() or 0)
finally:
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value)