This commit is contained in:
2026-06-09 16:47:20 +08:00
parent 731552f93d
commit d27e123624
3 changed files with 51 additions and 61 deletions
+34 -42
View File
@@ -38,22 +38,18 @@ class VerifiedSession:
@dataclass(slots=True)
class WarmContext:
"""保活的浏览器页面:通过 Cloudflare 挑战后长期驻留,供后续请求复用
"""保活的浏览器上下文:通过 Cloudflare 挑战后长期驻留,供后续请求复用
cf_clearance 无法以任何形式重放(跨上下文、同上下文新标签页均被判 403),
因此保留通过挑战的「页面(标签页)」本身,后续请求复用同一页面重新导航——
模拟真实站内跳转(自动带 Referer、Sec-Fetch-Site: same-origin),
这是唯一可能被 CF 接受的复用形态。
nav_lock 串行化同一页面上的导航(一个页面同一时刻只能有一次导航)。
cf_clearance 是上下文级 cookie:只要请求指纹一致(见 stealth 钩子集成),
复用通过挑战的上下文、在其内开新标签页访问目标页即可被 CF 接受。
同一上下文可并发开多个标签页;带引用计数以保证并发使用时安全退役。
"""
session_id: str
context: Any # Playwright BrowserContext
page: Any # Playwright Page(通过挑战的当前标签页,复用导航)
nav_lock: asyncio.Lock # 串行化该页面上的导航
cf_clearance: str | None
created_at: float
last_used_at: float
in_use: int = 0 # 当前引用该页面的请求数(用于安全退役)
in_use: int = 0 # 当前正在该上下文内使用页面的请求数(用于安全退役)
retired: bool = False # 已从注册表摘除、等待空闲后关闭
close_done: bool = False # 关闭只执行一次的标记
@@ -168,42 +164,41 @@ class CloudflareSessionManager:
return age >= self._settings.session_ttl_seconds
async def _fetch_via_warm(self, warm: WarmContext, target_url: str) -> str | None:
"""复用保活页面(当前标签页)重新导航到目标页
"""在保活上下文内开新标签页访问目标页(同上下文 cookie 共享,clearance 复用)
在同一页面上 goto 新 URL,浏览器自动带上当前页 Referer 与
Sec-Fetch-Site: same-origin,模拟真实站内跳转。nav_lock 串行化同页导航。
携带同站 Referer,使复用请求更像站内跳转。同一上下文可并发开多个标签页。
Returns:
通过验证的页面 HTML;若判定已失效(非 200 / 挑战未过 / 页面异常),返回 None
通过验证的页面 HTML;若判定已失效(非 200 / 挑战未过 / 上下文异常),返回 None
"""
warm.in_use += 1
try:
async with warm.nav_lock:
async with self._browser_pool.navigation_slot():
response = await warm.page.goto(
target_url,
wait_until="domcontentloaded",
timeout=int(self._settings.request_timeout_seconds * 1000),
async with self._browser_pool.page_in_context(warm.context) as page:
response = await page.goto(
target_url,
wait_until="domcontentloaded",
timeout=int(self._settings.request_timeout_seconds * 1000),
referer=self._settings.base_url,
)
status = response.status if response is not None else None
if status != 200:
logger.info("保活上下文复用导航非 200,判定失效:url=%s status=%s", target_url, status)
return None
# 首响应 200:极少数情况下仍是软挑战,用短超时给一次自动通过的机会
try:
await self._wait_for_clearance(
page, timeout_seconds=self._settings.cloudflare_reuse_wait_timeout_seconds
)
status = response.status if response is not None else None
if status != 200:
logger.info("保活页面复用导航非 200,判定失效:url=%s status=%s", target_url, status)
return None
except (CloudflareChallengeError, UpstreamBlockedError):
logger.info("保活上下文复用软挑战未通过,判定失效:url=%s", target_url)
return None
# 首响应 200:极少数情况下仍是软挑战,用短超时给一次自动通过的机会
try:
await self._wait_for_clearance(
warm.page, timeout_seconds=self._settings.cloudflare_reuse_wait_timeout_seconds
)
except (CloudflareChallengeError, UpstreamBlockedError):
logger.info("保活页面复用软挑战未通过,判定失效:url=%s", target_url)
return None
warm.last_used_at = asyncio.get_running_loop().time()
return await warm.page.content()
warm.last_used_at = asyncio.get_running_loop().time()
return await page.content()
except Exception as exc:
# 页面/上下文/浏览器已被关闭(如浏览器回收)或导航异常:视为失效,触发重建
logger.info("保活页面复用异常,判定失效:url=%s err=%s", target_url, exc)
# 上下文/浏览器已被关闭(如浏览器回收)或导航异常:视为失效,触发重建
logger.info("保活上下文复用异常,判定失效:url=%s err=%s", target_url, exc)
return None
finally:
warm.in_use -= 1
@@ -211,7 +206,7 @@ class CloudflareSessionManager:
await self._close_warm_context(warm)
async def _build_warm_context(self, session_name: str, target_url: str) -> tuple[WarmContext, str]:
"""创建一个通过 Cloudflare 挑战的保活页面,并返回本次访问的页面 HTML。"""
"""创建一个通过 Cloudflare 挑战的保活上下文,并返回本次访问的页面 HTML。"""
# 退役并关闭可能残留的旧上下文(如已过期未复用的),避免覆盖注册表导致泄漏
stale = self._warm_contexts.get(session_name)
if stale is not None:
@@ -219,8 +214,7 @@ class CloudflareSessionManager:
context = await self._browser_pool.create_persistent_context()
try:
page = await self._browser_pool.create_persistent_page(context)
async with self._browser_pool.navigation_slot():
async with self._browser_pool.page_in_context(context) as page:
response = await page.goto(
target_url,
wait_until="domcontentloaded",
@@ -244,14 +238,12 @@ class CloudflareSessionManager:
warm = WarmContext(
session_id=str(uuid4()),
context=context,
page=page,
nav_lock=asyncio.Lock(),
cf_clearance=cf_clearance,
created_at=now,
last_used_at=now,
)
self._warm_contexts[session_name] = warm
logger.info("保活页面创建完成:session_name=%s cf_clearance=%s", session_name, bool(cf_clearance))
logger.info("保活上下文创建完成:session_name=%s cf_clearance=%s", session_name, bool(cf_clearance))
return warm, html_content
async def _retire_warm(self, session_name: str, warm: WarmContext) -> None: