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