From 08afc7d4ad6c932d09db8079e6f919877e90c3d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Thu, 6 Aug 2026 11:50:08 +0900 Subject: [PATCH] =?UTF-8?q?fix(lps):=20sticky=20=EB=A7=8C=EB=A3=8C=20?= =?UTF-8?q?=ED=9A=8C=EC=A0=84=EC=9D=B4=20=EC=A0=80=ED=8A=B8=EB=9E=98?= =?UTF-8?q?=ED=94=BD=EC=97=90=EC=84=9C=20=EC=95=88=20=EB=A8=B9=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20+=20prod=20=ED=85=9C=ED=94=8C=EB=A6=BF?= =?UTF-8?q?=EC=97=90=20kr=5Fhost=20=EB=88=84=EB=9D=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lps/config/config.prod.toml.example | 7 +++++- lps/services/search/browser_base.py | 38 ++++++++++++++++++++--------- lps/tests/test_browser_base.py | 29 +++++++++++++++++++--- 3 files changed, 58 insertions(+), 16 deletions(-) diff --git a/lps/config/config.prod.toml.example b/lps/config/config.prod.toml.example index c4ef0f2..2c5783f 100644 --- a/lps/config/config.prod.toml.example +++ b/lps/config/config.prod.toml.example @@ -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 = "" 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분) diff --git a/lps/services/search/browser_base.py b/lps/services/search/browser_base.py index 5acb316..eca440a 100644 --- a/lps/services/search/browser_base.py +++ b/lps/services/search/browser_base.py @@ -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): diff --git a/lps/tests/test_browser_base.py b/lps/tests/test_browser_base.py index 6e7ccac..0b14f1a 100644 --- a/lps/tests/test_browser_base.py +++ b/lps/tests/test_browser_base.py @@ -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(["ok"] * 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(["ok"] * 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(["ok"] * 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