XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 1
UTF-8
from __future__ import annotations

"""
Aliyun Captcha V3 solver for the GLM (z.ai) provider.

Faithful Python port of the TypeScript reference implementation
``providers/glm/captcha-solver.ts``.

Loads the AliyunCaptcha.js SDK into a headless Chromium page with stealth
mitigations, then calls ``startTracelessVerification()`` to obtain a
``captcha_verify_param``. Tokens are cached for 45 seconds and the browser
instance is reused across solves.

The gpt4free stack standardises on ``zendriver`` (aliased as ``nodriver``) for
all browser automation, so this module uses the same CDP-based driver via
``get_nodriver`` instead of pulling in Playwright as an extra dependency.
Request interception is performed with the CDP ``Fetch`` domain, which is the
CDP equivalent of Playwright's ``page.route()`` used by the TS reference.
"""

import asyncio
import base64
import time
from pathlib import Path
from typing import Optional

from ... import debug

try:
    import zendriver as nodriver
    from zendriver import cdp

    has_nodriver = True
except ImportError:
    has_nodriver = False

from ...requests import get_nodriver


# z.ai Aliyun Captcha config (from window.AliyunCaptchaConfig on the page)
CAPTCHA_CONFIG = {
    "region": "sgp",
    "prefix": "no8xfe",
    "sceneId": "didk33e0",
}

# Bundled copy of the Aliyun captcha SDK — embedded directly in the page HTML,
# exactly like the TS reference (NOT loaded from alicdn via <script src>).
_BUNDLED_SDK_PATH = Path(__file__).parent / "AliyunCaptcha.js.txt"

TOKEN_TTL_S = 45  # captcha_verify_param is cached for 45 seconds
SOLVE_RETRIES = 3
SOLVE_TIMEOUT_MS = 40_000  # hard cap for a single solve attempt
SDK_LOAD_TIMEOUT_MS = 20_000

LAUNCH_ARGS = [
    "--no-sandbox",
    "--disable-blink-features=AutomationControlled",
    "--disable-features=ChromeWhatsNewUI",
    "--no-first-run",
    "--no-default-browser-check",
]

USER_AGENT = (
    "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
    "(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"
)

# Stealth init script — hides automation signals from the captcha SDK.
# Identical to the TS reference.
STEALTH_INIT_SCRIPT = """
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
window.chrome = { runtime: {}, loadTimes: () => ({}), csi: () => ({}), app: {} };
Object.defineProperty(navigator, 'plugins', { get: () => [1,2,3,4,5] });
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
Object.defineProperty(navigator, 'maxTouchPoints', { get: () => 0 });
"""

# In-process cache for the solved captcha token.
_cached_token: dict = {"verify_param": None, "expires_at": 0.0}
# Serialises concurrent solves so we never launch more than one browser solve
# at the same time for the same process.
_solve_lock: Optional[asyncio.Lock] = None
# Reused browser instance (launched once, kept alive across solves).
_browser: Optional[object] = None
_browser_lock: Optional[asyncio.Lock] = None


def _get_solve_lock() -> asyncio.Lock:
    """Return a process-wide solve lock, creating it lazily."""
    global _solve_lock
    if _solve_lock is None:
        _solve_lock = asyncio.Lock()
    return _solve_lock


def _get_browser_lock() -> asyncio.Lock:
    """Return a process-wide browser lock, creating it lazily."""
    global _browser_lock
    if _browser_lock is None:
        _browser_lock = asyncio.Lock()
    return _browser_lock


def _load_bundled_sdk() -> str:
    """Return the bundled Aliyun captcha SDK source.

    The SDK is embedded directly in the page HTML (not loaded from alicdn),
    matching the TS reference exactly.
    """
    return _BUNDLED_SDK_PATH.read_text(encoding="utf-8")


