From d27e1236240788056c62245dbb4c81f93753d254 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Tue, 9 Jun 2026 16:47:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=962?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/browser_pool.py | 29 ++++++------ app/services/cloudflare_session.py | 76 +++++++++++++----------------- tests/test_warm_context.py | 7 +-- 3 files changed, 51 insertions(+), 61 deletions(-) diff --git a/app/services/browser_pool.py b/app/services/browser_pool.py index 02efd9d..6932cba 100644 --- a/app/services/browser_pool.py +++ b/app/services/browser_pool.py @@ -317,25 +317,19 @@ class BrowserPool: async def create_persistent_context(self, storage_state: dict[str, Any] | None = None) -> Any: """创建一个由调用方负责生命周期的长存上下文。 - 用于 cf_clearance 保活复用:通过挑战后保留该上下文及其页面,后续请求 - 复用同一页面(当前标签页)重新导航。调用方需在不再使用时调用 - close_context 释放。 + 用于 cf_clearance 保活复用:通过挑战后保留该上下文,后续请求在其内开新 + 标签页(page_in_context)访问目标页——同上下文 cookie 共享,clearance 有效。 + 调用方需在不再使用时调用 close_context 释放。 """ await self._ensure_browser_ready() return await self._build_context_obj(storage_state) - 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]: - """获取一个并发槽位用于一次页面导航(不创建/关闭页面)。 + async def page_in_context(self, context: Any) -> AsyncIterator[Any]: + """在既有(长存)上下文中开一个新标签页,复用并发信号量。 - 用于在长存页面上做导航时复用全局并发信号量与回收计数。 + 结束时仅关闭该页面,不关闭上下文(上下文生命周期由调用方维护)。 + 同一上下文可并发开多个页面,各自独立导航。 Raises: ResourceBusyError: 等待可用槽位超时 @@ -349,12 +343,19 @@ class BrowserPool: except TimeoutError as exc: raise ResourceBusyError() from exc + page = None counted = False try: self._active_contexts += 1 counted = True - yield + page = await self._configure_page(context) + yield page 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() diff --git a/app/services/cloudflare_session.py b/app/services/cloudflare_session.py index 90f5cba..d57a5aa 100644 --- a/app/services/cloudflare_session.py +++ b/app/services/cloudflare_session.py @@ -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: diff --git a/tests/test_warm_context.py b/tests/test_warm_context.py index de80686..65bcfdc 100644 --- a/tests/test_warm_context.py +++ b/tests/test_warm_context.py @@ -64,12 +64,9 @@ class _FakeBrowserPool: self.contexts.append(ctx) return ctx - async def create_persistent_page(self, context: _FakeContext) -> _FakePage: - return _FakePage(self, context) - @asynccontextmanager - async def navigation_slot(self): - yield + async def page_in_context(self, context: _FakeContext): + yield _FakePage(self, context) async def close_context(self, context: _FakeContext): await context.close()