네이버 차단은 두 종류인데 지금까지 똑같이 '회전 후 재시도'로 처리했다(실측):
비정상적인 접근 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.
72 lines
3.6 KiB
Python
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)
|