z-Captcha / server.py
bg5's picture
Upload 4 files
d1239db verified
Raw History Blame Contribute Delete
17.7 kB
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Z.ai Captcha Token Service
Standalone FastAPI service that manages a Playwright browser session to
generate Aliyun Captcha tokens for Z.ai API requests.
Deploy separately from the main API proxy to isolate browser memory usage.
Endpoints:
POST /token - Generate a fresh captcha token
GET /health - Service health check
"""
import asyncio
import base64
import json
import logging
import os
import time
from contextlib import asynccontextmanager
from typing import Dict, Optional
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import uvicorn
logger = logging.getLogger("captcha")
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s")
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
CAPTCHA_HOST = os.getenv("CAPTCHA_HOST", "0.0.0.0")
CAPTCHA_PORT = int(os.getenv("CAPTCHA_PORT", "9000"))
CAPTCHA_HEADLESS = os.getenv("CAPTCHA_HEADLESS", "true").lower() == "true"
CAPTCHA_TOKEN_TIMEOUT = float(os.getenv("CAPTCHA_TOKEN_TIMEOUT", "15.0"))
CAPTCHA_PAGE_IDLE_TTL = float(os.getenv("CAPTCHA_PAGE_IDLE_TTL", "300"))
# ---------------------------------------------------------------------------
# JWT helpers
# ---------------------------------------------------------------------------
def decode_jwt_payload(token: str) -> Dict:
"""Decode JWT payload without signature verification."""
parts = token.split(".")
if len(parts) < 2:
raise ValueError("Invalid JWT: expected 3 dot-separated parts")
padded = parts[1] + "=" * (4 - len(parts[1]) % 4)
return json.loads(base64.urlsafe_b64decode(padded))
def extract_user_id(jwt_token: str) -> str:
payload = decode_jwt_payload(jwt_token)
user_id = payload.get("id")
if not user_id:
raise ValueError("JWT missing 'id' field")
return user_id
# ---------------------------------------------------------------------------
# Browser session manager
# ---------------------------------------------------------------------------
class CaptchaBrowser:
"""Manages a single Playwright browser with per-user page tabs."""
def __init__(self):
self._playwright = None
self._browser = None
self._context = None
self._pages: Dict[str, Dict] = {}
self._cleanup_task: Optional[asyncio.Task] = None
async def start(self):
from playwright.async_api import async_playwright
self._playwright = await async_playwright().start()
self._browser = await self._playwright.chromium.launch(
headless=CAPTCHA_HEADLESS,
)
self._context = await self._browser.new_context(
viewport={"width": 1265, "height": 1281},
user_agent=(
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"
),
)
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
logger.info(f"Browser started (headless={CAPTCHA_HEADLESS})")
# 连通性自检
await self._connectivity_check()
async def _connectivity_check(self):
"""启动时验证 chat.z.ai 可达且 captcha SDK 可用。"""
logger.info("running connectivity check...")
page = await self._context.new_page()
errors = []
# 1. DNS / TCP 可达性 + 等待 Svelte 渲染完成
try:
resp = await page.goto(
"https://chat.z.ai",
wait_until="domcontentloaded",
timeout=20000,
)
if resp is None:
errors.append("page.goto returned None (possible network block)")
elif resp.status >= 400:
errors.append(f"HTTP {resp.status}")
else:
title = await page.title()
logger.info(f" page loaded: status={resp.status} title={title!r}")
except Exception as e:
errors.append(f"cannot reach chat.z.ai: {e}")
logger.warning(f" FAIL: page load error: {e}")
await page.close()
if errors:
logger.warning("CONNECTIVITY CHECK FAILED:")
for e in errors:
logger.warning(f" - {e}")
return
# 2. 等待 Svelte 渲染 chat UI(DOMContentLoaded 后 Svelte 异步挂载)
try:
await page.wait_for_selector(
'textarea, [contenteditable="true"], [role="textbox"]',
timeout=15000,
)
logger.info(f" input element appeared after wait")
except Exception:
logger.warning(" input element did not appear within 15s")
# 3. Chat UI 完整性
checks = await page.evaluate("""() => {
const textarea = document.querySelector('textarea');
const editableDiv = document.querySelector('[contenteditable="true"]');
const roleTextbox = document.querySelector('[role="textbox"]');
return {
textarea: !!textarea,
contentEditableTag: editableDiv ? editableDiv.tagName : null,
roleTextboxTag: roleTextbox ? roleTextbox.tagName : null,
hasLocalStorage: (() => { try { return !!window.localStorage; } catch(e) { return false; } })(),
};
}""")
logger.info(f" textarea: {'OK' if checks.get('textarea') else 'MISSING'}")
logger.info(f" contentEditable: {checks.get('contentEditableTag') or 'MISSING'}")
logger.info(f" role=textbox: {checks.get('roleTextboxTag') or 'MISSING'}")
logger.info(f" localStorage: {'OK' if checks.get('hasLocalStorage') else 'UNAVAILABLE'}")
if not checks.get("textarea") and not checks.get("contentEditableTag") and not checks.get("roleTextboxTag"):
errors.append("no input element found — chat UI incomplete")
if not checks.get("hasLocalStorage"):
errors.append("localStorage not available")
# 4. Captcha SDK 检测
captcha_objects = await page.evaluate("""() => {
const found = [];
// 阿里云验证码 SDK 全局对象
if (typeof window.AWSC !== 'undefined') found.push('AWSC');
if (typeof window.NVC !== 'undefined') found.push('NVC');
if (typeof window.AliyunCaptcha !== 'undefined') found.push('AliyunCaptcha');
if (typeof window.__captcha_init !== 'undefined') found.push('__captcha_init');
// 检查页面中是否注入了 captcha 相关 script
const scripts = Array.from(document.querySelectorAll('script[src]'))
.filter(s => /captcha|aliyun/.test(s.src));
if (scripts.length > 0) found.push('captcha_script_tags:' + scripts.length);
return found;
}""")
if captcha_objects:
logger.info(f" captcha SDK objects: {captcha_objects}")
else:
logger.info(" captcha SDK objects: none detected (may load on demand)")
await page.close()
if errors:
logger.warning("CONNECTIVITY CHECK WARNINGS:")
for e in errors:
logger.warning(f" ! {e}")
else:
logger.info("connectivity check PASSED")
async def _cleanup_loop(self):
while True:
await asyncio.sleep(60)
now = time.monotonic()
to_remove = [
uid for uid, e in self._pages.items()
if now - e["last_used"] > CAPTCHA_PAGE_IDLE_TTL
]
for uid in to_remove:
await self._close_page(uid)
if to_remove:
logger.info(f"Cleaned up {len(to_remove)} idle pages")
async def _close_page(self, user_id: str):
entry = self._pages.pop(user_id, None)
if entry:
try:
await entry["page"].close()
except Exception:
pass
async def _get_or_create_page(self, user_id: str, jwt_token: str) -> Dict:
if user_id in self._pages:
entry = self._pages[user_id]
entry["last_used"] = time.monotonic()
return entry
page = await self._context.new_page()
async def _permanent_route(route):
try:
await route.continue_()
except Exception:
pass
await page.route("**/api/v2/chat/completions**", _permanent_route)
# 导航到 chat.z.ai,带超时保护
try:
resp = await page.goto(
"https://chat.z.ai",
wait_until="domcontentloaded",
timeout=20000,
)
if resp is None or resp.status >= 400:
logger.warning(f"page load failed for {user_id}: status={resp.status if resp else 'N/A'}")
await page.close()
raise HTTPException(
status_code=503,
detail="Failed to load chat.z.ai (network may be restricted)",
)
except Exception as e:
await page.close()
logger.warning(f"page goto error for {user_id}: {e}")
raise HTTPException(
status_code=503,
detail=f"Cannot reach chat.z.ai: {e}",
)
# 等待 Svelte 异步渲染 chat UI
try:
await page.wait_for_selector(
'textarea, [contenteditable="true"], [role="textbox"]',
timeout=15000,
)
except Exception:
title = await page.title()
logger.warning(f"input element did not appear for {user_id}, title: {title}")
await page.close()
raise HTTPException(
status_code=503,
detail=f"chat.z.ai chat UI did not render (title: {title})",
)
await page.evaluate(
"""(t) => { localStorage.setItem('token', t); }""",
jwt_token,
)
await page.reload(wait_until="domcontentloaded")
await asyncio.sleep(3)
entry = {
"page": page,
"lock": asyncio.Lock(),
"last_used": time.monotonic(),
}
self._pages[user_id] = entry
return entry
async def get_token(self, jwt_token: str) -> str:
user_id = extract_user_id(jwt_token)
logger.info(f"token requested for user_id={user_id[:12]}...")
entry = await self._get_or_create_page(user_id, jwt_token)
async with entry["lock"]:
try:
return await self._trigger_captcha(entry)
except HTTPException:
# 触发失败时清理可能的坏页面,下次请求重建
await self._close_page(user_id)
raise
async def _trigger_captcha(self, entry: Dict) -> str:
page = entry["page"]
token_holder: Dict[str, Optional[str]] = {"value": None}
event = asyncio.Event()
async def capture_route(route):
try:
body = route.request.post_data_json # property, not method
if body:
has_captcha = "captcha_verify_param" in body
logger.debug(
f"route hit: has_captcha={has_captcha} "
f"model={body.get('model', 'N/A')}"
)
if has_captcha:
token_holder["value"] = body["captcha_verify_param"]
event.set()
await route.abort("blockedbyclient")
return
else:
logger.debug("route hit: no JSON body")
except Exception as e:
logger.warning(f"route hit: parse error: {e}")
try:
await route.continue_()
except Exception:
pass
await page.unroute("**/api/v2/chat/completions**")
await page.route("**/api/v2/chat/completions**", capture_route)
# 使用 Playwright 原生 fill + click send button
input_selector = (
'textarea, [contenteditable="true"], [role="textbox"]'
)
try:
await page.wait_for_selector(input_selector, timeout=5000)
await page.fill(input_selector, "hi")
except Exception:
logger.warning("input element not found or fill failed")
raise HTTPException(
status_code=503,
detail="Chat input not found, page may need re-initialization",
)
# 调试:检查 send 按钮状态
btn_state = await page.evaluate("""() => {
const btn = document.querySelector('#send-message-button');
if (!btn) return 'not_found';
return btn.disabled ? 'disabled' : 'enabled';
}""")
logger.debug(f"send button state after fill: {btn_state}")
if btn_state == 'disabled':
# Svelte 可能没有正确响应 fill,尝试手动激活按钮
await page.evaluate("""() => {
const btn = document.querySelector('#send-message-button');
if (btn) btn.disabled = false;
}""")
await page.click('#send-message-button', timeout=5000)
logger.debug("clicked send button")
try:
await asyncio.wait_for(event.wait(), timeout=CAPTCHA_TOKEN_TIMEOUT)
except asyncio.TimeoutError:
raise HTTPException(
status_code=503,
detail="Captcha token generation timed out",
)
# Restore permanent pass-through route
async def _permanent_route(route):
try:
await route.continue_()
except Exception:
pass
await page.unroute("**/api/v2/chat/completions**")
await page.route("**/api/v2/chat/completions**", _permanent_route)
token = token_holder["value"]
if not token:
logger.warning("token capture failed: empty value in intercepted request")
raise HTTPException(
status_code=503,
detail="Failed to capture captcha token",
)
logger.info(f"token captured successfully, len={len(token)}")
return token
async def health(self) -> Dict:
return {
"status": "ok" if self._browser and self._browser.is_connected() else "degraded",
"browser_connected": self._browser.is_connected() if self._browser else False,
"active_pages": len(self._pages),
}
async def shutdown(self):
if self._cleanup_task:
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
for uid in list(self._pages.keys()):
await self._close_page(uid)
if self._context:
await self._context.close()
if self._browser:
await self._browser.close()
if self._playwright:
await self._playwright.stop()
logger.info("Browser shut down")
# ---------------------------------------------------------------------------
# FastAPI application
# ---------------------------------------------------------------------------
_captcha_browser: Optional[CaptchaBrowser] = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global _captcha_browser
_captcha_browser = CaptchaBrowser()
await _captcha_browser.start()
yield
if _captcha_browser:
await _captcha_browser.shutdown()
app = FastAPI(
title="Z.ai Captcha Token Service",
lifespan=lifespan,
docs_url=None,
redoc_url=None,
openapi_url=None,
)
class TokenRequest(BaseModel):
jwt_token: str
class TokenResponse(BaseModel):
captcha_verify_param: str
certify_id: str
@app.post("/token", response_model=TokenResponse)
async def generate_token(req: TokenRequest):
if not _captcha_browser:
raise HTTPException(status_code=503, detail="Service not initialized")
try:
token = await _captcha_browser.get_token(req.jwt_token)
try:
padded = token + "=" * (4 - len(token) % 4)
data = json.loads(base64.urlsafe_b64decode(padded))
certify_id = data.get("certifyId", "unknown")
except Exception:
certify_id = "unknown"
return TokenResponse(captcha_verify_param=token, certify_id=certify_id)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=503, detail=str(e))
@app.get("/health")
async def health():
if not _captcha_browser:
return {"status": "initializing"}
return await _captcha_browser.health()
if __name__ == "__main__":
uvicorn.run(
"server:app",
host=CAPTCHA_HOST,
port=CAPTCHA_PORT,
log_level="info",
reload=False,
)