fix(services): playwright-fetcher CF JS 챌린지 통과 대기 — aiche.org 인터스티셜 스냅샷 함정 (검증 게이트 발견)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,9 @@ _CHALLENGE_MARKERS = (
|
|||||||
"Checking your browser before",
|
"Checking your browser before",
|
||||||
"captcha-delivery.com",
|
"captcha-delivery.com",
|
||||||
"geo.captcha-delivery",
|
"geo.captcha-delivery",
|
||||||
|
# CF JS 챌린지 인터스티셜의 스크립트 도메인 (aiche.org 실측 2026-06-11) —
|
||||||
|
# fetcher 의 챌린지 대기를 끝까지 통과 못 한 최종 HTML 만 여기 걸린다.
|
||||||
|
"challenges.cloudflare.com",
|
||||||
)
|
)
|
||||||
|
|
||||||
_ROBOTS_CACHE_TTL = 24 * 3600 # 24h
|
_ROBOTS_CACHE_TTL = 24 * 3600 # 24h
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ AUTH_DIR = Path("/auth")
|
|||||||
NAV_TIMEOUT_MS = 45_000
|
NAV_TIMEOUT_MS = 45_000
|
||||||
SETTLE_MS = 1_500 # domcontentloaded 후 lazy 본문 settle 대기
|
SETTLE_MS = 1_500 # domcontentloaded 후 lazy 본문 settle 대기
|
||||||
MAX_DOWNLOAD_BYTES = 60 * 1024 * 1024
|
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")
|
app = FastAPI(title="playwright-fetcher")
|
||||||
_browser_slot = asyncio.Semaphore(1) # 동시 1 인스턴스 (B-3 ① persistent 제약과 동일 규율)
|
_browser_slot = asyncio.Semaphore(1) # 동시 1 인스턴스 (B-3 ① persistent 제약과 동일 규율)
|
||||||
@@ -73,6 +78,16 @@ def _context_kwargs(state: Path | None) -> dict:
|
|||||||
return kwargs
|
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]:
|
async def _browse(url: str, state: Path | None) -> tuple[str, str, str]:
|
||||||
"""(html, final_url, visible_text). 요청당 브라우저 — 종료를 finally 로 보장."""
|
"""(html, final_url, visible_text). 요청당 브라우저 — 종료를 finally 로 보장."""
|
||||||
async with async_playwright() as pw:
|
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))
|
context = await browser.new_context(**_context_kwargs(state))
|
||||||
page = await context.new_page()
|
page = await context.new_page()
|
||||||
await page.goto(url, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS)
|
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()
|
html = await page.content()
|
||||||
final_url = page.url
|
final_url = page.url
|
||||||
text = await page.evaluate("document.body ? document.body.innerText : ''")
|
text = await page.evaluate("document.body ? document.body.innerText : ''")
|
||||||
@@ -127,7 +142,7 @@ async def download(req: DownloadReq):
|
|||||||
page = await context.new_page()
|
page = await context.new_page()
|
||||||
await page.goto(req.referer, wait_until="domcontentloaded",
|
await page.goto(req.referer, wait_until="domcontentloaded",
|
||||||
timeout=NAV_TIMEOUT_MS)
|
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)
|
resp = await context.request.get(req.url, timeout=NAV_TIMEOUT_MS)
|
||||||
body = await resp.body()
|
body = await resp.body()
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
Reference in New Issue
Block a user