perf(lps): Step2/3 — Turnstile 웜업·동적 차단 + 유휴 브라우저 정리

G마켓/옥션 Cloudflare Turnstile 을 실제로 통과시키고, 쌓이는 브라우저를 정리한다.

Turnstile 규명(실측): patchright 가 콜드 ~12초에 자동 통과, 통과 후 cf_clearance 쿠키로
이후 요청은 ~5초(웜). 단 **리소스를 조금이라도 차단하면 Turnstile 지문검사에 걸려 실패**
(이미지만 막아도 실패). 하지만 **cf_clearance 확보 후엔 차단해도 재챌린지 없음**.
- 동적 차단: _block_active + _blocking_now() 훅. ESM 은 cf_clearance 있을 때만 차단
  (콜드=허용→Turnstile 통과, 웜=이미지/미디어/폰트 차단→대역폭↓). ready_timeout 25s.
  best-effort Turnstile 체크박스 클릭(인터랙티브/나쁜IP 대비).
- 웜업: 기동 시 gmarket/auction/coupang 를 1회 풀어 쿠키 선점(백그라운드) → 실 작업은 웜.
- 유휴 브라우저 정리: _last_used + close_if_idle + run_browser_reaper(유휴 120s) — 메모리 회수.
  쿠키는 user_data_dir 에 남아 재기동해도 (같은 IP면) 웜 유지.
- 테스트: 유휴 정리 2종.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-09 20:20:58 +09:00
parent 4b8dee8583
commit 8a594b667d
4 changed files with 140 additions and 6 deletions

View File

@ -73,6 +73,7 @@ class BrowserSearchAdapter(SearchAdapter):
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
self._pw = None
@ -84,6 +85,7 @@ class BrowserSearchAdapter(SearchAdapter):
self._lock = asyncio.Lock()
self._ok = 0
self._blocked = 0
self._last_used = 0.0 # 마지막 검색 시각(monotonic) — 유휴 브라우저 정리 판단용
self.last_bytes = 0 # 직전 search 의 처리 HTML 바이트(계측용) — 호출부가 await 직후 읽는다
# ---- 사이트별 훅 --------------------------------------------------
@ -112,11 +114,16 @@ class BrowserSearchAdapter(SearchAdapter):
# ---- 공통 브라우저 수명 -------------------------------------------
async def _route(self, route):
if route.request.resource_type in self.blocked_resource_types:
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
@ -166,6 +173,7 @@ class BrowserSearchAdapter(SearchAdapter):
# ---- 검색(프록시 전송오류·봇 감지 → 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()
@ -173,6 +181,7 @@ class BrowserSearchAdapter(SearchAdapter):
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 보호)
try:
await page.goto(url, wait_until="domcontentloaded", timeout=40000)
await self._wait_ready(page)
@ -224,6 +233,18 @@ class BrowserSearchAdapter(SearchAdapter):
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 초과 → 브라우저 정리(다음 검색 때 재기동)")
await self._close_ctx()
async def close(self):
if self._ctx is not None:
await self._ctx.close()

View File