def _build_page_html() -> str:
    """Build the minimal HTML page that hosts the Aliyun captcha SDK.

    The SDK is embedded directly via ``<script>...</script>`` — NOT loaded from
    alicdn via ``<script src>``. This matches the TS reference exactly and
    ensures the SDK's traceless verification is tied to the chat.z.ai origin
    served via request interception.
    """
    sdk = _load_bundled_sdk()
    # Escape </script> in the SDK source so it doesn't break the HTML.
    safe_sdk = sdk.replace("</script>", "<\\/script>")
    return f"""<!DOCTYPE html><html><head></head><body>
<div id="captcha-element"></div>
<button id="captcha-button"></button>
<script>{safe_sdk}</script>
</body></html>"""


async def _get_browser():
    """Return a reused browser instance, launching it once on first call.

    Mirrors the TS reference's ``getBrowser()`` which keeps a single
    ``browserPromise`` and reuses it across solves.
    """
    global _browser
    async with _get_browser_lock():
        if _browser is not None:
            try:
                # Check if the browser is still connected by opening a tab.
                test_tab = await _browser.get("about:blank")
                await test_tab.close()
                return _browser
            except Exception:
                _browser = None

        browser, _stop = await get_nodriver(
            user_data_dir="glm-captcha",
            browser_args=LAUNCH_ARGS,
        )
        _browser = browser
        return _browser


async def _intercept_and_fulfill(page, page_url: str, html: str) -> None:
    """Intercept navigation to ``page_url`` and serve ``html`` for the document.

    Uses the CDP ``Fetch`` domain. Only the top-level document request is
    fulfilled; every other request is allowed to continue to the network.

    This is the CDP equivalent of Playwright's ``page.route()`` used by the
    TS reference.
    """
    await page.send(
        cdp.fetch.enable(
            patterns=[
                cdp.fetch.RequestPattern(
                    request_stage=cdp.fetch.RequestStage.REQUEST,
                )
            ],
        )
    )

    async def on_request_paused(event: cdp.fetch.RequestPaused, page=None):
        request = event.request
        url = request.url
        # Only fulfil the top-level document navigation to chat.z.ai.
        is_document = url == page_url or url == page_url.rstrip("/")
        if is_document and event.resource_type == cdp.network.ResourceType.DOCUMENT:
            await page.send(
                cdp.fetch.fulfill_request(
                    request_id=event.request_id,
                    response_code=200,
                    response_headers=[
                        cdp.fetch.HeaderEntry(
                            name="Content-Type", value="text/html; charset=utf-8"
                        ),
                    ],
                    body=base64.b64encode(html.encode("utf-8")).decode("ascii"),
                )
            )
        else:
            await page.send(cdp.fetch.continue_request(request_id=event.request_id))

    page.add_handler(cdp.fetch.RequestPaused, on_request_paused)


