Files
surugaya/app/services/cloudflare_session.py
T
2026-06-09 10:13:28 +08:00

343 lines
15 KiB
Python

"""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,
)