"""B-3 구독 세션 Playwright fetcher (plan crawl-24x7-1) + 익명 브라우저 fetch/다운로드 (사이클 3). storage_state JSON(쿠키+localStorage 스냅샷) 기반 인증 페이지 fetch + 내용 기반 probe. - 동시 1 인스턴스 (글로벌 세마포어) — 계정 보호 + 사람 속도는 호출측 politeness 가 담당. - 요청당 브라우저 기동/종료 — 컨텍스트 메모리 누적·hang 잔존 차단 (저빈도라 기동비용 무관). - 세션 파일: /auth/{profile}.json (호스트 ~/.local/share/crawl-auth/, ro mount, 600). 부재 = 503 profile_missing (silent fallback 없음 — 호출측이 degrade). - 시간 기반 만료 판정 금지 — probe 는 알려진 유료 기사에서 본문 길이 + 페이월 마커 부재 검증 (만료 후 200 '페이월 안내문'이 본문으로 저장되는 silent corruption 차단). 사이클 3 증축 (C-2 CCPS Beacon — aiche.org 가 평문 httpx 를 UA 무관 403): - /fetch profile 생략 = 익명 컨텍스트 (storage_state 없음, 공개 페이지의 WAF 우회 전용). - /download = referer 페이지를 먼저 방문(WAF 쿠키 획득) 후 같은 컨텍스트의 request.get 으로 바이너리(PDF) 다운로드 — base64 반환, 60MB cap. """ import asyncio import base64 import logging from pathlib import Path from fastapi import FastAPI, HTTPException from playwright.async_api import async_playwright, Error as PlaywrightError from pydantic import BaseModel, Field logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s") logger = logging.getLogger("playwright-fetcher") 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 제약과 동일 규율) class FetchReq(BaseModel): url: str # None = 익명 컨텍스트 (공개 페이지 WAF 우회 — CCPS). 값 = B-3 구독 세션. profile: str | None = Field(default=None, pattern=r"^[a-z0-9_-]{1,50}$") class ProbeReq(BaseModel): profile: str = Field(pattern=r"^[a-z0-9_-]{1,50}$") probe_url: str min_body_chars: int = 800 paywall_markers: list[str] = [] class DownloadReq(BaseModel): url: str # referer 페이지를 먼저 방문해 WAF 챌린지 쿠키를 컨텍스트에 적재 후 다운로드 referer: str | None = None profile: str | None = Field(default=None, pattern=r"^[a-z0-9_-]{1,50}$") def _state_path(profile: str) -> Path: p = AUTH_DIR / f"{profile}.json" if not p.is_file(): raise HTTPException(503, detail={"error_reason": "profile_missing", "profile": profile}) return p def _context_kwargs(state: Path | None) -> dict: kwargs = {"viewport": {"width": 1366, "height": 900}} if state is not None: # B-3 르몽드 세션 회귀 방지 — 기존 인증 fetch 의 locale 그대로 kwargs["storage_state"] = str(state) kwargs["locale"] = "fr-FR" else: kwargs["locale"] = "en-US" 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: browser = await pw.chromium.launch(headless=True) try: 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 _settle(page) html = await page.content() final_url = page.url text = await page.evaluate("document.body ? document.body.innerText : ''") return html, final_url, text finally: await browser.close() @app.get("/health") def health(): profiles = sorted(p.stem for p in AUTH_DIR.glob("*.json")) if AUTH_DIR.is_dir() else [] return {"status": "ok", "profiles": profiles} @app.post("/fetch") async def fetch(req: FetchReq): state = _state_path(req.profile) if req.profile else None async with _browser_slot: try: html, final_url, _ = await _browse(req.url, state) except PlaywrightError as e: logger.warning("fetch 실패 %s: %s", req.url, e) raise HTTPException(502, detail={"error_reason": "browse_failed", "message": str(e)[:300]}) logger.info("fetch ok profile=%s %s (%d bytes)", req.profile or "-", req.url, len(html)) return {"html": html, "final_url": final_url} @app.post("/download") async def download(req: DownloadReq): """바이너리(PDF 등) 다운로드 — referer 방문으로 WAF 쿠키 획득 후 같은 컨텍스트로 GET. 응답의 status/content_type 판정은 호출측(crawl_politeness) 책임 — 여기서는 전송 계층 오류만 502 로 구분 (silent fallback 없음). """ state = _state_path(req.profile) if req.profile else None async with _browser_slot: try: async with async_playwright() as pw: browser = await pw.chromium.launch(headless=True) try: context = await browser.new_context(**_context_kwargs(state)) if req.referer: page = await context.new_page() await page.goto(req.referer, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS) await _settle(page) # CF 챌린지 통과 쿠키를 컨텍스트에 적재 resp = await context.request.get(req.url, timeout=NAV_TIMEOUT_MS) body = await resp.body() finally: await browser.close() except PlaywrightError as e: logger.warning("download 실패 %s: %s", req.url, e) raise HTTPException(502, detail={"error_reason": "download_failed", "message": str(e)[:300]}) if len(body) > MAX_DOWNLOAD_BYTES: raise HTTPException(502, detail={"error_reason": "too_large", "bytes": len(body)}) logger.info("download status=%d %s (%d bytes)", resp.status, req.url, len(body)) return { "status": resp.status, "content_type": resp.headers.get("content-type", ""), "body_b64": base64.b64encode(body).decode(), } @app.post("/probe") async def probe(req: ProbeReq): """내용 기반 세션 probe — ok=False 사유를 명시 반환 (호출측이 health 에 기록).""" state = _state_path(req.profile) async with _browser_slot: try: _, final_url, text = await _browse(req.probe_url, state) except PlaywrightError as e: return {"ok": False, "reason": f"browse_failed: {str(e)[:200]}", "body_chars": 0} body_chars = len(text.strip()) hit = next((m for m in req.paywall_markers if m and m.lower() in text.lower()), None) if hit: return {"ok": False, "reason": f"paywall_marker: {hit}", "body_chars": body_chars} if body_chars < req.min_body_chars: return {"ok": False, "reason": f"body_too_short: {body_chars} < {req.min_body_chars}", "body_chars": body_chars} logger.info("probe ok profile=%s (%d chars, final=%s)", req.profile, body_chars, final_url) return {"ok": True, "reason": None, "body_chars": body_chars}