async def _solve_once() -> str:
    """Solve the captcha once and return the captcha_verify_param string.

    Mirrors ``solveInBrowser()`` from the TS reference.
    """
    browser = await _get_browser()
    page_url = "https://chat.z.ai/"
    html = _build_page_html()

    # Open a new tab for this solve.
    tab = await browser.get("about:blank")

    try:
        # Install the Fetch interceptor before navigating so we catch the
        # initial document request.
        await _intercept_and_fulfill(tab, page_url, html)

        # Apply stealth mitigations before any page script runs.
        await tab.evaluate(STEALTH_INIT_SCRIPT, await_promise=False)

        # Navigate to the intercepted page.
        await tab.get(page_url)

        # Set the captcha config the same way the z.ai bundle does.
        await tab.evaluate(
            f"window.AliyunCaptchaConfig = {{region: {CAPTCHA_CONFIG['region']!r}, "
            f"prefix: {CAPTCHA_CONFIG['prefix']!r}}};",
            await_promise=False,
        )

        # Wait for the SDK to expose initAliyunCaptcha.
        # The SDK is embedded directly in the HTML, so it should be available
        # immediately after the page loads.
        waited = 0
        while True:
            ready = await tab.evaluate(
                "typeof window.initAliyunCaptcha === 'function'",
                await_promise=False,
            )
            if ready:
                break
            if waited >= SDK_LOAD_TIMEOUT_MS:
                raise TimeoutError("Aliyun captcha SDK failed to load")
            await asyncio.sleep(0.5)
            waited += 500

        # Solve the captcha — identical JS to the TS reference.
        cfg = CAPTCHA_CONFIG
        solve_js = """
        (async (cfg) => {
            return new Promise((resolve, reject) => {
                const timeout = setTimeout(
                    () => reject(new Error('Captcha solve timeout after ' + cfg.timeout + 'ms')),
                    cfg.timeout
                );
                window.initAliyunCaptcha({
                    SceneId: cfg.sceneId,
                    mode: 'popup',
                    region: cfg.region,
                    prefix: cfg.prefix,
                    language: 'en',
                    element: '#captcha-element',
                    button: '#captcha-button',
                    captchaLogoImg: '',
                    showErrorTip: false,
                    success: (param) => { clearTimeout(timeout); resolve(param); },
                    fail: (err) => { clearTimeout(timeout); reject(new Error('SDK fail: ' + JSON.stringify(err))); },
                    getInstance: (inst) => { inst.startTracelessVerification(); }
                });
            });
        })
        """

        param = await tab.evaluate(
            f"{solve_js}({{'region': {cfg['region']!r}, 'prefix': {cfg['prefix']!r}, "
            f"'sceneId': {cfg['sceneId']!r}, 'timeout': {SOLVE_TIMEOUT_MS}}})",
            await_promise=True,
        )
        if not isinstance(param, str) or not param:
            raise RuntimeError(f"Captcha solver returned an invalid token: {param!r}")
        debug.log("GLM captcha solved successfully")
        return param
    finally:
        # Close the tab but keep the browser alive for reuse.
        try:
            await tab.close()
        except Exception:
            pass


async def _solve_with_retry() -> str:
    """Solve the captcha with retry, matching SOLVE_RETRIES attempts."""
    last_err: Optional[Exception] = None
    for attempt in range(1, SOLVE_RETRIES + 1):
        try:
            debug.log(f"GLM captcha: solve attempt {attempt}/{SOLVE_RETRIES}")
            return await _solve_once()
        except Exception as err:
            last_err = err
            debug.log(f"GLM captcha: attempt {attempt} failed: {err}")
            if attempt < SOLVE_RETRIES:
                await asyncio.sleep(1)
    raise TimeoutError(
        f"GLM captcha solving failed after {SOLVE_RETRIES} attempts: {last_err}"
    ) from last_err


async def get_captcha_verify_param() -> str:
    """Return a fresh ``captcha_verify_param`` for the GLM API.

    A valid token is cached for ``TOKEN_TTL_S`` seconds (45s). Concurrent
    callers share a single solve to avoid spawning multiple browsers.
    """
    if _cached_token["verify_param"] and _cached_token["expires_at"] > time.time():
        return _cached_token["verify_param"]

    async with _get_solve_lock():
        # Re-check inside the lock — another coroutine may have just solved it.
        if _cached_token["verify_param"] and _cached_token["expires_at"] > time.time():
            return _cached_token["verify_param"]
        verify_param = await _solve_with_retry()
        _cached_token["verify_param"] = verify_param
        _cached_token["expires_at"] = time.time() + TOKEN_TTL_S
        return verify_param


def invalidate_captcha_token() -> None:
    """Force-invalidate the cached token.

    Call this after a 403 / FRONTEND_CAPTCHA error so the next request resolves
    a fresh token instead of reusing a rejected one.
    """
    _cached_token["verify_param"] = None
    _cached_token["expires_at"] = 0.0


def is_available() -> bool:
    """Whether the captcha solver can run (requires zendriver)."""
    return has_nodriver