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:
parent
4b8dee8583
commit
8a594b667d
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>'
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user