@ -5,8 +5,10 @@
챌린지는 ready_selector(카드) 가 렌더될 때까지 대기하면 자연히 통과한다.
"""
import time
from urllib.parse import quote
from common.logger import LOG
from services.search.browser_base import BrowserSearchAdapter
from services.search.contract import NormalizedProduct
from services.search.card_parser import parse_cards
@ -17,12 +19,20 @@ from services.search.esm.selectors import SITES
_BLOCK_MARKERS = ("errors.edgesuite.net", "You don't have permission to access", "비정상적인 접근", "잠시만 기다리")
# Cloudflare Turnstile('사람인지 확인' 체크박스) 위젯 iframe.
_CF_IFRAME = 'iframe[src*="challenges.cloudflare.com"]'
class EsmAdapter(BrowserSearchAdapter):
block_markers = _BLOCK_MARKERS
min_result_html = 8000 # 챌린지 페이지(~21KB)는 크므로 short 폴백은 명백한 에러만
ready_timeout_ms = 12000 # 챌린지 통과(정상 ~9초). 폴백은 데드라인이 상한이라 과도한 대기 회피
block_resources_default = True # 라우팅 on — 단, 아래 집합만(CSS/JS 유지)
blocked_resource_types = {"image", "media", "font"} # 대역폭 대부분(이미지)만 차단, 챌린지/렌더용 CSS/JS는 유지
# G마켓/옥션은 Cloudflare Turnstile. patchright 가 콜드 ~12초에 자동 통과하고, 통과하면 cf_clearance
# 쿠키로 이후 요청은 ~5초(웜). 콜드 자동통과 여유로 25초까지 대기(잡은 폴백 데드라인 15s 가 상한).
ready_timeout_ms = 25000
# Cloudflare Turnstile 은 리소스 로딩 패턴까지 지문검사 → 챌린지 solving 중 차단하면 봇 판정(이미지만 막아도 실패).
# 하지만 **cf_clearance 확보(웜) 후엔 차단해도 재챌린지 없음**(실측) → 동적 차단: 콜드=허용, 웜=차단.
block_resources_default = True
blocked_resource_types = {"image", "media", "font"}
def __init__(self, site: str, **kwargs):
if site not in SITES:
@ -37,3 +47,41 @@ class EsmAdapter(BrowserSearchAdapter):
def _parse(self, html: str) -> list[NormalizedProduct]:
return parse_cards(html, self._site.cards)
async def _blocking_now(self) -> bool:
"""Turnstile 챌린지를 이미 통과(cf_clearance 쿠키 있음)했을 때만 리소스 차단.
콜드(쿠키 없음)엔 차단하면 Turnstile 지문검사에 걸리므로 허용."""
if not self._block_resources or self._ctx is None:
return False
try:
cookies = await self._ctx.cookies(self._site.search_url)
return any(c.get("name") == "cf_clearance" for c in cookies)
except Exception:
return False
async def _wait_ready(self, page):
"""Turnstile 대응 렌더 대기: 카드가 뜰 때까지 폴링하되, 매 회 인터랙티브 체크박스면 클릭 시도.
대부분은 patchright 가 자동 통과(클릭 무해). cf_clearance 웜 상태면 카드가 곧바로 뜬다."""
deadline = time.monotonic() + self.ready_timeout_ms / 1000
while time.monotonic() < deadline:
try:
if await page.query_selector(self.ready_selector):
return
except Exception:
pass
await self._try_pass_turnstile(page)
await page.wait_for_timeout(1000)
async def _try_pass_turnstile(self, page):
"""Turnstile 인터랙티브 체크박스(나쁜 IP 에스컬레이션)면 클릭. best-effort — 없거나 실패해도 무시.
위젯은 challenges.cloudflare.com cross-origin iframe 안에 있다."""
try:
fl = page.frame_locator(_CF_IFRAME)
for sel in ('input[type="checkbox"]', 'label'):
loc = fl.locator(sel)
if await loc.count():
await loc.first.click(timeout=1200)
LOG.d(f"[{self.source}] Turnstile 체크박스 클릭 시도")
return
except Exception:
pass

View File

@ -4,8 +4,10 @@ from pathlib import Path
import pytest
import time
from services.search.card_parser import parse_cards
from services.search.browser_base import is_proxy_error
from services.search.browser_base import is_proxy_error, BrowserSearchAdapter
from services.search.esm.selectors import GMARKET, AUCTION
from services.search.st11.selectors import CARDS as ST11
@ -62,6 +64,36 @@ def test_is_proxy_error_false(msg):
assert is_proxy_error(msg) is False
class _IdleAdapter(BrowserSearchAdapter):
source = "test"
def _search_url(self, q, l): return ""
def _parse(self, h): return []
class _FakeCtx:
def __init__(self): self.closed = False
async def close(self): self.closed = True
async def test_close_if_idle_keeps_recent_closes_idle():
ad = _IdleAdapter()
ad._ctx = _FakeCtx()
ad._last_used = time.monotonic() # 방금 사용
await ad.close_if_idle(60)
assert ad._ctx is not None # 최근 사용 → 유지
ctx = ad._ctx
ad._last_used = time.monotonic() - 100 # 100s 전(유휴)
await ad.close_if_idle(60)
assert ad._ctx is None and ctx.closed # 유휴 초과 → 브라우저 정리
async def test_close_if_idle_skips_when_no_ctx():
ad = _IdleAdapter() # 브라우저 미기동
await ad.close_if_idle(0) # 예외 없이 no-op
assert ad._ctx is None
def test_dedup_by_link():
# 동일 링크 카드가 반복돼도 1건으로 축약
card = ('<div class="box__item-container"><a href="/x"><span class="text__item-title">상품명 물병</span></a>'

View File

@ -65,6 +65,34 @@ def _build_worker(i: int, concurrency: int, has_openai: bool, neg_cache, history
return handler, list(adapters.values()) + list(fallback_adapters.values())
async def _warmup_worker(worker_adapters):
"""워커의 챌린지 소스(Turnstile/Akamai)를 미리 1회 풀어 쿠키(cf_clearance 등)를 확보한다.
콜드 비용을 시작 시 1회로 몰아, 이후 실 작업은 웜(빠름). 백그라운드로 돌려 잡 처리를 막지 않는다."""
for ad in worker_adapters:
if ad.source not in ("gmarket", "auction", "coupang"):
continue
try:
await ad.search("생수", limit=1)
LOG.i(f"[warmup:{ad.source}] 챌린지 통과·쿠키 확보")
except Exception as ex:
LOG.w(f"[warmup:{ad.source}] 실패(무시, 첫 잡에서 재시도): {type(ex).__name__}")
async def run_browser_reaper(adapters, stop, idle_sec: float = 120.0, interval: float = 30.0):
"""유휴 브라우저 정리 루프 — 일정 시간 검색 없는 어댑터의 Chrome 을 닫아 메모리를 회수한다.
쿠키는 user_data_dir 에 남아, 다음 검색 때 재기동해도 (같은 IP면) 웜 유지."""
while not stop.is_set():
try:
await asyncio.wait_for(stop.wait(), timeout=interval)
except asyncio.TimeoutError:
pass
for ad in adapters:
try:
await ad.close_if_idle(idle_sec)
except Exception as ex:
LOG.e_no_callstack(f"[browser-reaper] 정리 실패(무시): {ex}")
async def main(concurrency: int = 1):
queue = JobQueue()
neg_cache, history = NegativeCache(), PriceHistory() # DB 기반 — 워커 공유 안전
@ -82,11 +110,13 @@ async def main(concurrency: int = 1):
stop = asyncio.Event()
listeners: list[JobListener] = []
tasks: list[asyncio.Task] = []
bg_tasks: list[asyncio.Task] = [] # 웜업 등 백그라운드(짧게 끝남, gather 대상 아님)
all_adapters = []
for i in range(concurrency):
handler, worker_adapters = _build_worker(i, concurrency, has_openai, neg_cache, history)
all_adapters += worker_adapters
bg_tasks.append(asyncio.create_task(_warmup_worker(worker_adapters))) # 챌린지 쿠키 선점(백그라운드)
listener = JobListener()
await listener.start()
listeners.append(listener)
@ -94,12 +124,15 @@ async def main(concurrency: int = 1):
tasks.append(asyncio.create_task(worker.run(listener, stop)))
tasks.append(asyncio.create_task(run_reaper(queue, stop)))
LOG.i(f"LPS 워커 {concurrency}개 + reaper 기동 (워커별 브라우저 세트 — 상품 {concurrency}개 동시 검색)")
tasks.append(asyncio.create_task(run_browser_reaper(all_adapters, stop))) # 유휴 브라우저 정리
LOG.i(f"LPS 워커 {concurrency}개 + reaper + 브라우저정리(유휴 120s) 기동 (워커별 세트 · 상품 {concurrency}개 동시)")
try:
await asyncio.gather(*tasks)
finally:
stop.set()
for t in bg_tasks:
t.cancel()
for listener in listeners:
await listener.close()
for adapter in all_adapters: