253 lines
11 KiB
Python
253 lines
11 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 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,
|
|
)
|