直接复用页面,避免CF验证
This commit is contained in:
@@ -302,18 +302,25 @@ class BrowserPool:
|
||||
async def create_persistent_context(self, storage_state: dict[str, Any] | None = None) -> Any:
|
||||
"""创建一个由调用方负责生命周期的长存上下文。
|
||||
|
||||
用于 cf_clearance 保活复用:通过挑战后保留该上下文,后续请求在其内
|
||||
开新页面访问目标页(同一上下文内 cf_clearance 有效)。调用方需在不再
|
||||
使用时调用 close_context 释放。
|
||||
用于 cf_clearance 保活复用:通过挑战后保留该上下文及其页面,后续请求
|
||||
复用同一页面(当前标签页)重新导航。调用方需在不再使用时调用
|
||||
close_context 释放。
|
||||
"""
|
||||
await self._ensure_browser_ready()
|
||||
return await self._build_context_obj(storage_state)
|
||||
|
||||
@asynccontextmanager
|
||||
async def page_in_context(self, context: Any) -> AsyncIterator[Any]:
|
||||
"""在既有(长存)上下文中开一个页面,复用并发信号量。
|
||||
async def create_persistent_page(self, context: Any) -> Any:
|
||||
"""在长存上下文中创建一个长存页面(设置反检测请求头)。
|
||||
|
||||
结束时仅关闭页面,不关闭上下文(上下文生命周期由调用方维护)。
|
||||
该页面不会被自动关闭,调用方通过 close_context 关闭其所属上下文时一并释放。
|
||||
"""
|
||||
return await self._configure_page(context)
|
||||
|
||||
@asynccontextmanager
|
||||
async def navigation_slot(self) -> AsyncIterator[None]:
|
||||
"""获取一个并发槽位用于一次页面导航(不创建/关闭页面)。
|
||||
|
||||
用于在长存页面上做导航时复用全局并发信号量与回收计数。
|
||||
|
||||
Raises:
|
||||
ResourceBusyError: 等待可用槽位超时
|
||||
@@ -327,19 +334,12 @@ class BrowserPool:
|
||||
except TimeoutError as exc:
|
||||
raise ResourceBusyError() from exc
|
||||
|
||||
page = None
|
||||
counted = False
|
||||
try:
|
||||
self._active_contexts += 1
|
||||
counted = True
|
||||
page = await self._configure_page(context)
|
||||
yield page
|
||||
yield
|
||||
finally:
|
||||
if page is not None:
|
||||
try:
|
||||
await page.close()
|
||||
except Exception:
|
||||
logger.debug("关闭复用上下文页面失败", exc_info=True)
|
||||
if counted:
|
||||
self._active_contexts -= 1
|
||||
self._semaphore.release()
|
||||
|
||||
@@ -38,18 +38,22 @@ class VerifiedSession:
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WarmContext:
|
||||
"""保活的浏览器上下文:通过 Cloudflare 挑战后长期驻留,供后续请求复用
|
||||
"""保活的浏览器页面:通过 Cloudflare 挑战后长期驻留,供后续请求复用
|
||||
|
||||
cf_clearance 无法跨上下文重放,因此保留通过挑战的上下文本身,
|
||||
后续请求在同一上下文内开新页面访问目标页(上下文内 clearance 有效)。
|
||||
带引用计数以保证并发使用时安全退役。
|
||||
cf_clearance 无法以任何形式重放(跨上下文、同上下文新标签页均被判 403),
|
||||
因此保留通过挑战的「页面(标签页)」本身,后续请求复用同一页面重新导航——
|
||||
模拟真实站内跳转(自动带 Referer、Sec-Fetch-Site: same-origin),
|
||||
这是唯一可能被 CF 接受的复用形态。
|
||||
nav_lock 串行化同一页面上的导航(一个页面同一时刻只能有一次导航)。
|
||||
"""
|
||||
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 # 关闭只执行一次的标记
|
||||
|
||||
@@ -164,41 +168,42 @@ class CloudflareSessionManager:
|
||||
return age >= self._settings.session_ttl_seconds
|
||||
|
||||
async def _fetch_via_warm(self, warm: WarmContext, target_url: str) -> str | None:
|
||||
"""在保活上下文内开新页面访问目标页。
|
||||
"""复用保活页面(当前标签页)重新导航到目标页。
|
||||
|
||||
在同一页面上 goto 新 URL,浏览器自动带上当前页 Referer 与
|
||||
Sec-Fetch-Site: same-origin,模拟真实站内跳转。nav_lock 串行化同页导航。
|
||||
|
||||
Returns:
|
||||
通过验证的页面 HTML;若判定上下文已失效(非 200 / 挑战未过 / 上下文异常),返回 None
|
||||
通过验证的页面 HTML;若判定已失效(非 200 / 挑战未过 / 页面异常),返回 None
|
||||
"""
|
||||
warm.in_use += 1
|
||||
try:
|
||||
async with self._browser_pool.page_in_context(warm.context) as page:
|
||||
# 携带同站 Referer,使复用请求看起来像站内跳转而非「凭空直达」,
|
||||
# 规避 CF 对「持有 clearance 却无浏览上下文」请求的 403 判定。
|
||||
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
|
||||
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),
|
||||
)
|
||||
except (CloudflareChallengeError, UpstreamBlockedError):
|
||||
logger.info("保活上下文复用软挑战未通过,判定失效:url=%s", target_url)
|
||||
return None
|
||||
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
|
||||
|
||||
warm.last_used_at = asyncio.get_running_loop().time()
|
||||
return await page.content()
|
||||
# 首响应 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()
|
||||
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
|
||||
@@ -206,7 +211,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:
|
||||
@@ -214,7 +219,8 @@ class CloudflareSessionManager:
|
||||
|
||||
context = await self._browser_pool.create_persistent_context()
|
||||
try:
|
||||
async with self._browser_pool.page_in_context(context) as page:
|
||||
page = await self._browser_pool.create_persistent_page(context)
|
||||
async with self._browser_pool.navigation_slot():
|
||||
response = await page.goto(
|
||||
target_url,
|
||||
wait_until="domcontentloaded",
|
||||
@@ -238,12 +244,14 @@ 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:
|
||||
|
||||
Reference in New Issue
Block a user