o2o-negosium-original/lps/services/search/browser_base.py
민헌 7b9d895ead fix(lps): 풀 고갈 시 남의 IP 를 빌려 쓰던 문제 — 임대 없으면 크롤하지 않는다
동시 다상품 검색 점검 중 발견. 워커 3개(소유자 6)가 포트 2개/게이트웨이를 두고 경합하는
상황을 실제 코드로 돌리니, 임대를 못 받은 워커가 **남이 쥔 포트를 그대로 집어 같은 IP 로
동시에 요청**했다:

  coupang-w1   사용=70002 임대=70002
  coupang-w2   사용=70002 임대=None    ← 같은 IP 를 둘이 사용

원인은 _port() 의 계산식 폴백이다. 장부 모드에서 acquire 가 None 을 줘도 시간창 계산으로
포트를 하나 골라 돌려줬다. 포트 장부가 존재하는 이유("워커 N개가 같은 IP 에 요청을 몰면 그 IP 가
빨리 탄다" — port_registry.py 도입 배경)를 정면으로 무너뜨리는 경로다. 게다가 하필 **풀이 마른
상태 = IP 가 가장 귀할 때** 발동해, 남은 IP 를 두 배 속도로 태우는 악순환을 만든다.

→ 장부 모드에선 임대한 포트만 쓴다(없으면 None). 못 받으면 AdapterError 로 실패하고 잡이
   백오프 후 재시도한다 — 그 사이 쿨다운이 풀린다. 풀 고갈 자체는 proxy_ports_low 가 이미 운다.
→ playwright_proxy() 도 임대가 없으면 예외. 여기서 None 을 돌려주면 **프록시 없이** 브라우저가
   떠 서버 공인 IP 로 크롤하게 되는데, 그 IP 가 타면 회전으로 복구할 수 없다.

동시성 점검 결과(포트 20개/게이트웨이, 워커 3개):
  정상 24건 동시     성공 24 · 포트 중복 보유 0
  풀 고갈           성공 4/6(2건은 정상적으로 실패) · **같은 IP 공유 0**
  전면 차단          소각이 어댑터당 2개에서 멈춤(게이트웨이당 6/20) · 브레이커 6/6 트립

테스트 3건 추가(고갈 시 None 반환·남의 포트 미사용 / 임대 없는 playwright_proxy 예외 /
검색이 깔끔히 실패), 전체 256 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:01:15 +09:00

526 lines
34 KiB
Python

"""브라우저 기반 검색 어댑터 공통 베이스.
쿠팡·G마켓·옥션·11번가처럼 안티봇(Akamai/ESM 챌린지 등) 때문에 실제 Chrome(patchright)로
뚫어야 하는 소스의 공통 machinery 를 모은다:
브라우저 수명 관리 · 프록시 sticky 회전 · 리소스 차단(대역폭↓) · 차단 감지 + IP 회전 재시도 + 감지 기록.
사이트별 차이는 훅으로 분리한다:
_search_url(query, limit) 검색 URL
_parse(html) HTML → NormalizedProduct[]
ready_selector 결과 렌더 완료 신호 셀렉터(챌린지 통과 대기용)
block_markers/min_result_html 차단 판정(0건일 때)
detect_block 은 순수 함수로 분리 — 브라우저 없이 단위 테스트 가능.
"""
import asyncio
import time
from abc import abstractmethod
from patchright.async_api import async_playwright
from common.logger import LOG
from config.server_configs import decodo_config, worker_config
from services.search.contract import SearchAdapter, NormalizedProduct, AdapterError, AdapterHealth
from services.search.rate_limiter import RateLimiter
# 대역폭 절감 기본 차단 집합(쿠팡): 이미지/미디어/폰트/CSS. 쿠팡은 CSS 없이도 파싱·Akamai 통과 OK.
# 오픈마켓(ESM/11번가)은 CSS/JS 를 막으면 렌더/챌린지가 깨져 이미지·미디어·폰트만 막는다(어댑터에서 override).
_BLOCKED_RESOURCES = {"image", "media", "font", "stylesheet"}
# 브라우저 실행 대상([WorkerConfig]): 로컬 Mac=실제 Chrome(channel), 컨테이너=시스템 chromium(executable).
# headless 는 안티봇에 탐지되므로 서버에선 Xvfb(가상 디스플레이)로 headful 실행한다(headless 실측 실패).
_CHROME_CHANNEL = worker_config.chrome_channel
_CHROME_EXECUTABLE = worker_config.chrome_executable or None
def detect_block(html: str, product_count: int, markers: tuple, min_len: int) -> str | None:
"""0건 응답의 차단 여부 판정(순수 함수). 반환: 차단 마커(차단) 또는 None(정상 빈결과).
상품이 있으면 항상 None. 알려진 마커 우선, 없으면 비정상적으로 짧은 HTML 을 미지의 차단으로 폴백."""
if product_count > 0:
return None
marker = next((m for m in markers if m in html), None)
if marker is None and len(html) < min_len:
marker = f"short_html({len(html)}B)"
return marker
# 프록시 전송 실패 마커 — 사이트 차단이 아니라 DECODO 포트/IP 사망·세션만료. 봇 감지와 별개로 IP 회전 트리거.
_PROXY_ERR_MARKERS = (
"ERR_TUNNEL_CONNECTION_FAILED", "ERR_PROXY_CONNECTION_FAILED", "ERR_HTTP_RESPONSE_CODE_FAILURE",
"ERR_NO_SUPPORTED_PROXIES", "ERR_SOCKS_CONNECTION_FAILED", "ERR_CONNECTION_CLOSED",
"Proxy Authentication", "status code 407", "407 ",
)
# 서킷브레이커가 트립했을 때 남기는 감지 마커. HTML 에서 나오는 마커가 아니라 **연속 패턴**으로
# 판정한 것이라, 알림(worker_main._fatal_markers)이 이 값을 '회전 무효' 계열로 함께 센다.
ENV_BLOCK_MARKER = "env_block"
def is_proxy_error(msg: str) -> bool:
"""예외 메시지가 프록시 전송 실패(포트/IP 사망·407)인지(순수 함수, 단위 테스트 가능).
True 면 사이트 차단이 아니라 프록시 문제 → 다른 IP 로 회전하면 회복 가능."""
return any(m in (msg or "") for m in _PROXY_ERR_MARKERS)
class BrowserSearchAdapter(SearchAdapter):
"""patchright(스텔스 Chrome) 기반 검색 어댑터 베이스. persistent context 로 브라우저를 재사용한다."""
# 서브클래스 오버라이드 지점
block_markers: tuple = ()
# 회전해도 소용없는 차단 마커. IP 를 바꿔도 같은 결과가 나오는 구조적 원인(게이트웨이 국가가
# 틀렸다 등)이라, 여기 걸리면 **포트를 태우지 않고 재시도도 하지 않는다**.
# 태우면 안 되는 이유: 원인이 IP 가 아닌데 100개를 순서대로 쿨다운시키고, 같은 게이트웨이를
# 쓰는 다른 소스(쿠팡)의 풀까지 말려버린다.
fatal_block_markers: tuple = ()
# 환경 차단 판정 임계 — 서로 다른 IP 가 연속으로 **첫 요청부터**(ip_req#1) 막힌 횟수.
# 이 조합은 IP 로 설명되지 않는다: IP 평판 문제라면 몇 개는 통과해야 하고, 요청을 너무
# 많이 쐈다면 #1 이 아니라 뒤쪽에서 막힌다. 남는 원인은 환경 쪽이다(컨테이너·게이트웨이
# 국가/대역 전체 차단 등) — 회전해도 못 고친다.
# 임계 없이 계속 태우면 잡 16건에 100포트가 30분 쿨다운으로 묶여 같은 게이트웨이를 쓰는
# 다른 소스까지 말린다(2026-08-05 실측). 실제 장부에도 9분간 11포트 연속 소각 이력이 있다.
env_block_threshold: int = 3
min_result_html: int = 10000
ready_selector: str = "body"
ready_timeout_ms: int = 20000
block_resources_default: bool = True # 리소스 차단 라우팅 on/off 기본값.
blocked_resource_types: set = _BLOCKED_RESOURCES # 차단할 resource_type(사이트별 override — 오픈마켓은 CSS/JS 유지)
scroll_steps: int = 0 # >0 이면 렌더 대기 전 스크롤(지연 로딩 트리거, 예: 11번가)
max_proxy_retries: int = 2 # 프록시 전송오류(포트 사망) 시 IP 회전 재시도 횟수
# launch_persistent_context 에 얹을 사이트별 옵션. 네이버 WTM 은 **한국 IP + en-US 로케일** 조합을
# 봇으로 본다(실측: 같은 IP·같은 브라우저에서 locale 만 ko-KR 로 주면 캡차→정상). 기본은 비움.
context_options: dict = {}
def __init__(self, headless: bool = False, user_data_dir: str | None = None, rate_limiter: RateLimiter | None = None,
proxy=None, block_resources: bool | None = None, on_detect=None, max_block_retries: int = 1,
ip_request_budget: int | None = None, on_session_end=None):
self._headless = headless
self._user_data_dir = user_data_dir or f"/tmp/lps_{self.source}_profile"
self._rl = rate_limiter or RateLimiter()
self._proxy = proxy # DecodoProxy 등 (없으면 직접 연결)
self._block_resources = self.block_resources_default if block_resources is None else block_resources
self._block_active = self._block_resources # 요청별 실제 차단 여부(_blocking_now 로 갱신)
self._on_detect = on_detect # async def(event: dict) — 감지 영속화(선택)
self._max_block_retries = max_block_retries
# IP(포트 세션)당 요청 예산([DecodoConfig].ip_request_budget) — 도달하면 차단당하기 **전에**
# 선제 회전해 IP 평판을 보존한다. 실측상 5회 부근 차단 이력 → 기본 3. 0=비활성(시간창 회전만).
self._ip_budget = decodo_config.ip_request_budget if ip_request_budget is None else ip_request_budget
self._on_session_end = on_session_end # async def(event: dict) — IP 세션 종료 기록(선택, 상한 튜닝 데이터)
self._pw = None
self._ctx = None
# ── IP(포트) 세션 상태 — 브라우저 수명과 **분리**한다 ────────────────────
# 시계는 **하나만** 둔다(_session_started_at). 예전엔 브라우저 기동 시각(_launched_at)이
# 따로 있어 sticky 만료·세션 지속시간을 그 시계로 쟀는데, 유휴 정리로 브라우저가 닫혔다
# 열릴 때마다 시계가 되감겨 실제 IP 사용 시간과 어긋났다(F1·F4 의 공통 원인).
# 예산은 '이 IP 로 몇 번 요청했나'지 '이 브라우저로 몇 번'이 아니다. 예전엔 아래 값들을
# 브라우저 재기동 때마다 0 으로 되돌렸는데, 유휴 정리(close_if_idle)는 브라우저만 닫고
# 임대는 그대로 두므로 **같은 IP 를 계속 쓰면서 카운터만 리셋**됐다. 게다가 ensure_port
# 는 재기동마다 임대를 renew 해 만료를 뒤로 민다 — 검색이 유휴 임계(120s)보다 뜸하고
# 임대(10분)보다 잦으면 한 IP 에 영원히 고정된다(2026-08-05 실측: 6회 검색이 전부 같은
# 포트·ip_req#1). 그러면 예산이 영영 발화하지 않고, bot_detection.ip_request_no 도 항상
# 1 로 찍혀 '몇 번째 요청에서 막혔나'(IP 평판이냐 예산이냐) 진단이 통째로 무너진다.
# → 이 값들은 **포트가 실제로 바뀔 때만** 리셋한다(_begin_ip_session).
self._session_started_at = 0.0 # 현재 IP 세션 시작 시각(monotonic)
self._ip_requests = 0 # 현재 IP 로 보낸 요청 수
self._current_port = None
self._force_recycle = False
self._lock = asyncio.Lock()
self._ok = 0
self._blocked = 0
self._sess_ok = 0 # 현재 IP 세션의 성공/차단(세션 종료 기록용)
self._sess_blocked = 0
# 환경 차단 서킷브레이커 상태 — '첫 요청부터 막힌' 서로 다른 포트들. 성공 1회로 초기화된다
# (타이머가 없어도 환경이 회복되면 스스로 풀린다).
self._fresh_ip_blocks: list = []
self._env_blocked = False
self._end_reason = None # 이번 IP 세션이 끝나는 이유(budget/block/proxy_error/window/shutdown)
self._last_used = 0.0 # 마지막 검색 시각(monotonic) — 유휴 브라우저 정리 판단용
self._cdp = None # CDP 세션(실제 네트워크 바이트 계측용). 미지원 시 None → DOM 크기 폴백
self._net_bytes = 0 # 현재 검색의 실제 전송 바이트(encodedDataLength 누적)
self.last_bytes = 0 # 직전 search 의 전송 바이트(계측용) — 호출부가 await 직후 읽는다
# ---- 사이트별 훅 --------------------------------------------------
@abstractmethod
def _search_url(self, query: str, limit: int) -> str:
raise NotImplementedError
@abstractmethod
def _parse(self, html: str) -> list[NormalizedProduct]:
raise NotImplementedError
async def _wait_ready(self, page):
"""결과 렌더 대기. 지연 로딩(scroll_steps>0)이면 먼저 스크롤로 트리거하고, ready_selector 등장까지 대기.
챌린지형(ESM '잠시만')은 이 대기 시간 안에 자동 통과(IP 평판 좋을 때). 타임아웃은 예외로 두지 않는다
— 이후 parse 0건이면 차단 판정 로직(마커/짧은HTML)이 처리해 IP 회전을 유도."""
for _ in range(self.scroll_steps):
try:
await page.evaluate("window.scrollBy(0, 1500)")
except Exception:
break
await page.wait_for_timeout(1000)
try:
await page.wait_for_selector(self.ready_selector, timeout=self.ready_timeout_ms)
except Exception:
pass
# ---- 공통 브라우저 수명 -------------------------------------------
async def _route(self, route):
if self._block_active and route.request.resource_type in self.blocked_resource_types:
await route.abort()
else:
await route.continue_()
async def _blocking_now(self) -> bool:
"""이번 요청에서 리소스를 실제로 차단할지. 기본은 설정값 그대로.
ESM(Turnstile)은 챌린지 solving 중엔 차단하면 안 되므로 override(웜=cf_clearance 있으면만 차단)."""
return self._block_resources
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._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 가 **같은 포트를
계속 붙잡아** 회전이 일어나지 않는다(로그만 '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
LOG.d(f"[{self.source}] 브라우저 재기동")
await self._close_ctx()
return True
async def _ensure_browser(self):
if not await self._recycle_if_due():
return
if self._pw is None:
self._pw = await async_playwright().start()
kwargs = dict(user_data_dir=self._user_data_dir, headless=self._headless, no_viewport=True)
kwargs.update(self.context_options) # 사이트별 컨텍스트 옵션(예: 네이버 locale/timezone)
if _CHROME_EXECUTABLE:
kwargs["executable_path"] = _CHROME_EXECUTABLE # 컨테이너: 시스템 chromium
# 컨테이너(root)에선 sandbox 불가 → --no-sandbox 필수(없으면 런칭 행). /dev/shm 부족 크래시 방지.
kwargs["args"] = ["--no-sandbox", "--disable-dev-shm-usage"]
else:
kwargs["channel"] = _CHROME_CHANNEL # 로컬: 실제 Chrome
if self._proxy and self._proxy.enabled:
# 포트 확정은 여기서만 한다(유일한 async 지점) — 회전·차단으로 밀린 DB 반영도 함께 flush.
ensure = getattr(self._proxy, "ensure_port", None)
if ensure is not None and await ensure() is None:
# 전부 임대/휴식/쿨다운 중 = 지금은 안전하게 크롤할 IP 가 없다.
# 남의 임대를 빌려 쓰거나 프록시 없이 도는 대신 실패한다 — 잡은 백오프 후 재시도하고,
# 그 사이 쿨다운이 풀린다. 풀 고갈 자체는 proxy_ports_low 알림이 따로 운다.
self._note_result(False)
raise AdapterError(
f"{self.source} 가용 프록시 IP 없음(전부 임대/휴식/쿨다운) — 잠시 후 재시도",
source=self.source)
kwargs["proxy"] = self._proxy.playwright_proxy()
await self._begin_ip_session(self._proxy.current_port)
else:
await self._begin_ip_session(None) # 프록시 없음 = IP 하나짜리 세션
self._ctx = await self._pw.chromium.launch_persistent_context(**kwargs)
if self._block_resources:
await self._ctx.route("**/*", self._route)
self._force_recycle = False
async def _begin_ip_session(self, port):
"""IP 세션의 경계를 관리한다 — **포트가 실제로 바뀔 때만** 이전 세션을 마감하고 카운터를 리셋.
같은 포트로 브라우저만 재기동한 경우(유휴 정리 후 복귀 등)는 여전히 같은 IP 이므로
요청 수·성공/차단 누적을 그대로 이어간다. 이게 예산(_budget_reached)과
bot_detection.ip_request_no 를 실제 IP 사용량에 맞추는 유일한 지점이다.
"""
if self._session_started_at and port == self._current_port:
return # 같은 IP 계속 사용 — 카운터 유지
await self._record_session_end() # 이전 IP 세션 마감(요청이 있었을 때만 기록)
self._current_port = port
self._session_started_at = time.monotonic()
self._ip_requests = 0
self._sess_ok = self._sess_blocked = 0
async def _close_ctx(self):
# 밀린 반납·차단을 먼저 DB 에 반영한다 — 여기서 흘리지 않으면 종료 시 유실돼
# 다른 프로세스가 방금 태운 IP 를 그대로 집는다.
flush = getattr(self._proxy, "flush", None)
if flush is not None:
try:
await flush()
except Exception as ex:
LOG.d(f"[{self.source}] 포트 상태 flush 실패(무시): {type(ex).__name__}")
self._cdp = None # 컨텍스트와 함께 CDP 세션도 죽음 → 다음 검색 때 재부착
if self._ctx is not None:
# 세션 종료 기록은 여기서 하지 않는다 — 브라우저를 닫는 것과 IP 세션이 끝나는 것은
# 다른 사건이다(유휴 정리는 브라우저만 닫고 같은 IP 로 돌아온다).
# 기록은 포트가 바뀌는 _begin_ip_session 과 최종 close() 가 담당한다.
try:
await self._ctx.close()
finally:
self._ctx = None
async def _record_session_end(self):
"""IP 세션 종료 1건 기록 — '이 IP 로 몇 번 요청하고 어떻게 끝났나'. 예산(상한) 튜닝의 원천 데이터.
요청이 없던 세션은 노이즈라 기록하지 않는다. 기록 실패가 검색을 막지 않는다."""
if self._on_session_end is None or self._ip_requests == 0:
self._end_reason = None
return
event = {"source": self.source, "proxy_port": self._current_port,
"requests": self._ip_requests, "ok_count": self._sess_ok, "blocked_count": self._sess_blocked,
# 브라우저 기동 시각이 아니라 **IP 세션** 시작 시각 기준 — 유휴 정리로 브라우저가
# 한 번 닫혀도 그 IP 를 쥔 총 시간이 나온다(안전 상한을 시간축으로도 보기 위해).
"elapsed_sec": int(time.monotonic() - (self._session_started_at or time.monotonic())),
"end_reason": self._end_reason or "window"}
self._end_reason = None
# 마감한 세션은 비운다 — 멱등성 확보. 안 비우면 close() 가 두 번 불릴 때(종료 경로가
# 겹치는 경우) 같은 IP 세션이 두 건으로 기록돼 예산 튜닝 통계가 부풀려진다.
self._ip_requests = 0
self._sess_ok = self._sess_blocked = 0
try:
await self._on_session_end(event)
except Exception as ex:
LOG.e_no_callstack(f"[{self.source}] IP 세션 기록 실패(무시): {ex}")
def _add_net(self, event):
"""CDP Network.loadingFinished 콜백 — 실제 전송 바이트(encodedDataLength) 누적."""
try:
self._net_bytes += int(event.get("encodedDataLength", 0) or 0)
except Exception:
pass
async def _ensure_net_meter(self, page):
"""CDP 네트워크 계측 세션 부착(컨텍스트당 1회). 실패(미지원)하면 DOM 크기로 폴백."""
if self._cdp is not None:
return
try:
self._cdp = await self._ctx.new_cdp_session(page)
await self._cdp.send("Network.enable")
self._cdp.on("Network.loadingFinished", self._add_net)
except Exception:
self._cdp = None
@property
def uses_proxy(self) -> bool:
"""이 어댑터가 프록시(DECODO)를 경유하는지 — 대역폭 비용 귀속용."""
return bool(self._proxy and self._proxy.enabled)
def _budget_reached(self) -> bool:
"""현재 IP 로 요청 예산을 소진했는지(선제 회전 트리거). 프록시 미사용·예산 0(비활성)이면 False."""
return self.uses_proxy and self._ip_budget > 0 and self._ip_requests >= self._ip_budget
def _note_fresh_ip_block(self) -> bool:
"""'새 IP 의 첫 요청부터 차단' 을 **서로 다른 포트** 기준으로 세고, 임계 도달 시 트립한다.
같은 포트를 두 번 세지 않는 게 핵심이다 — 한 IP 가 반복해 막히는 건 그냥 나쁜 IP 지
환경 문제가 아니다. 트립 후에는 더 세지 않는다(목록이 무한히 자라지 않게).
"""
if self._env_blocked:
return True
if not self.uses_proxy or self._ip_requests != 1:
return False # 뒤쪽 요청에서 막힌 건 '이 IP 를 많이 썼다' 신호
if self._current_port not in self._fresh_ip_blocks:
self._fresh_ip_blocks.append(self._current_port)
self._env_blocked = len(self._fresh_ip_blocks) >= self.env_block_threshold
return self._env_blocked
def _clear_env_block(self):
"""성공 1회 = 환경이 멀쩡하다는 증거. 별도 타이머 없이 여기서 스스로 풀린다."""
if self._env_blocked:
LOG.i(f"[{self.source}] 환경 차단 해제 — 검색 성공(서킷브레이커 리셋)")
self._fresh_ip_blocks.clear()
self._env_blocked = False
def _rotate_ip(self, reason: str, kind: str = "rotate", warn: bool = True):
"""즉시 다음 IP(포트)로 회전 예약 + 다음 _ensure_browser 에서 브라우저 재기동.
kind 는 세션 종료 사유로 기록된다(budget=선제/block=차단/proxy_error=포트사망)."""
if self._proxy and self._proxy.enabled:
self._proxy.rotate(kind) # budget(선제)이면 놓는 포트에 휴식이 붙는다
self._force_recycle = True
self._end_reason = kind
(LOG.w if warn else LOG.i)(f"[{self.source}] IP 회전 — {reason}")
# ---- 검색(프록시 전송오류·봇 감지 → IP 회전 인라인 재시도) --------
async def search(self, query: str, limit: int = 40) -> list[NormalizedProduct]:
async with self._lock: # 인스턴스 내 검색 직렬화(브라우저 컨텍스트 공유)
self._last_used = time.monotonic()
proxy_retries, block_retries = self.max_proxy_retries, self._max_block_retries
while True:
await self._rl.wait()
# 예산 도달 → 차단당하기 전에 선제 회전. 이 포트는 불탄 게 아니라 쿨다운 없이
# 로테이션 복귀 시 재사용된다(IP 평판 보존이 목적).
if self._budget_reached():
self._rotate_ip(f"요청예산 {self._ip_budget}회 도달 — 선제 회전", kind="budget", warn=False)
await self._ensure_browser()
self._ip_requests += 1
page = self._ctx.pages[0] if self._ctx.pages else await self._ctx.new_page()
url = self._search_url(query, limit)
self._block_active = await self._blocking_now() # 챌린지 solving 중이면 차단 해제(Turnstile 보호)
await self._ensure_net_meter(page)
self._net_bytes = 0 # 이 검색의 전송 바이트만 집계
try:
await page.goto(url, wait_until="domcontentloaded", timeout=40000)
await self._wait_ready(page)
html = await page.content()
except Exception as ex:
# 프록시 전송 실패(포트/IP 사망·407)면 사이트 문제가 아니므로 IP 회전 후 재시도
if self.uses_proxy and is_proxy_error(str(ex)) and proxy_retries > 0:
proxy_retries -= 1
self._proxy.mark_burned(self._current_port) # 죽은 포트 — 쿨다운 뒤 복귀(sticky 만료로 새 IP)
self._rotate_ip(f"프록시 전송오류({type(ex).__name__}) 재시도 {self.max_proxy_retries - proxy_retries}/{self.max_proxy_retries}", kind="proxy_error")
continue
self._note_result(False)
raise AdapterError(f"{self.source} 검색 실패: {ex}", source=self.source) from ex
# 실제 프록시 전송 바이트(CDP encodedDataLength) — 미지원 시 DOM 크기 폴백
self.last_bytes = self._net_bytes if self._cdp is not None else len(html.encode("utf-8"))
products = self._parse(html)
if products:
self._ok += 1
self._sess_ok += 1
self._clear_env_block()
self._note_result(True)
LOG.d(f"[{self.source}] query={query!r} → {len(products)}건 (limit {limit}, ip_req#{self._ip_requests})")
return products[:limit]
marker = detect_block(html, len(products), self.block_markers, self.min_result_html)
blocked = marker is not None
fatal = blocked and marker in self.fatal_block_markers
self._blocked += 1
if blocked:
self._sess_blocked += 1
await self._report_detection(query, marker, len(html))
if fatal:
# 회전해도 같은 결과다 — 태우지 않고 즉시 포기해 원인(설정)을 드러낸다.
self._note_result(False)
LOG.e_no_callstack(
f"[{self.source}] 구조적 차단 '{marker}' — IP 회전으로 회복 불가. "
f"게이트웨이/국가 설정을 확인하세요([DecodoConfig].kr_host 등)")
raise AdapterError(f"{self.source} 구조적 차단 ({marker}) — 설정 확인 필요",
source=self.source, blocked=True, fatal=True)
# 확신도에 따라 대응을 가른다.
# 알려진 마커 사이트가 대놓고 막았다 → 태울 근거가 있다
# short_html 폴백 '0건인데 페이지가 짧다'는 정황일 뿐이다. 진짜 '검색결과
# 없음' 페이지일 수 있는데(네이버 무결과 페이지 크기는
# 아직 실측된 적이 없다) 확신 없이 태우면 정상 응답에
# IP 를 30분 묶고, 서킷브레이커까지 오작동해 소스를 멈춘다.
# → 미지의 경우엔 회전·재시도까지만 한다(짧고 되돌릴 수 있는 대응).
# 판단 근거는 bot_detection 에 marker='short_html(NB)' 로 쌓이므로,
# 나중에 그 분포를 보고 임계(min_result_html)를 실측으로 정할 수 있다.
known = marker in self.block_markers
if not known:
LOG.d(f"[{self.source}] 미지의 0건 응답({marker}) — 태우지 않고 회전만 한다")
if known and self._note_fresh_ip_block():
# 서로 다른 IP 가 연속으로 첫 요청부터 막혔다 = IP 문제가 아니다.
# **태우지 않고** 포기한다. 태워봐야 원인은 그대로인데 풀만 마른다.
# IP 는 바꿔둔다(쿨다운 없이 반납) — 죽은 IP 를 계속 쥐고 있지 않도록.
await self._report_env_block(query, marker)
self._rotate_ip("환경 차단 — 태우지 않고 IP 만 교체", kind="block", warn=False)
self._note_result(False)
raise AdapterError(
f"{self.source} 환경 차단 (IP {len(self._fresh_ip_blocks)}개가 첫 요청부터 차단, "
f"최근 마커={marker}) — IP 회전으로 회복 불가",
source=self.source, blocked=True, fatal=True)
if known and self.uses_proxy:
self._proxy.mark_burned(self._current_port) # 불탄 포트 — 쿨다운 격리(로테이션이 건너뜀)
if blocked and self.uses_proxy and block_retries > 0:
block_retries -= 1
self._rotate_ip(f"봇 감지 재시도 {self._max_block_retries - block_retries}/{self._max_block_retries}", kind="block")
continue
if blocked: # 재시도 소진/비활성 — 불탄 포트로 다음 검색을 하지 않도록 회전만 예약하고 포기
self._rotate_ip("봇 감지 — 다음 검색은 새 IP", kind="block")
self._note_result(False)
raise AdapterError(f"{self.source} 결과 없음/차단 (query={query!r}, blocked={blocked})", source=self.source, blocked=blocked)
async def _report_detection(self, query: str, marker: str, html_len: int):
# 경과는 **IP 세션** 기준 — 'ip_req#N 을 몇 초 만에 쐈나'가 차단 진단의 축이다.
elapsed = int(time.monotonic() - (self._session_started_at or time.monotonic()))
LOG.w(f"[{self.source}][BOT-DETECTED] ip_req#{self._ip_requests} port={self._current_port} "
f"elapsed={elapsed}s headless={self._headless} marker={marker!r} query={query!r} html_len={html_len}")
if self._on_detect is not None:
event = {"source": self.source, "query": query, "ip_request_no": self._ip_requests,
"proxy_port": self._current_port, "elapsed_sec": elapsed, "marker": marker,
"headless": self._headless, "html_len": html_len}
try:
await self._on_detect(event)
except Exception as ex:
LOG.e_no_callstack(f"[{self.source}] 감지 기록 실패(무시): {ex}")
async def _report_env_block(self, query: str, marker: str):
"""서킷브레이커가 막은 검색을 감지 이력으로 남긴다 — 알림이 이 마커를 세서 즉시 알린다.
트립 1회만이 아니라 **막힌 검색마다** 남긴다. 알림은 '최근 1시간 fatal 마커 수'로 판정하므로,
한 번만 기록하면 문제가 그대로인데도 1시간 뒤 '해소' 알림이 나간다.
"""
LOG.e_no_callstack(
f"[{self.source}] 환경 차단 — 서로 다른 IP {len(self._fresh_ip_blocks)}개가 모두 "
f"첫 요청부터 막혔습니다(마커={marker!r}). IP 회전으로는 회복되지 않습니다. "
f"실행 환경(컨테이너 vs 호스트)과 게이트웨이 설정을 확인하세요. "
f"— 포트는 더 태우지 않습니다")
await self._report_detection(query, ENV_BLOCK_MARKER, 0)
async def health(self) -> AdapterHealth:
total = self._ok + self._blocked
rate = (self._ok / total) if total else 0.0
return AdapterHealth(source=self.source, ok=(self._blocked == 0 or rate > 0.5),
recent_success_rate=rate, blocked_rate=(self._blocked / total) if total else 0.0)
async def close_if_idle(self, idle_sec: float):
"""일정 시간 검색이 없으면 브라우저 컨텍스트를 정리(메모리 회수). playwright 는 유지 —
다음 검색 때 재기동한다. cf_clearance 등 쿠키는 user_data_dir 에 남아 재기동해도 웜 유지.
**IP 세션은 끝나지 않는다** — 임대는 그대로 두고 같은 IP 로 돌아오므로, 요청 예산 카운터도
이어진다(예전엔 여기서 리셋돼 예산이 발화하지 않았다)."""
if self._ctx is None or self._lock.locked(): # 검색 중이면 건너뜀
return
if time.monotonic() - self._last_used < idle_sec:
return
async with self._lock:
if self._ctx is not None and time.monotonic() - self._last_used >= idle_sec:
LOG.d(f"[{self.source}] 유휴 {idle_sec:.0f}s 초과 → 브라우저 정리"
f"(IP 세션 유지 · ip_req#{self._ip_requests})")
await self._close_ctx()
async def close(self):
if self._end_reason is None:
self._end_reason = "shutdown"
await self._close_ctx()
await self._record_session_end() # 프로세스 종료 = IP 세션도 여기서 끝난다
# 쥐고 있던 포트를 돌려준다 — 안 그러면 재시작해도 임대 만료(최대 sticky 수명)까지
# 그 IP 를 아무도 못 쓴다. 정리 실패가 종료를 막지는 않는다(임대는 만료로도 회수된다).
release = getattr(self._proxy, "release", None)
if release is not None:
try:
await release()
except Exception as ex:
LOG.d(f"[{self.source}] 포트 반납 실패(무시 — 임대 만료로 회수됨): {type(ex).__name__}")
if self._pw is not None:
await self._pw.stop()
self._pw = None