From 8a594b667d7d90b12bb22000edb6b7a8de979f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Thu, 9 Jul 2026 20:20:58 +0900 Subject: [PATCH] =?UTF-8?q?perf(lps):=20Step2/3=20=E2=80=94=20Turnstile=20?= =?UTF-8?q?=EC=9B=9C=EC=97=85=C2=B7=EB=8F=99=EC=A0=81=20=EC=B0=A8=EB=8B=A8?= =?UTF-8?q?=20+=20=EC=9C=A0=ED=9C=B4=20=EB=B8=8C=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=EC=A0=80=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lps/services/search/browser_base.py | 23 +++++++++++- lps/services/search/esm/adapter.py | 54 +++++++++++++++++++++++++++-- lps/tests/test_openmarket_parser.py | 34 +++++++++++++++++- lps/worker_main.py | 35 ++++++++++++++++++- 4 files changed, 140 insertions(+), 6 deletions(-) diff --git a/lps/services/search/browser_base.py b/lps/services/search/browser_base.py index a1d8ab4..f8bd033 100644 --- a/lps/services/search/browser_base.py +++ b/lps/services/search/browser_base.py @@ -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() diff --git a/lps/services/search/esm/adapter.py b/lps/services/search/esm/adapter.py index beb8938..0b60e6e 100644 --- a/lps/services/search/esm/adapter.py +++ b/lps/services/search/esm/adapter.py @@ -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 diff --git a/lps/tests/test_openmarket_parser.py b/lps/tests/test_openmarket_parser.py index c8c8e2a..9c4460a 100644 --- a/lps/tests/test_openmarket_parser.py +++ b/lps/tests/test_openmarket_parser.py @@ -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 = ('
상품명 물병' diff --git a/lps/worker_main.py b/lps/worker_main.py index a286e6e..30ae912 100644 --- a/lps/worker_main.py +++ b/lps/worker_main.py @@ -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: