한 DECODO 계정을 여러 워커 프로세스가 나눠 쓰는 전제로 전환한다. 인메모리 장부는 프로세스마다 따로라 (1) 같은 IP 를 동시에 잡고 (2) 한쪽이 태운 IP 를 다른 쪽이 곧바로 집으며 (3) 재시작하면 쿨다운이 통째로 사라졌다. proxy_port 테이블 = 단일 진실. 상태는 세 시각으로만 표현한다(leased/rest/cooldown_until). - acquire: 한 UPDATE 안에서 FOR UPDATE SKIP LOCKED 로 후보를 잠그고 임대까지 끝낸다 (잡 큐와 같은 방식 — SELECT 후 UPDATE 로 나누면 그 틈에 다른 프로세스가 같은 행을 집는다) - 회전은 LRU(last_used_at). 프로세스가 몇 개든 '가장 오래 안 쓴 IP'를 집으므로 전체가 자연히 한 바퀴씩 돈다 → 프로세스별 seed_offset 계산 제거 - 죽은 프로세스 회수: leased_until 만료로 자동 복귀(별도 reaper 불필요) - 차단·휴식은 전역이라 재시작해도 유지된다 DB 왕복은 비동기라 검색 루프(동기)에서 곧바로 못 한다 → 회전·차단을 pending 에 적어두고 ensure_port(브라우저 재기동 직전, async)에서 한 번에 flush. _close_ctx 에서도 flush 해 종료 시 유실(=태운 IP 를 남이 그대로 집는 상황)을 막는다. **프로필 슬롯**(services/search/profile_slot): Chrome 은 user_data_dir 당 1 인스턴스다. 예전엔 워커 인덱스로만 갈라서 프로세스 2개면 같은 경로를 잡아 두 번째가 통째로 죽었다 (실측: 잡 3건 중 2건 DEAD, TargetClosedError). 파일 락으로 슬롯을 선점한다 — PID 경로가 아니라 슬롯이라 재시작 시 재사용돼 웜 쿠키(cf_clearance·Akamai)를 버리지 않는다. 검증: 프로세스 2개 동시 acquire 20회 → 중복 배정 0건. 워커 2프로세스 e2e → 잡 3건 모두 DONE(네이버가 삼다수 최저가 획득 8,960 < 13,200). 테스트 14건 추가, 전체 217 passed.
358 lines
20 KiB
Python
358 lines
20 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 ",
|
|
)
|
|
|
|
|
|
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 = ()
|
|
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
|
|
self._launched_at = 0.0
|
|
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
|
|
self._end_reason = None # 이번 세션이 끝나는 이유(budget/block/proxy_error/window/idle/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 _recycle_due(self) -> bool:
|
|
if self._force_recycle:
|
|
return True
|
|
if not (self._proxy and self._proxy.enabled):
|
|
return False
|
|
return (time.monotonic() - self._launched_at) > self._proxy.session_minutes * 60
|
|
|
|
async def _ensure_browser(self):
|
|
if self._ctx is not None:
|
|
if self._recycle_due():
|
|
LOG.d(f"[{self.source}] 브라우저 재기동(IP 회전)")
|
|
if self._end_reason is None: # force 가 아닌 시간창 만료 재기동
|
|
self._end_reason = "window"
|
|
await self._close_ctx()
|
|
else:
|
|
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:
|
|
await ensure()
|
|
kwargs["proxy"] = self._proxy.playwright_proxy()
|
|
self._current_port = self._proxy.current_port
|
|
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._ip_requests = 0
|
|
self._sess_ok = self._sess_blocked = 0
|
|
self._end_reason = None
|
|
self._force_recycle = False
|
|
|
|
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:
|
|
await self._record_session_end()
|
|
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,
|
|
"elapsed_sec": int(time.monotonic() - self._launched_at),
|
|
"end_reason": self._end_reason or "window"}
|
|
self._end_reason = None
|
|
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 _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._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
|
|
self._blocked += 1
|
|
if blocked:
|
|
self._sess_blocked += 1
|
|
await self._report_detection(query, marker, len(html))
|
|
if 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):
|
|
elapsed = int(time.monotonic() - self._launched_at)
|
|
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 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 에 남아 재기동해도 웜 유지."""
|
|
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 초과 → 브라우저 정리(다음 검색 때 재기동)")
|
|
if self._end_reason is None:
|
|
self._end_reason = "idle"
|
|
await self._close_ctx()
|
|
|
|
async def close(self):
|
|
if self._end_reason is None:
|
|
self._end_reason = "shutdown"
|
|
await self._close_ctx()
|
|
if self._pw is not None:
|
|
await self._pw.stop()
|
|
self._pw = None
|