"""Cloudflare 会话管理器:自动获取并复用通过验证的浏览器会话 骏河屋网站受 Cloudflare 保护,首次访问需通过浏览器完成 JS 挑战。 本模块负责: - 检测并等待 Cloudflare 挑战完成 - 保存通过验证后的浏览器存储状态(含 cf_clearance Cookie) - 后续请求复用已验证会话,避免重复触发挑战 - 会话失效时支持主动失效和重建 """ from __future__ import annotations import asyncio import hashlib import logging from collections.abc import Iterable from dataclasses import dataclass from urllib.parse import urlparse from uuid import uuid4 from pathlib import Path from app.core.config import Settings from app.core.errors import CloudflareChallengeError, UpstreamBlockedError from app.services.browser_pool import BrowserPool from app.services.session_store import SessionState, SessionStore logger = logging.getLogger(__name__) @dataclass(slots=True) class VerifiedSession: """已通过 Cloudflare 验证的会话信息""" session_id: str storage_state: dict cf_clearance: str | None # cf_clearance Cookie 值 html: str | None = None # 新创建会话时携带的页面 HTML,复用场景为 None class CloudflareSessionManager: """Cloudflare 会话管理器,负责会话的获取、验证和失效""" def __init__( self, settings: Settings, browser_pool: BrowserPool, session_store: SessionStore, ): self._settings = settings self._browser_pool = browser_pool self._session_store = session_store async def get_verified_session(self, target_url: str, force_refresh: bool = False) -> VerifiedSession: """获取一个已通过 Cloudflare 验证的会话 优先复用已有会话;当 force_refresh=True 或无可用会话时, 启动浏览器访问目标页面,等待 Cloudflare 挑战通过后保存会话。 Args: target_url: 目标页面 URL,用于确定会话名称 force_refresh: 是否强制创建新会话(忽略已有会话) Returns: VerifiedSession: 已验证的会话信息 Raises: UpstreamBlockedError: 目标站点返回阻断响应(如 1020/403) CloudflareChallengeError: 等待 Cloudflare 挑战超时 """ session_name = self._session_name_for_url(target_url) if not force_refresh: existing = await self._session_store.get_session(session_name) if existing is not None: logger.debug("复用已验证会话:session_name=%s session_id=%s", session_name, existing.session_id) return self._to_verified_session(existing) logger.debug("创建新会话:session_name=%s force_refresh=%s", session_name, force_refresh) session = await self._create_verified_session(session_name=session_name, target_url=target_url) return session async def fetch_html(self, target_url: str) -> tuple[str, VerifiedSession]: """获取目标页面 HTML,并屏蔽会话复用失败的细节 统一处理三种情形,确保调用方拿到的要么是有效页面,要么是明确的上游异常: 1. 新建会话:直接复用创建时携带的页面 HTML,无需二次导航 2. 复用会话:带 storage_state 重新导航,并消化可能出现的 Cloudflare 挑战 3. 复用失败:主动失效后强制重建一次,对调用方透明(避免「一次成功一次失败」交替) Args: target_url: 目标页面 URL Returns: (html, session) 元组,session 为最终生效的会话 Raises: UpstreamBlockedError: 会话重建后仍无法获取有效页面 CloudflareChallengeError: 等待 Cloudflare 挑战超时 """ session = await self.get_verified_session(target_url) if session.html is not None: return session.html, session html = await self._try_fetch_with_session(target_url, session) if html is not None: return html, session # 复用会话已失效:失效并强制重建,新会话自带页面 HTML logger.info("复用会话失败,强制重建会话:url=%s", target_url) await self.invalidate(target_url) session = await self.get_verified_session(target_url, force_refresh=True) if session.html is not None: return session.html, session # 兜底:force_refresh 理论上必带 HTML,缺失时再导航一次 html = await self._try_fetch_with_session(target_url, session) if html is None: raise UpstreamBlockedError(f"会话重建后仍无法获取页面:url={target_url}") return html, session async def _try_fetch_with_session(self, target_url: str, session: VerifiedSession) -> str | None: """带已有会话导航并消化 Cloudflare 挑战 与新建路径保持一致:导航后等待挑战自动通过,而非一遇非 200 就判死。 CF 对携带过期 cf_clearance 的请求常直接返回挑战页(首响应可能非 200), 因此即便首响应非 200,也先给页面一次自动通过 Turnstile 的机会, 仅当挑战超时/被硬阻断、或通过后仍无有效内容时才判定会话失效。 Returns: 通过验证的页面 HTML;若判定会话已失效,返回 None """ async with self._browser_pool.page_session(storage_state=session.storage_state) as (_, page): response = await page.goto( target_url, wait_until="domcontentloaded", timeout=int(self._settings.request_timeout_seconds * 1000), ) status = response.status if response is not None else None try: # 复用使用短超时:快速验证能否通过,不行就放弃复用交由上层重建, # 避免在无法自动通过的挑战上空等整个 cloudflare_wait_timeout_seconds。 await self._wait_for_clearance( page, timeout_seconds=self._settings.cloudflare_reuse_wait_timeout_seconds ) except (CloudflareChallengeError, UpstreamBlockedError): logger.info( "复用会话未通过挑战,判定失效:url=%s status=%s had_cf_clearance=%s", target_url, status, bool(session.cf_clearance), ) return None html = await page.content() # 挑战已通过但页面仍是挑战壳(极少数 wait 提前返回的情况):判失效,触发重建 if self._looks_like_challenge((await page.title()).lower(), html.lower()): logger.info( "复用会话通过等待后仍无有效内容,判定失效:url=%s status=%s had_cf_clearance=%s", target_url, status, bool(session.cf_clearance), ) return None return html async def invalidate(self, target_url: str) -> None: """主动让会话失效 当识别到被阻断/挑战失败时调用,下次请求将创建新会话。 Args: target_url: 目标页面 URL,用于定位需要失效的会话 """ session_name = self._session_name_for_url(target_url) logger.warning("会话失效:session_name=%s", session_name) await self._session_store.invalidate_session(session_name) async def _create_verified_session(self, session_name: str, target_url: str) -> VerifiedSession: """通过真实浏览器访问目标页面,让 Cloudflare 挑战在浏览器侧完成 流程: 1. 从浏览器池获取页面会话 2. 访问目标页面 3. 等待 Cloudflare 挑战通过 4. 保存浏览器存储状态和 cf_clearance Cookie 5. 同时返回页面 HTML,避免调用方二次加载同一页面 """ async with self._browser_pool.page_session(keep_open_on_success=False) as (context, page): logger.debug("开始访问(用于通过挑战):%s", target_url) response = await page.goto( target_url, wait_until="domcontentloaded", timeout=int(self._settings.request_timeout_seconds * 1000), ) status = response.status if status != 200: try: screenshot_dir = Path(__file__).resolve().parents[2] / self._settings.log_dir / "screenshots" screenshot_dir.mkdir(parents=True, exist_ok=True) screenshot_file = screenshot_dir / f"{uuid4().hex}.png" await page.screenshot(path=str(screenshot_file), full_page=True) screenshot_path = str(screenshot_file) except Exception: screenshot_path = None raise UpstreamBlockedError(f"Upstream status={status}, 页面返回非200状态码, 截图地址:{screenshot_path}") await self._wait_for_clearance(page) html_content = await page.content() storage_state = await context.storage_state() cookies = await context.cookies() cf_clearance = self._get_cookie_value(cookies, "cf_clearance") logger.debug("挑战通过:session_name=%s cf_clearance=%s", session_name, bool(cf_clearance)) saved = await self._session_store.save_session( session_name=session_name, session_id=str(uuid4()), user_agent=self._settings.browser_user_agent, proxy_key=self._settings.proxy_server or "direct", storage_state=storage_state, cf_clearance=cf_clearance, ) return VerifiedSession( session_id=saved.session_id, storage_state=saved.storage_state, cf_clearance=saved.cf_clearance, html=html_content, ) async def _wait_for_clearance(self, page: object, timeout_seconds: float | None = None) -> None: """检测并等待 Cloudflare 挑战完成 循环检测页面标题和内容: - 如果检测到阻断响应(如 1020/403),直接抛出异常 - 如果检测到挑战页面(如 "Just a moment"),继续等待 - 如果既非阻断也非挑战,认为已通过 Args: page: Playwright 页面对象 timeout_seconds: 等待超时(秒);为 None 时使用新建会话的较长超时。 复用会话时传入较短超时,避免在无法通过的挑战上长时间空等。 Raises: UpstreamBlockedError: 检测到被 Cloudflare 阻断 CloudflareChallengeError: 等待挑战超时 """ wait_timeout = ( self._settings.cloudflare_wait_timeout_seconds if timeout_seconds is None else timeout_seconds ) timeout_at = asyncio.get_running_loop().time() + wait_timeout while True: title = (await page.title()).lower() content = (await page.content()).lower() if self._is_blocked_response(title, content): logger.warning("检测到被阻断响应(可能是 1020/403)") raise UpstreamBlockedError("Cloudflare returned a blocked response") if not self._looks_like_challenge(title, content): return try: cf_iframe = page.frame_locator('iframe[src*="turnstile"], iframe[src*="cloudflare"]') if await cf_iframe.locator('input[type="checkbox"]').count() > 0: checkbox = cf_iframe.locator('input[type="checkbox"]') if await checkbox.is_visible(): logger.debug("检测到 Turnstile 复选框,尝试自动点击") await checkbox.click() await page.wait_for_timeout(2000) except Exception as e: logger.debug("自动点击 Turnstile 失败或未找到: %s", e) if asyncio.get_running_loop().time() >= timeout_at: logger.warning("等待挑战超时(秒):%s", wait_timeout) raise CloudflareChallengeError() await page.wait_for_timeout(1500) @staticmethod def _get_cookie_value(cookies: Iterable[dict], name: str) -> str | None: """从 Cookie 列表中获取指定名称的 Cookie 值""" for cookie in cookies: if cookie.get("name") == name: return cookie.get("value") return None @staticmethod def _looks_like_challenge(title: str, content: str) -> bool: """判断页面是否仍处于 Cloudflare 挑战中 先排除正常页面(搜索列表/商品详情页中也可能包含 Cloudflare 脚本), 再检查标题和内容中是否包含挑战标志(如 "Just a moment")。 """ # 正常页面的 DOM 特征,出现这些说明挑战已通过 if ( 'class="item_box' in content or "product-name" in content or 'id="item_title"' in content or "h1_title_product" in content or 'id="item_detailinfo"' in content or "tbl_product_info" in content or "商品詳細情報" in content ): return False challenge_markers = ( "just a moment", "checking your browser", "verify you are human", "attention required", "cf-challenge", "cf-turnstile", "cf_chl_opt", "cf-please-wait", "challenge-form", ) return any(marker in title or marker in content for marker in challenge_markers) @staticmethod def _is_blocked_response(title: str, content: str) -> bool: """判断页面是否为 Cloudflare 阻断响应(如 1020 错误)""" blocked_markers = ( "access denied", "error code 1020", "forbidden", "temporarily blocked", ) return any(marker in title or marker in content for marker in blocked_markers) def _session_name_for_url(self, target_url: str) -> str: """根据 URL、代理和 User-Agent 生成会话名称 同一域名下仍共享会话,但会按当前代理与 UA 做进一步隔离, 避免不同运行身份误复用同一套 Cloudflare 验证状态。 """ parsed = urlparse(target_url) proxy_key = self._settings.proxy_server or "direct" user_agent = self._settings.browser_user_agent user_agent_hash = hashlib.sha1(user_agent.encode("utf-8")).hexdigest()[:12] return f"{parsed.netloc}|{proxy_key}|{user_agent_hash}" @staticmethod def _to_verified_session(state: SessionState) -> VerifiedSession: """将 SessionState 转换为 VerifiedSession(复用场景,html 为空)""" return VerifiedSession( session_id=state.session_id, storage_state=state.storage_state, cf_clearance=state.cf_clearance, html=None, )