fix(lps): sticky 만료 회전이 저트래픽에서 안 먹던 문제 + prod 템플릿에 kr_host 누락
prod 영향 점검 중 발견. 직전 커밋(377389f)의 sticky 만료 회전은 **브라우저가 열려 있을 때만** 동작했다 — `_ctx is None` 이면 만료 검사를 건너뛰었다. 그런데 negodata 연동은 수동 트리거 전용이라 검색이 드문드문 들어오고, 그때는 유휴 정리(120s)로 브라우저가 닫힌 채 매 검색이 그 경로로 들어온다. 즉 **실사용 패턴에서만 안 먹는** 반쪽 수정이었다(377389f커밋 메시지·README 의 '고쳤다'는 서술이 부정확했다). 실측(합성 게이트웨이·10포트, sticky 매번 경과): 연속 검색 IP 6개 순환 ✅ 저트래픽 · 예산 3 IP 2개 예산이 대신 회전시켜 가려져 있었음 저트래픽 · 예산 0 **IP 1개 고정** ❌ ([DecodoConfig] 주석의 '0=시간창 회전만'이 거짓) 원인은 시계가 둘이었던 것이다. sticky 만료를 브라우저 기동 시각(_launched_at)으로 쟀는데, 브라우저는 닫혔다 열릴 때마다 시계가 되감긴다. IP 를 쥔 시간과 어긋나는 이 구조가 F1(예산 미발화)과 F4(회전 안 됨)의 공통 원인이었다. → 시계를 _session_started_at 하나로 통일하고 _launched_at 을 제거했다. 만료 판정은 브라우저가 닫혀 있어도 수행한다. 세 시나리오 모두 정상 회전 확인. **prod 템플릿 kr_host 누락**(기존 문제, 이번 변경과 무관): config.prod.toml.example 에 kr_host 가 없어 그대로 복사하면 네이버가 국가 무지정 게이트웨이로 떨어진다. 해외 residential IP 는 '비정상적인 접근'(2.6KB) 하드차단이고 회전으로 회복 불가라 네이버 결과가 통째로 0건이 된다. kr_host + naver_ip_request_budget 을 경고 주석과 함께 추가. 테스트 2건 추가(저트래픽 sticky 만료 회전 / IP 를 쥔 시간이 수명 내면 회전 안 함), 전체 253 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
7b1bcb6d24
commit
08afc7d4ad
@ -70,11 +70,16 @@ model = "gpt-4o-mini"
|
||||
|
||||
[DecodoConfig]
|
||||
host = "gate.decodo.com"
|
||||
# ⚠️ 네이버는 **한국 IP 가 필수**다. 이 줄이 비면 네이버가 host(국가 무지정)로 떨어지고,
|
||||
# 해외 residential IP 로는 매 검색이 하드차단('비정상적인 접근', 2.6KB)된다 — IP 를 아무리
|
||||
# 회전해도 회복되지 않는 구조적 차단이라 네이버 결과가 통째로 0건이 된다(fatal_block 알림).
|
||||
kr_host = "kr.decodo.com" # 한국 타깃 게이트웨이(네이버용). 비우면 host 사용 = 네이버 불가
|
||||
username = "<DECODO_USERNAME>"
|
||||
password = "<DECODO_PASSWORD>"
|
||||
port_start = 10001
|
||||
port_end = 10100
|
||||
session_minutes = 10
|
||||
cost_per_gb = 3.0
|
||||
ip_request_budget = 3 # IP당 요청 예산. 차단이 ip_req#1 에 몰리면 이 값과 무관한 문제다
|
||||
ip_request_budget = 3 # 쿠팡 IP당 요청 예산. 차단이 ip_req#1 에 몰리면 이 값과 무관한 문제다
|
||||
naver_ip_request_budget = 10 # 네이버는 IP 당 12회까지 무차단 실측 → 여유 둔 10
|
||||
port_cooldown_sec = 0 # 0=자동 max(sticky, 30분)
|
||||
|
||||
@ -109,8 +109,10 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
self._on_session_end = on_session_end # async def(event: dict) — IP 세션 종료 기록(선택, 상한 튜닝 데이터)
|
||||
self._pw = None
|
||||
self._ctx = None
|
||||
self._launched_at = 0.0 # 현재 브라우저 컨텍스트 기동 시각(시간창 재기동 판정용)
|
||||
# ── IP(포트) 세션 상태 — 브라우저 수명과 **분리**한다 ────────────────────
|
||||
# 시계는 **하나만** 둔다(_session_started_at). 예전엔 브라우저 기동 시각(_launched_at)이
|
||||
# 따로 있어 sticky 만료·세션 지속시간을 그 시계로 쟀는데, 유휴 정리로 브라우저가 닫혔다
|
||||
# 열릴 때마다 시계가 되감겨 실제 IP 사용 시간과 어긋났다(F1·F4 의 공통 원인).
|
||||
# 예산은 '이 IP 로 몇 번 요청했나'지 '이 브라우저로 몇 번'이 아니다. 예전엔 아래 값들을
|
||||
# 브라우저 재기동 때마다 0 으로 되돌렸는데, 유휴 정리(close_if_idle)는 브라우저만 닫고
|
||||
# 임대는 그대로 두므로 **같은 IP 를 계속 쓰면서 카운터만 리셋**됐다. 게다가 ensure_port
|
||||
@ -174,30 +176,43 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
ESM(Turnstile)은 챌린지 solving 중엔 차단하면 안 되므로 override(웜=cf_clearance 있으면만 차단)."""
|
||||
return self._block_resources
|
||||
|
||||
def _recycle_due(self) -> bool:
|
||||
if self._force_recycle:
|
||||
return True
|
||||
if not (self._proxy and self._proxy.enabled):
|
||||
def _sticky_expired(self) -> bool:
|
||||
"""현재 IP 세션이 제공자 sticky 수명을 넘겼는지.
|
||||
|
||||
기준은 브라우저 기동 시각이 **아니라 IP 세션 시작 시각**이다. 유휴 정리로 브라우저가
|
||||
닫혀 있어도 그 IP 를 쥔 시간은 계속 흐르기 때문이다 — 브라우저 시계로 재면 저트래픽에서
|
||||
재기동마다 시계가 되감겨 만료를 영영 못 본다(실측: 예산을 끄면 한 IP 에 고정됐다).
|
||||
"""
|
||||
if not (self._proxy and self._proxy.enabled) or not self._session_started_at:
|
||||
return False
|
||||
return (time.monotonic() - self._launched_at) > self._proxy.session_minutes * 60
|
||||
return (time.monotonic() - self._session_started_at) > self._proxy.session_minutes * 60
|
||||
|
||||
def _recycle_due(self) -> bool:
|
||||
"""브라우저를 새로 띄워야 하는지 = 회전이 예약됐거나 IP 가 수명을 다했는지.
|
||||
브라우저 수명은 IP 수명을 따라간다 — IP 가 그대로면 컨텍스트도 재사용한다(웜 쿠키 보존)."""
|
||||
return self._force_recycle or self._sticky_expired()
|
||||
|
||||
async def _recycle_if_due(self) -> bool:
|
||||
"""기존 컨텍스트를 계속 쓸 수 있으면 False, 닫고 새로 띄워야 하면 True.
|
||||
|
||||
재기동 사유는 두 가지고 처리가 다르다:
|
||||
force_recycle 이미 회전이 예약된 상태(예산·차단·프록시오류) — 여기서 또 회전하지 않는다
|
||||
시간창 만료 제공자 쪽 sticky 세션도 끝났다 = 같은 포트라도 IP 가 바뀐다
|
||||
시간창 만료인데 임대를 쥔 채 브라우저만 다시 띄우면 ensure_port 의 renew 가 **같은 포트를
|
||||
sticky 만료 제공자 쪽 세션도 끝났다 = 같은 포트라도 IP 가 바뀐다
|
||||
만료인데 임대를 쥔 채 브라우저만 다시 띄우면 ensure_port 의 renew 가 **같은 포트를
|
||||
계속 붙잡아** 회전이 일어나지 않는다(로그만 'IP 회전'이라 찍혔다). 명시적으로 놓아준다 —
|
||||
그래야 LRU 가 다음 IP 를 내주고 요청 카운터도 새 IP 기준으로 다시 센다.
|
||||
|
||||
⚠️ 만료 판정은 **브라우저가 닫혀 있어도** 한다. 유휴 정리가 낀 저트래픽(negodata 의
|
||||
수동 트리거가 이 패턴이다)에서는 매 검색이 `_ctx is None` 으로 들어오는데, 여기서
|
||||
건너뛰면 sticky 가 몇 번을 지나도 회전하지 않는다.
|
||||
"""
|
||||
if not self._force_recycle and self._sticky_expired():
|
||||
self._rotate_ip(f"sticky {self._proxy.session_minutes}분 만료 — 새 IP",
|
||||
kind="window", warn=False)
|
||||
if self._ctx is None:
|
||||
return True
|
||||
if not self._recycle_due():
|
||||
return False
|
||||
if not self._force_recycle:
|
||||
self._rotate_ip(f"sticky {self._proxy.session_minutes}분 만료 — 새 IP",
|
||||
kind="window", warn=False)
|
||||
LOG.d(f"[{self.source}] 브라우저 재기동")
|
||||
await self._close_ctx()
|
||||
return True
|
||||
@ -227,7 +242,6 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
self._ctx = await self._pw.chromium.launch_persistent_context(**kwargs)
|
||||
if self._block_resources:
|
||||
await self._ctx.route("**/*", self._route)
|
||||
self._launched_at = time.monotonic()
|
||||
self._force_recycle = False
|
||||
|
||||
async def _begin_ip_session(self, port):
|
||||
|
||||
@ -77,7 +77,6 @@ class _MockAdapter(BrowserSearchAdapter):
|
||||
return
|
||||
self._ctx = _MockCtx(self._page)
|
||||
await self._begin_ip_session(self._proxy.current_port if self._proxy else None)
|
||||
self._launched_at = time.monotonic()
|
||||
self._force_recycle = False
|
||||
|
||||
async def _ensure_net_meter(self, page): pass # CDP 없음 → last_bytes=DOM 크기
|
||||
@ -208,17 +207,41 @@ async def test_sticky_window_expiry_actually_rotates_the_ip():
|
||||
await ad.search("q")
|
||||
assert (ad._current_port, ad._proxy.rotations) == (10001, 0)
|
||||
|
||||
ad._launched_at -= ad._proxy.session_minutes * 60 + 1 # sticky 수명 경과
|
||||
ad._session_started_at -= ad._proxy.session_minutes * 60 + 1 # sticky 수명 경과
|
||||
await ad.search("q")
|
||||
assert ad._proxy.kinds == ["window"], "시간창 만료는 window 사유로 회전한다"
|
||||
assert (ad._current_port, ad._ip_requests) == (10002, 1), "새 IP 로 바뀌고 카운터도 새로 센다"
|
||||
|
||||
|
||||
async def test_sticky_expiry_rotates_even_when_the_browser_was_idle_closed():
|
||||
"""저트래픽(negodata 수동 트리거)에서는 매 검색이 브라우저 닫힌 채로 들어온다.
|
||||
예전엔 그 경로에서 만료 검사를 건너뛰어, 예산을 끄면 한 IP 에 영원히 고정됐다(실측)."""
|
||||
ad = _MockAdapter(_MockPage(["<PRODUCT>ok</PRODUCT>"] * 3), proxy=_MockProxy(),
|
||||
ip_request_budget=0) # 예산 비활성 = 시간창만이 회전 수단
|
||||
seen = []
|
||||
for _ in range(3):
|
||||
await ad.search("q")
|
||||
seen.append(ad._current_port)
|
||||
await _idle_cleanup(ad) # 브라우저만 닫힘(임대는 유지)
|
||||
ad._session_started_at -= ad._proxy.session_minutes * 60 + 1 # sticky 수명 경과
|
||||
assert len(set(seen)) == 3, f"sticky 만료마다 새 IP 여야 한다 — 실제: {seen}"
|
||||
assert ad._proxy.kinds == ["window", "window"]
|
||||
|
||||
|
||||
async def test_sticky_clock_follows_the_ip_not_the_browser():
|
||||
"""브라우저를 몇 번 닫았다 열든, IP 를 쥔 시간이 수명을 넘지 않으면 회전하지 않는다."""
|
||||
ad = _MockAdapter(_MockPage(["<PRODUCT>ok</PRODUCT>"] * 3), proxy=_MockProxy(), ip_request_budget=0)
|
||||
for _ in range(3):
|
||||
await ad.search("q")
|
||||
await _idle_cleanup(ad)
|
||||
assert ad._proxy.rotations == 0 and ad._current_port == 10001
|
||||
|
||||
|
||||
async def test_forced_rotation_does_not_double_rotate_on_window():
|
||||
"""이미 회전이 예약된 상태(예산·차단)에서 시간창까지 걸려도 회전은 1번이어야 한다."""
|
||||
ad = _MockAdapter(_MockPage(["<PRODUCT>ok</PRODUCT>"] * 2), proxy=_MockProxy(), ip_request_budget=1)
|
||||
await ad.search("q") # 예산 1회 → 다음 검색 전에 선제 회전 예약
|
||||
ad._launched_at -= ad._proxy.session_minutes * 60 + 1
|
||||
ad._session_started_at -= ad._proxy.session_minutes * 60 + 1
|
||||
await ad.search("q")
|
||||
assert ad._proxy.kinds == ["budget"], "예약된 회전이 우선 — window 로 덧회전하지 않는다"
|
||||
assert ad._proxy.rotations == 1
|
||||
|
||||
Loading…
Reference in New Issue
Block a user