쿠팡 크롤 IP 를 '막힐 때까지' 쓰던 방식을 '막히기 전에 교체'로 전환한다. - 요청 예산(LPS_IP_REQUEST_BUDGET, 기본 3): IP당 요청 수가 예산에 닿으면 차단 전에 선제 회전. 실측상 5회 부근 차단 이력이 있어 보수적으로 3회. 선제 교체된 포트는 평판이 깨끗해 로테이션 복귀 시 재사용된다. - 포트 쿨다운(LPS_PORT_COOLDOWN_SEC, 기본 max(sticky,30분)): 차단 감지· 전송오류 포트는 격리하고 _port() 가 건너뛴다. 전 포트 쿨다운이면 만료 임박 포트 사용(가용성 우선). 포트 수는 config 범위에서 동적 산출. - 차단 재시도 소진 시에도 회전 예약 — 불탄 포트로 다음 검색을 하지 않음. - ip_session 테이블 신설: 세션마다 요청 수·성공/차단·종료 사유(budget/ block/proxy_error/window/idle/shutdown)를 기록. bot_detection 과 달리 무사 종료도 남아 예산 상한 튜닝의 원천 데이터가 된다(쿼리 database.md). models.py·migrations·init.sql(lps_db 섹션) 동행 갱신, dev DB 적용 완료. - 테스트 17건 추가(쿨다운·예산 판정·세션 기록·CRUD), 전체 126 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
137 lines
4.7 KiB
Python
137 lines
4.7 KiB
Python
"""IP 선제 로테이션 테스트 — 요청 예산 판정·세션 종료 기록(브라우저 불필요) + CRUD(실 lps_db)."""
|
|
|
|
import pytest_asyncio
|
|
from sqlalchemy import text
|
|
|
|
from config.config_models import DecodoConfig
|
|
from crud.ip_session import IpSessionLog
|
|
from services.search.coupang.adapter import CoupangAdapter
|
|
from services.search.proxy import DecodoProxy
|
|
|
|
|
|
def _proxy():
|
|
return DecodoProxy(DecodoConfig(host="gate.decodo.com", username="u", password="p",
|
|
port_start=10001, port_end=10010, session_minutes=10))
|
|
|
|
|
|
def _adapter(**kw):
|
|
# __init__ 은 브라우저를 띄우지 않는다 — 예산/세션 부기 로직만 검증
|
|
return CoupangAdapter(headless=True, user_data_dir="/tmp/lps_test_profile", **kw)
|
|
|
|
|
|
# ---- 요청 예산(선제 회전) 판정 ------------------------------------------
|
|
|
|
def test_budget_reached_only_with_proxy():
|
|
ad = _adapter(ip_request_budget=3)
|
|
ad._ip_requests = 3
|
|
assert ad._budget_reached() is False # 프록시 미사용 — 예산 개념 없음
|
|
ad._proxy = _proxy()
|
|
assert ad._budget_reached() is True
|
|
|
|
|
|
def test_budget_zero_disables_preemptive_rotation():
|
|
ad = _adapter(ip_request_budget=0, proxy=_proxy())
|
|
ad._ip_requests = 999
|
|
assert ad._budget_reached() is False
|
|
|
|
|
|
def test_budget_not_reached_below_threshold():
|
|
ad = _adapter(ip_request_budget=3, proxy=_proxy())
|
|
ad._ip_requests = 2
|
|
assert ad._budget_reached() is False
|
|
|
|
|
|
def test_default_budget_is_conservative():
|
|
ad = _adapter(proxy=_proxy())
|
|
assert ad._ip_budget == 3 # 실측상 5회 부근 차단 → 기본 3회
|
|
|
|
|
|
def test_rotate_ip_records_end_reason_and_recycles():
|
|
ad = _adapter(proxy=_proxy())
|
|
before = ad._proxy.current_port
|
|
ad._rotate_ip("예산 도달", kind="budget", warn=False)
|
|
assert ad._end_reason == "budget"
|
|
assert ad._force_recycle is True
|
|
assert ad._proxy.current_port != before # 즉시 다음 포트
|
|
|
|
|
|
# ---- 세션 종료 기록 -------------------------------------------------------
|
|
|
|
async def test_session_end_event_shape():
|
|
events = []
|
|
|
|
async def cb(e):
|
|
events.append(e)
|
|
|
|
ad = _adapter(proxy=_proxy(), on_session_end=cb)
|
|
ad._ip_requests, ad._sess_ok, ad._sess_blocked = 3, 2, 1
|
|
ad._current_port, ad._end_reason = 10005, "budget"
|
|
await ad._record_session_end()
|
|
assert events == [{
|
|
"source": "coupang", "proxy_port": 10005, "requests": 3,
|
|
"ok_count": 2, "blocked_count": 1, "elapsed_sec": events[0]["elapsed_sec"],
|
|
"end_reason": "budget",
|
|
}]
|
|
assert ad._end_reason is None # 기록 후 리셋
|
|
|
|
|
|
async def test_session_end_skips_empty_session():
|
|
events = []
|
|
|
|
async def cb(e):
|
|
events.append(e)
|
|
|
|
ad = _adapter(on_session_end=cb)
|
|
ad._ip_requests = 0 # 요청 없던 세션(유휴 정리 등)은 노이즈 — 미기록
|
|
await ad._record_session_end()
|
|
assert events == []
|
|
|
|
|
|
async def test_session_end_defaults_to_window_reason():
|
|
events = []
|
|
|
|
async def cb(e):
|
|
events.append(e)
|
|
|
|
ad = _adapter(on_session_end=cb)
|
|
ad._ip_requests = 2 # end_reason 미지정 = 시간창 만료 재기동
|
|
await ad._record_session_end()
|
|
assert events[0]["end_reason"] == "window"
|
|
|
|
|
|
async def test_session_end_callback_failure_is_swallowed():
|
|
async def boom(e):
|
|
raise RuntimeError("db down")
|
|
|
|
ad = _adapter(on_session_end=boom)
|
|
ad._ip_requests = 1
|
|
await ad._record_session_end() # 예외가 검색을 막지 않는다
|
|
|
|
|
|
# ---- CRUD (실 lps_db) ----------------------------------------------------
|
|
|
|
@pytest_asyncio.fixture
|
|
async def ip_log(db_engine):
|
|
async with db_engine.begin() as conn:
|
|
await conn.execute(text("TRUNCATE ip_session"))
|
|
return IpSessionLog()
|
|
|
|
|
|
async def test_record_persists_session(ip_log, db_engine):
|
|
await ip_log.record({"source": "coupang", "proxy_port": 10003, "requests": 3,
|
|
"ok_count": 3, "blocked_count": 0, "elapsed_sec": 120, "end_reason": "budget"})
|
|
async with db_engine.begin() as conn:
|
|
row = (await conn.execute(text(
|
|
"SELECT source, proxy_port, requests, ok_count, end_reason FROM ip_session"
|
|
))).first()
|
|
assert row.source == "coupang" and row.proxy_port == 10003
|
|
assert row.requests == 3 and row.ok_count == 3 and row.end_reason == "budget"
|
|
|
|
|
|
async def test_recent_stats_groups_by_reason(ip_log, db_engine):
|
|
for reason in ("budget", "budget", "block"):
|
|
await ip_log.record({"source": "coupang", "requests": 3, "ok_count": 3,
|
|
"blocked_count": 0, "end_reason": reason})
|
|
stats = await ip_log.recent_stats(60)
|
|
assert stats == {"budget": 2, "block": 1}
|