This commit is contained in:
2026-06-08 16:09:13 +08:00
commit 518c6b3c7a
32 changed files with 4156 additions and 0 deletions
+252
View File
@@ -0,0 +1,252 @@
"""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 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) -> None:
"""检测并等待 Cloudflare 挑战完成
循环检测页面标题和内容:
- 如果检测到阻断响应(如 1020/403),直接抛出异常
- 如果检测到挑战页面(如 "Just a moment"),继续等待
- 如果既非阻断也非挑战,认为已通过
Raises:
UpstreamBlockedError: 检测到被 Cloudflare 阻断
CloudflareChallengeError: 等待挑战超时
"""
timeout_at = asyncio.get_running_loop().time() + self._settings.cloudflare_wait_timeout_seconds
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", self._settings.cloudflare_wait_timeout_seconds)
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,
)