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._rl = rate_limiter or RateLimiter()
|
||||||
self._proxy = proxy # DecodoProxy 등 (없으면 직접 연결)
|
self._proxy = proxy # DecodoProxy 등 (없으면 직접 연결)
|
||||||
self._block_resources = self.block_resources_default if block_resources is None else block_resources
|
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._on_detect = on_detect # async def(event: dict) — 감지 영속화(선택)
|
||||||
self._max_block_retries = max_block_retries
|
self._max_block_retries = max_block_retries
|
||||||
self._pw = None
|
self._pw = None
|
||||||
@ -84,6 +85,7 @@ class BrowserSearchAdapter(SearchAdapter):
|
|||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
self._ok = 0
|
self._ok = 0
|
||||||
self._blocked = 0
|
self._blocked = 0
|
||||||
|
self._last_used = 0.0 # 마지막 검색 시각(monotonic) — 유휴 브라우저 정리 판단용
|
||||||
self.last_bytes = 0 # 직전 search 의 처리 HTML 바이트(계측용) — 호출부가 await 직후 읽는다
|
self.last_bytes = 0 # 직전 search 의 처리 HTML 바이트(계측용) — 호출부가 await 직후 읽는다
|
||||||
|
|
||||||
# ---- 사이트별 훅 --------------------------------------------------
|
# ---- 사이트별 훅 --------------------------------------------------
|
||||||
@ -112,11 +114,16 @@ class BrowserSearchAdapter(SearchAdapter):
|
|||||||
|
|
||||||
# ---- 공통 브라우저 수명 -------------------------------------------
|
# ---- 공통 브라우저 수명 -------------------------------------------
|
||||||
async def _route(self, route):
|
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()
|
await route.abort()
|
||||||
else:
|
else:
|
||||||
await route.continue_()
|
await route.continue_()
|
||||||
|
|
||||||
|
async def _blocking_now(self) -> bool:
|
||||||
|
"""이번 요청에서 리소스를 실제로 차단할지. 기본은 설정값 그대로.
|
||||||
|
ESM(Turnstile)은 챌린지 solving 중엔 차단하면 안 되므로 override(웜=cf_clearance 있으면만 차단)."""
|
||||||
|
return self._block_resources
|
||||||
|
|
||||||
def _recycle_due(self) -> bool:
|
def _recycle_due(self) -> bool:
|
||||||
if self._force_recycle:
|
if self._force_recycle:
|
||||||
return True
|
return True
|
||||||
@ -166,6 +173,7 @@ class BrowserSearchAdapter(SearchAdapter):
|
|||||||
# ---- 검색(프록시 전송오류·봇 감지 → IP 회전 인라인 재시도) --------
|
# ---- 검색(프록시 전송오류·봇 감지 → IP 회전 인라인 재시도) --------
|
||||||
async def search(self, query: str, limit: int = 40) -> list[NormalizedProduct]:
|
async def search(self, query: str, limit: int = 40) -> list[NormalizedProduct]:
|
||||||
async with self._lock: # 인스턴스 내 검색 직렬화(브라우저 컨텍스트 공유)
|
async with self._lock: # 인스턴스 내 검색 직렬화(브라우저 컨텍스트 공유)
|
||||||
|
self._last_used = time.monotonic()
|
||||||
proxy_retries, block_retries = self.max_proxy_retries, self._max_block_retries
|
proxy_retries, block_retries = self.max_proxy_retries, self._max_block_retries
|
||||||
while True:
|
while True:
|
||||||
await self._rl.wait()
|
await self._rl.wait()
|
||||||
@ -173,6 +181,7 @@ class BrowserSearchAdapter(SearchAdapter):
|
|||||||
self._ip_requests += 1
|
self._ip_requests += 1
|
||||||
page = self._ctx.pages[0] if self._ctx.pages else await self._ctx.new_page()
|
page = self._ctx.pages[0] if self._ctx.pages else await self._ctx.new_page()
|
||||||
url = self._search_url(query, limit)
|
url = self._search_url(query, limit)
|
||||||
|
self._block_active = await self._blocking_now() # 챌린지 solving 중이면 차단 해제(Turnstile 보호)
|
||||||
try:
|
try:
|
||||||
await page.goto(url, wait_until="domcontentloaded", timeout=40000)
|
await page.goto(url, wait_until="domcontentloaded", timeout=40000)
|
||||||
await self._wait_ready(page)
|
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),
|
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)
|
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):
|
async def close(self):
|
||||||
if self._ctx is not None:
|
if self._ctx is not None:
|
||||||
await self._ctx.close()
|
await self._ctx.close()
|
||||||
|
|||||||
@ -5,8 +5,10 @@
|
|||||||
챌린지는 ready_selector(카드) 가 렌더될 때까지 대기하면 자연히 통과한다.
|
챌린지는 ready_selector(카드) 가 렌더될 때까지 대기하면 자연히 통과한다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from common.logger import LOG
|
||||||
from services.search.browser_base import BrowserSearchAdapter
|
from services.search.browser_base import BrowserSearchAdapter
|
||||||
from services.search.contract import NormalizedProduct
|
from services.search.contract import NormalizedProduct
|
||||||
from services.search.card_parser import parse_cards
|
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", "비정상적인 접근", "잠시만 기다리")
|
_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):
|
class EsmAdapter(BrowserSearchAdapter):
|
||||||
block_markers = _BLOCK_MARKERS
|
block_markers = _BLOCK_MARKERS
|
||||||
min_result_html = 8000 # 챌린지 페이지(~21KB)는 크므로 short 폴백은 명백한 에러만
|
min_result_html = 8000 # 챌린지 페이지(~21KB)는 크므로 short 폴백은 명백한 에러만
|
||||||
ready_timeout_ms = 12000 # 챌린지 통과(정상 ~9초). 폴백은 데드라인이 상한이라 과도한 대기 회피
|
# G마켓/옥션은 Cloudflare Turnstile. patchright 가 콜드 ~12초에 자동 통과하고, 통과하면 cf_clearance
|
||||||
block_resources_default = True # 라우팅 on — 단, 아래 집합만(CSS/JS 유지)
|
# 쿠키로 이후 요청은 ~5초(웜). 콜드 자동통과 여유로 25초까지 대기(잡은 폴백 데드라인 15s 가 상한).
|
||||||
blocked_resource_types = {"image", "media", "font"} # 대역폭 대부분(이미지)만 차단, 챌린지/렌더용 CSS/JS는 유지
|
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):
|
def __init__(self, site: str, **kwargs):
|
||||||
if site not in SITES:
|
if site not in SITES:
|
||||||
@ -37,3 +47,41 @@ class EsmAdapter(BrowserSearchAdapter):
|
|||||||
|
|
||||||
def _parse(self, html: str) -> list[NormalizedProduct]:
|
def _parse(self, html: str) -> list[NormalizedProduct]:
|
||||||
return parse_cards(html, self._site.cards)
|
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 pytest
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
from services.search.card_parser import parse_cards
|
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.esm.selectors import GMARKET, AUCTION
|
||||||
from services.search.st11.selectors import CARDS as ST11
|
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
|
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():
|
def test_dedup_by_link():
|
||||||
# 동일 링크 카드가 반복돼도 1건으로 축약
|
# 동일 링크 카드가 반복돼도 1건으로 축약
|
||||||
card = ('<div class="box__item-container"><a href="/x"><span class="text__item-title">상품명 물병</span></a>'
|
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())
|
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):
|
async def main(concurrency: int = 1):
|
||||||
queue = JobQueue()
|
queue = JobQueue()
|
||||||
neg_cache, history = NegativeCache(), PriceHistory() # DB 기반 — 워커 공유 안전
|
neg_cache, history = NegativeCache(), PriceHistory() # DB 기반 — 워커 공유 안전
|
||||||
@ -82,11 +110,13 @@ async def main(concurrency: int = 1):
|
|||||||
stop = asyncio.Event()
|
stop = asyncio.Event()
|
||||||
listeners: list[JobListener] = []
|
listeners: list[JobListener] = []
|
||||||
tasks: list[asyncio.Task] = []
|
tasks: list[asyncio.Task] = []
|
||||||
|
bg_tasks: list[asyncio.Task] = [] # 웜업 등 백그라운드(짧게 끝남, gather 대상 아님)
|
||||||
all_adapters = []
|
all_adapters = []
|
||||||
|
|
||||||
for i in range(concurrency):
|
for i in range(concurrency):
|
||||||
handler, worker_adapters = _build_worker(i, concurrency, has_openai, neg_cache, history)
|
handler, worker_adapters = _build_worker(i, concurrency, has_openai, neg_cache, history)
|
||||||
all_adapters += worker_adapters
|
all_adapters += worker_adapters
|
||||||
|
bg_tasks.append(asyncio.create_task(_warmup_worker(worker_adapters))) # 챌린지 쿠키 선점(백그라운드)
|
||||||
listener = JobListener()
|
listener = JobListener()
|
||||||
await listener.start()
|
await listener.start()
|
||||||
listeners.append(listener)
|
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(worker.run(listener, stop)))
|
||||||
|
|
||||||
tasks.append(asyncio.create_task(run_reaper(queue, 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:
|
try:
|
||||||
await asyncio.gather(*tasks)
|
await asyncio.gather(*tasks)
|
||||||
finally:
|
finally:
|
||||||
stop.set()
|
stop.set()
|
||||||
|
for t in bg_tasks:
|
||||||
|
t.cancel()
|
||||||
for listener in listeners:
|
for listener in listeners:
|
||||||
await listener.close()
|
await listener.close()
|
||||||
for adapter in all_adapters:
|
for adapter in all_adapters:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user