diff --git a/app/core/crawl_politeness.py b/app/core/crawl_politeness.py index 017381b..06bb328 100644 --- a/app/core/crawl_politeness.py +++ b/app/core/crawl_politeness.py @@ -49,6 +49,9 @@ _CHALLENGE_MARKERS = ( "Checking your browser before", "captcha-delivery.com", "geo.captcha-delivery", + # CF JS 챌린지 인터스티셜의 스크립트 도메인 (aiche.org 실측 2026-06-11) — + # fetcher 의 챌린지 대기를 끝까지 통과 못 한 최종 HTML 만 여기 걸린다. + "challenges.cloudflare.com", ) _ROBOTS_CACHE_TTL = 24 * 3600 # 24h diff --git a/services/playwright-fetcher/server.py b/services/playwright-fetcher/server.py index 78a85ce..ead843b 100644 --- a/services/playwright-fetcher/server.py +++ b/services/playwright-fetcher/server.py @@ -30,6 +30,11 @@ AUTH_DIR = Path("/auth") NAV_TIMEOUT_MS = 45_000 SETTLE_MS = 1_500 # domcontentloaded 후 lazy 본문 settle 대기 MAX_DOWNLOAD_BYTES = 60 * 1024 * 1024 +# Cloudflare JS 챌린지(title='Just a moment...')는 통과에 수 초 + 자동 재네비게이션이 +# 걸린다 — aiche.org 실측(2026-06-11): 1.5s settle 시점 스냅샷 = 인터스티셜. +# 통과 못 하면 호출측 _CHALLENGE_MARKERS 가 최종 HTML 에서 차단 판정. +CHALLENGE_POLL_TRIES = 8 +CHALLENGE_POLL_MS = 2_500 app = FastAPI(title="playwright-fetcher") _browser_slot = asyncio.Semaphore(1) # 동시 1 인스턴스 (B-3 ① persistent 제약과 동일 규율) @@ -73,6 +78,16 @@ def _context_kwargs(state: Path | None) -> dict: return kwargs +async def _settle(page) -> None: + """기본 settle + CF JS 챌린지 통과 대기 (통과 실패 시 인터스티셜 그대로 반환).""" + await page.wait_for_timeout(SETTLE_MS) + for _ in range(CHALLENGE_POLL_TRIES): + title = (await page.title()).lower() + if "just a moment" not in title: + return + await page.wait_for_timeout(CHALLENGE_POLL_MS) + + async def _browse(url: str, state: Path | None) -> tuple[str, str, str]: """(html, final_url, visible_text). 요청당 브라우저 — 종료를 finally 로 보장.""" async with async_playwright() as pw: @@ -81,7 +96,7 @@ async def _browse(url: str, state: Path | None) -> tuple[str, str, str]: context = await browser.new_context(**_context_kwargs(state)) page = await context.new_page() await page.goto(url, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS) - await page.wait_for_timeout(SETTLE_MS) + await _settle(page) html = await page.content() final_url = page.url text = await page.evaluate("document.body ? document.body.innerText : ''") @@ -127,7 +142,7 @@ async def download(req: DownloadReq): page = await context.new_page() await page.goto(req.referer, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS) - await page.wait_for_timeout(SETTLE_MS) + await _settle(page) # CF 챌린지 통과 쿠키를 컨텍스트에 적재 resp = await context.request.get(req.url, timeout=NAV_TIMEOUT_MS) body = await resp.body() finally: