diff --git a/src/api/admin.py b/src/api/admin.py
index 0f5d51c..3faf2c6 100644
--- a/src/api/admin.py
+++ b/src/api/admin.py
@@ -1,2088 +1,2088 @@
-"""Admin API routes"""
-import asyncio
-import json
-from fastapi import APIRouter, Depends, HTTPException, Header, Request
-from fastapi.responses import JSONResponse
-from pydantic import BaseModel
-from typing import Optional, List, Dict, Any
-import secrets
-import time
-import re
-import urllib.error
-import urllib.request
-from urllib.parse import urlparse
-from curl_cffi.requests import AsyncSession
-from ..core.auth import AuthManager
-from ..core.database import Database
-from ..core.config import config
-from ..services.token_manager import TokenManager
-from ..services.proxy_manager import ProxyManager
-from ..services.concurrency_manager import ConcurrencyManager
-
-try:
- import httpx
-except ImportError:
- httpx = None
-
-router = APIRouter()
-
-# Dependency injection
-token_manager: TokenManager = None
-proxy_manager: ProxyManager = None
-db: Database = None
-concurrency_manager: Optional[ConcurrencyManager] = None
-
-# Store active admin session tokens (in production, use Redis or database)
-active_admin_tokens = set()
-SUPPORTED_API_CAPTCHA_METHODS = {"yescaptcha", "capmonster", "ezcaptcha", "capsolver"}
-
-
-def _mask_token(token: Optional[str]) -> str:
- if not token:
- return ""
- if len(token) <= 24:
- return token
- return f"{token[:18]}...{token[-8:]}"
-
-
-def _truncate_text(text: Any, limit: int = 240) -> str:
- value = str(text or "").strip()
- if len(value) <= limit:
- return value
- return f"{value[:limit - 3]}..."
-
-
-def _extract_error_summary(payload: Any) -> str:
- """从响应体里提取用户可读的错误摘要。"""
- if payload is None:
- return ""
-
- if isinstance(payload, str):
- raw = payload.strip()
- if not raw:
- return ""
- try:
- return _extract_error_summary(json.loads(raw))
- except Exception:
- return _truncate_text(raw)
-
- if isinstance(payload, dict):
- for key in ("error_summary", "error_message", "detail", "message"):
- value = payload.get(key)
- if isinstance(value, str) and value.strip():
- return _truncate_text(value)
-
- error_value = payload.get("error")
- if isinstance(error_value, dict):
- for key in ("message", "detail", "reason", "code"):
- value = error_value.get(key)
- if isinstance(value, str) and value.strip():
- return _truncate_text(value)
- elif isinstance(error_value, str) and error_value.strip():
- return _truncate_text(error_value)
-
- for nested_key in ("response", "data"):
- nested = payload.get(nested_key)
- if isinstance(nested, (dict, list, str)):
- summary = _extract_error_summary(nested)
- if summary:
- return summary
-
- return ""
-
- if isinstance(payload, list):
- for item in payload:
- summary = _extract_error_summary(item)
- if summary:
- return summary
- return ""
-
- return _truncate_text(payload)
-
-
-def _guess_client_hints_from_user_agent(user_agent: str) -> Dict[str, str]:
- """根据 UA 补全常见的 sec-ch-* 头。"""
- ua = (user_agent or "").strip()
- if not ua:
- return {}
-
- headers: Dict[str, str] = {}
- major_match = re.search(r"(?:Chrome|Chromium|Edg|EdgA|EdgiOS)/(\d+)", ua)
- is_mobile = any(token in ua for token in ("Android", "iPhone", "iPad", "Mobile"))
- headers["sec-ch-ua-mobile"] = "?1" if is_mobile else "?0"
-
- if "Windows" in ua:
- headers["sec-ch-ua-platform"] = '"Windows"'
- elif "Macintosh" in ua or "Mac OS X" in ua:
- headers["sec-ch-ua-platform"] = '"macOS"'
- elif "Android" in ua:
- headers["sec-ch-ua-platform"] = '"Android"'
- elif "iPhone" in ua or "iPad" in ua:
- headers["sec-ch-ua-platform"] = '"iOS"'
- elif "Linux" in ua:
- headers["sec-ch-ua-platform"] = '"Linux"'
-
- if major_match:
- major = major_match.group(1)
- if "Edg/" in ua:
- headers["sec-ch-ua"] = (
- f'"Not:A-Brand";v="99", "Microsoft Edge";v="{major}", "Chromium";v="{major}"'
- )
- else:
- headers["sec-ch-ua"] = (
- f'"Not:A-Brand";v="99", "Google Chrome";v="{major}", "Chromium";v="{major}"'
- )
-
- return headers
-
-
-def _guess_impersonate_from_user_agent(user_agent: str) -> str:
- """从 UA 选择可用的 curl_cffi 浏览器指纹版本。"""
- ua = (user_agent or "").strip()
- major_match = re.search(r"(?:Chrome|Chromium|Edg|EdgA|EdgiOS)/(\d+)", ua)
- if not major_match:
- return "chrome120"
-
- try:
- major = int(major_match.group(1))
- except Exception:
- return "chrome120"
-
- if major >= 124:
- return "chrome124"
- if major >= 120:
- return "chrome120"
- return "chrome120"
-
-
-def _build_proxy_map(proxy_url: str) -> Optional[Dict[str, str]]:
- normalized = (proxy_url or "").strip()
- if not normalized:
- return None
- return {"http": normalized, "https": normalized}
-
-
-def _normalize_http_base_url(base_url: str) -> str:
- normalized = (base_url or "").strip().rstrip("/")
- if not normalized:
- raise RuntimeError("远程打码服务地址未配置")
-
- parsed = urlparse(normalized)
- if parsed.scheme not in {"http", "https"} or not parsed.netloc:
- raise RuntimeError("远程打码服务地址格式错误,必须是 http(s)://host[:port]")
-
- return normalized
-
-
-def _get_remote_browser_client_config() -> tuple[str, str, int]:
- base_url = _normalize_http_base_url(config.remote_browser_base_url)
- api_key = (config.remote_browser_api_key or "").strip()
- if not api_key:
- raise RuntimeError("远程打码服务 API Key 未配置")
- timeout = max(5, int(config.remote_browser_timeout or 60))
- return base_url, api_key, timeout
-
-
-def _build_remote_browser_http_timeout(read_timeout: float) -> Any:
- read_value = max(3.0, float(read_timeout))
- write_value = min(10.0, max(3.0, read_value))
- if httpx is None:
- return read_value
- return httpx.Timeout(
- connect=2.5,
- read=read_value,
- write=write_value,
- pool=2.5,
- )
-
-
-def _parse_json_response_text(text: str) -> Optional[Any]:
- if not text:
- return None
- try:
- return json.loads(text)
- except Exception:
- return None
-
-
-async def _stdlib_json_http_request(
- method: str,
- url: str,
- headers: Dict[str, str],
- payload: Optional[Dict[str, Any]],
- timeout: int,
-) -> tuple[int, Optional[Any], str]:
- req_headers = dict(headers or {})
- req_headers.setdefault("Accept", "application/json")
- request_method = (method or "GET").upper()
- request_data: Optional[bytes] = None
-
- if payload is not None:
- req_headers["Content-Type"] = "application/json; charset=utf-8"
- if request_method != "GET":
- request_data = json.dumps(payload).encode("utf-8")
-
- def do_request() -> tuple[int, str]:
- request = urllib.request.Request(
- url=url,
- data=request_data,
- headers=req_headers,
- method=request_method,
- )
- opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
- try:
- with opener.open(request, timeout=max(1.0, float(timeout))) as response:
- status_code = int(getattr(response, "status", 0) or response.getcode() or 0)
- body = response.read()
- charset = response.headers.get_content_charset() or "utf-8"
- return status_code, body.decode(charset, errors="replace")
- except urllib.error.HTTPError as exc:
- body = exc.read()
- charset = exc.headers.get_content_charset() if exc.headers else None
- return int(getattr(exc, "code", 0) or 0), body.decode(charset or "utf-8", errors="replace")
-
- try:
- status_code, text = await asyncio.to_thread(do_request)
- except Exception as e:
- raise RuntimeError(f"远程打码服务请求失败: {e}") from e
-
- return status_code, _parse_json_response_text(text), text
-
-
-async def _sync_json_http_request(
- method: str,
- url: str,
- headers: Dict[str, str],
- payload: Optional[Dict[str, Any]],
- timeout: int,
-) -> tuple[int, Optional[Any], str]:
- req_headers = dict(headers or {})
- req_headers.setdefault("Accept", "application/json")
- request_method = (method or "GET").upper()
- request_kwargs: Dict[str, Any] = {
- "headers": req_headers,
- "timeout": _build_remote_browser_http_timeout(timeout),
- }
-
- if payload is not None:
- req_headers["Content-Type"] = "application/json; charset=utf-8"
- if request_method != "GET":
- request_kwargs["json"] = payload
-
- if httpx is None:
- return await _stdlib_json_http_request(
- method=method,
- url=url,
- headers=req_headers,
- payload=payload,
- timeout=timeout,
- )
-
- try:
- # remote_browser 控制面是服务间 JSON API,使用 httpx 避免 curl_cffi 在当前
- # Windows + impersonate 场景下 POST body 丢失导致 FastAPI 直接判定 body 缺失。
- async with httpx.AsyncClient(follow_redirects=False, trust_env=False) as session:
- response = await session.request(
- method=request_method,
- url=url,
- **request_kwargs,
- )
- except Exception as e:
- raise RuntimeError(f"远程打码服务请求失败: {e}") from e
-
- status_code = int(getattr(response, "status_code", 0) or 0)
- text = response.text or ""
- parsed = _parse_json_response_text(text)
-
- return status_code, parsed, text
-
-
-async def _resolve_score_test_verify_proxy(
- captcha_method: str,
- browser_proxy_enabled: bool,
- browser_proxy_url: str
-) -> tuple[Optional[Dict[str, str]], bool, str, str]:
- """
- 选择 score-test 的 verify 请求代理,优先与浏览器打码代理保持一致。
- 返回: (proxies, used, source, proxy_url)
- """
- # 浏览器打码模式优先使用 browser_proxy,确保与取 token 出口一致
- if captcha_method in {"browser", "personal"} and browser_proxy_enabled and browser_proxy_url:
- proxy_map = _build_proxy_map(browser_proxy_url)
- if proxy_map:
- return proxy_map, True, "captcha_browser_proxy", browser_proxy_url
-
- # 退回请求代理配置
- try:
- if proxy_manager:
- proxy_cfg = await proxy_manager.get_proxy_config()
- if proxy_cfg and proxy_cfg.enabled and proxy_cfg.proxy_url:
- proxy_map = _build_proxy_map(proxy_cfg.proxy_url)
- if proxy_map:
- return proxy_map, True, "request_proxy", proxy_cfg.proxy_url
- except Exception:
- pass
-
- return None, False, "none", ""
-
-
-async def _solve_recaptcha_with_api_service(
- method: str,
- website_url: str,
- website_key: str,
- action: str,
- enterprise: bool = False
-) -> Optional[str]:
- """使用当前配置的第三方打码服务获取 token。"""
- if method == "yescaptcha":
- client_key = config.yescaptcha_api_key
- base_url = config.yescaptcha_base_url
- task_type = "RecaptchaV3TaskProxylessM1"
- elif method == "capmonster":
- client_key = config.capmonster_api_key
- base_url = config.capmonster_base_url
- task_type = "RecaptchaV3TaskProxyless"
- elif method == "ezcaptcha":
- client_key = config.ezcaptcha_api_key
- base_url = config.ezcaptcha_base_url
- task_type = "ReCaptchaV3TaskProxylessS9"
- elif method == "capsolver":
- client_key = config.capsolver_api_key
- base_url = config.capsolver_base_url
- task_type = "ReCaptchaV3EnterpriseTaskProxyLess" if enterprise else "ReCaptchaV3TaskProxyLess"
- else:
- raise RuntimeError(f"不支持的打码方式: {method}")
-
- if not client_key:
- raise RuntimeError(f"{method} API Key 未配置")
-
- task: Dict[str, Any] = {
- "websiteURL": website_url,
- "websiteKey": website_key,
- "type": task_type,
- "pageAction": action,
- }
-
- if enterprise and method == "capsolver":
- task["isEnterprise"] = True
-
- create_url = f"{base_url.rstrip('/')}/createTask"
- get_url = f"{base_url.rstrip('/')}/getTaskResult"
-
- async with AsyncSession() as session:
- create_resp = await session.post(
- create_url,
- json={"clientKey": client_key, "task": task},
- impersonate="chrome120",
- timeout=30
- )
- create_json = create_resp.json()
- task_id = create_json.get("taskId")
-
- if not task_id:
- error_desc = create_json.get("errorDescription") or create_json.get("errorMessage") or str(create_json)
- raise RuntimeError(f"{method} createTask 失败: {error_desc}")
-
- for _ in range(40):
- poll_resp = await session.post(
- get_url,
- json={"clientKey": client_key, "taskId": task_id},
- impersonate="chrome120",
- timeout=30
- )
- poll_json = poll_resp.json()
- if poll_json.get("status") == "ready":
- solution = poll_json.get("solution", {}) or {}
- token = solution.get("gRecaptchaResponse") or solution.get("token")
- if token:
- return token
- raise RuntimeError(f"{method} 返回结果缺少 token: {poll_json}")
-
- if poll_json.get("errorId") not in (None, 0):
- error_desc = poll_json.get("errorDescription") or poll_json.get("errorMessage") or str(poll_json)
- raise RuntimeError(f"{method} getTaskResult 失败: {error_desc}")
-
- await asyncio.sleep(3)
-
- raise RuntimeError(f"{method} 获取 token 超时")
-
-
-async def _score_test_with_remote_browser_service(
- website_url: str,
- website_key: str,
- verify_url: str,
- action: str,
- enterprise: bool = False,
-) -> Dict[str, Any]:
- """调用远程有头打码服务执行页面内打码+分数校验。"""
- base_url, api_key, timeout = _get_remote_browser_client_config()
- endpoint = f"{base_url}/api/v1/custom-score"
- request_payload = {
- "website_url": website_url,
- "website_key": website_key,
- "verify_url": verify_url,
- "action": action,
- "enterprise": enterprise,
- }
-
- status_code, response_payload, response_text = await _sync_json_http_request(
- method="POST",
- url=endpoint,
- headers={"Authorization": f"Bearer {api_key}"},
- payload=request_payload,
- timeout=timeout,
- )
-
- if status_code >= 400:
- detail = ""
- if isinstance(response_payload, dict):
- detail = response_payload.get("detail") or response_payload.get("message") or str(response_payload)
- if not detail:
- detail = (response_text or "").strip()
- raise RuntimeError(f"远程打码服务请求失败 (HTTP {status_code}): {detail or '未知错误'}")
-
- if not isinstance(response_payload, dict):
- raise RuntimeError("远程打码服务返回格式错误")
- return response_payload
-
-
-def set_dependencies(tm: TokenManager, pm: ProxyManager, database: Database, cm: Optional[ConcurrencyManager] = None):
- """Set service instances"""
- global token_manager, proxy_manager, db, concurrency_manager
- token_manager = tm
- proxy_manager = pm
- db = database
- concurrency_manager = cm
-
-
-# ========== Request Models ==========
-
-class LoginRequest(BaseModel):
- username: str
- password: str
-
-
-class AddTokenRequest(BaseModel):
- st: str
- project_id: Optional[str] = None # 用户可选输入project_id
- project_name: Optional[str] = None
- remark: Optional[str] = None
- captcha_proxy_url: Optional[str] = None
- image_enabled: bool = True
- video_enabled: bool = True
- image_concurrency: int = -1
- video_concurrency: int = -1
-
-
-class UpdateTokenRequest(BaseModel):
- st: str # Session Token (必填,用于刷新AT)
- project_id: Optional[str] = None # 用户可选输入project_id
- project_name: Optional[str] = None
- remark: Optional[str] = None
- captcha_proxy_url: Optional[str] = None
- image_enabled: Optional[bool] = None
- video_enabled: Optional[bool] = None
- image_concurrency: Optional[int] = None
- video_concurrency: Optional[int] = None
-
-
-class ProxyConfigRequest(BaseModel):
- proxy_enabled: bool
- proxy_url: Optional[str] = None
- media_proxy_enabled: Optional[bool] = None
- media_proxy_url: Optional[str] = None
-
-
-class ProxyTestRequest(BaseModel):
- proxy_url: str
- test_url: Optional[str] = "https://labs.google/"
- timeout_seconds: Optional[int] = 15
-
-
-class CaptchaScoreTestRequest(BaseModel):
- website_url: Optional[str] = "https://antcpt.com/score_detector/"
- website_key: Optional[str] = "6LcR_okUAAAAAPYrPe-HK_0RULO1aZM15ENyM-Mf"
- action: Optional[str] = "homepage"
- verify_url: Optional[str] = "https://antcpt.com/score_detector/verify.php"
- enterprise: Optional[bool] = False
-
-
-class GenerationConfigRequest(BaseModel):
- image_timeout: int
- video_timeout: int
-
-
-class CallLogicConfigRequest(BaseModel):
- call_mode: str
-
-
-class ChangePasswordRequest(BaseModel):
- username: Optional[str] = None
- old_password: str
- new_password: str
-
-
-class UpdateAPIKeyRequest(BaseModel):
- new_api_key: str
-
-
-class UpdateDebugConfigRequest(BaseModel):
- enabled: bool
-
-
-class UpdateAdminConfigRequest(BaseModel):
- error_ban_threshold: int
-
-
-class ST2ATRequest(BaseModel):
- """ST转AT请求"""
- st: str
-
-
-class ImportTokenItem(BaseModel):
- """导入Token项"""
- email: Optional[str] = None
- access_token: Optional[str] = None
- session_token: Optional[str] = None
- is_active: bool = True
- captcha_proxy_url: Optional[str] = None
- image_enabled: bool = True
- video_enabled: bool = True
- image_concurrency: int = -1
- video_concurrency: int = -1
-
-
-class ImportTokensRequest(BaseModel):
- """导入Token请求"""
- tokens: List[ImportTokenItem]
-
-
-# ========== Auth Middleware ==========
-
-async def verify_admin_token(authorization: str = Header(None)):
- """Verify admin session token (NOT API key)"""
- if not authorization or not authorization.startswith("Bearer "):
- raise HTTPException(status_code=401, detail="Missing authorization")
-
- token = authorization[7:]
-
- # Check if token is in active session tokens
- if token not in active_admin_tokens:
- raise HTTPException(status_code=401, detail="Invalid or expired admin token")
-
- return token
-
-
-# ========== Auth Endpoints ==========
-
-@router.post("/api/admin/login")
-async def admin_login(request: LoginRequest):
- """Admin login - returns session token (NOT API key)"""
- admin_config = await db.get_admin_config()
-
- if not AuthManager.verify_admin(request.username, request.password):
- raise HTTPException(status_code=401, detail="Invalid credentials")
-
- # Generate independent session token
- session_token = f"admin-{secrets.token_urlsafe(32)}"
-
- # Store in active tokens
- active_admin_tokens.add(session_token)
-
- return {
- "success": True,
- "token": session_token, # Session token (NOT API key)
- "username": admin_config.username
- }
-
-
-@router.post("/api/admin/logout")
-async def admin_logout(token: str = Depends(verify_admin_token)):
- """Admin logout - invalidate session token"""
- active_admin_tokens.discard(token)
- return {"success": True, "message": "退出登录成功"}
-
-
-@router.post("/api/admin/change-password")
-async def change_password(
- request: ChangePasswordRequest,
- token: str = Depends(verify_admin_token)
-):
- """Change admin password"""
- admin_config = await db.get_admin_config()
-
- # Verify old password
- if not AuthManager.verify_admin(admin_config.username, request.old_password):
- raise HTTPException(status_code=400, detail="旧密码错误")
-
- # Update password and username in database
- update_params = {"password": request.new_password}
- if request.username:
- update_params["username"] = request.username
-
- await db.update_admin_config(**update_params)
-
- # 🔥 Hot reload: sync database config to memory
- await db.reload_config_to_memory()
-
- # 🔑 Invalidate all admin session tokens (force re-login for security)
- active_admin_tokens.clear()
-
- return {"success": True, "message": "密码修改成功,请重新登录"}
-
-
-# ========== Token Management ==========
-
-@router.get("/api/tokens")
-async def get_tokens(token: str = Depends(verify_admin_token)):
- """Get all tokens with statistics"""
- token_rows = await db.get_all_tokens_with_stats()
- to_iso = lambda value: value.isoformat() if hasattr(value, "isoformat") else value
-
- return [{
- "id": row.get("id"),
- "st": row.get("st"), # Session Token for editing
- "at": row.get("at"), # Access Token for editing (从ST转换而来)
- "at_expires": to_iso(row.get("at_expires")) if row.get("at_expires") else None, # 🆕 AT过期时间
- "token": row.get("at"), # 兼容前端 token.token 的访问方式
- "email": row.get("email"),
- "name": row.get("name"),
- "remark": row.get("remark"),
- "is_active": bool(row.get("is_active")),
- "created_at": to_iso(row.get("created_at")) if row.get("created_at") else None,
- "last_used_at": to_iso(row.get("last_used_at")) if row.get("last_used_at") else None,
- "use_count": row.get("use_count"),
- "credits": row.get("credits"), # 🆕 余额
- "user_paygate_tier": row.get("user_paygate_tier"),
- "current_project_id": row.get("current_project_id"), # 🆕 项目ID
- "current_project_name": row.get("current_project_name"), # 🆕 项目名称
- "captcha_proxy_url": row.get("captcha_proxy_url") or "",
- "image_enabled": bool(row.get("image_enabled")),
- "video_enabled": bool(row.get("video_enabled")),
- "image_concurrency": row.get("image_concurrency"),
- "video_concurrency": row.get("video_concurrency"),
- "image_count": row.get("image_count", 0),
- "video_count": row.get("video_count", 0),
- "error_count": row.get("error_count", 0)
- } for row in token_rows] # 直接返回数组,兼容前端
-
-
-@router.post("/api/tokens")
-async def add_token(
- request: AddTokenRequest,
- token: str = Depends(verify_admin_token)
-):
- """Add a new token"""
- try:
- new_token = await token_manager.add_token(
- st=request.st,
- project_id=request.project_id, # 🆕 支持用户指定project_id
- project_name=request.project_name,
- remark=request.remark,
- captcha_proxy_url=request.captcha_proxy_url.strip() if request.captcha_proxy_url is not None else None,
- image_enabled=request.image_enabled,
- video_enabled=request.video_enabled,
- image_concurrency=request.image_concurrency,
- video_concurrency=request.video_concurrency
- )
-
- # 热更新并发限制,避免必须重启服务
- if concurrency_manager:
- await concurrency_manager.reset_token(
- new_token.id,
- image_concurrency=new_token.image_concurrency,
- video_concurrency=new_token.video_concurrency
- )
-
- return {
- "success": True,
- "message": "Token添加成功",
- "token": {
- "id": new_token.id,
- "email": new_token.email,
- "credits": new_token.credits,
- "project_id": new_token.current_project_id,
- "project_name": new_token.current_project_name
- }
- }
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- except Exception as e:
- raise HTTPException(status_code=500, detail=f"添加Token失败: {str(e)}")
-
-
-@router.put("/api/tokens/{token_id}")
-async def update_token(
- token_id: int,
- request: UpdateTokenRequest,
- token: str = Depends(verify_admin_token)
-):
- """Update token - 使用ST自动刷新AT"""
- try:
- # 先ST转AT
- result = await token_manager.flow_client.st_to_at(request.st)
- at = result["access_token"]
- expires = result.get("expires")
-
- # 解析过期时间
- from datetime import datetime
- at_expires = None
- if expires:
- try:
- at_expires = datetime.fromisoformat(expires.replace('Z', '+00:00'))
- except:
- pass
-
- # 更新token (包含AT、ST、AT过期时间、project_id和project_name)
- await token_manager.update_token(
- token_id=token_id,
- st=request.st,
- at=at,
- at_expires=at_expires, # 🆕 更新AT过期时间
- project_id=request.project_id,
- project_name=request.project_name,
- remark=request.remark,
- captcha_proxy_url=request.captcha_proxy_url.strip() if request.captcha_proxy_url is not None else None,
- image_enabled=request.image_enabled,
- video_enabled=request.video_enabled,
- image_concurrency=request.image_concurrency,
- video_concurrency=request.video_concurrency
- )
-
- # 热更新并发限制,确保管理台修改立即生效
- if concurrency_manager:
- updated_token = await token_manager.get_token(token_id)
- if updated_token:
- await concurrency_manager.reset_token(
- token_id,
- image_concurrency=updated_token.image_concurrency,
- video_concurrency=updated_token.video_concurrency
- )
-
- return {"success": True, "message": "Token更新成功"}
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
-
-
-@router.delete("/api/tokens/{token_id}")
-async def delete_token(
- token_id: int,
- token: str = Depends(verify_admin_token)
-):
- """Delete token"""
- try:
- await token_manager.delete_token(token_id)
- return {"success": True, "message": "Token删除成功"}
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
-
-
-@router.post("/api/tokens/{token_id}/enable")
-async def enable_token(
- token_id: int,
- token: str = Depends(verify_admin_token)
-):
- """Enable token"""
- await token_manager.enable_token(token_id)
- return {"success": True, "message": "Token已启用"}
-
-
-@router.post("/api/tokens/{token_id}/disable")
-async def disable_token(
- token_id: int,
- token: str = Depends(verify_admin_token)
-):
- """Disable token"""
- await token_manager.disable_token(token_id)
- return {"success": True, "message": "Token已禁用"}
-
-
-@router.post("/api/tokens/{token_id}/refresh-credits")
-async def refresh_credits(
- token_id: int,
- token: str = Depends(verify_admin_token)
-):
- """刷新Token余额 🆕"""
- try:
- credits = await token_manager.refresh_credits(token_id)
- return {
- "success": True,
- "message": "余额刷新成功",
- "credits": credits
- }
- except Exception as e:
- raise HTTPException(status_code=500, detail=f"刷新余额失败: {str(e)}")
-
-
-@router.post("/api/tokens/{token_id}/refresh-at")
-async def refresh_at(
- token_id: int,
- token: str = Depends(verify_admin_token)
-):
- """手动刷新Token的AT (使用ST转换) 🆕
-
- 如果 AT 刷新失败且处于 personal 模式,会自动尝试通过浏览器刷新 ST
- """
- from ..core.logger import debug_logger
- from ..core.config import config
-
- debug_logger.log_info(f"[API] 手动刷新 AT 请求: token_id={token_id}, captcha_method={config.captcha_method}")
-
- try:
- # 调用token_manager的内部刷新方法(包含 ST 自动刷新逻辑)
- success = await token_manager._refresh_at(token_id)
-
- if success:
- # 获取更新后的token信息
- updated_token = await token_manager.get_token(token_id)
-
- message = "AT刷新成功"
- if config.captcha_method == "personal":
- message += "(支持ST自动刷新)"
-
- debug_logger.log_info(f"[API] AT 刷新成功: token_id={token_id}")
-
- return {
- "success": True,
- "message": message,
- "token": {
- "id": updated_token.id,
- "email": updated_token.email,
- "at_expires": updated_token.at_expires.isoformat() if updated_token.at_expires else None
- }
- }
- else:
- debug_logger.log_error(f"[API] AT 刷新失败: token_id={token_id}")
-
- error_detail = "AT刷新失败"
- if config.captcha_method != "personal":
- error_detail += f"(当前打码模式: {config.captcha_method},ST自动刷新仅在 personal 模式下可用)"
-
- raise HTTPException(status_code=500, detail=error_detail)
- except HTTPException:
- raise
- except Exception as e:
- debug_logger.log_error(f"[API] 刷新AT异常: {str(e)}")
- raise HTTPException(status_code=500, detail=f"刷新AT失败: {str(e)}")
-
-
-@router.post("/api/tokens/st2at")
-async def st_to_at(
- request: ST2ATRequest,
- token: str = Depends(verify_admin_token)
-):
- """Convert Session Token to Access Token (仅转换,不添加到数据库)"""
- try:
- result = await token_manager.flow_client.st_to_at(request.st)
- return {
- "success": True,
- "message": "ST converted to AT successfully",
- "access_token": result["access_token"],
- "email": result.get("user", {}).get("email"),
- "expires": result.get("expires")
- }
- except Exception as e:
- raise HTTPException(status_code=400, detail=str(e))
-
-
-@router.post("/api/tokens/import")
-async def import_tokens(
- request: ImportTokensRequest,
- token: str = Depends(verify_admin_token)
-):
- """批量导入Token"""
- from datetime import datetime, timezone
-
- added = 0
- updated = 0
- errors = []
- # 保持与历史逻辑一致:按 created_at DESC 的结果中,优先命中同邮箱“最新一条”
- existing_by_email = {}
- for existing_token in await token_manager.get_all_tokens():
- if existing_token.email and existing_token.email not in existing_by_email:
- existing_by_email[existing_token.email] = existing_token
-
- for idx, item in enumerate(request.tokens):
- try:
- st = item.session_token
-
- if not st:
- errors.append(f"第{idx+1}项: 缺少 session_token")
- continue
-
- # 使用 ST 转 AT 获取用户信息
- try:
- result = await token_manager.flow_client.st_to_at(st)
- at = result["access_token"]
- email = result.get("user", {}).get("email")
- expires = result.get("expires")
-
- if not email:
- errors.append(f"第{idx+1}项: 无法获取邮箱信息")
- continue
-
- # 解析过期时间
- at_expires = None
- is_expired = False
- if expires:
- try:
- at_expires = datetime.fromisoformat(expires.replace('Z', '+00:00'))
- # 判断是否过期
- now = datetime.now(timezone.utc)
- is_expired = at_expires <= now
- except:
- pass
-
- # 使用邮箱检查是否已存在
- existing = existing_by_email.get(email)
-
- if existing:
- # 更新现有Token
- await token_manager.update_token(
- token_id=existing.id,
- st=st,
- at=at,
- at_expires=at_expires,
- captcha_proxy_url=item.captcha_proxy_url.strip() if item.captcha_proxy_url is not None else None,
- image_enabled=item.image_enabled,
- video_enabled=item.video_enabled,
- image_concurrency=item.image_concurrency,
- video_concurrency=item.video_concurrency
- )
- # 如果过期则禁用
- if is_expired:
- await token_manager.disable_token(existing.id)
- existing.is_active = False
- existing.st = st
- existing.at = at
- existing.at_expires = at_expires
- existing.captcha_proxy_url = item.captcha_proxy_url
- existing.image_enabled = item.image_enabled
- existing.video_enabled = item.video_enabled
- existing.image_concurrency = item.image_concurrency
- existing.video_concurrency = item.video_concurrency
- updated += 1
- else:
- # 添加新Token
- new_token = await token_manager.add_token(
- st=st,
- captcha_proxy_url=item.captcha_proxy_url.strip() if item.captcha_proxy_url is not None else None,
- image_enabled=item.image_enabled,
- video_enabled=item.video_enabled,
- image_concurrency=item.image_concurrency,
- video_concurrency=item.video_concurrency
- )
- # 如果过期则禁用
- if is_expired:
- await token_manager.disable_token(new_token.id)
- new_token.is_active = False
- existing_by_email[email] = new_token
- added += 1
-
- except Exception as e:
- errors.append(f"第{idx+1}项: {str(e)}")
-
- except Exception as e:
- errors.append(f"第{idx+1}项: {str(e)}")
-
- return {
- "success": True,
- "added": added,
- "updated": updated,
- "errors": errors if errors else None,
- "message": f"导入完成: 新增 {added} 个, 更新 {updated} 个" + (f", {len(errors)} 个失败" if errors else "")
- }
-
-
-# ========== Config Management ==========
-
-@router.get("/api/config/proxy")
-async def get_proxy_config(token: str = Depends(verify_admin_token)):
- """Get proxy configuration"""
- config = await proxy_manager.get_proxy_config()
- return {
- "success": True,
- "config": {
- "enabled": config.enabled,
- "proxy_url": config.proxy_url,
- "media_proxy_enabled": config.media_proxy_enabled,
- "media_proxy_url": config.media_proxy_url
- }
- }
-
-
-@router.get("/api/proxy/config")
-async def get_proxy_config_alias(token: str = Depends(verify_admin_token)):
- """Get proxy configuration (alias for frontend compatibility)"""
- config = await proxy_manager.get_proxy_config()
- return {
- "proxy_enabled": config.enabled, # Frontend expects proxy_enabled
- "proxy_url": config.proxy_url,
- "media_proxy_enabled": config.media_proxy_enabled,
- "media_proxy_url": config.media_proxy_url
- }
-
-
-@router.post("/api/proxy/config")
-async def update_proxy_config_alias(
- request: ProxyConfigRequest,
- token: str = Depends(verify_admin_token)
-):
- """Update proxy configuration (alias for frontend compatibility)"""
- try:
- await proxy_manager.update_proxy_config(
- enabled=request.proxy_enabled,
- proxy_url=request.proxy_url,
- media_proxy_enabled=request.media_proxy_enabled,
- media_proxy_url=request.media_proxy_url
- )
- except ValueError as e:
- return {"success": False, "message": str(e)}
- return {"success": True, "message": "代理配置更新成功"}
-
-
-@router.post("/api/config/proxy")
-async def update_proxy_config(
- request: ProxyConfigRequest,
- token: str = Depends(verify_admin_token)
-):
- """Update proxy configuration"""
- try:
- await proxy_manager.update_proxy_config(
- enabled=request.proxy_enabled,
- proxy_url=request.proxy_url,
- media_proxy_enabled=request.media_proxy_enabled,
- media_proxy_url=request.media_proxy_url
- )
- except ValueError as e:
- return {"success": False, "message": str(e)}
- return {"success": True, "message": "代理配置更新成功"}
-
-
-@router.post("/api/proxy/test")
-async def test_proxy_connectivity(
- request: ProxyTestRequest,
- token: str = Depends(verify_admin_token)
-):
- """测试代理是否可访问目标站点(默认 https://labs.google/)"""
- proxy_input = (request.proxy_url or "").strip()
- test_url = (request.test_url or "https://labs.google/").strip()
- timeout_seconds = int(request.timeout_seconds or 15)
- timeout_seconds = max(5, min(timeout_seconds, 60))
-
- if not proxy_input:
- return {
- "success": False,
- "message": "代理地址为空",
- "test_url": test_url
- }
-
- try:
- proxy_url = proxy_manager.normalize_proxy_url(proxy_input)
- except ValueError as e:
- return {
- "success": False,
- "message": str(e),
- "test_url": test_url
- }
-
- start_time = time.time()
- try:
- proxies = {"http": proxy_url, "https": proxy_url}
- async with AsyncSession() as session:
- resp = await session.get(
- test_url,
- proxies=proxies,
- timeout=timeout_seconds,
- impersonate="chrome120",
- allow_redirects=True,
- verify=False
- )
-
- elapsed_ms = int((time.time() - start_time) * 1000)
- status_code = resp.status_code
- final_url = str(resp.url)
- ok = 200 <= status_code < 400
-
- return {
- "success": ok,
- "message": "代理可用" if ok else f"代理可连通,但目标返回状态码 {status_code}",
- "test_url": test_url,
- "final_url": final_url,
- "status_code": status_code,
- "elapsed_ms": elapsed_ms
- }
- except Exception as e:
- elapsed_ms = int((time.time() - start_time) * 1000)
- return {
- "success": False,
- "message": f"代理测试失败: {str(e)}",
- "test_url": test_url,
- "elapsed_ms": elapsed_ms
- }
-
-
-@router.get("/api/config/generation")
-async def get_generation_config(token: str = Depends(verify_admin_token)):
- """Get generation timeout configuration"""
- config = await db.get_generation_config()
- return {
- "success": True,
- "config": {
- "image_timeout": config.image_timeout,
- "video_timeout": config.video_timeout
- }
- }
-
-
-@router.post("/api/config/generation")
-async def update_generation_config(
- request: GenerationConfigRequest,
- token: str = Depends(verify_admin_token)
-):
- """Update generation timeout configuration"""
- await db.update_generation_config(request.image_timeout, request.video_timeout)
-
- # 🔥 Hot reload: sync database config to memory
- await db.reload_config_to_memory()
-
- return {"success": True, "message": "生成配置更新成功"}
-
-
-@router.get("/api/call-logic/config")
-async def get_call_logic_config(token: str = Depends(verify_admin_token)):
- """Get token call logic configuration."""
- config_obj = await db.get_call_logic_config()
- call_mode = getattr(config_obj, "call_mode", None)
- if call_mode not in ("default", "polling"):
- call_mode = "polling" if getattr(config_obj, "polling_mode_enabled", False) else "default"
- return {
- "success": True,
- "config": {
- "call_mode": call_mode,
- "polling_mode_enabled": call_mode == "polling",
- }
- }
-
-
-@router.post("/api/call-logic/config")
-async def update_call_logic_config(
- request: CallLogicConfigRequest,
- token: str = Depends(verify_admin_token)
-):
- """Update token call logic configuration."""
- call_mode = request.call_mode if request.call_mode in ("default", "polling") else None
- if call_mode is None:
- raise HTTPException(status_code=400, detail="Invalid call_mode")
-
- await db.update_call_logic_config(call_mode)
- await db.reload_config_to_memory()
-
- return {
- "success": True,
- "message": "Token轮询模式保存成功",
- "config": {
- "call_mode": call_mode,
- "polling_mode_enabled": call_mode == "polling",
- }
- }
-
-
-# ========== System Info ==========
-
-@router.get("/api/system/info")
-async def get_system_info(token: str = Depends(verify_admin_token)):
- """Get system information"""
- stats = await db.get_system_info_stats()
-
- return {
- "success": True,
- "info": {
- "total_tokens": stats["total_tokens"],
- "active_tokens": stats["active_tokens"],
- "total_credits": stats["total_credits"],
- "version": "1.0.0"
- }
- }
-
-
-# ========== Additional Routes for Frontend Compatibility ==========
-
-@router.post("/api/login")
-async def login(request: LoginRequest):
- """Login endpoint (alias for /api/admin/login)"""
- return await admin_login(request)
-
-
-@router.post("/api/logout")
-async def logout(token: str = Depends(verify_admin_token)):
- """Logout endpoint (alias for /api/admin/logout)"""
- return await admin_logout(token)
-
-
-@router.get("/health")
-async def health_check():
- """Public health check endpoint - no auth required"""
- try:
- stats = await db.get_dashboard_stats()
- has_active_tokens = stats.get("active_tokens", 0) > 0
- except Exception:
- return {"backend_running": True, "has_active_tokens": False}
- return {"backend_running": True, "has_active_tokens": has_active_tokens}
-
-
-@router.get("/api/stats")
-async def get_stats(token: str = Depends(verify_admin_token)):
- """Get statistics for dashboard"""
- return await db.get_dashboard_stats()
-
-
-@router.get("/api/logs")
-async def get_logs(
- limit: int = 100,
- token: str = Depends(verify_admin_token)
-):
- """Get lightweight request logs for list view"""
- limit = max(1, min(limit, 100))
- logs = await db.get_logs(limit=limit, include_payload=False)
-
- result = []
- for log in logs:
- raw_status_code = log.get("status_code")
- try:
- status_code = int(raw_status_code) if raw_status_code is not None else None
- except (TypeError, ValueError):
- status_code = None
- result.append({
- "id": log.get("id"),
- "token_id": log.get("token_id"),
- "token_email": log.get("token_email"),
- "token_username": log.get("token_username"),
- "operation": log.get("operation"),
- "status_code": status_code if status_code is not None else raw_status_code,
- "duration": log.get("duration"),
- "status_text": log.get("status_text") or "",
- "progress": log.get("progress") or 0,
- "created_at": log.get("created_at"),
- "updated_at": log.get("updated_at"),
- "error_summary": _extract_error_summary(log.get("response_body_excerpt")) if status_code is not None and status_code >= 400 else "",
- })
- return result
-
-
-@router.get("/api/logs/{log_id}")
-async def get_log_detail(
- log_id: int,
- token: str = Depends(verify_admin_token)
-):
- """Get single request log detail (payload loaded on demand)"""
- log = await db.get_log_detail(log_id)
- if not log:
- raise HTTPException(status_code=404, detail="日志不存在")
-
- error_summary = _extract_error_summary(log.get("response_body"))
-
- return {
- "id": log.get("id"),
- "token_id": log.get("token_id"),
- "token_email": log.get("token_email"),
- "token_username": log.get("token_username"),
- "operation": log.get("operation"),
- "status_code": log.get("status_code"),
- "duration": log.get("duration"),
- "status_text": log.get("status_text") or "",
- "progress": log.get("progress") or 0,
- "created_at": log.get("created_at"),
- "updated_at": log.get("updated_at"),
- "error_summary": error_summary,
- "request_body": log.get("request_body"),
- "response_body": log.get("response_body")
- }
-
-
-@router.delete("/api/logs")
-async def clear_logs(token: str = Depends(verify_admin_token)):
- """Clear all logs"""
- try:
- await db.clear_all_logs()
- return {"success": True, "message": "所有日志已清空"}
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
-
-
-@router.get("/api/admin/config")
-async def get_admin_config(token: str = Depends(verify_admin_token)):
- """Get admin configuration"""
- admin_config = await db.get_admin_config()
-
- return {
- "admin_username": admin_config.username,
- "api_key": admin_config.api_key,
- "error_ban_threshold": admin_config.error_ban_threshold,
- "debug_enabled": config.debug_enabled # Return actual debug status
- }
-
-
-@router.post("/api/admin/config")
-async def update_admin_config(
- request: UpdateAdminConfigRequest,
- token: str = Depends(verify_admin_token)
-):
- """Update admin configuration (error_ban_threshold)"""
- # Update error_ban_threshold in database
- await db.update_admin_config(error_ban_threshold=request.error_ban_threshold)
-
- return {"success": True, "message": "配置更新成功"}
-
-
-@router.post("/api/admin/password")
-async def update_admin_password(
- request: ChangePasswordRequest,
- token: str = Depends(verify_admin_token)
-):
- """Update admin password"""
- return await change_password(request, token)
-
-
-@router.post("/api/admin/apikey")
-async def update_api_key(
- request: UpdateAPIKeyRequest,
- token: str = Depends(verify_admin_token)
-):
- """Update API key (for external API calls, NOT for admin login)"""
- # Update API key in database
- await db.update_admin_config(api_key=request.new_api_key)
-
- # 🔥 Hot reload: sync database config to memory
- await db.reload_config_to_memory()
-
- return {"success": True, "message": "API Key更新成功"}
-
-
-@router.post("/api/admin/debug")
-async def update_debug_config(
- request: UpdateDebugConfigRequest,
- token: str = Depends(verify_admin_token)
-):
- """Update debug configuration"""
- try:
- # Update in-memory config only (not database)
- # This ensures debug mode is automatically disabled on restart
- config.set_debug_enabled(request.enabled)
-
- status = "enabled" if request.enabled else "disabled"
- return {"success": True, "message": f"Debug mode {status}", "enabled": request.enabled}
- except Exception as e:
- raise HTTPException(status_code=500, detail=f"Failed to update debug config: {str(e)}")
-
-
-@router.get("/api/generation/timeout")
-async def get_generation_timeout(token: str = Depends(verify_admin_token)):
- """Get generation timeout configuration"""
- return await get_generation_config(token)
-
-
-@router.post("/api/generation/timeout")
-async def update_generation_timeout(
- request: GenerationConfigRequest,
- token: str = Depends(verify_admin_token)
-):
- """Update generation timeout configuration"""
- await db.update_generation_config(request.image_timeout, request.video_timeout)
-
- # 🔥 Hot reload: sync database config to memory
- await db.reload_config_to_memory()
-
- return {"success": True, "message": "生成配置更新成功"}
-
-
-# ========== AT Auto Refresh Config ==========
-
-@router.get("/api/token-refresh/config")
-async def get_token_refresh_config(token: str = Depends(verify_admin_token)):
- """Get AT auto refresh configuration (默认启用)"""
- return {
- "success": True,
- "config": {
- "at_auto_refresh_enabled": True # Flow2API默认启用AT自动刷新
- }
- }
-
-
-@router.post("/api/token-refresh/enabled")
-async def update_token_refresh_enabled(
- token: str = Depends(verify_admin_token)
-):
- """Update AT auto refresh enabled (Flow2API固定启用,此接口仅用于前端兼容)"""
- return {
- "success": True,
- "message": "Flow2API的AT自动刷新默认启用且无法关闭"
- }
-
-
+"""Admin API routes"""
+import asyncio
+import json
+from fastapi import APIRouter, Depends, HTTPException, Header, Request
+from fastapi.responses import JSONResponse
+from pydantic import BaseModel
+from typing import Optional, List, Dict, Any
+import secrets
+import time
+import re
+import urllib.error
+import urllib.request
+from urllib.parse import urlparse
+from curl_cffi.requests import AsyncSession
+from ..core.auth import AuthManager
+from ..core.database import Database
+from ..core.config import config
+from ..services.token_manager import TokenManager
+from ..services.proxy_manager import ProxyManager
+from ..services.concurrency_manager import ConcurrencyManager
+
+try:
+ import httpx
+except ImportError:
+ httpx = None
+
+router = APIRouter()
+
+# Dependency injection
+token_manager: TokenManager = None
+proxy_manager: ProxyManager = None
+db: Database = None
+concurrency_manager: Optional[ConcurrencyManager] = None
+
+# Store active admin session tokens (in production, use Redis or database)
+active_admin_tokens = set()
+SUPPORTED_API_CAPTCHA_METHODS = {"yescaptcha", "capmonster", "ezcaptcha", "capsolver"}
+
+
+def _mask_token(token: Optional[str]) -> str:
+ if not token:
+ return ""
+ if len(token) <= 24:
+ return token
+ return f"{token[:18]}...{token[-8:]}"
+
+
+def _truncate_text(text: Any, limit: int = 240) -> str:
+ value = str(text or "").strip()
+ if len(value) <= limit:
+ return value
+ return f"{value[:limit - 3]}..."
+
+
+def _extract_error_summary(payload: Any) -> str:
+ """从响应体里提取用户可读的错误摘要。"""
+ if payload is None:
+ return ""
+
+ if isinstance(payload, str):
+ raw = payload.strip()
+ if not raw:
+ return ""
+ try:
+ return _extract_error_summary(json.loads(raw))
+ except Exception:
+ return _truncate_text(raw)
+
+ if isinstance(payload, dict):
+ for key in ("error_summary", "error_message", "detail", "message"):
+ value = payload.get(key)
+ if isinstance(value, str) and value.strip():
+ return _truncate_text(value)
+
+ error_value = payload.get("error")
+ if isinstance(error_value, dict):
+ for key in ("message", "detail", "reason", "code"):
+ value = error_value.get(key)
+ if isinstance(value, str) and value.strip():
+ return _truncate_text(value)
+ elif isinstance(error_value, str) and error_value.strip():
+ return _truncate_text(error_value)
+
+ for nested_key in ("response", "data"):
+ nested = payload.get(nested_key)
+ if isinstance(nested, (dict, list, str)):
+ summary = _extract_error_summary(nested)
+ if summary:
+ return summary
+
+ return ""
+
+ if isinstance(payload, list):
+ for item in payload:
+ summary = _extract_error_summary(item)
+ if summary:
+ return summary
+ return ""
+
+ return _truncate_text(payload)
+
+
+def _guess_client_hints_from_user_agent(user_agent: str) -> Dict[str, str]:
+ """根据 UA 补全常见的 sec-ch-* 头。"""
+ ua = (user_agent or "").strip()
+ if not ua:
+ return {}
+
+ headers: Dict[str, str] = {}
+ major_match = re.search(r"(?:Chrome|Chromium|Edg|EdgA|EdgiOS)/(\d+)", ua)
+ is_mobile = any(token in ua for token in ("Android", "iPhone", "iPad", "Mobile"))
+ headers["sec-ch-ua-mobile"] = "?1" if is_mobile else "?0"
+
+ if "Windows" in ua:
+ headers["sec-ch-ua-platform"] = '"Windows"'
+ elif "Macintosh" in ua or "Mac OS X" in ua:
+ headers["sec-ch-ua-platform"] = '"macOS"'
+ elif "Android" in ua:
+ headers["sec-ch-ua-platform"] = '"Android"'
+ elif "iPhone" in ua or "iPad" in ua:
+ headers["sec-ch-ua-platform"] = '"iOS"'
+ elif "Linux" in ua:
+ headers["sec-ch-ua-platform"] = '"Linux"'
+
+ if major_match:
+ major = major_match.group(1)
+ if "Edg/" in ua:
+ headers["sec-ch-ua"] = (
+ f'"Not:A-Brand";v="99", "Microsoft Edge";v="{major}", "Chromium";v="{major}"'
+ )
+ else:
+ headers["sec-ch-ua"] = (
+ f'"Not:A-Brand";v="99", "Google Chrome";v="{major}", "Chromium";v="{major}"'
+ )
+
+ return headers
+
+
+def _guess_impersonate_from_user_agent(user_agent: str) -> str:
+ """从 UA 选择可用的 curl_cffi 浏览器指纹版本。"""
+ ua = (user_agent or "").strip()
+ major_match = re.search(r"(?:Chrome|Chromium|Edg|EdgA|EdgiOS)/(\d+)", ua)
+ if not major_match:
+ return "chrome120"
+
+ try:
+ major = int(major_match.group(1))
+ except Exception:
+ return "chrome120"
+
+ if major >= 124:
+ return "chrome124"
+ if major >= 120:
+ return "chrome120"
+ return "chrome120"
+
+
+def _build_proxy_map(proxy_url: str) -> Optional[Dict[str, str]]:
+ normalized = (proxy_url or "").strip()
+ if not normalized:
+ return None
+ return {"http": normalized, "https": normalized}
+
+
+def _normalize_http_base_url(base_url: str) -> str:
+ normalized = (base_url or "").strip().rstrip("/")
+ if not normalized:
+ raise RuntimeError("远程打码服务地址未配置")
+
+ parsed = urlparse(normalized)
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
+ raise RuntimeError("远程打码服务地址格式错误,必须是 http(s)://host[:port]")
+
+ return normalized
+
+
+def _get_remote_browser_client_config() -> tuple[str, str, int]:
+ base_url = _normalize_http_base_url(config.remote_browser_base_url)
+ api_key = (config.remote_browser_api_key or "").strip()
+ if not api_key:
+ raise RuntimeError("远程打码服务 API Key 未配置")
+ timeout = max(5, int(config.remote_browser_timeout or 60))
+ return base_url, api_key, timeout
+
+
+def _build_remote_browser_http_timeout(read_timeout: float) -> Any:
+ read_value = max(3.0, float(read_timeout))
+ write_value = min(10.0, max(3.0, read_value))
+ if httpx is None:
+ return read_value
+ return httpx.Timeout(
+ connect=2.5,
+ read=read_value,
+ write=write_value,
+ pool=2.5,
+ )
+
+
+def _parse_json_response_text(text: str) -> Optional[Any]:
+ if not text:
+ return None
+ try:
+ return json.loads(text)
+ except Exception:
+ return None
+
+
+async def _stdlib_json_http_request(
+ method: str,
+ url: str,
+ headers: Dict[str, str],
+ payload: Optional[Dict[str, Any]],
+ timeout: int,
+) -> tuple[int, Optional[Any], str]:
+ req_headers = dict(headers or {})
+ req_headers.setdefault("Accept", "application/json")
+ request_method = (method or "GET").upper()
+ request_data: Optional[bytes] = None
+
+ if payload is not None:
+ req_headers["Content-Type"] = "application/json; charset=utf-8"
+ if request_method != "GET":
+ request_data = json.dumps(payload).encode("utf-8")
+
+ def do_request() -> tuple[int, str]:
+ request = urllib.request.Request(
+ url=url,
+ data=request_data,
+ headers=req_headers,
+ method=request_method,
+ )
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
+ try:
+ with opener.open(request, timeout=max(1.0, float(timeout))) as response:
+ status_code = int(getattr(response, "status", 0) or response.getcode() or 0)
+ body = response.read()
+ charset = response.headers.get_content_charset() or "utf-8"
+ return status_code, body.decode(charset, errors="replace")
+ except urllib.error.HTTPError as exc:
+ body = exc.read()
+ charset = exc.headers.get_content_charset() if exc.headers else None
+ return int(getattr(exc, "code", 0) or 0), body.decode(charset or "utf-8", errors="replace")
+
+ try:
+ status_code, text = await asyncio.to_thread(do_request)
+ except Exception as e:
+ raise RuntimeError(f"远程打码服务请求失败: {e}") from e
+
+ return status_code, _parse_json_response_text(text), text
+
+
+async def _sync_json_http_request(
+ method: str,
+ url: str,
+ headers: Dict[str, str],
+ payload: Optional[Dict[str, Any]],
+ timeout: int,
+) -> tuple[int, Optional[Any], str]:
+ req_headers = dict(headers or {})
+ req_headers.setdefault("Accept", "application/json")
+ request_method = (method or "GET").upper()
+ request_kwargs: Dict[str, Any] = {
+ "headers": req_headers,
+ "timeout": _build_remote_browser_http_timeout(timeout),
+ }
+
+ if payload is not None:
+ req_headers["Content-Type"] = "application/json; charset=utf-8"
+ if request_method != "GET":
+ request_kwargs["json"] = payload
+
+ if httpx is None:
+ return await _stdlib_json_http_request(
+ method=method,
+ url=url,
+ headers=req_headers,
+ payload=payload,
+ timeout=timeout,
+ )
+
+ try:
+ # remote_browser 控制面是服务间 JSON API,使用 httpx 避免 curl_cffi 在当前
+ # Windows + impersonate 场景下 POST body 丢失导致 FastAPI 直接判定 body 缺失。
+ async with httpx.AsyncClient(follow_redirects=False, trust_env=False) as session:
+ response = await session.request(
+ method=request_method,
+ url=url,
+ **request_kwargs,
+ )
+ except Exception as e:
+ raise RuntimeError(f"远程打码服务请求失败: {e}") from e
+
+ status_code = int(getattr(response, "status_code", 0) or 0)
+ text = response.text or ""
+ parsed = _parse_json_response_text(text)
+
+ return status_code, parsed, text
+
+
+async def _resolve_score_test_verify_proxy(
+ captcha_method: str,
+ browser_proxy_enabled: bool,
+ browser_proxy_url: str
+) -> tuple[Optional[Dict[str, str]], bool, str, str]:
+ """
+ 选择 score-test 的 verify 请求代理,优先与浏览器打码代理保持一致。
+ 返回: (proxies, used, source, proxy_url)
+ """
+ # 浏览器打码模式优先使用 browser_proxy,确保与取 token 出口一致
+ if captcha_method in {"browser", "personal"} and browser_proxy_enabled and browser_proxy_url:
+ proxy_map = _build_proxy_map(browser_proxy_url)
+ if proxy_map:
+ return proxy_map, True, "captcha_browser_proxy", browser_proxy_url
+
+ # 退回请求代理配置
+ try:
+ if proxy_manager:
+ proxy_cfg = await proxy_manager.get_proxy_config()
+ if proxy_cfg and proxy_cfg.enabled and proxy_cfg.proxy_url:
+ proxy_map = _build_proxy_map(proxy_cfg.proxy_url)
+ if proxy_map:
+ return proxy_map, True, "request_proxy", proxy_cfg.proxy_url
+ except Exception:
+ pass
+
+ return None, False, "none", ""
+
+
+async def _solve_recaptcha_with_api_service(
+ method: str,
+ website_url: str,
+ website_key: str,
+ action: str,
+ enterprise: bool = False
+) -> Optional[str]:
+ """使用当前配置的第三方打码服务获取 token。"""
+ if method == "yescaptcha":
+ client_key = config.yescaptcha_api_key
+ base_url = config.yescaptcha_base_url
+ task_type = "RecaptchaV3TaskProxylessM1"
+ elif method == "capmonster":
+ client_key = config.capmonster_api_key
+ base_url = config.capmonster_base_url
+ task_type = "RecaptchaV3TaskProxyless"
+ elif method == "ezcaptcha":
+ client_key = config.ezcaptcha_api_key
+ base_url = config.ezcaptcha_base_url
+ task_type = "ReCaptchaV3TaskProxylessS9"
+ elif method == "capsolver":
+ client_key = config.capsolver_api_key
+ base_url = config.capsolver_base_url
+ task_type = "ReCaptchaV3EnterpriseTaskProxyLess" if enterprise else "ReCaptchaV3TaskProxyLess"
+ else:
+ raise RuntimeError(f"不支持的打码方式: {method}")
+
+ if not client_key:
+ raise RuntimeError(f"{method} API Key 未配置")
+
+ task: Dict[str, Any] = {
+ "websiteURL": website_url,
+ "websiteKey": website_key,
+ "type": task_type,
+ "pageAction": action,
+ }
+
+ if enterprise and method == "capsolver":
+ task["isEnterprise"] = True
+
+ create_url = f"{base_url.rstrip('/')}/createTask"
+ get_url = f"{base_url.rstrip('/')}/getTaskResult"
+
+ async with AsyncSession() as session:
+ create_resp = await session.post(
+ create_url,
+ json={"clientKey": client_key, "task": task},
+ impersonate="chrome120",
+ timeout=30
+ )
+ create_json = create_resp.json()
+ task_id = create_json.get("taskId")
+
+ if not task_id:
+ error_desc = create_json.get("errorDescription") or create_json.get("errorMessage") or str(create_json)
+ raise RuntimeError(f"{method} createTask 失败: {error_desc}")
+
+ for _ in range(40):
+ poll_resp = await session.post(
+ get_url,
+ json={"clientKey": client_key, "taskId": task_id},
+ impersonate="chrome120",
+ timeout=30
+ )
+ poll_json = poll_resp.json()
+ if poll_json.get("status") == "ready":
+ solution = poll_json.get("solution", {}) or {}
+ token = solution.get("gRecaptchaResponse") or solution.get("token")
+ if token:
+ return token
+ raise RuntimeError(f"{method} 返回结果缺少 token: {poll_json}")
+
+ if poll_json.get("errorId") not in (None, 0):
+ error_desc = poll_json.get("errorDescription") or poll_json.get("errorMessage") or str(poll_json)
+ raise RuntimeError(f"{method} getTaskResult 失败: {error_desc}")
+
+ await asyncio.sleep(3)
+
+ raise RuntimeError(f"{method} 获取 token 超时")
+
+
+async def _score_test_with_remote_browser_service(
+ website_url: str,
+ website_key: str,
+ verify_url: str,
+ action: str,
+ enterprise: bool = False,
+) -> Dict[str, Any]:
+ """调用远程有头打码服务执行页面内打码+分数校验。"""
+ base_url, api_key, timeout = _get_remote_browser_client_config()
+ endpoint = f"{base_url}/api/v1/custom-score"
+ request_payload = {
+ "website_url": website_url,
+ "website_key": website_key,
+ "verify_url": verify_url,
+ "action": action,
+ "enterprise": enterprise,
+ }
+
+ status_code, response_payload, response_text = await _sync_json_http_request(
+ method="POST",
+ url=endpoint,
+ headers={"Authorization": f"Bearer {api_key}"},
+ payload=request_payload,
+ timeout=timeout,
+ )
+
+ if status_code >= 400:
+ detail = ""
+ if isinstance(response_payload, dict):
+ detail = response_payload.get("detail") or response_payload.get("message") or str(response_payload)
+ if not detail:
+ detail = (response_text or "").strip()
+ raise RuntimeError(f"远程打码服务请求失败 (HTTP {status_code}): {detail or '未知错误'}")
+
+ if not isinstance(response_payload, dict):
+ raise RuntimeError("远程打码服务返回格式错误")
+ return response_payload
+
+
+def set_dependencies(tm: TokenManager, pm: ProxyManager, database: Database, cm: Optional[ConcurrencyManager] = None):
+ """Set service instances"""
+ global token_manager, proxy_manager, db, concurrency_manager
+ token_manager = tm
+ proxy_manager = pm
+ db = database
+ concurrency_manager = cm
+
+
+# ========== Request Models ==========
+
+class LoginRequest(BaseModel):
+ username: str
+ password: str
+
+
+class AddTokenRequest(BaseModel):
+ st: str
+ project_id: Optional[str] = None # 用户可选输入project_id
+ project_name: Optional[str] = None
+ remark: Optional[str] = None
+ captcha_proxy_url: Optional[str] = None
+ image_enabled: bool = True
+ video_enabled: bool = True
+ image_concurrency: int = -1
+ video_concurrency: int = -1
+
+
+class UpdateTokenRequest(BaseModel):
+ st: str # Session Token (必填,用于刷新AT)
+ project_id: Optional[str] = None # 用户可选输入project_id
+ project_name: Optional[str] = None
+ remark: Optional[str] = None
+ captcha_proxy_url: Optional[str] = None
+ image_enabled: Optional[bool] = None
+ video_enabled: Optional[bool] = None
+ image_concurrency: Optional[int] = None
+ video_concurrency: Optional[int] = None
+
+
+class ProxyConfigRequest(BaseModel):
+ proxy_enabled: bool
+ proxy_url: Optional[str] = None
+ media_proxy_enabled: Optional[bool] = None
+ media_proxy_url: Optional[str] = None
+
+
+class ProxyTestRequest(BaseModel):
+ proxy_url: str
+ test_url: Optional[str] = "https://labs.google/"
+ timeout_seconds: Optional[int] = 15
+
+
+class CaptchaScoreTestRequest(BaseModel):
+ website_url: Optional[str] = "https://antcpt.com/score_detector/"
+ website_key: Optional[str] = "6LcR_okUAAAAAPYrPe-HK_0RULO1aZM15ENyM-Mf"
+ action: Optional[str] = "homepage"
+ verify_url: Optional[str] = "https://antcpt.com/score_detector/verify.php"
+ enterprise: Optional[bool] = False
+
+
+class GenerationConfigRequest(BaseModel):
+ image_timeout: int
+ video_timeout: int
+
+
+class CallLogicConfigRequest(BaseModel):
+ call_mode: str
+
+
+class ChangePasswordRequest(BaseModel):
+ username: Optional[str] = None
+ old_password: str
+ new_password: str
+
+
+class UpdateAPIKeyRequest(BaseModel):
+ new_api_key: str
+
+
+class UpdateDebugConfigRequest(BaseModel):
+ enabled: bool
+
+
+class UpdateAdminConfigRequest(BaseModel):
+ error_ban_threshold: int
+
+
+class ST2ATRequest(BaseModel):
+ """ST转AT请求"""
+ st: str
+
+
+class ImportTokenItem(BaseModel):
+ """导入Token项"""
+ email: Optional[str] = None
+ access_token: Optional[str] = None
+ session_token: Optional[str] = None
+ is_active: bool = True
+ captcha_proxy_url: Optional[str] = None
+ image_enabled: bool = True
+ video_enabled: bool = True
+ image_concurrency: int = -1
+ video_concurrency: int = -1
+
+
+class ImportTokensRequest(BaseModel):
+ """导入Token请求"""
+ tokens: List[ImportTokenItem]
+
+
+# ========== Auth Middleware ==========
+
+async def verify_admin_token(authorization: str = Header(None)):
+ """Verify admin session token (NOT API key)"""
+ if not authorization or not authorization.startswith("Bearer "):
+ raise HTTPException(status_code=401, detail="Missing authorization")
+
+ token = authorization[7:]
+
+ # Check if token is in active session tokens
+ if token not in active_admin_tokens:
+ raise HTTPException(status_code=401, detail="Invalid or expired admin token")
+
+ return token
+
+
+# ========== Auth Endpoints ==========
+
+@router.post("/api/admin/login")
+async def admin_login(request: LoginRequest):
+ """Admin login - returns session token (NOT API key)"""
+ admin_config = await db.get_admin_config()
+
+ if not AuthManager.verify_admin(request.username, request.password):
+ raise HTTPException(status_code=401, detail="Invalid credentials")
+
+ # Generate independent session token
+ session_token = f"admin-{secrets.token_urlsafe(32)}"
+
+ # Store in active tokens
+ active_admin_tokens.add(session_token)
+
+ return {
+ "success": True,
+ "token": session_token, # Session token (NOT API key)
+ "username": admin_config.username
+ }
+
+
+@router.post("/api/admin/logout")
+async def admin_logout(token: str = Depends(verify_admin_token)):
+ """Admin logout - invalidate session token"""
+ active_admin_tokens.discard(token)
+ return {"success": True, "message": "退出登录成功"}
+
+
+@router.post("/api/admin/change-password")
+async def change_password(
+ request: ChangePasswordRequest,
+ token: str = Depends(verify_admin_token)
+):
+ """Change admin password"""
+ admin_config = await db.get_admin_config()
+
+ # Verify old password
+ if not AuthManager.verify_admin(admin_config.username, request.old_password):
+ raise HTTPException(status_code=400, detail="旧密码错误")
+
+ # Update password and username in database
+ update_params = {"password": request.new_password}
+ if request.username:
+ update_params["username"] = request.username
+
+ await db.update_admin_config(**update_params)
+
+ # 🔥 Hot reload: sync database config to memory
+ await db.reload_config_to_memory()
+
+ # 🔑 Invalidate all admin session tokens (force re-login for security)
+ active_admin_tokens.clear()
+
+ return {"success": True, "message": "密码修改成功,请重新登录"}
+
+
+# ========== Token Management ==========
+
+@router.get("/api/tokens")
+async def get_tokens(token: str = Depends(verify_admin_token)):
+ """Get all tokens with statistics"""
+ token_rows = await db.get_all_tokens_with_stats()
+ to_iso = lambda value: value.isoformat() if hasattr(value, "isoformat") else value
+
+ return [{
+ "id": row.get("id"),
+ "st": row.get("st"), # Session Token for editing
+ "at": row.get("at"), # Access Token for editing (从ST转换而来)
+ "at_expires": to_iso(row.get("at_expires")) if row.get("at_expires") else None, # 🆕 AT过期时间
+ "token": row.get("at"), # 兼容前端 token.token 的访问方式
+ "email": row.get("email"),
+ "name": row.get("name"),
+ "remark": row.get("remark"),
+ "is_active": bool(row.get("is_active")),
+ "created_at": to_iso(row.get("created_at")) if row.get("created_at") else None,
+ "last_used_at": to_iso(row.get("last_used_at")) if row.get("last_used_at") else None,
+ "use_count": row.get("use_count"),
+ "credits": row.get("credits"), # 🆕 余额
+ "user_paygate_tier": row.get("user_paygate_tier"),
+ "current_project_id": row.get("current_project_id"), # 🆕 项目ID
+ "current_project_name": row.get("current_project_name"), # 🆕 项目名称
+ "captcha_proxy_url": row.get("captcha_proxy_url") or "",
+ "image_enabled": bool(row.get("image_enabled")),
+ "video_enabled": bool(row.get("video_enabled")),
+ "image_concurrency": row.get("image_concurrency"),
+ "video_concurrency": row.get("video_concurrency"),
+ "image_count": row.get("image_count", 0),
+ "video_count": row.get("video_count", 0),
+ "error_count": row.get("error_count", 0)
+ } for row in token_rows] # 直接返回数组,兼容前端
+
+
+@router.post("/api/tokens")
+async def add_token(
+ request: AddTokenRequest,
+ token: str = Depends(verify_admin_token)
+):
+ """Add a new token"""
+ try:
+ new_token = await token_manager.add_token(
+ st=request.st,
+ project_id=request.project_id, # 🆕 支持用户指定project_id
+ project_name=request.project_name,
+ remark=request.remark,
+ captcha_proxy_url=request.captcha_proxy_url.strip() if request.captcha_proxy_url is not None else None,
+ image_enabled=request.image_enabled,
+ video_enabled=request.video_enabled,
+ image_concurrency=request.image_concurrency,
+ video_concurrency=request.video_concurrency
+ )
+
+ # 热更新并发限制,避免必须重启服务
+ if concurrency_manager:
+ await concurrency_manager.reset_token(
+ new_token.id,
+ image_concurrency=new_token.image_concurrency,
+ video_concurrency=new_token.video_concurrency
+ )
+
+ return {
+ "success": True,
+ "message": "Token添加成功",
+ "token": {
+ "id": new_token.id,
+ "email": new_token.email,
+ "credits": new_token.credits,
+ "project_id": new_token.current_project_id,
+ "project_name": new_token.current_project_name
+ }
+ }
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"添加Token失败: {str(e)}")
+
+
+@router.put("/api/tokens/{token_id}")
+async def update_token(
+ token_id: int,
+ request: UpdateTokenRequest,
+ token: str = Depends(verify_admin_token)
+):
+ """Update token - 使用ST自动刷新AT"""
+ try:
+ # 先ST转AT
+ result = await token_manager.flow_client.st_to_at(request.st)
+ at = result["access_token"]
+ expires = result.get("expires")
+
+ # 解析过期时间
+ from datetime import datetime
+ at_expires = None
+ if expires:
+ try:
+ at_expires = datetime.fromisoformat(expires.replace('Z', '+00:00'))
+ except:
+ pass
+
+ # 更新token (包含AT、ST、AT过期时间、project_id和project_name)
+ await token_manager.update_token(
+ token_id=token_id,
+ st=request.st,
+ at=at,
+ at_expires=at_expires, # 🆕 更新AT过期时间
+ project_id=request.project_id,
+ project_name=request.project_name,
+ remark=request.remark,
+ captcha_proxy_url=request.captcha_proxy_url.strip() if request.captcha_proxy_url is not None else None,
+ image_enabled=request.image_enabled,
+ video_enabled=request.video_enabled,
+ image_concurrency=request.image_concurrency,
+ video_concurrency=request.video_concurrency
+ )
+
+ # 热更新并发限制,确保管理台修改立即生效
+ if concurrency_manager:
+ updated_token = await token_manager.get_token(token_id)
+ if updated_token:
+ await concurrency_manager.reset_token(
+ token_id,
+ image_concurrency=updated_token.image_concurrency,
+ video_concurrency=updated_token.video_concurrency
+ )
+
+ return {"success": True, "message": "Token更新成功"}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.delete("/api/tokens/{token_id}")
+async def delete_token(
+ token_id: int,
+ token: str = Depends(verify_admin_token)
+):
+ """Delete token"""
+ try:
+ await token_manager.delete_token(token_id)
+ return {"success": True, "message": "Token删除成功"}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/api/tokens/{token_id}/enable")
+async def enable_token(
+ token_id: int,
+ token: str = Depends(verify_admin_token)
+):
+ """Enable token"""
+ await token_manager.enable_token(token_id)
+ return {"success": True, "message": "Token已启用"}
+
+
+@router.post("/api/tokens/{token_id}/disable")
+async def disable_token(
+ token_id: int,
+ token: str = Depends(verify_admin_token)
+):
+ """Disable token"""
+ await token_manager.disable_token(token_id)
+ return {"success": True, "message": "Token已禁用"}
+
+
+@router.post("/api/tokens/{token_id}/refresh-credits")
+async def refresh_credits(
+ token_id: int,
+ token: str = Depends(verify_admin_token)
+):
+ """刷新Token余额 🆕"""
+ try:
+ credits = await token_manager.refresh_credits(token_id)
+ return {
+ "success": True,
+ "message": "余额刷新成功",
+ "credits": credits
+ }
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"刷新余额失败: {str(e)}")
+
+
+@router.post("/api/tokens/{token_id}/refresh-at")
+async def refresh_at(
+ token_id: int,
+ token: str = Depends(verify_admin_token)
+):
+ """手动刷新Token的AT (使用ST转换) 🆕
+
+ 如果 AT 刷新失败且处于 personal 模式,会自动尝试通过浏览器刷新 ST
+ """
+ from ..core.logger import debug_logger
+ from ..core.config import config
+
+ debug_logger.log_info(f"[API] 手动刷新 AT 请求: token_id={token_id}, captcha_method={config.captcha_method}")
+
+ try:
+ # 调用token_manager的内部刷新方法(包含 ST 自动刷新逻辑)
+ success = await token_manager._refresh_at(token_id)
+
+ if success:
+ # 获取更新后的token信息
+ updated_token = await token_manager.get_token(token_id)
+
+ message = "AT刷新成功"
+ if config.captcha_method == "personal":
+ message += "(支持ST自动刷新)"
+
+ debug_logger.log_info(f"[API] AT 刷新成功: token_id={token_id}")
+
+ return {
+ "success": True,
+ "message": message,
+ "token": {
+ "id": updated_token.id,
+ "email": updated_token.email,
+ "at_expires": updated_token.at_expires.isoformat() if updated_token.at_expires else None
+ }
+ }
+ else:
+ debug_logger.log_error(f"[API] AT 刷新失败: token_id={token_id}")
+
+ error_detail = "AT刷新失败"
+ if config.captcha_method != "personal":
+ error_detail += f"(当前打码模式: {config.captcha_method},ST自动刷新仅在 personal 模式下可用)"
+
+ raise HTTPException(status_code=500, detail=error_detail)
+ except HTTPException:
+ raise
+ except Exception as e:
+ debug_logger.log_error(f"[API] 刷新AT异常: {str(e)}")
+ raise HTTPException(status_code=500, detail=f"刷新AT失败: {str(e)}")
+
+
+@router.post("/api/tokens/st2at")
+async def st_to_at(
+ request: ST2ATRequest,
+ token: str = Depends(verify_admin_token)
+):
+ """Convert Session Token to Access Token (仅转换,不添加到数据库)"""
+ try:
+ result = await token_manager.flow_client.st_to_at(request.st)
+ return {
+ "success": True,
+ "message": "ST converted to AT successfully",
+ "access_token": result["access_token"],
+ "email": result.get("user", {}).get("email"),
+ "expires": result.get("expires")
+ }
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.post("/api/tokens/import")
+async def import_tokens(
+ request: ImportTokensRequest,
+ token: str = Depends(verify_admin_token)
+):
+ """批量导入Token"""
+ from datetime import datetime, timezone
+
+ added = 0
+ updated = 0
+ errors = []
+ # 保持与历史逻辑一致:按 created_at DESC 的结果中,优先命中同邮箱“最新一条”
+ existing_by_email = {}
+ for existing_token in await token_manager.get_all_tokens():
+ if existing_token.email and existing_token.email not in existing_by_email:
+ existing_by_email[existing_token.email] = existing_token
+
+ for idx, item in enumerate(request.tokens):
+ try:
+ st = item.session_token
+
+ if not st:
+ errors.append(f"第{idx+1}项: 缺少 session_token")
+ continue
+
+ # 使用 ST 转 AT 获取用户信息
+ try:
+ result = await token_manager.flow_client.st_to_at(st)
+ at = result["access_token"]
+ email = result.get("user", {}).get("email")
+ expires = result.get("expires")
+
+ if not email:
+ errors.append(f"第{idx+1}项: 无法获取邮箱信息")
+ continue
+
+ # 解析过期时间
+ at_expires = None
+ is_expired = False
+ if expires:
+ try:
+ at_expires = datetime.fromisoformat(expires.replace('Z', '+00:00'))
+ # 判断是否过期
+ now = datetime.now(timezone.utc)
+ is_expired = at_expires <= now
+ except:
+ pass
+
+ # 使用邮箱检查是否已存在
+ existing = existing_by_email.get(email)
+
+ if existing:
+ # 更新现有Token
+ await token_manager.update_token(
+ token_id=existing.id,
+ st=st,
+ at=at,
+ at_expires=at_expires,
+ captcha_proxy_url=item.captcha_proxy_url.strip() if item.captcha_proxy_url is not None else None,
+ image_enabled=item.image_enabled,
+ video_enabled=item.video_enabled,
+ image_concurrency=item.image_concurrency,
+ video_concurrency=item.video_concurrency
+ )
+ # 如果过期则禁用
+ if is_expired:
+ await token_manager.disable_token(existing.id)
+ existing.is_active = False
+ existing.st = st
+ existing.at = at
+ existing.at_expires = at_expires
+ existing.captcha_proxy_url = item.captcha_proxy_url
+ existing.image_enabled = item.image_enabled
+ existing.video_enabled = item.video_enabled
+ existing.image_concurrency = item.image_concurrency
+ existing.video_concurrency = item.video_concurrency
+ updated += 1
+ else:
+ # 添加新Token
+ new_token = await token_manager.add_token(
+ st=st,
+ captcha_proxy_url=item.captcha_proxy_url.strip() if item.captcha_proxy_url is not None else None,
+ image_enabled=item.image_enabled,
+ video_enabled=item.video_enabled,
+ image_concurrency=item.image_concurrency,
+ video_concurrency=item.video_concurrency
+ )
+ # 如果过期则禁用
+ if is_expired:
+ await token_manager.disable_token(new_token.id)
+ new_token.is_active = False
+ existing_by_email[email] = new_token
+ added += 1
+
+ except Exception as e:
+ errors.append(f"第{idx+1}项: {str(e)}")
+
+ except Exception as e:
+ errors.append(f"第{idx+1}项: {str(e)}")
+
+ return {
+ "success": True,
+ "added": added,
+ "updated": updated,
+ "errors": errors if errors else None,
+ "message": f"导入完成: 新增 {added} 个, 更新 {updated} 个" + (f", {len(errors)} 个失败" if errors else "")
+ }
+
+
+# ========== Config Management ==========
+
+@router.get("/api/config/proxy")
+async def get_proxy_config(token: str = Depends(verify_admin_token)):
+ """Get proxy configuration"""
+ config = await proxy_manager.get_proxy_config()
+ return {
+ "success": True,
+ "config": {
+ "enabled": config.enabled,
+ "proxy_url": config.proxy_url,
+ "media_proxy_enabled": config.media_proxy_enabled,
+ "media_proxy_url": config.media_proxy_url
+ }
+ }
+
+
+@router.get("/api/proxy/config")
+async def get_proxy_config_alias(token: str = Depends(verify_admin_token)):
+ """Get proxy configuration (alias for frontend compatibility)"""
+ config = await proxy_manager.get_proxy_config()
+ return {
+ "proxy_enabled": config.enabled, # Frontend expects proxy_enabled
+ "proxy_url": config.proxy_url,
+ "media_proxy_enabled": config.media_proxy_enabled,
+ "media_proxy_url": config.media_proxy_url
+ }
+
+
+@router.post("/api/proxy/config")
+async def update_proxy_config_alias(
+ request: ProxyConfigRequest,
+ token: str = Depends(verify_admin_token)
+):
+ """Update proxy configuration (alias for frontend compatibility)"""
+ try:
+ await proxy_manager.update_proxy_config(
+ enabled=request.proxy_enabled,
+ proxy_url=request.proxy_url,
+ media_proxy_enabled=request.media_proxy_enabled,
+ media_proxy_url=request.media_proxy_url
+ )
+ except ValueError as e:
+ return {"success": False, "message": str(e)}
+ return {"success": True, "message": "代理配置更新成功"}
+
+
+@router.post("/api/config/proxy")
+async def update_proxy_config(
+ request: ProxyConfigRequest,
+ token: str = Depends(verify_admin_token)
+):
+ """Update proxy configuration"""
+ try:
+ await proxy_manager.update_proxy_config(
+ enabled=request.proxy_enabled,
+ proxy_url=request.proxy_url,
+ media_proxy_enabled=request.media_proxy_enabled,
+ media_proxy_url=request.media_proxy_url
+ )
+ except ValueError as e:
+ return {"success": False, "message": str(e)}
+ return {"success": True, "message": "代理配置更新成功"}
+
+
+@router.post("/api/proxy/test")
+async def test_proxy_connectivity(
+ request: ProxyTestRequest,
+ token: str = Depends(verify_admin_token)
+):
+ """测试代理是否可访问目标站点(默认 https://labs.google/)"""
+ proxy_input = (request.proxy_url or "").strip()
+ test_url = (request.test_url or "https://labs.google/").strip()
+ timeout_seconds = int(request.timeout_seconds or 15)
+ timeout_seconds = max(5, min(timeout_seconds, 60))
+
+ if not proxy_input:
+ return {
+ "success": False,
+ "message": "代理地址为空",
+ "test_url": test_url
+ }
+
+ try:
+ proxy_url = proxy_manager.normalize_proxy_url(proxy_input)
+ except ValueError as e:
+ return {
+ "success": False,
+ "message": str(e),
+ "test_url": test_url
+ }
+
+ start_time = time.time()
+ try:
+ proxies = {"http": proxy_url, "https": proxy_url}
+ async with AsyncSession() as session:
+ resp = await session.get(
+ test_url,
+ proxies=proxies,
+ timeout=timeout_seconds,
+ impersonate="chrome120",
+ allow_redirects=True,
+ verify=False
+ )
+
+ elapsed_ms = int((time.time() - start_time) * 1000)
+ status_code = resp.status_code
+ final_url = str(resp.url)
+ ok = 200 <= status_code < 400
+
+ return {
+ "success": ok,
+ "message": "代理可用" if ok else f"代理可连通,但目标返回状态码 {status_code}",
+ "test_url": test_url,
+ "final_url": final_url,
+ "status_code": status_code,
+ "elapsed_ms": elapsed_ms
+ }
+ except Exception as e:
+ elapsed_ms = int((time.time() - start_time) * 1000)
+ return {
+ "success": False,
+ "message": f"代理测试失败: {str(e)}",
+ "test_url": test_url,
+ "elapsed_ms": elapsed_ms
+ }
+
+
+@router.get("/api/config/generation")
+async def get_generation_config(token: str = Depends(verify_admin_token)):
+ """Get generation timeout configuration"""
+ config = await db.get_generation_config()
+ return {
+ "success": True,
+ "config": {
+ "image_timeout": config.image_timeout,
+ "video_timeout": config.video_timeout
+ }
+ }
+
+
+@router.post("/api/config/generation")
+async def update_generation_config(
+ request: GenerationConfigRequest,
+ token: str = Depends(verify_admin_token)
+):
+ """Update generation timeout configuration"""
+ await db.update_generation_config(request.image_timeout, request.video_timeout)
+
+ # 🔥 Hot reload: sync database config to memory
+ await db.reload_config_to_memory()
+
+ return {"success": True, "message": "生成配置更新成功"}
+
+
+@router.get("/api/call-logic/config")
+async def get_call_logic_config(token: str = Depends(verify_admin_token)):
+ """Get token call logic configuration."""
+ config_obj = await db.get_call_logic_config()
+ call_mode = getattr(config_obj, "call_mode", None)
+ if call_mode not in ("default", "polling"):
+ call_mode = "polling" if getattr(config_obj, "polling_mode_enabled", False) else "default"
+ return {
+ "success": True,
+ "config": {
+ "call_mode": call_mode,
+ "polling_mode_enabled": call_mode == "polling",
+ }
+ }
+
+
+@router.post("/api/call-logic/config")
+async def update_call_logic_config(
+ request: CallLogicConfigRequest,
+ token: str = Depends(verify_admin_token)
+):
+ """Update token call logic configuration."""
+ call_mode = request.call_mode if request.call_mode in ("default", "polling") else None
+ if call_mode is None:
+ raise HTTPException(status_code=400, detail="Invalid call_mode")
+
+ await db.update_call_logic_config(call_mode)
+ await db.reload_config_to_memory()
+
+ return {
+ "success": True,
+ "message": "Token轮询模式保存成功",
+ "config": {
+ "call_mode": call_mode,
+ "polling_mode_enabled": call_mode == "polling",
+ }
+ }
+
+
+# ========== System Info ==========
+
+@router.get("/api/system/info")
+async def get_system_info(token: str = Depends(verify_admin_token)):
+ """Get system information"""
+ stats = await db.get_system_info_stats()
+
+ return {
+ "success": True,
+ "info": {
+ "total_tokens": stats["total_tokens"],
+ "active_tokens": stats["active_tokens"],
+ "total_credits": stats["total_credits"],
+ "version": "1.0.0"
+ }
+ }
+
+
+# ========== Additional Routes for Frontend Compatibility ==========
+
+@router.post("/api/login")
+async def login(request: LoginRequest):
+ """Login endpoint (alias for /api/admin/login)"""
+ return await admin_login(request)
+
+
+@router.post("/api/logout")
+async def logout(token: str = Depends(verify_admin_token)):
+ """Logout endpoint (alias for /api/admin/logout)"""
+ return await admin_logout(token)
+
+
+@router.get("/health")
+async def health_check():
+ """Public health check endpoint - no auth required"""
+ try:
+ stats = await db.get_dashboard_stats()
+ has_active_tokens = stats.get("active_tokens", 0) > 0
+ except Exception:
+ return {"backend_running": True, "has_active_tokens": False}
+ return {"backend_running": True, "has_active_tokens": has_active_tokens}
+
+
+@router.get("/api/stats")
+async def get_stats(token: str = Depends(verify_admin_token)):
+ """Get statistics for dashboard"""
+ return await db.get_dashboard_stats()
+
+
+@router.get("/api/logs")
+async def get_logs(
+ limit: int = 100,
+ token: str = Depends(verify_admin_token)
+):
+ """Get lightweight request logs for list view"""
+ limit = max(1, min(limit, 100))
+ logs = await db.get_logs(limit=limit, include_payload=False)
+
+ result = []
+ for log in logs:
+ raw_status_code = log.get("status_code")
+ try:
+ status_code = int(raw_status_code) if raw_status_code is not None else None
+ except (TypeError, ValueError):
+ status_code = None
+ result.append({
+ "id": log.get("id"),
+ "token_id": log.get("token_id"),
+ "token_email": log.get("token_email"),
+ "token_username": log.get("token_username"),
+ "operation": log.get("operation"),
+ "status_code": status_code if status_code is not None else raw_status_code,
+ "duration": log.get("duration"),
+ "status_text": log.get("status_text") or "",
+ "progress": log.get("progress") or 0,
+ "created_at": log.get("created_at"),
+ "updated_at": log.get("updated_at"),
+ "error_summary": _extract_error_summary(log.get("response_body_excerpt")) if status_code is not None and status_code >= 400 else "",
+ })
+ return result
+
+
+@router.get("/api/logs/{log_id}")
+async def get_log_detail(
+ log_id: int,
+ token: str = Depends(verify_admin_token)
+):
+ """Get single request log detail (payload loaded on demand)"""
+ log = await db.get_log_detail(log_id)
+ if not log:
+ raise HTTPException(status_code=404, detail="日志不存在")
+
+ error_summary = _extract_error_summary(log.get("response_body"))
+
+ return {
+ "id": log.get("id"),
+ "token_id": log.get("token_id"),
+ "token_email": log.get("token_email"),
+ "token_username": log.get("token_username"),
+ "operation": log.get("operation"),
+ "status_code": log.get("status_code"),
+ "duration": log.get("duration"),
+ "status_text": log.get("status_text") or "",
+ "progress": log.get("progress") or 0,
+ "created_at": log.get("created_at"),
+ "updated_at": log.get("updated_at"),
+ "error_summary": error_summary,
+ "request_body": log.get("request_body"),
+ "response_body": log.get("response_body")
+ }
+
+
+@router.delete("/api/logs")
+async def clear_logs(token: str = Depends(verify_admin_token)):
+ """Clear all logs"""
+ try:
+ await db.clear_all_logs()
+ return {"success": True, "message": "所有日志已清空"}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/api/admin/config")
+async def get_admin_config(token: str = Depends(verify_admin_token)):
+ """Get admin configuration"""
+ admin_config = await db.get_admin_config()
+
+ return {
+ "admin_username": admin_config.username,
+ "api_key": admin_config.api_key,
+ "error_ban_threshold": admin_config.error_ban_threshold,
+ "debug_enabled": config.debug_enabled # Return actual debug status
+ }
+
+
+@router.post("/api/admin/config")
+async def update_admin_config(
+ request: UpdateAdminConfigRequest,
+ token: str = Depends(verify_admin_token)
+):
+ """Update admin configuration (error_ban_threshold)"""
+ # Update error_ban_threshold in database
+ await db.update_admin_config(error_ban_threshold=request.error_ban_threshold)
+
+ return {"success": True, "message": "配置更新成功"}
+
+
+@router.post("/api/admin/password")
+async def update_admin_password(
+ request: ChangePasswordRequest,
+ token: str = Depends(verify_admin_token)
+):
+ """Update admin password"""
+ return await change_password(request, token)
+
+
+@router.post("/api/admin/apikey")
+async def update_api_key(
+ request: UpdateAPIKeyRequest,
+ token: str = Depends(verify_admin_token)
+):
+ """Update API key (for external API calls, NOT for admin login)"""
+ # Update API key in database
+ await db.update_admin_config(api_key=request.new_api_key)
+
+ # 🔥 Hot reload: sync database config to memory
+ await db.reload_config_to_memory()
+
+ return {"success": True, "message": "API Key更新成功"}
+
+
+@router.post("/api/admin/debug")
+async def update_debug_config(
+ request: UpdateDebugConfigRequest,
+ token: str = Depends(verify_admin_token)
+):
+ """Update debug configuration"""
+ try:
+ # Update in-memory config only (not database)
+ # This ensures debug mode is automatically disabled on restart
+ config.set_debug_enabled(request.enabled)
+
+ status = "enabled" if request.enabled else "disabled"
+ return {"success": True, "message": f"Debug mode {status}", "enabled": request.enabled}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Failed to update debug config: {str(e)}")
+
+
+@router.get("/api/generation/timeout")
+async def get_generation_timeout(token: str = Depends(verify_admin_token)):
+ """Get generation timeout configuration"""
+ return await get_generation_config(token)
+
+
+@router.post("/api/generation/timeout")
+async def update_generation_timeout(
+ request: GenerationConfigRequest,
+ token: str = Depends(verify_admin_token)
+):
+ """Update generation timeout configuration"""
+ await db.update_generation_config(request.image_timeout, request.video_timeout)
+
+ # 🔥 Hot reload: sync database config to memory
+ await db.reload_config_to_memory()
+
+ return {"success": True, "message": "生成配置更新成功"}
+
+
+# ========== AT Auto Refresh Config ==========
+
+@router.get("/api/token-refresh/config")
+async def get_token_refresh_config(token: str = Depends(verify_admin_token)):
+ """Get AT auto refresh configuration (默认启用)"""
+ return {
+ "success": True,
+ "config": {
+ "at_auto_refresh_enabled": True # Flow2API默认启用AT自动刷新
+ }
+ }
+
+
+@router.post("/api/token-refresh/enabled")
+async def update_token_refresh_enabled(
+ token: str = Depends(verify_admin_token)
+):
+ """Update AT auto refresh enabled (Flow2API固定启用,此接口仅用于前端兼容)"""
+ return {
+ "success": True,
+ "message": "Flow2API的AT自动刷新默认启用且无法关闭"
+ }
+
+
async def _sync_runtime_cache_config():
from . import routes
if routes.generation_handler and routes.generation_handler.file_cache:
file_cache = routes.generation_handler.file_cache
file_cache.set_timeout(config.cache_timeout)
await file_cache.refresh_cleanup_task()
-
-# ========== Cache Configuration Endpoints ==========
-
-@router.get("/api/cache/config")
-async def get_cache_config(token: str = Depends(verify_admin_token)):
- """Get cache configuration"""
- cache_config = await db.get_cache_config()
-
- # Calculate effective base URL
- effective_base_url = cache_config.cache_base_url if cache_config.cache_base_url else f"http://127.0.0.1:8000"
-
- return {
- "success": True,
- "config": {
- "enabled": cache_config.cache_enabled,
- "timeout": cache_config.cache_timeout,
- "base_url": cache_config.cache_base_url or "",
- "effective_base_url": effective_base_url
- }
- }
-
-
-@router.post("/api/cache/enabled")
-async def update_cache_enabled(
- request: dict,
- token: str = Depends(verify_admin_token)
-):
- """Update cache enabled status"""
- enabled = request.get("enabled", False)
- await db.update_cache_config(enabled=enabled)
-
- # 🔥 Hot reload: sync database config to memory
- await db.reload_config_to_memory()
+
+# ========== Cache Configuration Endpoints ==========
+
+@router.get("/api/cache/config")
+async def get_cache_config(token: str = Depends(verify_admin_token)):
+ """Get cache configuration"""
+ cache_config = await db.get_cache_config()
+
+ # Calculate effective base URL
+ effective_base_url = cache_config.cache_base_url if cache_config.cache_base_url else f"http://127.0.0.1:8000"
+
+ return {
+ "success": True,
+ "config": {
+ "enabled": cache_config.cache_enabled,
+ "timeout": cache_config.cache_timeout,
+ "base_url": cache_config.cache_base_url or "",
+ "effective_base_url": effective_base_url
+ }
+ }
+
+
+@router.post("/api/cache/enabled")
+async def update_cache_enabled(
+ request: dict,
+ token: str = Depends(verify_admin_token)
+):
+ """Update cache enabled status"""
+ enabled = request.get("enabled", False)
+ await db.update_cache_config(enabled=enabled)
+
+ # 🔥 Hot reload: sync database config to memory
+ await db.reload_config_to_memory()
await _sync_runtime_cache_config()
-
- return {"success": True, "message": f"缓存已{'启用' if enabled else '禁用'}"}
-
-
-@router.post("/api/cache/config")
-async def update_cache_config_full(
- request: dict,
- token: str = Depends(verify_admin_token)
-):
- """Update complete cache configuration"""
- enabled = request.get("enabled")
- timeout = request.get("timeout")
- base_url = request.get("base_url")
-
- if timeout is not None:
- try:
- timeout = int(timeout)
- except (TypeError, ValueError):
- raise HTTPException(status_code=400, detail="缓存超时时间必须为整数")
- if timeout < 0:
- raise HTTPException(status_code=400, detail="缓存超时时间不能小于 0")
-
- await db.update_cache_config(enabled=enabled, timeout=timeout, base_url=base_url)
-
- # 🔥 Hot reload: sync database config to memory
- await db.reload_config_to_memory()
+
+ return {"success": True, "message": f"缓存已{'启用' if enabled else '禁用'}"}
+
+
+@router.post("/api/cache/config")
+async def update_cache_config_full(
+ request: dict,
+ token: str = Depends(verify_admin_token)
+):
+ """Update complete cache configuration"""
+ enabled = request.get("enabled")
+ timeout = request.get("timeout")
+ base_url = request.get("base_url")
+
+ if timeout is not None:
+ try:
+ timeout = int(timeout)
+ except (TypeError, ValueError):
+ raise HTTPException(status_code=400, detail="缓存超时时间必须为整数")
+ if timeout < 0:
+ raise HTTPException(status_code=400, detail="缓存超时时间不能小于 0")
+
+ await db.update_cache_config(enabled=enabled, timeout=timeout, base_url=base_url)
+
+ # 🔥 Hot reload: sync database config to memory
+ await db.reload_config_to_memory()
await _sync_runtime_cache_config()
-
- return {"success": True, "message": "缓存配置更新成功"}
-
-
-@router.post("/api/cache/base-url")
-async def update_cache_base_url(
- request: dict,
- token: str = Depends(verify_admin_token)
-):
- """Update cache base URL"""
- base_url = request.get("base_url", "")
- await db.update_cache_config(base_url=base_url)
-
- # 🔥 Hot reload: sync database config to memory
- await db.reload_config_to_memory()
+
+ return {"success": True, "message": "缓存配置更新成功"}
+
+
+@router.post("/api/cache/base-url")
+async def update_cache_base_url(
+ request: dict,
+ token: str = Depends(verify_admin_token)
+):
+ """Update cache base URL"""
+ base_url = request.get("base_url", "")
+ await db.update_cache_config(base_url=base_url)
+
+ # 🔥 Hot reload: sync database config to memory
+ await db.reload_config_to_memory()
await _sync_runtime_cache_config()
-
- return {"success": True, "message": "缓存Base URL更新成功"}
-
-
-@router.post("/api/captcha/config")
-async def update_captcha_config(
- request: dict,
- token: str = Depends(verify_admin_token)
-):
- """Update captcha configuration"""
- from ..services.browser_captcha import validate_browser_proxy_url
-
- captcha_method = request.get("captcha_method")
- yescaptcha_api_key = request.get("yescaptcha_api_key")
- yescaptcha_base_url = request.get("yescaptcha_base_url")
- capmonster_api_key = request.get("capmonster_api_key")
- capmonster_base_url = request.get("capmonster_base_url")
- ezcaptcha_api_key = request.get("ezcaptcha_api_key")
- ezcaptcha_base_url = request.get("ezcaptcha_base_url")
- capsolver_api_key = request.get("capsolver_api_key")
- capsolver_base_url = request.get("capsolver_base_url")
- remote_browser_base_url = request.get("remote_browser_base_url")
- remote_browser_api_key = request.get("remote_browser_api_key")
- remote_browser_timeout = request.get("remote_browser_timeout", 60)
- browser_proxy_enabled = request.get("browser_proxy_enabled", False)
- browser_proxy_url = request.get("browser_proxy_url", "")
- browser_count = request.get("browser_count", 1)
- personal_project_pool_size = request.get("personal_project_pool_size")
- personal_max_resident_tabs = request.get("personal_max_resident_tabs")
- personal_idle_tab_ttl_seconds = request.get("personal_idle_tab_ttl_seconds")
-
- # 验证浏览器代理URL格式
- if browser_proxy_enabled and browser_proxy_url:
- is_valid, error_msg = validate_browser_proxy_url(browser_proxy_url)
- if not is_valid:
- return {"success": False, "message": error_msg}
-
- if remote_browser_base_url:
- try:
- remote_browser_base_url = _normalize_http_base_url(remote_browser_base_url)
- except RuntimeError as e:
- return {"success": False, "message": str(e)}
-
- try:
- remote_browser_timeout = max(5, int(remote_browser_timeout or 60))
- except Exception:
- return {"success": False, "message": "远程打码超时时间必须是整数秒"}
-
- if captcha_method == "remote_browser":
- if not (remote_browser_base_url or "").strip():
- return {"success": False, "message": "remote_browser 模式需要配置远程打码服务地址"}
- if not (remote_browser_api_key or "").strip():
- return {"success": False, "message": "remote_browser 模式需要配置远程打码服务 API Key"}
-
- await db.update_captcha_config(
- captcha_method=captcha_method,
- yescaptcha_api_key=yescaptcha_api_key,
- yescaptcha_base_url=yescaptcha_base_url,
- capmonster_api_key=capmonster_api_key,
- capmonster_base_url=capmonster_base_url,
- ezcaptcha_api_key=ezcaptcha_api_key,
- ezcaptcha_base_url=ezcaptcha_base_url,
- capsolver_api_key=capsolver_api_key,
- capsolver_base_url=capsolver_base_url,
- remote_browser_base_url=remote_browser_base_url,
- remote_browser_api_key=remote_browser_api_key,
- remote_browser_timeout=remote_browser_timeout,
- browser_proxy_enabled=browser_proxy_enabled,
- browser_proxy_url=browser_proxy_url if browser_proxy_enabled else None,
- browser_count=max(1, int(browser_count)) if browser_count else 1,
- personal_project_pool_size=personal_project_pool_size,
- personal_max_resident_tabs=personal_max_resident_tabs,
- personal_idle_tab_ttl_seconds=personal_idle_tab_ttl_seconds
- )
-
- # 🔥 Hot reload: sync database config to memory
- await db.reload_config_to_memory()
-
- # 如果使用 browser 打码,热重载浏览器数量配置
- if captcha_method == "browser":
- try:
- from ..services.browser_captcha import BrowserCaptchaService
- service = await BrowserCaptchaService.get_instance(db)
- await service.reload_browser_count()
- except Exception:
- pass
-
- # 如果使用 personal 打码,热重载配置
- if captcha_method == "personal":
- try:
- from ..services.browser_captcha_personal import BrowserCaptchaService
- service = await BrowserCaptchaService.get_instance(db)
- await service.reload_config()
- except Exception as e:
- print(f"[Admin] Personal 配置热更新失败: {e}")
-
- return {"success": True, "message": "验证码配置更新成功"}
-
-
-@router.get("/api/captcha/config")
-async def get_captcha_config(token: str = Depends(verify_admin_token)):
- """Get captcha configuration"""
- captcha_config = await db.get_captcha_config()
- return {
- "captcha_method": captcha_config.captcha_method,
- "yescaptcha_api_key": captcha_config.yescaptcha_api_key,
- "yescaptcha_base_url": captcha_config.yescaptcha_base_url,
- "capmonster_api_key": captcha_config.capmonster_api_key,
- "capmonster_base_url": captcha_config.capmonster_base_url,
- "ezcaptcha_api_key": captcha_config.ezcaptcha_api_key,
- "ezcaptcha_base_url": captcha_config.ezcaptcha_base_url,
- "capsolver_api_key": captcha_config.capsolver_api_key,
- "capsolver_base_url": captcha_config.capsolver_base_url,
- "remote_browser_base_url": captcha_config.remote_browser_base_url,
- "remote_browser_api_key": captcha_config.remote_browser_api_key,
- "remote_browser_timeout": captcha_config.remote_browser_timeout,
- "browser_proxy_enabled": captcha_config.browser_proxy_enabled,
- "browser_proxy_url": captcha_config.browser_proxy_url or "",
- "browser_count": captcha_config.browser_count,
- "personal_project_pool_size": captcha_config.personal_project_pool_size,
- "personal_max_resident_tabs": captcha_config.personal_max_resident_tabs,
- "personal_idle_tab_ttl_seconds": captcha_config.personal_idle_tab_ttl_seconds
- }
-
-
-@router.post("/api/captcha/score-test")
-async def test_captcha_score(
- request: Optional[CaptchaScoreTestRequest] = None,
- token: str = Depends(verify_admin_token)
-):
- """使用当前打码方式获取 token,并提交到 antcpt 校验分数。"""
- req = request or CaptchaScoreTestRequest()
- website_url = (req.website_url or "https://antcpt.com/score_detector/").strip()
- website_key = (req.website_key or "6LcR_okUAAAAAPYrPe-HK_0RULO1aZM15ENyM-Mf").strip()
- action = (req.action or "homepage").strip()
- verify_url = (req.verify_url or "https://antcpt.com/score_detector/verify.php").strip()
- enterprise = bool(req.enterprise)
-
- started_at = time.time()
- captcha_config = await db.get_captcha_config()
- captcha_method = (captcha_config.captcha_method or config.captcha_method or "").strip().lower()
- browser_proxy_enabled = bool(captcha_config.browser_proxy_enabled)
- browser_proxy_url = captcha_config.browser_proxy_url or ""
-
- token_value: Optional[str] = None
- fingerprint: Optional[Dict[str, Any]] = None
- token_elapsed_ms = 0
- verify_elapsed_ms = 0
- verify_http_status = None
- verify_result: Dict[str, Any] = {}
- verify_headers: Dict[str, str] = {}
- verify_proxy_used = False
- verify_proxy_source = "none"
- verify_proxy_url = ""
- verify_impersonate = "chrome120"
- page_verify_only = captcha_method in {"browser", "personal", "remote_browser"}
- verify_mode = "browser_page" if page_verify_only else "server_post"
-
- try:
- token_start = time.time()
- if captcha_method == "browser":
- from ..services.browser_captcha import BrowserCaptchaService
- service = await BrowserCaptchaService.get_instance(db)
- score_payload, browser_id = await service.get_custom_score(
- website_url=website_url,
- website_key=website_key,
- verify_url=verify_url,
- action=action,
- enterprise=enterprise
- )
- if isinstance(score_payload, dict):
- token_value = score_payload.get("token")
- verify_elapsed_ms = int(score_payload.get("verify_elapsed_ms") or 0)
- verify_http_status = score_payload.get("verify_http_status")
- verify_result = score_payload.get("verify_result") if isinstance(score_payload.get("verify_result"), dict) else {}
- verify_mode = score_payload.get("verify_mode") or "browser_page"
- score_token_elapsed = score_payload.get("token_elapsed_ms")
- if isinstance(score_token_elapsed, (int, float)):
- token_elapsed_ms = int(score_token_elapsed)
- if token_value:
- fingerprint = await service.get_fingerprint(browser_id)
- verify_proxy_used = bool(browser_proxy_enabled and browser_proxy_url)
- verify_proxy_source = "captcha_browser_proxy" if verify_proxy_used else "browser_direct"
- verify_proxy_url = browser_proxy_url if verify_proxy_used else ""
- elif captcha_method == "personal":
- from ..services.browser_captcha_personal import BrowserCaptchaService
- service = await BrowserCaptchaService.get_instance(db)
- score_payload = await service.get_custom_score(
- website_url=website_url,
- website_key=website_key,
- verify_url=verify_url,
- action=action,
- enterprise=enterprise
- )
- if isinstance(score_payload, dict):
- token_value = score_payload.get("token")
- verify_elapsed_ms = int(score_payload.get("verify_elapsed_ms") or 0)
- verify_http_status = score_payload.get("verify_http_status")
- verify_result = score_payload.get("verify_result") if isinstance(score_payload.get("verify_result"), dict) else {}
- verify_mode = score_payload.get("verify_mode") or "browser_page"
- score_token_elapsed = score_payload.get("token_elapsed_ms")
- if isinstance(score_token_elapsed, (int, float)):
- token_elapsed_ms = int(score_token_elapsed)
- if token_value:
- fingerprint = service.get_last_fingerprint()
- verify_proxy_used = bool(browser_proxy_enabled and browser_proxy_url)
- verify_proxy_source = "captcha_browser_proxy" if verify_proxy_used else "browser_direct"
- verify_proxy_url = browser_proxy_url if verify_proxy_used else ""
- elif captcha_method == "remote_browser":
- score_payload = await _score_test_with_remote_browser_service(
- website_url=website_url,
- website_key=website_key,
- verify_url=verify_url,
- action=action,
- enterprise=enterprise,
- )
- if isinstance(score_payload, dict):
- if score_payload.get("success") is False:
- raise RuntimeError(score_payload.get("message") or "远程打码分数测试失败")
- token_value = score_payload.get("token")
- verify_elapsed_ms = int(score_payload.get("verify_elapsed_ms") or 0)
- verify_http_status = score_payload.get("verify_http_status")
- verify_result = score_payload.get("verify_result") if isinstance(score_payload.get("verify_result"), dict) else {}
- verify_mode = score_payload.get("verify_mode") or "remote_browser_page"
- score_token_elapsed = score_payload.get("token_elapsed_ms")
- if isinstance(score_token_elapsed, (int, float)):
- token_elapsed_ms = int(score_token_elapsed)
- fingerprint = score_payload.get("fingerprint") if isinstance(score_payload.get("fingerprint"), dict) else None
- elif captcha_method in SUPPORTED_API_CAPTCHA_METHODS:
- token_value = await _solve_recaptcha_with_api_service(
- method=captcha_method,
- website_url=website_url,
- website_key=website_key,
- action=action,
- enterprise=enterprise
- )
- else:
- return {
- "success": False,
- "message": f"当前打码方式不支持分数测试: {captcha_method}",
- "captcha_method": captcha_method,
- "website_url": website_url,
- "website_key": website_key,
- "action": action,
- "verify_url": verify_url,
- "enterprise": enterprise,
- "token_acquired": False,
- "elapsed_ms": int((time.time() - started_at) * 1000)
- }
- if token_elapsed_ms <= 0:
- token_elapsed_ms = int((time.time() - token_start) * 1000)
-
- # 远程有头打码的 custom-score 可能由页面内直接完成校验,
- # 在部分实现里不会显式回传 token,本地按 verify_result 兜底判定。
- if captcha_method == "remote_browser" and not token_value and isinstance(verify_result, dict):
- if verify_result.get("success") is True:
- token_value = verify_result.get("token") or verify_result.get("gRecaptchaResponse") or "__verified_by_remote__"
-
- if not token_value:
- return {
- "success": False,
- "message": "未获取到 reCAPTCHA token",
- "captcha_method": captcha_method,
- "website_url": website_url,
- "website_key": website_key,
- "action": action,
- "verify_url": verify_url,
- "enterprise": enterprise,
- "token_acquired": False,
- "token_elapsed_ms": token_elapsed_ms,
- "browser_proxy_enabled": browser_proxy_enabled,
- "browser_proxy_url": browser_proxy_url if browser_proxy_enabled else "",
- "fingerprint": fingerprint,
- "elapsed_ms": int((time.time() - started_at) * 1000)
- }
-
- if verify_mode == "server_post" and not page_verify_only:
- verify_start = time.time()
- verify_headers = {
- "accept": "application/json, text/javascript, */*; q=0.01",
- "content-type": "application/json",
- "origin": "https://antcpt.com",
- "referer": website_url,
- "x-requested-with": "XMLHttpRequest",
- }
- if isinstance(fingerprint, dict):
- ua = (fingerprint.get("user_agent") or "").strip()
- lang = (fingerprint.get("accept_language") or "").strip()
- sec_ch_ua = (fingerprint.get("sec_ch_ua") or "").strip()
- sec_ch_ua_mobile = (fingerprint.get("sec_ch_ua_mobile") or "").strip()
- sec_ch_ua_platform = (fingerprint.get("sec_ch_ua_platform") or "").strip()
-
- if ua:
- verify_headers["user-agent"] = ua
- if lang:
- verify_headers["accept-language"] = lang if "," in lang else f"{lang},zh;q=0.9"
- if sec_ch_ua:
- verify_headers["sec-ch-ua"] = sec_ch_ua
- if sec_ch_ua_mobile:
- verify_headers["sec-ch-ua-mobile"] = sec_ch_ua_mobile
- if sec_ch_ua_platform:
- verify_headers["sec-ch-ua-platform"] = sec_ch_ua_platform
-
- if verify_headers.get("user-agent"):
- for header_name, header_value in _guess_client_hints_from_user_agent(
- verify_headers.get("user-agent", "")
- ).items():
- if header_value and not verify_headers.get(header_name):
- verify_headers[header_name] = header_value
- verify_impersonate = _guess_impersonate_from_user_agent(verify_headers.get("user-agent", ""))
-
- verify_proxies, verify_proxy_used, verify_proxy_source, verify_proxy_url = (
- await _resolve_score_test_verify_proxy(
- captcha_method=captcha_method,
- browser_proxy_enabled=browser_proxy_enabled,
- browser_proxy_url=browser_proxy_url
- )
- )
-
- async with AsyncSession() as session:
- verify_resp = await session.post(
- verify_url,
- json={"g-recaptcha-response": token_value},
- headers=verify_headers,
- proxies=verify_proxies,
- impersonate=verify_impersonate,
- timeout=30
- )
- verify_elapsed_ms = int((time.time() - verify_start) * 1000)
- verify_http_status = verify_resp.status_code
-
- try:
- verify_result = verify_resp.json()
- except Exception:
- verify_result = {"raw": verify_resp.text}
- else:
- verify_headers = {
- "origin": "https://antcpt.com",
- "referer": website_url,
- "x-requested-with": "XMLHttpRequest",
- }
- if isinstance(fingerprint, dict):
- verify_headers.update({
- "user-agent": fingerprint.get("user_agent", ""),
- "accept-language": fingerprint.get("accept_language", ""),
- "sec-ch-ua": fingerprint.get("sec_ch_ua", ""),
- "sec-ch-ua-mobile": fingerprint.get("sec_ch_ua_mobile", ""),
- "sec-ch-ua-platform": fingerprint.get("sec_ch_ua_platform", ""),
- })
-
- verify_success = bool(verify_result.get("success")) if isinstance(verify_result, dict) else False
- score_value = verify_result.get("score") if isinstance(verify_result, dict) else None
-
- return {
- "success": verify_success,
- "message": "分数校验成功" if verify_success else "分数校验未通过",
- "captcha_method": captcha_method,
- "website_url": website_url,
- "website_key": website_key,
- "action": action,
- "verify_url": verify_url,
- "enterprise": enterprise,
- "token_acquired": True,
- "token_preview": _mask_token(token_value),
- "token_elapsed_ms": token_elapsed_ms,
- "verify_elapsed_ms": verify_elapsed_ms,
- "verify_http_status": verify_http_status,
- "score": score_value,
- "verify_result": verify_result,
- "verify_request_meta": {
- "mode": verify_mode,
- "proxy_used": verify_proxy_used,
- "user_agent": verify_headers.get("user-agent", ""),
- "accept_language": verify_headers.get("accept-language", ""),
- "sec_ch_ua": verify_headers.get("sec-ch-ua", ""),
- "sec_ch_ua_mobile": verify_headers.get("sec-ch-ua-mobile", ""),
- "sec_ch_ua_platform": verify_headers.get("sec-ch-ua-platform", ""),
- "origin": verify_headers.get("origin", ""),
- "referer": verify_headers.get("referer", ""),
- "x_requested_with": verify_headers.get("x-requested-with", ""),
- "proxy_source": verify_proxy_source,
- "proxy_url": verify_proxy_url,
- "impersonate": verify_impersonate,
- },
- "browser_proxy_enabled": browser_proxy_enabled,
- "browser_proxy_url": browser_proxy_url if browser_proxy_enabled else "",
- "fingerprint": fingerprint,
- "elapsed_ms": int((time.time() - started_at) * 1000)
- }
- except Exception as e:
- return {
- "success": False,
- "message": f"分数测试失败: {str(e)}",
- "captcha_method": captcha_method,
- "website_url": website_url,
- "website_key": website_key,
- "action": action,
- "verify_url": verify_url,
- "enterprise": enterprise,
- "token_acquired": bool(token_value),
- "token_preview": _mask_token(token_value),
- "token_elapsed_ms": token_elapsed_ms,
- "verify_elapsed_ms": verify_elapsed_ms,
- "verify_http_status": verify_http_status,
- "verify_result": verify_result,
- "verify_request_meta": {
- "mode": verify_mode,
- "proxy_used": verify_proxy_used,
- "user_agent": verify_headers.get("user-agent", ""),
- "accept_language": verify_headers.get("accept-language", ""),
- "sec_ch_ua": verify_headers.get("sec-ch-ua", ""),
- "sec_ch_ua_mobile": verify_headers.get("sec-ch-ua-mobile", ""),
- "sec_ch_ua_platform": verify_headers.get("sec-ch-ua-platform", ""),
- "origin": verify_headers.get("origin", ""),
- "referer": verify_headers.get("referer", ""),
- "x_requested_with": verify_headers.get("x-requested-with", ""),
- "proxy_source": verify_proxy_source,
- "proxy_url": verify_proxy_url,
- "impersonate": verify_impersonate,
- },
- "browser_proxy_enabled": browser_proxy_enabled,
- "browser_proxy_url": browser_proxy_url if browser_proxy_enabled else "",
- "fingerprint": fingerprint,
- "elapsed_ms": int((time.time() - started_at) * 1000)
- }
-
-
-# ========== Plugin Configuration Endpoints ==========
-
-@router.get("/api/plugin/config")
-async def get_plugin_config(request: Request, token: str = Depends(verify_admin_token)):
- """Get plugin configuration"""
- plugin_config = await db.get_plugin_config()
-
- # Get the actual domain and port from the request
- # This allows the connection URL to reflect the user's actual access path
- host_header = request.headers.get("host", "")
-
- # Generate connection URL based on actual request
- if host_header:
- # Use the actual domain/IP and port from the request
- connection_url = f"http://{host_header}/api/plugin/update-token"
- else:
- # Fallback to config-based URL
- from ..core.config import config
- server_host = config.server_host
- server_port = config.server_port
-
- if server_host == "0.0.0.0":
- connection_url = f"http://127.0.0.1:{server_port}/api/plugin/update-token"
- else:
- connection_url = f"http://{server_host}:{server_port}/api/plugin/update-token"
-
- return {
- "success": True,
- "config": {
- "connection_token": plugin_config.connection_token,
- "connection_url": connection_url,
- "auto_enable_on_update": plugin_config.auto_enable_on_update
- }
- }
-
-
-@router.post("/api/plugin/config")
-async def update_plugin_config(
- request: dict,
- token: str = Depends(verify_admin_token)
-):
- """Update plugin configuration"""
- connection_token = request.get("connection_token", "")
- auto_enable_on_update = request.get("auto_enable_on_update", True) # 默认开启
-
- # Generate random token if empty
- if not connection_token:
- connection_token = secrets.token_urlsafe(32)
-
- await db.update_plugin_config(
- connection_token=connection_token,
- auto_enable_on_update=auto_enable_on_update
- )
-
- return {
- "success": True,
- "message": "插件配置更新成功",
- "connection_token": connection_token,
- "auto_enable_on_update": auto_enable_on_update
- }
-
-
-@router.post("/api/plugin/update-token")
-async def plugin_update_token(request: dict, authorization: Optional[str] = Header(None)):
- """Receive token update from Chrome extension (no admin auth required, uses connection_token)"""
- # Verify connection token
- plugin_config = await db.get_plugin_config()
-
- # Extract token from Authorization header
- provided_token = None
- if authorization:
- if authorization.startswith("Bearer "):
- provided_token = authorization[7:]
- else:
- provided_token = authorization
-
- # Check if token matches
- if not plugin_config.connection_token or provided_token != plugin_config.connection_token:
- raise HTTPException(status_code=401, detail="Invalid connection token")
-
- # Extract session token from request
- session_token = request.get("session_token")
-
- if not session_token:
- raise HTTPException(status_code=400, detail="Missing session_token")
-
- # Step 1: Convert ST to AT to get user info (including email)
- try:
- result = await token_manager.flow_client.st_to_at(session_token)
- at = result["access_token"]
- expires = result.get("expires")
- user_info = result.get("user", {})
- email = user_info.get("email", "")
-
- if not email:
- raise HTTPException(status_code=400, detail="Failed to get email from session token")
-
- # Parse expiration time
- from datetime import datetime
- at_expires = None
- if expires:
- try:
- at_expires = datetime.fromisoformat(expires.replace('Z', '+00:00'))
- except:
- pass
-
- except Exception as e:
- raise HTTPException(status_code=400, detail=f"Invalid session token: {str(e)}")
-
- # Step 2: Check if token with this email exists
- existing_token = await db.get_token_by_email(email)
-
- if existing_token:
- # Update existing token
- try:
- # Update token
- await token_manager.update_token(
- token_id=existing_token.id,
- st=session_token,
- at=at,
- at_expires=at_expires
- )
-
- # Check if auto-enable is enabled and token is disabled
- if plugin_config.auto_enable_on_update and not existing_token.is_active:
- await token_manager.enable_token(existing_token.id)
- return {
- "success": True,
- "message": f"Token updated and auto-enabled for {email}",
- "action": "updated",
- "auto_enabled": True
- }
-
- return {
- "success": True,
- "message": f"Token updated for {email}",
- "action": "updated"
- }
- except Exception as e:
- raise HTTPException(status_code=500, detail=f"Failed to update token: {str(e)}")
- else:
- # Add new token
- try:
- new_token = await token_manager.add_token(
- st=session_token,
- remark="Added by Chrome Extension"
- )
-
- return {
- "success": True,
- "message": f"Token added for {new_token.email}",
- "action": "added",
- "token_id": new_token.id
- }
- except Exception as e:
- raise HTTPException(status_code=500, detail=f"Failed to add token: {str(e)}")
+
+ return {"success": True, "message": "缓存Base URL更新成功"}
+
+
+@router.post("/api/captcha/config")
+async def update_captcha_config(
+ request: dict,
+ token: str = Depends(verify_admin_token)
+):
+ """Update captcha configuration"""
+ from ..services.browser_captcha import validate_browser_proxy_url
+
+ captcha_method = request.get("captcha_method")
+ yescaptcha_api_key = request.get("yescaptcha_api_key")
+ yescaptcha_base_url = request.get("yescaptcha_base_url")
+ capmonster_api_key = request.get("capmonster_api_key")
+ capmonster_base_url = request.get("capmonster_base_url")
+ ezcaptcha_api_key = request.get("ezcaptcha_api_key")
+ ezcaptcha_base_url = request.get("ezcaptcha_base_url")
+ capsolver_api_key = request.get("capsolver_api_key")
+ capsolver_base_url = request.get("capsolver_base_url")
+ remote_browser_base_url = request.get("remote_browser_base_url")
+ remote_browser_api_key = request.get("remote_browser_api_key")
+ remote_browser_timeout = request.get("remote_browser_timeout", 60)
+ browser_proxy_enabled = request.get("browser_proxy_enabled", False)
+ browser_proxy_url = request.get("browser_proxy_url", "")
+ browser_count = request.get("browser_count", 1)
+ personal_project_pool_size = request.get("personal_project_pool_size")
+ personal_max_resident_tabs = request.get("personal_max_resident_tabs")
+ personal_idle_tab_ttl_seconds = request.get("personal_idle_tab_ttl_seconds")
+
+ # 验证浏览器代理URL格式
+ if browser_proxy_enabled and browser_proxy_url:
+ is_valid, error_msg = validate_browser_proxy_url(browser_proxy_url)
+ if not is_valid:
+ return {"success": False, "message": error_msg}
+
+ if remote_browser_base_url:
+ try:
+ remote_browser_base_url = _normalize_http_base_url(remote_browser_base_url)
+ except RuntimeError as e:
+ return {"success": False, "message": str(e)}
+
+ try:
+ remote_browser_timeout = max(5, int(remote_browser_timeout or 60))
+ except Exception:
+ return {"success": False, "message": "远程打码超时时间必须是整数秒"}
+
+ if captcha_method == "remote_browser":
+ if not (remote_browser_base_url or "").strip():
+ return {"success": False, "message": "remote_browser 模式需要配置远程打码服务地址"}
+ if not (remote_browser_api_key or "").strip():
+ return {"success": False, "message": "remote_browser 模式需要配置远程打码服务 API Key"}
+
+ await db.update_captcha_config(
+ captcha_method=captcha_method,
+ yescaptcha_api_key=yescaptcha_api_key,
+ yescaptcha_base_url=yescaptcha_base_url,
+ capmonster_api_key=capmonster_api_key,
+ capmonster_base_url=capmonster_base_url,
+ ezcaptcha_api_key=ezcaptcha_api_key,
+ ezcaptcha_base_url=ezcaptcha_base_url,
+ capsolver_api_key=capsolver_api_key,
+ capsolver_base_url=capsolver_base_url,
+ remote_browser_base_url=remote_browser_base_url,
+ remote_browser_api_key=remote_browser_api_key,
+ remote_browser_timeout=remote_browser_timeout,
+ browser_proxy_enabled=browser_proxy_enabled,
+ browser_proxy_url=browser_proxy_url if browser_proxy_enabled else None,
+ browser_count=max(1, int(browser_count)) if browser_count else 1,
+ personal_project_pool_size=personal_project_pool_size,
+ personal_max_resident_tabs=personal_max_resident_tabs,
+ personal_idle_tab_ttl_seconds=personal_idle_tab_ttl_seconds
+ )
+
+ # 🔥 Hot reload: sync database config to memory
+ await db.reload_config_to_memory()
+
+ # 如果使用 browser 打码,热重载浏览器数量配置
+ if captcha_method == "browser":
+ try:
+ from ..services.browser_captcha import BrowserCaptchaService
+ service = await BrowserCaptchaService.get_instance(db)
+ await service.reload_browser_count()
+ except Exception:
+ pass
+
+ # 如果使用 personal 打码,热重载配置
+ if captcha_method == "personal":
+ try:
+ from ..services.browser_captcha_personal import BrowserCaptchaService
+ service = await BrowserCaptchaService.get_instance(db)
+ await service.reload_config()
+ except Exception as e:
+ print(f"[Admin] Personal 配置热更新失败: {e}")
+
+ return {"success": True, "message": "验证码配置更新成功"}
+
+
+@router.get("/api/captcha/config")
+async def get_captcha_config(token: str = Depends(verify_admin_token)):
+ """Get captcha configuration"""
+ captcha_config = await db.get_captcha_config()
+ return {
+ "captcha_method": captcha_config.captcha_method,
+ "yescaptcha_api_key": captcha_config.yescaptcha_api_key,
+ "yescaptcha_base_url": captcha_config.yescaptcha_base_url,
+ "capmonster_api_key": captcha_config.capmonster_api_key,
+ "capmonster_base_url": captcha_config.capmonster_base_url,
+ "ezcaptcha_api_key": captcha_config.ezcaptcha_api_key,
+ "ezcaptcha_base_url": captcha_config.ezcaptcha_base_url,
+ "capsolver_api_key": captcha_config.capsolver_api_key,
+ "capsolver_base_url": captcha_config.capsolver_base_url,
+ "remote_browser_base_url": captcha_config.remote_browser_base_url,
+ "remote_browser_api_key": captcha_config.remote_browser_api_key,
+ "remote_browser_timeout": captcha_config.remote_browser_timeout,
+ "browser_proxy_enabled": captcha_config.browser_proxy_enabled,
+ "browser_proxy_url": captcha_config.browser_proxy_url or "",
+ "browser_count": captcha_config.browser_count,
+ "personal_project_pool_size": captcha_config.personal_project_pool_size,
+ "personal_max_resident_tabs": captcha_config.personal_max_resident_tabs,
+ "personal_idle_tab_ttl_seconds": captcha_config.personal_idle_tab_ttl_seconds
+ }
+
+
+@router.post("/api/captcha/score-test")
+async def test_captcha_score(
+ request: Optional[CaptchaScoreTestRequest] = None,
+ token: str = Depends(verify_admin_token)
+):
+ """使用当前打码方式获取 token,并提交到 antcpt 校验分数。"""
+ req = request or CaptchaScoreTestRequest()
+ website_url = (req.website_url or "https://antcpt.com/score_detector/").strip()
+ website_key = (req.website_key or "6LcR_okUAAAAAPYrPe-HK_0RULO1aZM15ENyM-Mf").strip()
+ action = (req.action or "homepage").strip()
+ verify_url = (req.verify_url or "https://antcpt.com/score_detector/verify.php").strip()
+ enterprise = bool(req.enterprise)
+
+ started_at = time.time()
+ captcha_config = await db.get_captcha_config()
+ captcha_method = (captcha_config.captcha_method or config.captcha_method or "").strip().lower()
+ browser_proxy_enabled = bool(captcha_config.browser_proxy_enabled)
+ browser_proxy_url = captcha_config.browser_proxy_url or ""
+
+ token_value: Optional[str] = None
+ fingerprint: Optional[Dict[str, Any]] = None
+ token_elapsed_ms = 0
+ verify_elapsed_ms = 0
+ verify_http_status = None
+ verify_result: Dict[str, Any] = {}
+ verify_headers: Dict[str, str] = {}
+ verify_proxy_used = False
+ verify_proxy_source = "none"
+ verify_proxy_url = ""
+ verify_impersonate = "chrome120"
+ page_verify_only = captcha_method in {"browser", "personal", "remote_browser"}
+ verify_mode = "browser_page" if page_verify_only else "server_post"
+
+ try:
+ token_start = time.time()
+ if captcha_method == "browser":
+ from ..services.browser_captcha import BrowserCaptchaService
+ service = await BrowserCaptchaService.get_instance(db)
+ score_payload, browser_id = await service.get_custom_score(
+ website_url=website_url,
+ website_key=website_key,
+ verify_url=verify_url,
+ action=action,
+ enterprise=enterprise
+ )
+ if isinstance(score_payload, dict):
+ token_value = score_payload.get("token")
+ verify_elapsed_ms = int(score_payload.get("verify_elapsed_ms") or 0)
+ verify_http_status = score_payload.get("verify_http_status")
+ verify_result = score_payload.get("verify_result") if isinstance(score_payload.get("verify_result"), dict) else {}
+ verify_mode = score_payload.get("verify_mode") or "browser_page"
+ score_token_elapsed = score_payload.get("token_elapsed_ms")
+ if isinstance(score_token_elapsed, (int, float)):
+ token_elapsed_ms = int(score_token_elapsed)
+ if token_value:
+ fingerprint = await service.get_fingerprint(browser_id)
+ verify_proxy_used = bool(browser_proxy_enabled and browser_proxy_url)
+ verify_proxy_source = "captcha_browser_proxy" if verify_proxy_used else "browser_direct"
+ verify_proxy_url = browser_proxy_url if verify_proxy_used else ""
+ elif captcha_method == "personal":
+ from ..services.browser_captcha_personal import BrowserCaptchaService
+ service = await BrowserCaptchaService.get_instance(db)
+ score_payload = await service.get_custom_score(
+ website_url=website_url,
+ website_key=website_key,
+ verify_url=verify_url,
+ action=action,
+ enterprise=enterprise
+ )
+ if isinstance(score_payload, dict):
+ token_value = score_payload.get("token")
+ verify_elapsed_ms = int(score_payload.get("verify_elapsed_ms") or 0)
+ verify_http_status = score_payload.get("verify_http_status")
+ verify_result = score_payload.get("verify_result") if isinstance(score_payload.get("verify_result"), dict) else {}
+ verify_mode = score_payload.get("verify_mode") or "browser_page"
+ score_token_elapsed = score_payload.get("token_elapsed_ms")
+ if isinstance(score_token_elapsed, (int, float)):
+ token_elapsed_ms = int(score_token_elapsed)
+ if token_value:
+ fingerprint = service.get_last_fingerprint()
+ verify_proxy_used = bool(browser_proxy_enabled and browser_proxy_url)
+ verify_proxy_source = "captcha_browser_proxy" if verify_proxy_used else "browser_direct"
+ verify_proxy_url = browser_proxy_url if verify_proxy_used else ""
+ elif captcha_method == "remote_browser":
+ score_payload = await _score_test_with_remote_browser_service(
+ website_url=website_url,
+ website_key=website_key,
+ verify_url=verify_url,
+ action=action,
+ enterprise=enterprise,
+ )
+ if isinstance(score_payload, dict):
+ if score_payload.get("success") is False:
+ raise RuntimeError(score_payload.get("message") or "远程打码分数测试失败")
+ token_value = score_payload.get("token")
+ verify_elapsed_ms = int(score_payload.get("verify_elapsed_ms") or 0)
+ verify_http_status = score_payload.get("verify_http_status")
+ verify_result = score_payload.get("verify_result") if isinstance(score_payload.get("verify_result"), dict) else {}
+ verify_mode = score_payload.get("verify_mode") or "remote_browser_page"
+ score_token_elapsed = score_payload.get("token_elapsed_ms")
+ if isinstance(score_token_elapsed, (int, float)):
+ token_elapsed_ms = int(score_token_elapsed)
+ fingerprint = score_payload.get("fingerprint") if isinstance(score_payload.get("fingerprint"), dict) else None
+ elif captcha_method in SUPPORTED_API_CAPTCHA_METHODS:
+ token_value = await _solve_recaptcha_with_api_service(
+ method=captcha_method,
+ website_url=website_url,
+ website_key=website_key,
+ action=action,
+ enterprise=enterprise
+ )
+ else:
+ return {
+ "success": False,
+ "message": f"当前打码方式不支持分数测试: {captcha_method}",
+ "captcha_method": captcha_method,
+ "website_url": website_url,
+ "website_key": website_key,
+ "action": action,
+ "verify_url": verify_url,
+ "enterprise": enterprise,
+ "token_acquired": False,
+ "elapsed_ms": int((time.time() - started_at) * 1000)
+ }
+ if token_elapsed_ms <= 0:
+ token_elapsed_ms = int((time.time() - token_start) * 1000)
+
+ # 远程有头打码的 custom-score 可能由页面内直接完成校验,
+ # 在部分实现里不会显式回传 token,本地按 verify_result 兜底判定。
+ if captcha_method == "remote_browser" and not token_value and isinstance(verify_result, dict):
+ if verify_result.get("success") is True:
+ token_value = verify_result.get("token") or verify_result.get("gRecaptchaResponse") or "__verified_by_remote__"
+
+ if not token_value:
+ return {
+ "success": False,
+ "message": "未获取到 reCAPTCHA token",
+ "captcha_method": captcha_method,
+ "website_url": website_url,
+ "website_key": website_key,
+ "action": action,
+ "verify_url": verify_url,
+ "enterprise": enterprise,
+ "token_acquired": False,
+ "token_elapsed_ms": token_elapsed_ms,
+ "browser_proxy_enabled": browser_proxy_enabled,
+ "browser_proxy_url": browser_proxy_url if browser_proxy_enabled else "",
+ "fingerprint": fingerprint,
+ "elapsed_ms": int((time.time() - started_at) * 1000)
+ }
+
+ if verify_mode == "server_post" and not page_verify_only:
+ verify_start = time.time()
+ verify_headers = {
+ "accept": "application/json, text/javascript, */*; q=0.01",
+ "content-type": "application/json",
+ "origin": "https://antcpt.com",
+ "referer": website_url,
+ "x-requested-with": "XMLHttpRequest",
+ }
+ if isinstance(fingerprint, dict):
+ ua = (fingerprint.get("user_agent") or "").strip()
+ lang = (fingerprint.get("accept_language") or "").strip()
+ sec_ch_ua = (fingerprint.get("sec_ch_ua") or "").strip()
+ sec_ch_ua_mobile = (fingerprint.get("sec_ch_ua_mobile") or "").strip()
+ sec_ch_ua_platform = (fingerprint.get("sec_ch_ua_platform") or "").strip()
+
+ if ua:
+ verify_headers["user-agent"] = ua
+ if lang:
+ verify_headers["accept-language"] = lang if "," in lang else f"{lang},zh;q=0.9"
+ if sec_ch_ua:
+ verify_headers["sec-ch-ua"] = sec_ch_ua
+ if sec_ch_ua_mobile:
+ verify_headers["sec-ch-ua-mobile"] = sec_ch_ua_mobile
+ if sec_ch_ua_platform:
+ verify_headers["sec-ch-ua-platform"] = sec_ch_ua_platform
+
+ if verify_headers.get("user-agent"):
+ for header_name, header_value in _guess_client_hints_from_user_agent(
+ verify_headers.get("user-agent", "")
+ ).items():
+ if header_value and not verify_headers.get(header_name):
+ verify_headers[header_name] = header_value
+ verify_impersonate = _guess_impersonate_from_user_agent(verify_headers.get("user-agent", ""))
+
+ verify_proxies, verify_proxy_used, verify_proxy_source, verify_proxy_url = (
+ await _resolve_score_test_verify_proxy(
+ captcha_method=captcha_method,
+ browser_proxy_enabled=browser_proxy_enabled,
+ browser_proxy_url=browser_proxy_url
+ )
+ )
+
+ async with AsyncSession() as session:
+ verify_resp = await session.post(
+ verify_url,
+ json={"g-recaptcha-response": token_value},
+ headers=verify_headers,
+ proxies=verify_proxies,
+ impersonate=verify_impersonate,
+ timeout=30
+ )
+ verify_elapsed_ms = int((time.time() - verify_start) * 1000)
+ verify_http_status = verify_resp.status_code
+
+ try:
+ verify_result = verify_resp.json()
+ except Exception:
+ verify_result = {"raw": verify_resp.text}
+ else:
+ verify_headers = {
+ "origin": "https://antcpt.com",
+ "referer": website_url,
+ "x-requested-with": "XMLHttpRequest",
+ }
+ if isinstance(fingerprint, dict):
+ verify_headers.update({
+ "user-agent": fingerprint.get("user_agent", ""),
+ "accept-language": fingerprint.get("accept_language", ""),
+ "sec-ch-ua": fingerprint.get("sec_ch_ua", ""),
+ "sec-ch-ua-mobile": fingerprint.get("sec_ch_ua_mobile", ""),
+ "sec-ch-ua-platform": fingerprint.get("sec_ch_ua_platform", ""),
+ })
+
+ verify_success = bool(verify_result.get("success")) if isinstance(verify_result, dict) else False
+ score_value = verify_result.get("score") if isinstance(verify_result, dict) else None
+
+ return {
+ "success": verify_success,
+ "message": "分数校验成功" if verify_success else "分数校验未通过",
+ "captcha_method": captcha_method,
+ "website_url": website_url,
+ "website_key": website_key,
+ "action": action,
+ "verify_url": verify_url,
+ "enterprise": enterprise,
+ "token_acquired": True,
+ "token_preview": _mask_token(token_value),
+ "token_elapsed_ms": token_elapsed_ms,
+ "verify_elapsed_ms": verify_elapsed_ms,
+ "verify_http_status": verify_http_status,
+ "score": score_value,
+ "verify_result": verify_result,
+ "verify_request_meta": {
+ "mode": verify_mode,
+ "proxy_used": verify_proxy_used,
+ "user_agent": verify_headers.get("user-agent", ""),
+ "accept_language": verify_headers.get("accept-language", ""),
+ "sec_ch_ua": verify_headers.get("sec-ch-ua", ""),
+ "sec_ch_ua_mobile": verify_headers.get("sec-ch-ua-mobile", ""),
+ "sec_ch_ua_platform": verify_headers.get("sec-ch-ua-platform", ""),
+ "origin": verify_headers.get("origin", ""),
+ "referer": verify_headers.get("referer", ""),
+ "x_requested_with": verify_headers.get("x-requested-with", ""),
+ "proxy_source": verify_proxy_source,
+ "proxy_url": verify_proxy_url,
+ "impersonate": verify_impersonate,
+ },
+ "browser_proxy_enabled": browser_proxy_enabled,
+ "browser_proxy_url": browser_proxy_url if browser_proxy_enabled else "",
+ "fingerprint": fingerprint,
+ "elapsed_ms": int((time.time() - started_at) * 1000)
+ }
+ except Exception as e:
+ return {
+ "success": False,
+ "message": f"分数测试失败: {str(e)}",
+ "captcha_method": captcha_method,
+ "website_url": website_url,
+ "website_key": website_key,
+ "action": action,
+ "verify_url": verify_url,
+ "enterprise": enterprise,
+ "token_acquired": bool(token_value),
+ "token_preview": _mask_token(token_value),
+ "token_elapsed_ms": token_elapsed_ms,
+ "verify_elapsed_ms": verify_elapsed_ms,
+ "verify_http_status": verify_http_status,
+ "verify_result": verify_result,
+ "verify_request_meta": {
+ "mode": verify_mode,
+ "proxy_used": verify_proxy_used,
+ "user_agent": verify_headers.get("user-agent", ""),
+ "accept_language": verify_headers.get("accept-language", ""),
+ "sec_ch_ua": verify_headers.get("sec-ch-ua", ""),
+ "sec_ch_ua_mobile": verify_headers.get("sec-ch-ua-mobile", ""),
+ "sec_ch_ua_platform": verify_headers.get("sec-ch-ua-platform", ""),
+ "origin": verify_headers.get("origin", ""),
+ "referer": verify_headers.get("referer", ""),
+ "x_requested_with": verify_headers.get("x-requested-with", ""),
+ "proxy_source": verify_proxy_source,
+ "proxy_url": verify_proxy_url,
+ "impersonate": verify_impersonate,
+ },
+ "browser_proxy_enabled": browser_proxy_enabled,
+ "browser_proxy_url": browser_proxy_url if browser_proxy_enabled else "",
+ "fingerprint": fingerprint,
+ "elapsed_ms": int((time.time() - started_at) * 1000)
+ }
+
+
+# ========== Plugin Configuration Endpoints ==========
+
+@router.get("/api/plugin/config")
+async def get_plugin_config(request: Request, token: str = Depends(verify_admin_token)):
+ """Get plugin configuration"""
+ plugin_config = await db.get_plugin_config()
+
+ # Get the actual domain and port from the request
+ # This allows the connection URL to reflect the user's actual access path
+ host_header = request.headers.get("host", "")
+
+ # Generate connection URL based on actual request
+ if host_header:
+ # Use the actual domain/IP and port from the request
+ connection_url = f"http://{host_header}/api/plugin/update-token"
+ else:
+ # Fallback to config-based URL
+ from ..core.config import config
+ server_host = config.server_host
+ server_port = config.server_port
+
+ if server_host == "0.0.0.0":
+ connection_url = f"http://127.0.0.1:{server_port}/api/plugin/update-token"
+ else:
+ connection_url = f"http://{server_host}:{server_port}/api/plugin/update-token"
+
+ return {
+ "success": True,
+ "config": {
+ "connection_token": plugin_config.connection_token,
+ "connection_url": connection_url,
+ "auto_enable_on_update": plugin_config.auto_enable_on_update
+ }
+ }
+
+
+@router.post("/api/plugin/config")
+async def update_plugin_config(
+ request: dict,
+ token: str = Depends(verify_admin_token)
+):
+ """Update plugin configuration"""
+ connection_token = request.get("connection_token", "")
+ auto_enable_on_update = request.get("auto_enable_on_update", True) # 默认开启
+
+ # Generate random token if empty
+ if not connection_token:
+ connection_token = secrets.token_urlsafe(32)
+
+ await db.update_plugin_config(
+ connection_token=connection_token,
+ auto_enable_on_update=auto_enable_on_update
+ )
+
+ return {
+ "success": True,
+ "message": "插件配置更新成功",
+ "connection_token": connection_token,
+ "auto_enable_on_update": auto_enable_on_update
+ }
+
+
+@router.post("/api/plugin/update-token")
+async def plugin_update_token(request: dict, authorization: Optional[str] = Header(None)):
+ """Receive token update from Chrome extension (no admin auth required, uses connection_token)"""
+ # Verify connection token
+ plugin_config = await db.get_plugin_config()
+
+ # Extract token from Authorization header
+ provided_token = None
+ if authorization:
+ if authorization.startswith("Bearer "):
+ provided_token = authorization[7:]
+ else:
+ provided_token = authorization
+
+ # Check if token matches
+ if not plugin_config.connection_token or provided_token != plugin_config.connection_token:
+ raise HTTPException(status_code=401, detail="Invalid connection token")
+
+ # Extract session token from request
+ session_token = request.get("session_token")
+
+ if not session_token:
+ raise HTTPException(status_code=400, detail="Missing session_token")
+
+ # Step 1: Convert ST to AT to get user info (including email)
+ try:
+ result = await token_manager.flow_client.st_to_at(session_token)
+ at = result["access_token"]
+ expires = result.get("expires")
+ user_info = result.get("user", {})
+ email = user_info.get("email", "")
+
+ if not email:
+ raise HTTPException(status_code=400, detail="Failed to get email from session token")
+
+ # Parse expiration time
+ from datetime import datetime
+ at_expires = None
+ if expires:
+ try:
+ at_expires = datetime.fromisoformat(expires.replace('Z', '+00:00'))
+ except:
+ pass
+
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=f"Invalid session token: {str(e)}")
+
+ # Step 2: Check if token with this email exists
+ existing_token = await db.get_token_by_email(email)
+
+ if existing_token:
+ # Update existing token
+ try:
+ # Update token
+ await token_manager.update_token(
+ token_id=existing_token.id,
+ st=session_token,
+ at=at,
+ at_expires=at_expires
+ )
+
+ # Check if auto-enable is enabled and token is disabled
+ if plugin_config.auto_enable_on_update and not existing_token.is_active:
+ await token_manager.enable_token(existing_token.id)
+ return {
+ "success": True,
+ "message": f"Token updated and auto-enabled for {email}",
+ "action": "updated",
+ "auto_enabled": True
+ }
+
+ return {
+ "success": True,
+ "message": f"Token updated for {email}",
+ "action": "updated"
+ }
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Failed to update token: {str(e)}")
+ else:
+ # Add new token
+ try:
+ new_token = await token_manager.add_token(
+ st=session_token,
+ remark="Added by Chrome Extension"
+ )
+
+ return {
+ "success": True,
+ "message": f"Token added for {new_token.email}",
+ "action": "added",
+ "token_id": new_token.id
+ }
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Failed to add token: {str(e)}")
diff --git a/src/main.py b/src/main.py
index 4554ae1..b46ceb9 100644
--- a/src/main.py
+++ b/src/main.py
@@ -1,126 +1,126 @@
-"""FastAPI application initialization"""
-from fastapi import FastAPI
-from fastapi.responses import HTMLResponse, FileResponse
-from fastapi.staticfiles import StaticFiles
-from fastapi.middleware.cors import CORSMiddleware
-from contextlib import asynccontextmanager
-from pathlib import Path
-
-from .core.config import config
-from .core.database import Database
-from .services.flow_client import FlowClient
-from .services.proxy_manager import ProxyManager
-from .services.token_manager import TokenManager
-from .services.load_balancer import LoadBalancer
-from .services.concurrency_manager import ConcurrencyManager
-from .services.generation_handler import GenerationHandler
-from .api import routes, admin
-
-
-@asynccontextmanager
-async def lifespan(app: FastAPI):
- """Application lifespan manager"""
- # Startup
- print("=" * 60)
- print("Flow2API Starting...")
- print("=" * 60)
-
- # Get config from setting.toml
- config_dict = config.get_raw_config()
-
- # Check if database exists (determine if first startup)
- is_first_startup = not db.db_exists()
-
- # Initialize database tables structure
- await db.init_db()
-
- # Handle database initialization based on startup type
- if is_first_startup:
- print("🎉 First startup detected. Initializing database and configuration from setting.toml...")
- await db.init_config_from_toml(config_dict, is_first_startup=True)
- print("✓ Database and configuration initialized successfully.")
- else:
- print("🔄 Existing database detected. Checking for missing tables and columns...")
- await db.check_and_migrate_db(config_dict)
- print("✓ Database migration check completed.")
-
+"""FastAPI application initialization"""
+from fastapi import FastAPI
+from fastapi.responses import HTMLResponse, FileResponse
+from fastapi.staticfiles import StaticFiles
+from fastapi.middleware.cors import CORSMiddleware
+from contextlib import asynccontextmanager
+from pathlib import Path
+
+from .core.config import config
+from .core.database import Database
+from .services.flow_client import FlowClient
+from .services.proxy_manager import ProxyManager
+from .services.token_manager import TokenManager
+from .services.load_balancer import LoadBalancer
+from .services.concurrency_manager import ConcurrencyManager
+from .services.generation_handler import GenerationHandler
+from .api import routes, admin
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ """Application lifespan manager"""
+ # Startup
+ print("=" * 60)
+ print("Flow2API Starting...")
+ print("=" * 60)
+
+ # Get config from setting.toml
+ config_dict = config.get_raw_config()
+
+ # Check if database exists (determine if first startup)
+ is_first_startup = not db.db_exists()
+
+ # Initialize database tables structure
+ await db.init_db()
+
+ # Handle database initialization based on startup type
+ if is_first_startup:
+ print("🎉 First startup detected. Initializing database and configuration from setting.toml...")
+ await db.init_config_from_toml(config_dict, is_first_startup=True)
+ print("✓ Database and configuration initialized successfully.")
+ else:
+ print("🔄 Existing database detected. Checking for missing tables and columns...")
+ await db.check_and_migrate_db(config_dict)
+ print("✓ Database migration check completed.")
+
# 启动时统一把数据库配置同步到内存,避免 personal/brower 相关运行时配置遗漏。
await db.reload_config_to_memory()
generation_handler.file_cache.set_timeout(config.cache_timeout)
cache_cleanup_enabled = await generation_handler.file_cache.refresh_cleanup_task()
captcha_config = await db.get_captcha_config()
-
- # 尽量在浏览器服务启动前就拿到 token 快照,后续并发管理和预热共用。
- tokens = await token_manager.get_all_tokens()
-
- # Initialize browser captcha service if needed
- browser_service = None
- if captcha_config.captcha_method == "personal":
- from .services.browser_captcha_personal import BrowserCaptchaService
- browser_service = await BrowserCaptchaService.get_instance(db)
- print("✓ Browser captcha service initialized (nodriver mode)")
-
- warmup_limit = max(1, int(config.personal_max_resident_tabs or 1))
- warmup_project_ids = await token_manager.get_personal_warmup_project_ids(
- tokens=tokens,
- limit=warmup_limit,
- )
-
- warmed_slots = []
- warmup_error = None
- try:
- warmed_slots = await browser_service.warmup_resident_tabs(
- warmup_project_ids,
- limit=warmup_limit,
- )
- except Exception as e:
- warmup_error = e
- print(
- "⚠ Browser captcha resident warmup failed: "
- f"{type(e).__name__}: {e}"
- )
- if warmed_slots:
- print(
- f"✓ Browser captcha shared resident tabs warmed "
- f"({len(warmed_slots)} slot(s), limit={warmup_limit})"
- )
- elif warmup_error is not None:
- print("⚠ Browser captcha resident warmup skipped for this startup")
- elif tokens:
- print("⚠ Browser captcha resident warmup skipped: no tab warmed successfully")
- else:
- # 没有任何可用 token 时,打开登录窗口供用户手动操作
- await browser_service.open_login_window()
- print("⚠ No active token found, opened login window for manual setup")
- elif captcha_config.captcha_method == "browser":
- from .services.browser_captcha import BrowserCaptchaService
- browser_service = await BrowserCaptchaService.get_instance(db)
- await browser_service.warmup_browser_slots()
- print("? Browser captcha service initialized (headed mode)")
-
- # Initialize concurrency manager
- await concurrency_manager.initialize(tokens)
-
- if config.captcha_method == "remote_browser":
- try:
- warmed_projects = await flow_client.prefill_remote_browser_for_tokens(tokens, action="IMAGE_GENERATION")
- print(f"✓ Remote browser pool prefill started for {warmed_projects} project(s)")
- except Exception as e:
- print(f"⚠ Remote browser pool prefill failed: {e}")
-
+
+ # 尽量在浏览器服务启动前就拿到 token 快照,后续并发管理和预热共用。
+ tokens = await token_manager.get_all_tokens()
+
+ # Initialize browser captcha service if needed
+ browser_service = None
+ if captcha_config.captcha_method == "personal":
+ from .services.browser_captcha_personal import BrowserCaptchaService
+ browser_service = await BrowserCaptchaService.get_instance(db)
+ print("✓ Browser captcha service initialized (nodriver mode)")
+
+ warmup_limit = max(1, int(config.personal_max_resident_tabs or 1))
+ warmup_project_ids = await token_manager.get_personal_warmup_project_ids(
+ tokens=tokens,
+ limit=warmup_limit,
+ )
+
+ warmed_slots = []
+ warmup_error = None
+ try:
+ warmed_slots = await browser_service.warmup_resident_tabs(
+ warmup_project_ids,
+ limit=warmup_limit,
+ )
+ except Exception as e:
+ warmup_error = e
+ print(
+ "⚠ Browser captcha resident warmup failed: "
+ f"{type(e).__name__}: {e}"
+ )
+ if warmed_slots:
+ print(
+ f"✓ Browser captcha shared resident tabs warmed "
+ f"({len(warmed_slots)} slot(s), limit={warmup_limit})"
+ )
+ elif warmup_error is not None:
+ print("⚠ Browser captcha resident warmup skipped for this startup")
+ elif tokens:
+ print("⚠ Browser captcha resident warmup skipped: no tab warmed successfully")
+ else:
+ # 没有任何可用 token 时,打开登录窗口供用户手动操作
+ await browser_service.open_login_window()
+ print("⚠ No active token found, opened login window for manual setup")
+ elif captcha_config.captcha_method == "browser":
+ from .services.browser_captcha import BrowserCaptchaService
+ browser_service = await BrowserCaptchaService.get_instance(db)
+ await browser_service.warmup_browser_slots()
+ print("? Browser captcha service initialized (headed mode)")
+
+ # Initialize concurrency manager
+ await concurrency_manager.initialize(tokens)
+
+ if config.captcha_method == "remote_browser":
+ try:
+ warmed_projects = await flow_client.prefill_remote_browser_for_tokens(tokens, action="IMAGE_GENERATION")
+ print(f"✓ Remote browser pool prefill started for {warmed_projects} project(s)")
+ except Exception as e:
+ print(f"⚠ Remote browser pool prefill failed: {e}")
+
# Start 429 auto-unban task
import asyncio
- async def auto_unban_task():
- """定时任务:每小时检查并解禁429被禁用的token"""
- while True:
- try:
- await asyncio.sleep(3600) # 每小时执行一次
- await token_manager.auto_unban_429_tokens()
- except Exception as e:
- print(f"❌ Auto-unban task error: {e}")
-
- auto_unban_task_handle = asyncio.create_task(auto_unban_task())
-
+ async def auto_unban_task():
+ """定时任务:每小时检查并解禁429被禁用的token"""
+ while True:
+ try:
+ await asyncio.sleep(3600) # 每小时执行一次
+ await token_manager.auto_unban_429_tokens()
+ except Exception as e:
+ print(f"❌ Auto-unban task error: {e}")
+
+ auto_unban_task_handle = asyncio.create_task(auto_unban_task())
+
print(f"✓ Database initialized")
print(f"✓ Total tokens: {len(tokens)}")
print(f"✓ Cache: {'Enabled' if config.cache_enabled else 'Disabled'} (timeout: {config.cache_timeout}s)")
@@ -129,110 +129,110 @@ async def lifespan(app: FastAPI):
else:
print("✓ File cache cleanup task disabled (timeout <= 0)")
print(f"✓ 429 auto-unban task started (runs every hour)")
- print(f"✓ Server running on http://{config.server_host}:{config.server_port}")
- print("=" * 60)
-
- yield
-
- # Shutdown
- print("Flow2API Shutting down...")
- # Stop file cache cleanup task
- await generation_handler.file_cache.stop_cleanup_task()
- # Stop auto-unban task
- auto_unban_task_handle.cancel()
- try:
- await auto_unban_task_handle
- except asyncio.CancelledError:
- pass
- # Close browser if initialized
- if browser_service:
- await browser_service.close()
- print("✓ Browser captcha service closed")
- print("✓ File cache cleanup task stopped")
- print("✓ 429 auto-unban task stopped")
-
-
-# Initialize components
-db = Database()
-proxy_manager = ProxyManager(db)
-flow_client = FlowClient(proxy_manager, db)
-token_manager = TokenManager(db, flow_client)
-concurrency_manager = ConcurrencyManager()
-load_balancer = LoadBalancer(token_manager, concurrency_manager)
-generation_handler = GenerationHandler(
- flow_client,
- token_manager,
- load_balancer,
- db,
- concurrency_manager,
- proxy_manager # 添加 proxy_manager 参数
-)
-
-# Set dependencies
-routes.set_generation_handler(generation_handler)
-admin.set_dependencies(token_manager, proxy_manager, db, concurrency_manager)
-
-# Create FastAPI app
-app = FastAPI(
- title="Flow2API",
- description="OpenAI-compatible API for Google VideoFX (Veo)",
- version="1.0.0",
- lifespan=lifespan
-)
-
-# CORS middleware
-app.add_middleware(
- CORSMiddleware,
- allow_origins=["*"],
- allow_credentials=True,
- allow_methods=["*"],
- allow_headers=["*"],
-)
-
-# Include routers
-app.include_router(routes.router)
-app.include_router(admin.router)
-
-# Static files - serve tmp directory for cached files
-tmp_dir = Path(__file__).parent.parent / "tmp"
-tmp_dir.mkdir(exist_ok=True)
-app.mount("/tmp", StaticFiles(directory=str(tmp_dir)), name="tmp")
-
-# HTML routes for frontend
-static_path = Path(__file__).parent.parent / "static"
-
-
-@app.get("/", response_class=HTMLResponse)
-async def index():
- """Redirect to login page"""
- login_file = static_path / "login.html"
- if login_file.exists():
- return FileResponse(str(login_file))
- return HTMLResponse(content="
Flow2API
Frontend not found
", status_code=404)
-
-
-@app.get("/login", response_class=HTMLResponse)
-async def login_page():
- """Login page"""
- login_file = static_path / "login.html"
- if login_file.exists():
- return FileResponse(str(login_file))
- return HTMLResponse(content="Login Page Not Found
", status_code=404)
-
-
-@app.get("/manage", response_class=HTMLResponse)
-async def manage_page():
- """Management console page"""
- manage_file = static_path / "manage.html"
- if manage_file.exists():
- return FileResponse(str(manage_file))
- return HTMLResponse(content="Management Page Not Found
", status_code=404)
-
-
-@app.get("/test", response_class=HTMLResponse)
-async def test_page():
- """Model testing page"""
- test_file = static_path / "test.html"
- if test_file.exists():
- return FileResponse(str(test_file))
- return HTMLResponse(content="Test Page Not Found
", status_code=404)
+ print(f"✓ Server running on http://{config.server_host}:{config.server_port}")
+ print("=" * 60)
+
+ yield
+
+ # Shutdown
+ print("Flow2API Shutting down...")
+ # Stop file cache cleanup task
+ await generation_handler.file_cache.stop_cleanup_task()
+ # Stop auto-unban task
+ auto_unban_task_handle.cancel()
+ try:
+ await auto_unban_task_handle
+ except asyncio.CancelledError:
+ pass
+ # Close browser if initialized
+ if browser_service:
+ await browser_service.close()
+ print("✓ Browser captcha service closed")
+ print("✓ File cache cleanup task stopped")
+ print("✓ 429 auto-unban task stopped")
+
+
+# Initialize components
+db = Database()
+proxy_manager = ProxyManager(db)
+flow_client = FlowClient(proxy_manager, db)
+token_manager = TokenManager(db, flow_client)
+concurrency_manager = ConcurrencyManager()
+load_balancer = LoadBalancer(token_manager, concurrency_manager)
+generation_handler = GenerationHandler(
+ flow_client,
+ token_manager,
+ load_balancer,
+ db,
+ concurrency_manager,
+ proxy_manager # 添加 proxy_manager 参数
+)
+
+# Set dependencies
+routes.set_generation_handler(generation_handler)
+admin.set_dependencies(token_manager, proxy_manager, db, concurrency_manager)
+
+# Create FastAPI app
+app = FastAPI(
+ title="Flow2API",
+ description="OpenAI-compatible API for Google VideoFX (Veo)",
+ version="1.0.0",
+ lifespan=lifespan
+)
+
+# CORS middleware
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+# Include routers
+app.include_router(routes.router)
+app.include_router(admin.router)
+
+# Static files - serve tmp directory for cached files
+tmp_dir = Path(__file__).parent.parent / "tmp"
+tmp_dir.mkdir(exist_ok=True)
+app.mount("/tmp", StaticFiles(directory=str(tmp_dir)), name="tmp")
+
+# HTML routes for frontend
+static_path = Path(__file__).parent.parent / "static"
+
+
+@app.get("/", response_class=HTMLResponse)
+async def index():
+ """Redirect to login page"""
+ login_file = static_path / "login.html"
+ if login_file.exists():
+ return FileResponse(str(login_file))
+ return HTMLResponse(content="Flow2API
Frontend not found
", status_code=404)
+
+
+@app.get("/login", response_class=HTMLResponse)
+async def login_page():
+ """Login page"""
+ login_file = static_path / "login.html"
+ if login_file.exists():
+ return FileResponse(str(login_file))
+ return HTMLResponse(content="Login Page Not Found
", status_code=404)
+
+
+@app.get("/manage", response_class=HTMLResponse)
+async def manage_page():
+ """Management console page"""
+ manage_file = static_path / "manage.html"
+ if manage_file.exists():
+ return FileResponse(str(manage_file))
+ return HTMLResponse(content="Management Page Not Found
", status_code=404)
+
+
+@app.get("/test", response_class=HTMLResponse)
+async def test_page():
+ """Model testing page"""
+ test_file = static_path / "test.html"
+ if test_file.exists():
+ return FileResponse(str(test_file))
+ return HTMLResponse(content="Test Page Not Found
", status_code=404)
diff --git a/src/services/browser_captcha_personal.py b/src/services/browser_captcha_personal.py
index 677d953..ef5b541 100644
--- a/src/services/browser_captcha_personal.py
+++ b/src/services/browser_captcha_personal.py
@@ -1,326 +1,326 @@
-"""
-浏览器自动化获取 reCAPTCHA token
-使用 nodriver (undetected-chromedriver 继任者) 实现反检测浏览器
-支持常驻模式:维护全局共享的常驻标签页池,即时生成 token
-"""
-import asyncio
-import inspect
-import time
-import os
-import sys
-import re
-import json
-import shutil
-import tempfile
-import subprocess
-from typing import Optional, Dict, Any, Iterable
-
-from ..core.logger import debug_logger
-from ..core.config import config
-
-
-# ==================== Docker 环境检测 ====================
-def _is_running_in_docker() -> bool:
- """检测是否在 Docker 容器中运行"""
- # 方法1: 检查 /.dockerenv 文件
- if os.path.exists('/.dockerenv'):
- return True
- # 方法2: 检查 cgroup
- try:
- with open('/proc/1/cgroup', 'r') as f:
- content = f.read()
- if 'docker' in content or 'kubepods' in content or 'containerd' in content:
- return True
- except:
- pass
- # 方法3: 检查环境变量
- if os.environ.get('DOCKER_CONTAINER') or os.environ.get('KUBERNETES_SERVICE_HOST'):
- return True
- return False
-
-
-IS_DOCKER = _is_running_in_docker()
-
-
-def _is_truthy_env(name: str) -> bool:
- """判断环境变量是否为 true。"""
- value = os.environ.get(name, "")
- return value.strip().lower() in {"1", "true", "yes", "on"}
-
-
-ALLOW_DOCKER_HEADED = (
- _is_truthy_env("ALLOW_DOCKER_HEADED_CAPTCHA")
- or _is_truthy_env("ALLOW_DOCKER_BROWSER_CAPTCHA")
-)
-DOCKER_HEADED_BLOCKED = IS_DOCKER and not ALLOW_DOCKER_HEADED
-
-
-# ==================== nodriver 自动安装 ====================
-def _run_pip_install(package: str, use_mirror: bool = False) -> bool:
- """运行 pip install 命令
-
- Args:
- package: 包名
- use_mirror: 是否使用国内镜像
-
- Returns:
- 是否安装成功
- """
- cmd = [sys.executable, '-m', 'pip', 'install', package]
- if use_mirror:
- cmd.extend(['-i', 'https://pypi.tuna.tsinghua.edu.cn/simple'])
-
- try:
- debug_logger.log_info(f"[BrowserCaptcha] 正在安装 {package}...")
- print(f"[BrowserCaptcha] 正在安装 {package}...")
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
- if result.returncode == 0:
- debug_logger.log_info(f"[BrowserCaptcha] ✅ {package} 安装成功")
- print(f"[BrowserCaptcha] ✅ {package} 安装成功")
- return True
- else:
- debug_logger.log_warning(f"[BrowserCaptcha] {package} 安装失败: {result.stderr[:200]}")
- return False
- except Exception as e:
- debug_logger.log_warning(f"[BrowserCaptcha] {package} 安装异常: {e}")
- return False
-
-
-def _ensure_nodriver_installed() -> bool:
- """确保 nodriver 已安装
-
- Returns:
- 是否安装成功/已安装
- """
- try:
- import nodriver
- debug_logger.log_info("[BrowserCaptcha] nodriver 已安装")
- return True
- except ImportError:
- pass
-
- debug_logger.log_info("[BrowserCaptcha] nodriver 未安装,开始自动安装...")
- print("[BrowserCaptcha] nodriver 未安装,开始自动安装...")
-
- # 先尝试官方源
- if _run_pip_install('nodriver', use_mirror=False):
- return True
-
- # 官方源失败,尝试国内镜像
- debug_logger.log_info("[BrowserCaptcha] 官方源安装失败,尝试国内镜像...")
- print("[BrowserCaptcha] 官方源安装失败,尝试国内镜像...")
- if _run_pip_install('nodriver', use_mirror=True):
- return True
-
- debug_logger.log_error("[BrowserCaptcha] ❌ nodriver 自动安装失败,请手动安装: pip install nodriver")
- print("[BrowserCaptcha] ❌ nodriver 自动安装失败,请手动安装: pip install nodriver")
- return False
-
-
-# 尝试导入 nodriver
-uc = None
-NODRIVER_AVAILABLE = False
-
-if DOCKER_HEADED_BLOCKED:
- debug_logger.log_warning(
- "[BrowserCaptcha] 检测到 Docker 环境,默认禁用内置浏览器打码。"
- "如需启用请设置 ALLOW_DOCKER_HEADED_CAPTCHA=true,并提供 DISPLAY/Xvfb。"
- )
- print("[BrowserCaptcha] ⚠️ 检测到 Docker 环境,默认禁用内置浏览器打码")
- print("[BrowserCaptcha] 如需启用请设置 ALLOW_DOCKER_HEADED_CAPTCHA=true,并提供 DISPLAY/Xvfb")
-else:
- if IS_DOCKER and ALLOW_DOCKER_HEADED:
- debug_logger.log_warning(
- "[BrowserCaptcha] Docker 内置浏览器打码白名单已启用,请确保 DISPLAY/Xvfb 可用"
- )
- print("[BrowserCaptcha] ✅ Docker 内置浏览器打码白名单已启用")
- if _ensure_nodriver_installed():
- try:
- import nodriver as uc
- NODRIVER_AVAILABLE = True
- except ImportError as e:
- debug_logger.log_error(f"[BrowserCaptcha] nodriver 导入失败: {e}")
- print(f"[BrowserCaptcha] ❌ nodriver 导入失败: {e}")
-
-
-def _parse_proxy_url(proxy_url: str):
- """Parse a proxy URL into (protocol, host, port, username, password)."""
- if not proxy_url:
- return None, None, None, None, None
- url = proxy_url.strip()
- if not re.match(r'^(http|https|socks5h?|socks5)://', url):
- url = f"http://{url}"
- m = re.match(r'^(socks5h?|socks5|http|https)://(?:([^:]+):([^@]+)@)?([^:]+):(\d+)$', url)
- if not m:
- return None, None, None, None, None
- protocol, username, password, host, port = m.groups()
- if protocol == "socks5h":
- protocol = "socks5"
- return protocol, host, port, username, password
-
-
-def _create_proxy_auth_extension(protocol: str, host: str, port: str, username: str, password: str) -> str:
- """Create a temporary Chrome extension directory for proxy authentication.
- Returns the path to the extension directory."""
- ext_dir = tempfile.mkdtemp(prefix="nodriver_proxy_auth_")
-
- scheme_map = {"http": "http", "https": "https", "socks5": "socks5"}
- scheme = scheme_map.get(protocol, "http")
-
- manifest = {
- "version": "1.0.0",
- "manifest_version": 2,
- "name": "Proxy Auth Helper",
- "permissions": [
- "proxy", "tabs", "unlimitedStorage", "storage",
- "", "webRequest", "webRequestBlocking"
- ],
- "background": {"scripts": ["background.js"]},
- "minimum_chrome_version": "76.0.0"
- }
- background_js = (
- "var config = {\n"
- ' mode: "fixed_servers",\n'
- " rules: {\n"
- " singleProxy: {\n"
- f' scheme: "{scheme}",\n'
- f' host: "{host}",\n'
- f" port: parseInt({port})\n"
- " },\n"
- ' bypassList: ["localhost"]\n'
- " }\n"
- "};\n"
- 'chrome.proxy.settings.set({value: config, scope: "regular"}, function(){});\n'
- "chrome.webRequest.onAuthRequired.addListener(\n"
- " function(details) {\n"
- " return {\n"
- " authCredentials: {\n"
- f' username: "{username}",\n'
- f' password: "{password}"\n'
- " }\n"
- " };\n"
- " },\n"
- ' {urls: [""]},\n'
- " ['blocking']\n"
- ");\n"
- )
- with open(os.path.join(ext_dir, "manifest.json"), "w", encoding="utf-8") as f:
- json.dump(manifest, f, indent=2)
- with open(os.path.join(ext_dir, "background.js"), "w", encoding="utf-8") as f:
- f.write(background_js)
- return ext_dir
-
-
-class ResidentTabInfo:
- """常驻标签页信息结构"""
- def __init__(self, tab, slot_id: str, project_id: Optional[str] = None):
- self.tab = tab
- self.slot_id = slot_id
- self.project_id = project_id or slot_id
- self.recaptcha_ready = False
- self.created_at = time.time()
- self.last_used_at = time.time() # 最后使用时间
- self.use_count = 0 # 使用次数
- self.solve_lock = asyncio.Lock() # 串行化同一标签页上的执行,降低并发冲突
-
-
-class BrowserCaptchaService:
- """浏览器自动化获取 reCAPTCHA token(nodriver 有头模式)
-
- 支持两种模式:
- 1. 常驻模式 (Resident Mode): 维护全局共享常驻标签页池,谁抢到空闲页谁执行
- 2. 传统模式 (Legacy Mode): 每次请求创建新标签页 (fallback)
- """
-
- _instance: Optional['BrowserCaptchaService'] = None
- _lock = asyncio.Lock()
-
- def __init__(self, db=None):
- """初始化服务"""
- self.headless = False # nodriver 有头模式
- self.browser = None
- self._initialized = False
- self.website_key = "6LdsFiUsAAAAAIjVDZcuLhaHiDn5nnHVXVRQGeMV"
- self.db = db
- # 使用 None 让 nodriver 自动创建临时目录,避免目录锁定问题
- self.user_data_dir = None
-
- # 常驻模式相关属性:打码标签页是全局共享池,不再按 project_id 一对一绑定
- self._resident_tabs: dict[str, 'ResidentTabInfo'] = {} # slot_id -> 常驻标签页信息
- self._project_resident_affinity: dict[str, str] = {} # project_id -> slot_id(最近一次使用)
- self._resident_slot_seq = 0
- self._resident_pick_index = 0
- self._resident_lock = asyncio.Lock() # 保护常驻标签页操作
- self._browser_lock = asyncio.Lock() # 保护浏览器初始化/关闭/重启,避免重复拉起实例
- self._tab_build_lock = asyncio.Lock() # 串行化冷启动/重建,降低 nodriver 抖动
- self._legacy_lock = asyncio.Lock() # 避免 legacy fallback 并发失控创建临时标签页
- self._max_resident_tabs = 5 # 最大常驻标签页数量(支持并发)
- self._idle_tab_ttl_seconds = 600 # 标签页空闲超时(秒)
- self._idle_reaper_task: Optional[asyncio.Task] = None # 空闲回收任务
- self._command_timeout_seconds = 8.0
- self._navigation_timeout_seconds = 20.0
- self._solve_timeout_seconds = 45.0
- self._session_refresh_timeout_seconds = 45.0
-
- # 兼容旧 API(保留 single resident 属性作为别名)
- self.resident_project_id: Optional[str] = None # 向后兼容
- self.resident_tab = None # 向后兼容
- self._running = False # 向后兼容
- self._recaptcha_ready = False # 向后兼容
- self._last_fingerprint: Optional[Dict[str, Any]] = None
- self._resident_error_streaks: dict[str, int] = {}
- self._proxy_url: Optional[str] = None
- self._proxy_ext_dir: Optional[str] = None
- # 自定义站点打码常驻页(用于 score-test)
- self._custom_tabs: dict[str, Dict[str, Any]] = {}
- self._custom_lock = asyncio.Lock()
-
- @classmethod
- async def get_instance(cls, db=None) -> 'BrowserCaptchaService':
- """获取单例实例"""
- if cls._instance is None:
- async with cls._lock:
- if cls._instance is None:
- cls._instance = cls(db)
- # 启动空闲标签页回收任务
- cls._instance._idle_reaper_task = asyncio.create_task(
- cls._instance._idle_tab_reaper_loop()
- )
- return cls._instance
-
- async def reload_config(self):
- """热更新配置(从数据库重新加载)"""
- from ..core.config import config
- old_max_tabs = self._max_resident_tabs
- old_idle_ttl = self._idle_tab_ttl_seconds
-
- self._max_resident_tabs = config.personal_max_resident_tabs
- self._idle_tab_ttl_seconds = config.personal_idle_tab_ttl_seconds
-
- debug_logger.log_info(
- f"[BrowserCaptcha] Personal 配置已热更新: "
- f"max_tabs {old_max_tabs}->{self._max_resident_tabs}, "
- f"idle_ttl {old_idle_ttl}s->{self._idle_tab_ttl_seconds}s"
- )
-
- def _check_available(self):
- """检查服务是否可用"""
- if DOCKER_HEADED_BLOCKED:
- raise RuntimeError(
- "检测到 Docker 环境,默认禁用内置浏览器打码。"
- "如需启用请设置环境变量 ALLOW_DOCKER_HEADED_CAPTCHA=true,并提供 DISPLAY/Xvfb。"
- )
- if IS_DOCKER and not os.environ.get("DISPLAY"):
- raise RuntimeError(
- "Docker 内置浏览器打码已启用,但 DISPLAY 未设置。"
- "请设置 DISPLAY(例如 :99)并启动 Xvfb。"
- )
- if not NODRIVER_AVAILABLE or uc is None:
- raise RuntimeError(
- "nodriver 未安装或不可用。"
- "请手动安装: pip install nodriver"
- )
-
+"""
+浏览器自动化获取 reCAPTCHA token
+使用 nodriver (undetected-chromedriver 继任者) 实现反检测浏览器
+支持常驻模式:维护全局共享的常驻标签页池,即时生成 token
+"""
+import asyncio
+import inspect
+import time
+import os
+import sys
+import re
+import json
+import shutil
+import tempfile
+import subprocess
+from typing import Optional, Dict, Any, Iterable
+
+from ..core.logger import debug_logger
+from ..core.config import config
+
+
+# ==================== Docker 环境检测 ====================
+def _is_running_in_docker() -> bool:
+ """检测是否在 Docker 容器中运行"""
+ # 方法1: 检查 /.dockerenv 文件
+ if os.path.exists('/.dockerenv'):
+ return True
+ # 方法2: 检查 cgroup
+ try:
+ with open('/proc/1/cgroup', 'r') as f:
+ content = f.read()
+ if 'docker' in content or 'kubepods' in content or 'containerd' in content:
+ return True
+ except:
+ pass
+ # 方法3: 检查环境变量
+ if os.environ.get('DOCKER_CONTAINER') or os.environ.get('KUBERNETES_SERVICE_HOST'):
+ return True
+ return False
+
+
+IS_DOCKER = _is_running_in_docker()
+
+
+def _is_truthy_env(name: str) -> bool:
+ """判断环境变量是否为 true。"""
+ value = os.environ.get(name, "")
+ return value.strip().lower() in {"1", "true", "yes", "on"}
+
+
+ALLOW_DOCKER_HEADED = (
+ _is_truthy_env("ALLOW_DOCKER_HEADED_CAPTCHA")
+ or _is_truthy_env("ALLOW_DOCKER_BROWSER_CAPTCHA")
+)
+DOCKER_HEADED_BLOCKED = IS_DOCKER and not ALLOW_DOCKER_HEADED
+
+
+# ==================== nodriver 自动安装 ====================
+def _run_pip_install(package: str, use_mirror: bool = False) -> bool:
+ """运行 pip install 命令
+
+ Args:
+ package: 包名
+ use_mirror: 是否使用国内镜像
+
+ Returns:
+ 是否安装成功
+ """
+ cmd = [sys.executable, '-m', 'pip', 'install', package]
+ if use_mirror:
+ cmd.extend(['-i', 'https://pypi.tuna.tsinghua.edu.cn/simple'])
+
+ try:
+ debug_logger.log_info(f"[BrowserCaptcha] 正在安装 {package}...")
+ print(f"[BrowserCaptcha] 正在安装 {package}...")
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
+ if result.returncode == 0:
+ debug_logger.log_info(f"[BrowserCaptcha] ✅ {package} 安装成功")
+ print(f"[BrowserCaptcha] ✅ {package} 安装成功")
+ return True
+ else:
+ debug_logger.log_warning(f"[BrowserCaptcha] {package} 安装失败: {result.stderr[:200]}")
+ return False
+ except Exception as e:
+ debug_logger.log_warning(f"[BrowserCaptcha] {package} 安装异常: {e}")
+ return False
+
+
+def _ensure_nodriver_installed() -> bool:
+ """确保 nodriver 已安装
+
+ Returns:
+ 是否安装成功/已安装
+ """
+ try:
+ import nodriver
+ debug_logger.log_info("[BrowserCaptcha] nodriver 已安装")
+ return True
+ except ImportError:
+ pass
+
+ debug_logger.log_info("[BrowserCaptcha] nodriver 未安装,开始自动安装...")
+ print("[BrowserCaptcha] nodriver 未安装,开始自动安装...")
+
+ # 先尝试官方源
+ if _run_pip_install('nodriver', use_mirror=False):
+ return True
+
+ # 官方源失败,尝试国内镜像
+ debug_logger.log_info("[BrowserCaptcha] 官方源安装失败,尝试国内镜像...")
+ print("[BrowserCaptcha] 官方源安装失败,尝试国内镜像...")
+ if _run_pip_install('nodriver', use_mirror=True):
+ return True
+
+ debug_logger.log_error("[BrowserCaptcha] ❌ nodriver 自动安装失败,请手动安装: pip install nodriver")
+ print("[BrowserCaptcha] ❌ nodriver 自动安装失败,请手动安装: pip install nodriver")
+ return False
+
+
+# 尝试导入 nodriver
+uc = None
+NODRIVER_AVAILABLE = False
+
+if DOCKER_HEADED_BLOCKED:
+ debug_logger.log_warning(
+ "[BrowserCaptcha] 检测到 Docker 环境,默认禁用内置浏览器打码。"
+ "如需启用请设置 ALLOW_DOCKER_HEADED_CAPTCHA=true,并提供 DISPLAY/Xvfb。"
+ )
+ print("[BrowserCaptcha] ⚠️ 检测到 Docker 环境,默认禁用内置浏览器打码")
+ print("[BrowserCaptcha] 如需启用请设置 ALLOW_DOCKER_HEADED_CAPTCHA=true,并提供 DISPLAY/Xvfb")
+else:
+ if IS_DOCKER and ALLOW_DOCKER_HEADED:
+ debug_logger.log_warning(
+ "[BrowserCaptcha] Docker 内置浏览器打码白名单已启用,请确保 DISPLAY/Xvfb 可用"
+ )
+ print("[BrowserCaptcha] ✅ Docker 内置浏览器打码白名单已启用")
+ if _ensure_nodriver_installed():
+ try:
+ import nodriver as uc
+ NODRIVER_AVAILABLE = True
+ except ImportError as e:
+ debug_logger.log_error(f"[BrowserCaptcha] nodriver 导入失败: {e}")
+ print(f"[BrowserCaptcha] ❌ nodriver 导入失败: {e}")
+
+
+def _parse_proxy_url(proxy_url: str):
+ """Parse a proxy URL into (protocol, host, port, username, password)."""
+ if not proxy_url:
+ return None, None, None, None, None
+ url = proxy_url.strip()
+ if not re.match(r'^(http|https|socks5h?|socks5)://', url):
+ url = f"http://{url}"
+ m = re.match(r'^(socks5h?|socks5|http|https)://(?:([^:]+):([^@]+)@)?([^:]+):(\d+)$', url)
+ if not m:
+ return None, None, None, None, None
+ protocol, username, password, host, port = m.groups()
+ if protocol == "socks5h":
+ protocol = "socks5"
+ return protocol, host, port, username, password
+
+
+def _create_proxy_auth_extension(protocol: str, host: str, port: str, username: str, password: str) -> str:
+ """Create a temporary Chrome extension directory for proxy authentication.
+ Returns the path to the extension directory."""
+ ext_dir = tempfile.mkdtemp(prefix="nodriver_proxy_auth_")
+
+ scheme_map = {"http": "http", "https": "https", "socks5": "socks5"}
+ scheme = scheme_map.get(protocol, "http")
+
+ manifest = {
+ "version": "1.0.0",
+ "manifest_version": 2,
+ "name": "Proxy Auth Helper",
+ "permissions": [
+ "proxy", "tabs", "unlimitedStorage", "storage",
+ "", "webRequest", "webRequestBlocking"
+ ],
+ "background": {"scripts": ["background.js"]},
+ "minimum_chrome_version": "76.0.0"
+ }
+ background_js = (
+ "var config = {\n"
+ ' mode: "fixed_servers",\n'
+ " rules: {\n"
+ " singleProxy: {\n"
+ f' scheme: "{scheme}",\n'
+ f' host: "{host}",\n'
+ f" port: parseInt({port})\n"
+ " },\n"
+ ' bypassList: ["localhost"]\n'
+ " }\n"
+ "};\n"
+ 'chrome.proxy.settings.set({value: config, scope: "regular"}, function(){});\n'
+ "chrome.webRequest.onAuthRequired.addListener(\n"
+ " function(details) {\n"
+ " return {\n"
+ " authCredentials: {\n"
+ f' username: "{username}",\n'
+ f' password: "{password}"\n'
+ " }\n"
+ " };\n"
+ " },\n"
+ ' {urls: [""]},\n'
+ " ['blocking']\n"
+ ");\n"
+ )
+ with open(os.path.join(ext_dir, "manifest.json"), "w", encoding="utf-8") as f:
+ json.dump(manifest, f, indent=2)
+ with open(os.path.join(ext_dir, "background.js"), "w", encoding="utf-8") as f:
+ f.write(background_js)
+ return ext_dir
+
+
+class ResidentTabInfo:
+ """常驻标签页信息结构"""
+ def __init__(self, tab, slot_id: str, project_id: Optional[str] = None):
+ self.tab = tab
+ self.slot_id = slot_id
+ self.project_id = project_id or slot_id
+ self.recaptcha_ready = False
+ self.created_at = time.time()
+ self.last_used_at = time.time() # 最后使用时间
+ self.use_count = 0 # 使用次数
+ self.solve_lock = asyncio.Lock() # 串行化同一标签页上的执行,降低并发冲突
+
+
+class BrowserCaptchaService:
+ """浏览器自动化获取 reCAPTCHA token(nodriver 有头模式)
+
+ 支持两种模式:
+ 1. 常驻模式 (Resident Mode): 维护全局共享常驻标签页池,谁抢到空闲页谁执行
+ 2. 传统模式 (Legacy Mode): 每次请求创建新标签页 (fallback)
+ """
+
+ _instance: Optional['BrowserCaptchaService'] = None
+ _lock = asyncio.Lock()
+
+ def __init__(self, db=None):
+ """初始化服务"""
+ self.headless = False # nodriver 有头模式
+ self.browser = None
+ self._initialized = False
+ self.website_key = "6LdsFiUsAAAAAIjVDZcuLhaHiDn5nnHVXVRQGeMV"
+ self.db = db
+ # 使用 None 让 nodriver 自动创建临时目录,避免目录锁定问题
+ self.user_data_dir = None
+
+ # 常驻模式相关属性:打码标签页是全局共享池,不再按 project_id 一对一绑定
+ self._resident_tabs: dict[str, 'ResidentTabInfo'] = {} # slot_id -> 常驻标签页信息
+ self._project_resident_affinity: dict[str, str] = {} # project_id -> slot_id(最近一次使用)
+ self._resident_slot_seq = 0
+ self._resident_pick_index = 0
+ self._resident_lock = asyncio.Lock() # 保护常驻标签页操作
+ self._browser_lock = asyncio.Lock() # 保护浏览器初始化/关闭/重启,避免重复拉起实例
+ self._tab_build_lock = asyncio.Lock() # 串行化冷启动/重建,降低 nodriver 抖动
+ self._legacy_lock = asyncio.Lock() # 避免 legacy fallback 并发失控创建临时标签页
+ self._max_resident_tabs = 5 # 最大常驻标签页数量(支持并发)
+ self._idle_tab_ttl_seconds = 600 # 标签页空闲超时(秒)
+ self._idle_reaper_task: Optional[asyncio.Task] = None # 空闲回收任务
+ self._command_timeout_seconds = 8.0
+ self._navigation_timeout_seconds = 20.0
+ self._solve_timeout_seconds = 45.0
+ self._session_refresh_timeout_seconds = 45.0
+
+ # 兼容旧 API(保留 single resident 属性作为别名)
+ self.resident_project_id: Optional[str] = None # 向后兼容
+ self.resident_tab = None # 向后兼容
+ self._running = False # 向后兼容
+ self._recaptcha_ready = False # 向后兼容
+ self._last_fingerprint: Optional[Dict[str, Any]] = None
+ self._resident_error_streaks: dict[str, int] = {}
+ self._proxy_url: Optional[str] = None
+ self._proxy_ext_dir: Optional[str] = None
+ # 自定义站点打码常驻页(用于 score-test)
+ self._custom_tabs: dict[str, Dict[str, Any]] = {}
+ self._custom_lock = asyncio.Lock()
+
+ @classmethod
+ async def get_instance(cls, db=None) -> 'BrowserCaptchaService':
+ """获取单例实例"""
+ if cls._instance is None:
+ async with cls._lock:
+ if cls._instance is None:
+ cls._instance = cls(db)
+ # 启动空闲标签页回收任务
+ cls._instance._idle_reaper_task = asyncio.create_task(
+ cls._instance._idle_tab_reaper_loop()
+ )
+ return cls._instance
+
+ async def reload_config(self):
+ """热更新配置(从数据库重新加载)"""
+ from ..core.config import config
+ old_max_tabs = self._max_resident_tabs
+ old_idle_ttl = self._idle_tab_ttl_seconds
+
+ self._max_resident_tabs = config.personal_max_resident_tabs
+ self._idle_tab_ttl_seconds = config.personal_idle_tab_ttl_seconds
+
+ debug_logger.log_info(
+ f"[BrowserCaptcha] Personal 配置已热更新: "
+ f"max_tabs {old_max_tabs}->{self._max_resident_tabs}, "
+ f"idle_ttl {old_idle_ttl}s->{self._idle_tab_ttl_seconds}s"
+ )
+
+ def _check_available(self):
+ """检查服务是否可用"""
+ if DOCKER_HEADED_BLOCKED:
+ raise RuntimeError(
+ "检测到 Docker 环境,默认禁用内置浏览器打码。"
+ "如需启用请设置环境变量 ALLOW_DOCKER_HEADED_CAPTCHA=true,并提供 DISPLAY/Xvfb。"
+ )
+ if IS_DOCKER and not os.environ.get("DISPLAY"):
+ raise RuntimeError(
+ "Docker 内置浏览器打码已启用,但 DISPLAY 未设置。"
+ "请设置 DISPLAY(例如 :99)并启动 Xvfb。"
+ )
+ if not NODRIVER_AVAILABLE or uc is None:
+ raise RuntimeError(
+ "nodriver 未安装或不可用。"
+ "请手动安装: pip install nodriver"
+ )
+
async def _run_with_timeout(self, awaitable, timeout_seconds: float, label: str):
"""统一收口 nodriver 操作超时,避免单次卡死拖住整条请求链路。"""
effective_timeout = max(0.5, float(timeout_seconds or 0))
@@ -348,444 +348,444 @@ class BrowserCaptchaService:
raise RuntimeError(
f"DISPLAY={display_value} 对应的 Xvfb socket 未就绪: {socket_path}"
)
-
- async def _tab_evaluate(self, tab, script: str, label: str, timeout_seconds: Optional[float] = None):
- return await self._run_with_timeout(
- tab.evaluate(script),
- timeout_seconds or self._command_timeout_seconds,
- label,
- )
-
- async def _tab_get(self, tab, url: str, label: str, timeout_seconds: Optional[float] = None):
- return await self._run_with_timeout(
- tab.get(url),
- timeout_seconds or self._navigation_timeout_seconds,
- label,
- )
-
- async def _browser_get(self, url: str, label: str, new_tab: bool = False, timeout_seconds: Optional[float] = None):
- return await self._run_with_timeout(
- self.browser.get(url, new_tab=new_tab),
- timeout_seconds or self._navigation_timeout_seconds,
- label,
- )
-
- async def _tab_reload(self, tab, label: str, timeout_seconds: Optional[float] = None):
- return await self._run_with_timeout(
- tab.reload(),
- timeout_seconds or self._navigation_timeout_seconds,
- label,
- )
-
- async def _get_browser_cookies(self, label: str, timeout_seconds: Optional[float] = None):
- return await self._run_with_timeout(
- self.browser.cookies.get_all(),
- timeout_seconds or self._command_timeout_seconds,
- label,
- )
-
- async def _browser_send_command(
- self,
- method: str,
- params: Optional[Dict[str, Any]] = None,
- label: Optional[str] = None,
- timeout_seconds: Optional[float] = None,
- ):
- return await self._run_with_timeout(
- self.browser.connection.send(method, params) if params else self.browser.connection.send(method),
- timeout_seconds or self._command_timeout_seconds,
- label or method,
- )
-
- async def _idle_tab_reaper_loop(self):
- """空闲标签页回收循环"""
- while True:
- try:
- await asyncio.sleep(30) # 每30秒检查一次
- current_time = time.time()
- tabs_to_close = []
-
- async with self._resident_lock:
- for slot_id, resident_info in list(self._resident_tabs.items()):
- if resident_info.solve_lock.locked():
- continue
- idle_seconds = current_time - resident_info.last_used_at
- if idle_seconds >= self._idle_tab_ttl_seconds:
- tabs_to_close.append(slot_id)
- debug_logger.log_info(
- f"[BrowserCaptcha] slot={slot_id} 空闲 {idle_seconds:.0f}s,准备回收"
- )
-
- for slot_id in tabs_to_close:
- await self._close_resident_tab(slot_id)
-
- except asyncio.CancelledError:
- return
- except Exception as e:
- debug_logger.log_warning(f"[BrowserCaptcha] 空闲标签页回收异常: {e}")
-
- async def _evict_lru_tab_if_needed(self) -> bool:
- """如果达到共享池上限,使用 LRU 策略淘汰最久未使用的空闲标签页。"""
- async with self._resident_lock:
- if len(self._resident_tabs) < self._max_resident_tabs:
- return True
-
- lru_slot_id = None
- lru_project_hint = None
- lru_last_used = float('inf')
-
- for slot_id, resident_info in self._resident_tabs.items():
- if resident_info.solve_lock.locked():
- continue
- if resident_info.last_used_at < lru_last_used:
- lru_last_used = resident_info.last_used_at
- lru_slot_id = slot_id
- lru_project_hint = resident_info.project_id
-
- if lru_slot_id:
- debug_logger.log_info(
- f"[BrowserCaptcha] 标签页数量达到上限({self._max_resident_tabs}),"
- f"淘汰最久未使用的 slot={lru_slot_id}, project_hint={lru_project_hint}"
- )
- await self._close_resident_tab(lru_slot_id)
- return True
-
- debug_logger.log_warning(
- f"[BrowserCaptcha] 标签页数量达到上限({self._max_resident_tabs}),"
- "但当前没有可安全淘汰的空闲标签页"
- )
- return False
-
- async def _get_reserved_tab_ids(self) -> set[int]:
- """收集当前被 resident/custom 池占用的标签页,legacy 模式不得复用。"""
- reserved_tab_ids: set[int] = set()
-
- async with self._resident_lock:
- for resident_info in self._resident_tabs.values():
- if resident_info and resident_info.tab:
- reserved_tab_ids.add(id(resident_info.tab))
-
- async with self._custom_lock:
- for item in self._custom_tabs.values():
- tab = item.get("tab") if isinstance(item, dict) else None
- if tab:
- reserved_tab_ids.add(id(tab))
-
- return reserved_tab_ids
-
- def _next_resident_slot_id(self) -> str:
- self._resident_slot_seq += 1
- return f"slot-{self._resident_slot_seq}"
-
- def _forget_project_affinity_for_slot_locked(self, slot_id: Optional[str]):
- if not slot_id:
- return
- stale_projects = [
- project_id
- for project_id, mapped_slot_id in self._project_resident_affinity.items()
- if mapped_slot_id == slot_id
- ]
- for project_id in stale_projects:
- self._project_resident_affinity.pop(project_id, None)
-
- def _resolve_affinity_slot_locked(self, project_id: Optional[str]) -> Optional[str]:
- normalized_project_id = str(project_id or "").strip()
- if not normalized_project_id:
- return None
- slot_id = self._project_resident_affinity.get(normalized_project_id)
- if slot_id and slot_id in self._resident_tabs:
- return slot_id
- if slot_id:
- self._project_resident_affinity.pop(normalized_project_id, None)
- return None
-
- def _remember_project_affinity(self, project_id: Optional[str], slot_id: Optional[str], resident_info: Optional[ResidentTabInfo]):
- normalized_project_id = str(project_id or "").strip()
- if not normalized_project_id or not slot_id or resident_info is None:
- return
- self._project_resident_affinity[normalized_project_id] = slot_id
- resident_info.project_id = normalized_project_id
-
- def _resolve_resident_slot_for_project_locked(
- self,
- project_id: Optional[str] = None,
- ) -> tuple[Optional[str], Optional[ResidentTabInfo]]:
- """优先走最近映射;没有映射时退化到共享池全局挑选。"""
- slot_id = self._resolve_affinity_slot_locked(project_id)
- if slot_id:
- resident_info = self._resident_tabs.get(slot_id)
- if resident_info and resident_info.tab:
- return slot_id, resident_info
- return self._select_resident_slot_locked(project_id)
-
- def _select_resident_slot_locked(
- self,
- project_id: Optional[str] = None,
- ) -> tuple[Optional[str], Optional[ResidentTabInfo]]:
- candidates = [
- (slot_id, resident_info)
- for slot_id, resident_info in self._resident_tabs.items()
- if resident_info and resident_info.tab
- ]
- if not candidates:
- return None, None
-
- # 共享打码池不再按 project_id 绑定;这里只根据“是否就绪 / 是否空闲 / 使用历史”
- # 做全局选择,避免 4 token/4 project 时把请求硬绑定到固定 tab。
- ready_idle = [
- (slot_id, resident_info)
- for slot_id, resident_info in candidates
- if resident_info.recaptcha_ready and not resident_info.solve_lock.locked()
- ]
- ready_busy = [
- (slot_id, resident_info)
- for slot_id, resident_info in candidates
- if resident_info.recaptcha_ready and resident_info.solve_lock.locked()
- ]
- cold_idle = [
- (slot_id, resident_info)
- for slot_id, resident_info in candidates
- if not resident_info.recaptcha_ready and not resident_info.solve_lock.locked()
- ]
-
- pool = ready_idle or ready_busy or cold_idle or candidates
- pool.sort(key=lambda item: (item[1].last_used_at, item[1].use_count, item[1].created_at, item[0]))
-
- pick_index = self._resident_pick_index % len(pool)
- self._resident_pick_index = (self._resident_pick_index + 1) % max(len(candidates), 1)
- return pool[pick_index]
-
- async def _ensure_resident_tab(
- self,
- project_id: Optional[str] = None,
- *,
- force_create: bool = False,
- return_slot_key: bool = False,
- ):
- """确保共享打码标签页池中有可用 tab。
-
- 逻辑:
- - 优先复用空闲 tab
- - 如果所有 tab 都忙且未到上限,继续扩容
- - 到达上限后允许请求排队等待已有 tab
- """
- def wrap(slot_id: Optional[str], resident_info: Optional[ResidentTabInfo]):
- if return_slot_key:
- return slot_id, resident_info
- return resident_info
-
- async with self._resident_lock:
- slot_id, resident_info = self._select_resident_slot_locked(project_id)
- if self._resident_tabs:
- all_busy = all(info.solve_lock.locked() for info in self._resident_tabs.values())
- else:
- all_busy = True
-
- should_create = force_create or not resident_info or (all_busy and len(self._resident_tabs) < self._max_resident_tabs)
- if not should_create:
- return wrap(slot_id, resident_info)
-
- if len(self._resident_tabs) >= self._max_resident_tabs:
- return wrap(slot_id, resident_info)
-
- async with self._tab_build_lock:
- async with self._resident_lock:
- slot_id, resident_info = self._select_resident_slot_locked(project_id)
- if self._resident_tabs:
- all_busy = all(info.solve_lock.locked() for info in self._resident_tabs.values())
- else:
- all_busy = True
-
- should_create = force_create or not resident_info or (all_busy and len(self._resident_tabs) < self._max_resident_tabs)
- if not should_create:
- return wrap(slot_id, resident_info)
-
- if len(self._resident_tabs) >= self._max_resident_tabs:
- return wrap(slot_id, resident_info)
-
- new_slot_id = self._next_resident_slot_id()
-
- resident_info = await self._create_resident_tab(new_slot_id, project_id=project_id)
- if resident_info is None:
- async with self._resident_lock:
- slot_id, fallback_info = self._select_resident_slot_locked(project_id)
- return wrap(slot_id, fallback_info)
-
- async with self._resident_lock:
- self._resident_tabs[new_slot_id] = resident_info
- self._sync_compat_resident_state()
- return wrap(new_slot_id, resident_info)
-
- async def _rebuild_resident_tab(
- self,
- project_id: Optional[str] = None,
- *,
- slot_id: Optional[str] = None,
- return_slot_key: bool = False,
- ):
- """重建共享池中的一个标签页。优先重建当前项目最近使用的 slot。"""
- def wrap(actual_slot_id: Optional[str], resident_info: Optional[ResidentTabInfo]):
- if return_slot_key:
- return actual_slot_id, resident_info
- return resident_info
-
- async with self._tab_build_lock:
- async with self._resident_lock:
- actual_slot_id = slot_id
- if actual_slot_id is None:
- actual_slot_id, _ = self._resolve_resident_slot_for_project_locked(project_id)
-
- old_resident = self._resident_tabs.pop(actual_slot_id, None) if actual_slot_id else None
- self._forget_project_affinity_for_slot_locked(actual_slot_id)
- if actual_slot_id:
- self._resident_error_streaks.pop(actual_slot_id, None)
- self._sync_compat_resident_state()
-
- if old_resident:
- try:
- async with old_resident.solve_lock:
- await self._close_tab_quietly(old_resident.tab)
- except Exception:
- await self._close_tab_quietly(old_resident.tab)
-
- actual_slot_id = actual_slot_id or self._next_resident_slot_id()
- resident_info = await self._create_resident_tab(actual_slot_id, project_id=project_id)
- if resident_info is None:
- debug_logger.log_warning(
- f"[BrowserCaptcha] slot={actual_slot_id}, project_id={project_id} 重建共享标签页失败"
- )
- return wrap(actual_slot_id, None)
-
- async with self._resident_lock:
- self._resident_tabs[actual_slot_id] = resident_info
- self._remember_project_affinity(project_id, actual_slot_id, resident_info)
- self._sync_compat_resident_state()
- return wrap(actual_slot_id, resident_info)
-
- def _sync_compat_resident_state(self):
- """同步旧版单 resident 兼容属性。"""
- first_resident = next(iter(self._resident_tabs.values()), None)
- if first_resident:
- self.resident_project_id = first_resident.project_id
- self.resident_tab = first_resident.tab
- self._running = True
- self._recaptcha_ready = bool(first_resident.recaptcha_ready)
- else:
- self.resident_project_id = None
- self.resident_tab = None
- self._running = False
- self._recaptcha_ready = False
-
- async def _close_tab_quietly(self, tab):
- if not tab:
- return
- try:
- await self._run_with_timeout(
- tab.close(),
- timeout_seconds=5.0,
- label="tab.close",
- )
- except Exception:
- pass
-
- async def _stop_browser_process(self, browser_instance):
- """兼容 nodriver 同步 stop API,安全停止浏览器进程。"""
- if not browser_instance:
- return
- stop_method = getattr(browser_instance, "stop", None)
- if stop_method is None:
- return
- result = stop_method()
- if inspect.isawaitable(result):
- await self._run_with_timeout(
- result,
- timeout_seconds=10.0,
- label="browser.stop",
- )
-
- async def _shutdown_browser_runtime_locked(self, reason: str):
- """在持有 _browser_lock 的前提下,彻底清理当前浏览器运行态。"""
- browser_instance = self.browser
- self.browser = None
- self._initialized = False
- self._last_fingerprint = None
- self._cleanup_proxy_extension()
- self._proxy_url = None
-
- async with self._resident_lock:
- resident_items = list(self._resident_tabs.values())
- self._resident_tabs.clear()
- self._project_resident_affinity.clear()
- self._resident_error_streaks.clear()
- self._sync_compat_resident_state()
-
- custom_items = list(self._custom_tabs.values())
- self._custom_tabs.clear()
-
- closed_tabs = set()
-
- async def close_once(tab):
- if not tab:
- return
- tab_key = id(tab)
- if tab_key in closed_tabs:
- return
- closed_tabs.add(tab_key)
- await self._close_tab_quietly(tab)
-
- for resident_info in resident_items:
- await close_once(resident_info.tab)
-
- for item in custom_items:
- tab = item.get("tab") if isinstance(item, dict) else None
- await close_once(tab)
-
- if browser_instance:
- try:
- await self._stop_browser_process(browser_instance)
- except Exception as e:
- debug_logger.log_warning(
- f"[BrowserCaptcha] 停止浏览器实例失败 ({reason}): {e}"
- )
-
- async def _resolve_personal_proxy(self):
- """Read proxy config for personal captcha browser.
- Priority: captcha browser_proxy > request proxy."""
- if not self.db:
- return None, None, None, None, None
- try:
- captcha_cfg = await self.db.get_captcha_config()
- if captcha_cfg.browser_proxy_enabled and captcha_cfg.browser_proxy_url:
- url = captcha_cfg.browser_proxy_url.strip()
- if url:
- debug_logger.log_info(f"[BrowserCaptcha] Personal 使用验证码代理: {url}")
- return _parse_proxy_url(url)
- except Exception as e:
- debug_logger.log_warning(f"[BrowserCaptcha] 读取验证码代理配置失败: {e}")
- try:
- proxy_cfg = await self.db.get_proxy_config()
- if proxy_cfg and proxy_cfg.enabled and proxy_cfg.proxy_url:
- url = proxy_cfg.proxy_url.strip()
- if url:
- debug_logger.log_info(f"[BrowserCaptcha] Personal 回退使用请求代理: {url}")
- return _parse_proxy_url(url)
- except Exception as e:
- debug_logger.log_warning(f"[BrowserCaptcha] 读取请求代理配置失败: {e}")
- return None, None, None, None, None
-
- def _cleanup_proxy_extension(self):
- """Remove temporary proxy auth extension directory."""
- if self._proxy_ext_dir and os.path.isdir(self._proxy_ext_dir):
- try:
- shutil.rmtree(self._proxy_ext_dir, ignore_errors=True)
- except Exception:
- pass
- self._proxy_ext_dir = None
-
- async def initialize(self):
- """初始化 nodriver 浏览器"""
- self._check_available()
-
+
+ async def _tab_evaluate(self, tab, script: str, label: str, timeout_seconds: Optional[float] = None):
+ return await self._run_with_timeout(
+ tab.evaluate(script),
+ timeout_seconds or self._command_timeout_seconds,
+ label,
+ )
+
+ async def _tab_get(self, tab, url: str, label: str, timeout_seconds: Optional[float] = None):
+ return await self._run_with_timeout(
+ tab.get(url),
+ timeout_seconds or self._navigation_timeout_seconds,
+ label,
+ )
+
+ async def _browser_get(self, url: str, label: str, new_tab: bool = False, timeout_seconds: Optional[float] = None):
+ return await self._run_with_timeout(
+ self.browser.get(url, new_tab=new_tab),
+ timeout_seconds or self._navigation_timeout_seconds,
+ label,
+ )
+
+ async def _tab_reload(self, tab, label: str, timeout_seconds: Optional[float] = None):
+ return await self._run_with_timeout(
+ tab.reload(),
+ timeout_seconds or self._navigation_timeout_seconds,
+ label,
+ )
+
+ async def _get_browser_cookies(self, label: str, timeout_seconds: Optional[float] = None):
+ return await self._run_with_timeout(
+ self.browser.cookies.get_all(),
+ timeout_seconds or self._command_timeout_seconds,
+ label,
+ )
+
+ async def _browser_send_command(
+ self,
+ method: str,
+ params: Optional[Dict[str, Any]] = None,
+ label: Optional[str] = None,
+ timeout_seconds: Optional[float] = None,
+ ):
+ return await self._run_with_timeout(
+ self.browser.connection.send(method, params) if params else self.browser.connection.send(method),
+ timeout_seconds or self._command_timeout_seconds,
+ label or method,
+ )
+
+ async def _idle_tab_reaper_loop(self):
+ """空闲标签页回收循环"""
+ while True:
+ try:
+ await asyncio.sleep(30) # 每30秒检查一次
+ current_time = time.time()
+ tabs_to_close = []
+
+ async with self._resident_lock:
+ for slot_id, resident_info in list(self._resident_tabs.items()):
+ if resident_info.solve_lock.locked():
+ continue
+ idle_seconds = current_time - resident_info.last_used_at
+ if idle_seconds >= self._idle_tab_ttl_seconds:
+ tabs_to_close.append(slot_id)
+ debug_logger.log_info(
+ f"[BrowserCaptcha] slot={slot_id} 空闲 {idle_seconds:.0f}s,准备回收"
+ )
+
+ for slot_id in tabs_to_close:
+ await self._close_resident_tab(slot_id)
+
+ except asyncio.CancelledError:
+ return
+ except Exception as e:
+ debug_logger.log_warning(f"[BrowserCaptcha] 空闲标签页回收异常: {e}")
+
+ async def _evict_lru_tab_if_needed(self) -> bool:
+ """如果达到共享池上限,使用 LRU 策略淘汰最久未使用的空闲标签页。"""
+ async with self._resident_lock:
+ if len(self._resident_tabs) < self._max_resident_tabs:
+ return True
+
+ lru_slot_id = None
+ lru_project_hint = None
+ lru_last_used = float('inf')
+
+ for slot_id, resident_info in self._resident_tabs.items():
+ if resident_info.solve_lock.locked():
+ continue
+ if resident_info.last_used_at < lru_last_used:
+ lru_last_used = resident_info.last_used_at
+ lru_slot_id = slot_id
+ lru_project_hint = resident_info.project_id
+
+ if lru_slot_id:
+ debug_logger.log_info(
+ f"[BrowserCaptcha] 标签页数量达到上限({self._max_resident_tabs}),"
+ f"淘汰最久未使用的 slot={lru_slot_id}, project_hint={lru_project_hint}"
+ )
+ await self._close_resident_tab(lru_slot_id)
+ return True
+
+ debug_logger.log_warning(
+ f"[BrowserCaptcha] 标签页数量达到上限({self._max_resident_tabs}),"
+ "但当前没有可安全淘汰的空闲标签页"
+ )
+ return False
+
+ async def _get_reserved_tab_ids(self) -> set[int]:
+ """收集当前被 resident/custom 池占用的标签页,legacy 模式不得复用。"""
+ reserved_tab_ids: set[int] = set()
+
+ async with self._resident_lock:
+ for resident_info in self._resident_tabs.values():
+ if resident_info and resident_info.tab:
+ reserved_tab_ids.add(id(resident_info.tab))
+
+ async with self._custom_lock:
+ for item in self._custom_tabs.values():
+ tab = item.get("tab") if isinstance(item, dict) else None
+ if tab:
+ reserved_tab_ids.add(id(tab))
+
+ return reserved_tab_ids
+
+ def _next_resident_slot_id(self) -> str:
+ self._resident_slot_seq += 1
+ return f"slot-{self._resident_slot_seq}"
+
+ def _forget_project_affinity_for_slot_locked(self, slot_id: Optional[str]):
+ if not slot_id:
+ return
+ stale_projects = [
+ project_id
+ for project_id, mapped_slot_id in self._project_resident_affinity.items()
+ if mapped_slot_id == slot_id
+ ]
+ for project_id in stale_projects:
+ self._project_resident_affinity.pop(project_id, None)
+
+ def _resolve_affinity_slot_locked(self, project_id: Optional[str]) -> Optional[str]:
+ normalized_project_id = str(project_id or "").strip()
+ if not normalized_project_id:
+ return None
+ slot_id = self._project_resident_affinity.get(normalized_project_id)
+ if slot_id and slot_id in self._resident_tabs:
+ return slot_id
+ if slot_id:
+ self._project_resident_affinity.pop(normalized_project_id, None)
+ return None
+
+ def _remember_project_affinity(self, project_id: Optional[str], slot_id: Optional[str], resident_info: Optional[ResidentTabInfo]):
+ normalized_project_id = str(project_id or "").strip()
+ if not normalized_project_id or not slot_id or resident_info is None:
+ return
+ self._project_resident_affinity[normalized_project_id] = slot_id
+ resident_info.project_id = normalized_project_id
+
+ def _resolve_resident_slot_for_project_locked(
+ self,
+ project_id: Optional[str] = None,
+ ) -> tuple[Optional[str], Optional[ResidentTabInfo]]:
+ """优先走最近映射;没有映射时退化到共享池全局挑选。"""
+ slot_id = self._resolve_affinity_slot_locked(project_id)
+ if slot_id:
+ resident_info = self._resident_tabs.get(slot_id)
+ if resident_info and resident_info.tab:
+ return slot_id, resident_info
+ return self._select_resident_slot_locked(project_id)
+
+ def _select_resident_slot_locked(
+ self,
+ project_id: Optional[str] = None,
+ ) -> tuple[Optional[str], Optional[ResidentTabInfo]]:
+ candidates = [
+ (slot_id, resident_info)
+ for slot_id, resident_info in self._resident_tabs.items()
+ if resident_info and resident_info.tab
+ ]
+ if not candidates:
+ return None, None
+
+ # 共享打码池不再按 project_id 绑定;这里只根据“是否就绪 / 是否空闲 / 使用历史”
+ # 做全局选择,避免 4 token/4 project 时把请求硬绑定到固定 tab。
+ ready_idle = [
+ (slot_id, resident_info)
+ for slot_id, resident_info in candidates
+ if resident_info.recaptcha_ready and not resident_info.solve_lock.locked()
+ ]
+ ready_busy = [
+ (slot_id, resident_info)
+ for slot_id, resident_info in candidates
+ if resident_info.recaptcha_ready and resident_info.solve_lock.locked()
+ ]
+ cold_idle = [
+ (slot_id, resident_info)
+ for slot_id, resident_info in candidates
+ if not resident_info.recaptcha_ready and not resident_info.solve_lock.locked()
+ ]
+
+ pool = ready_idle or ready_busy or cold_idle or candidates
+ pool.sort(key=lambda item: (item[1].last_used_at, item[1].use_count, item[1].created_at, item[0]))
+
+ pick_index = self._resident_pick_index % len(pool)
+ self._resident_pick_index = (self._resident_pick_index + 1) % max(len(candidates), 1)
+ return pool[pick_index]
+
+ async def _ensure_resident_tab(
+ self,
+ project_id: Optional[str] = None,
+ *,
+ force_create: bool = False,
+ return_slot_key: bool = False,
+ ):
+ """确保共享打码标签页池中有可用 tab。
+
+ 逻辑:
+ - 优先复用空闲 tab
+ - 如果所有 tab 都忙且未到上限,继续扩容
+ - 到达上限后允许请求排队等待已有 tab
+ """
+ def wrap(slot_id: Optional[str], resident_info: Optional[ResidentTabInfo]):
+ if return_slot_key:
+ return slot_id, resident_info
+ return resident_info
+
+ async with self._resident_lock:
+ slot_id, resident_info = self._select_resident_slot_locked(project_id)
+ if self._resident_tabs:
+ all_busy = all(info.solve_lock.locked() for info in self._resident_tabs.values())
+ else:
+ all_busy = True
+
+ should_create = force_create or not resident_info or (all_busy and len(self._resident_tabs) < self._max_resident_tabs)
+ if not should_create:
+ return wrap(slot_id, resident_info)
+
+ if len(self._resident_tabs) >= self._max_resident_tabs:
+ return wrap(slot_id, resident_info)
+
+ async with self._tab_build_lock:
+ async with self._resident_lock:
+ slot_id, resident_info = self._select_resident_slot_locked(project_id)
+ if self._resident_tabs:
+ all_busy = all(info.solve_lock.locked() for info in self._resident_tabs.values())
+ else:
+ all_busy = True
+
+ should_create = force_create or not resident_info or (all_busy and len(self._resident_tabs) < self._max_resident_tabs)
+ if not should_create:
+ return wrap(slot_id, resident_info)
+
+ if len(self._resident_tabs) >= self._max_resident_tabs:
+ return wrap(slot_id, resident_info)
+
+ new_slot_id = self._next_resident_slot_id()
+
+ resident_info = await self._create_resident_tab(new_slot_id, project_id=project_id)
+ if resident_info is None:
+ async with self._resident_lock:
+ slot_id, fallback_info = self._select_resident_slot_locked(project_id)
+ return wrap(slot_id, fallback_info)
+
+ async with self._resident_lock:
+ self._resident_tabs[new_slot_id] = resident_info
+ self._sync_compat_resident_state()
+ return wrap(new_slot_id, resident_info)
+
+ async def _rebuild_resident_tab(
+ self,
+ project_id: Optional[str] = None,
+ *,
+ slot_id: Optional[str] = None,
+ return_slot_key: bool = False,
+ ):
+ """重建共享池中的一个标签页。优先重建当前项目最近使用的 slot。"""
+ def wrap(actual_slot_id: Optional[str], resident_info: Optional[ResidentTabInfo]):
+ if return_slot_key:
+ return actual_slot_id, resident_info
+ return resident_info
+
+ async with self._tab_build_lock:
+ async with self._resident_lock:
+ actual_slot_id = slot_id
+ if actual_slot_id is None:
+ actual_slot_id, _ = self._resolve_resident_slot_for_project_locked(project_id)
+
+ old_resident = self._resident_tabs.pop(actual_slot_id, None) if actual_slot_id else None
+ self._forget_project_affinity_for_slot_locked(actual_slot_id)
+ if actual_slot_id:
+ self._resident_error_streaks.pop(actual_slot_id, None)
+ self._sync_compat_resident_state()
+
+ if old_resident:
+ try:
+ async with old_resident.solve_lock:
+ await self._close_tab_quietly(old_resident.tab)
+ except Exception:
+ await self._close_tab_quietly(old_resident.tab)
+
+ actual_slot_id = actual_slot_id or self._next_resident_slot_id()
+ resident_info = await self._create_resident_tab(actual_slot_id, project_id=project_id)
+ if resident_info is None:
+ debug_logger.log_warning(
+ f"[BrowserCaptcha] slot={actual_slot_id}, project_id={project_id} 重建共享标签页失败"
+ )
+ return wrap(actual_slot_id, None)
+
+ async with self._resident_lock:
+ self._resident_tabs[actual_slot_id] = resident_info
+ self._remember_project_affinity(project_id, actual_slot_id, resident_info)
+ self._sync_compat_resident_state()
+ return wrap(actual_slot_id, resident_info)
+
+ def _sync_compat_resident_state(self):
+ """同步旧版单 resident 兼容属性。"""
+ first_resident = next(iter(self._resident_tabs.values()), None)
+ if first_resident:
+ self.resident_project_id = first_resident.project_id
+ self.resident_tab = first_resident.tab
+ self._running = True
+ self._recaptcha_ready = bool(first_resident.recaptcha_ready)
+ else:
+ self.resident_project_id = None
+ self.resident_tab = None
+ self._running = False
+ self._recaptcha_ready = False
+
+ async def _close_tab_quietly(self, tab):
+ if not tab:
+ return
+ try:
+ await self._run_with_timeout(
+ tab.close(),
+ timeout_seconds=5.0,
+ label="tab.close",
+ )
+ except Exception:
+ pass
+
+ async def _stop_browser_process(self, browser_instance):
+ """兼容 nodriver 同步 stop API,安全停止浏览器进程。"""
+ if not browser_instance:
+ return
+ stop_method = getattr(browser_instance, "stop", None)
+ if stop_method is None:
+ return
+ result = stop_method()
+ if inspect.isawaitable(result):
+ await self._run_with_timeout(
+ result,
+ timeout_seconds=10.0,
+ label="browser.stop",
+ )
+
+ async def _shutdown_browser_runtime_locked(self, reason: str):
+ """在持有 _browser_lock 的前提下,彻底清理当前浏览器运行态。"""
+ browser_instance = self.browser
+ self.browser = None
+ self._initialized = False
+ self._last_fingerprint = None
+ self._cleanup_proxy_extension()
+ self._proxy_url = None
+
+ async with self._resident_lock:
+ resident_items = list(self._resident_tabs.values())
+ self._resident_tabs.clear()
+ self._project_resident_affinity.clear()
+ self._resident_error_streaks.clear()
+ self._sync_compat_resident_state()
+
+ custom_items = list(self._custom_tabs.values())
+ self._custom_tabs.clear()
+
+ closed_tabs = set()
+
+ async def close_once(tab):
+ if not tab:
+ return
+ tab_key = id(tab)
+ if tab_key in closed_tabs:
+ return
+ closed_tabs.add(tab_key)
+ await self._close_tab_quietly(tab)
+
+ for resident_info in resident_items:
+ await close_once(resident_info.tab)
+
+ for item in custom_items:
+ tab = item.get("tab") if isinstance(item, dict) else None
+ await close_once(tab)
+
+ if browser_instance:
+ try:
+ await self._stop_browser_process(browser_instance)
+ except Exception as e:
+ debug_logger.log_warning(
+ f"[BrowserCaptcha] 停止浏览器实例失败 ({reason}): {e}"
+ )
+
+ async def _resolve_personal_proxy(self):
+ """Read proxy config for personal captcha browser.
+ Priority: captcha browser_proxy > request proxy."""
+ if not self.db:
+ return None, None, None, None, None
+ try:
+ captcha_cfg = await self.db.get_captcha_config()
+ if captcha_cfg.browser_proxy_enabled and captcha_cfg.browser_proxy_url:
+ url = captcha_cfg.browser_proxy_url.strip()
+ if url:
+ debug_logger.log_info(f"[BrowserCaptcha] Personal 使用验证码代理: {url}")
+ return _parse_proxy_url(url)
+ except Exception as e:
+ debug_logger.log_warning(f"[BrowserCaptcha] 读取验证码代理配置失败: {e}")
+ try:
+ proxy_cfg = await self.db.get_proxy_config()
+ if proxy_cfg and proxy_cfg.enabled and proxy_cfg.proxy_url:
+ url = proxy_cfg.proxy_url.strip()
+ if url:
+ debug_logger.log_info(f"[BrowserCaptcha] Personal 回退使用请求代理: {url}")
+ return _parse_proxy_url(url)
+ except Exception as e:
+ debug_logger.log_warning(f"[BrowserCaptcha] 读取请求代理配置失败: {e}")
+ return None, None, None, None, None
+
+ def _cleanup_proxy_extension(self):
+ """Remove temporary proxy auth extension directory."""
+ if self._proxy_ext_dir and os.path.isdir(self._proxy_ext_dir):
+ try:
+ shutil.rmtree(self._proxy_ext_dir, ignore_errors=True)
+ except Exception:
+ pass
+ self._proxy_ext_dir = None
+
+ async def initialize(self):
+ """初始化 nodriver 浏览器"""
+ self._check_available()
+
async with self._browser_lock:
browser_needs_restart = False
browser_executable_path = None
@@ -796,20 +796,20 @@ class BrowserCaptchaService:
try:
if self.browser.stopped:
debug_logger.log_warning("[BrowserCaptcha] 浏览器已停止,准备重新初始化...")
- browser_needs_restart = True
- else:
- if self._idle_reaper_task is None or self._idle_reaper_task.done():
- self._idle_reaper_task = asyncio.create_task(self._idle_tab_reaper_loop())
- return
- except Exception as e:
- debug_logger.log_warning(f"[BrowserCaptcha] 浏览器状态检查异常,准备重新初始化: {e}")
- browser_needs_restart = True
- elif self.browser is not None or self._initialized:
- browser_needs_restart = True
-
- if browser_needs_restart:
- await self._shutdown_browser_runtime_locked(reason="initialize_recovery")
-
+ browser_needs_restart = True
+ else:
+ if self._idle_reaper_task is None or self._idle_reaper_task.done():
+ self._idle_reaper_task = asyncio.create_task(self._idle_tab_reaper_loop())
+ return
+ except Exception as e:
+ debug_logger.log_warning(f"[BrowserCaptcha] 浏览器状态检查异常,准备重新初始化: {e}")
+ browser_needs_restart = True
+ elif self.browser is not None or self._initialized:
+ browser_needs_restart = True
+
+ if browser_needs_restart:
+ await self._shutdown_browser_runtime_locked(reason="initialize_recovery")
+
try:
if self.user_data_dir:
debug_logger.log_info(f"[BrowserCaptcha] 正在启动 nodriver 浏览器 (用户数据目录: {self.user_data_dir})...")
@@ -852,17 +852,17 @@ class BrowserCaptchaService:
self._cleanup_proxy_extension()
self._proxy_url = None
protocol, host, port, username, password = await self._resolve_personal_proxy()
- proxy_server_arg = None
- if protocol and host and port:
- if username and password:
- self._proxy_ext_dir = _create_proxy_auth_extension(protocol, host, port, username, password)
- debug_logger.log_info(
- f"[BrowserCaptcha] Personal 代理需要认证,已创建扩展: {self._proxy_ext_dir}"
- )
- proxy_server_arg = f"--proxy-server={protocol}://{host}:{port}"
- self._proxy_url = f"{protocol}://{host}:{port}"
- debug_logger.log_info(f"[BrowserCaptcha] Personal 浏览器代理: {self._proxy_url}")
-
+ proxy_server_arg = None
+ if protocol and host and port:
+ if username and password:
+ self._proxy_ext_dir = _create_proxy_auth_extension(protocol, host, port, username, password)
+ debug_logger.log_info(
+ f"[BrowserCaptcha] Personal 代理需要认证,已创建扩展: {self._proxy_ext_dir}"
+ )
+ proxy_server_arg = f"--proxy-server={protocol}://{host}:{port}"
+ self._proxy_url = f"{protocol}://{host}:{port}"
+ debug_logger.log_info(f"[BrowserCaptcha] Personal 浏览器代理: {self._proxy_url}")
+
browser_args = [
'--disable-quic',
'--disable-features=UseDnsHttpsSvcb',
@@ -952,7 +952,7 @@ class BrowserCaptchaService:
if self._idle_reaper_task is None or self._idle_reaper_task.done():
self._idle_reaper_task = asyncio.create_task(self._idle_tab_reaper_loop())
debug_logger.log_info(f"[BrowserCaptcha] ✅ nodriver 浏览器已启动 (Profile: {self.user_data_dir})")
-
+
except Exception as e:
self.browser = None
self._initialized = False
@@ -964,1552 +964,1552 @@ class BrowserCaptchaService:
f"args={' '.join(effective_launch_args) if effective_launch_args else ''}"
)
raise
-
- async def warmup_resident_tabs(self, project_ids: Iterable[str], limit: Optional[int] = None) -> list[str]:
- """预热共享打码标签页池,减少首个请求的冷启动抖动。"""
- normalized_project_ids: list[str] = []
- seen_projects = set()
- for raw_project_id in project_ids:
- project_id = str(raw_project_id or "").strip()
- if not project_id or project_id in seen_projects:
- continue
- seen_projects.add(project_id)
- normalized_project_ids.append(project_id)
-
- await self.initialize()
-
- try:
- warm_limit = self._max_resident_tabs if limit is None else max(1, min(self._max_resident_tabs, int(limit)))
- except Exception:
- warm_limit = self._max_resident_tabs
-
- warmed_slots: list[str] = []
- for index in range(warm_limit):
- warm_project_id = normalized_project_ids[index] if index < len(normalized_project_ids) else f"warmup-{index + 1}"
- slot_id, resident_info = await self._ensure_resident_tab(
- warm_project_id,
- force_create=True,
- return_slot_key=True,
- )
- if resident_info and resident_info.tab and slot_id:
- if slot_id not in warmed_slots:
- warmed_slots.append(slot_id)
- continue
- debug_logger.log_warning(f"[BrowserCaptcha] 预热共享标签页失败 (seed={warm_project_id})")
-
- return warmed_slots
-
- # ========== 常驻模式 API ==========
-
- async def start_resident_mode(self, project_id: str):
- """启动常驻模式
-
- Args:
- project_id: 用于常驻的项目 ID
- """
- if not str(project_id or "").strip():
- debug_logger.log_warning("[BrowserCaptcha] 启动常驻模式失败:project_id 为空")
- return
-
- warmed_slots = await self.warmup_resident_tabs([project_id], limit=1)
- if warmed_slots:
- debug_logger.log_info(f"[BrowserCaptcha] ✅ 共享常驻打码池已启动 (seed_project: {project_id})")
- return
-
- debug_logger.log_error(f"[BrowserCaptcha] 常驻模式启动失败 (seed_project: {project_id})")
-
- async def stop_resident_mode(self, project_id: Optional[str] = None):
- """停止常驻模式
-
- Args:
- project_id: 指定 project_id 或 slot_id;如果为 None 则关闭所有常驻标签页
- """
- target_slot_id = None
- if project_id:
- async with self._resident_lock:
- target_slot_id = project_id if project_id in self._resident_tabs else self._resolve_affinity_slot_locked(project_id)
-
- if target_slot_id:
- await self._close_resident_tab(target_slot_id)
- self._resident_error_streaks.pop(target_slot_id, None)
- debug_logger.log_info(f"[BrowserCaptcha] 已关闭共享标签页 slot={target_slot_id} (request={project_id})")
- return
-
- async with self._resident_lock:
- slot_ids = list(self._resident_tabs.keys())
- resident_items = list(self._resident_tabs.values())
- self._resident_tabs.clear()
- self._project_resident_affinity.clear()
- self._resident_error_streaks.clear()
- self._sync_compat_resident_state()
-
- for resident_info in resident_items:
- if resident_info and resident_info.tab:
- await self._close_tab_quietly(resident_info.tab)
- debug_logger.log_info(f"[BrowserCaptcha] 已关闭所有共享常驻标签页 (共 {len(slot_ids)} 个)")
-
- async def _wait_for_document_ready(self, tab, retries: int = 30, interval_seconds: float = 1.0) -> bool:
- """等待页面文档加载完成。"""
- for _ in range(retries):
- try:
- ready_state = await self._tab_evaluate(
- tab,
- "document.readyState",
- label="document.readyState",
- timeout_seconds=2.0,
- )
- if ready_state == "complete":
- return True
- except Exception:
- pass
- await asyncio.sleep(interval_seconds)
- return False
-
- def _is_server_side_flow_error(self, error_text: str) -> bool:
- error_lower = (error_text or "").lower()
- return any(keyword in error_lower for keyword in [
- "http error 500",
- "public_error",
- "internal error",
- "reason=internal",
- "reason: internal",
- "\"reason\":\"internal\"",
- "server error",
- "upstream error",
- ])
-
- async def _clear_tab_site_storage(self, tab) -> Dict[str, Any]:
- """清理当前站点的本地存储状态,但保留 cookies 登录态。"""
- result = await self._tab_evaluate(tab, """
- (async () => {
- const summary = {
- local_storage_cleared: false,
- session_storage_cleared: false,
- cache_storage_deleted: [],
- indexed_db_deleted: [],
- indexed_db_errors: [],
- service_worker_unregistered: 0,
- };
-
- try {
- window.localStorage.clear();
- summary.local_storage_cleared = true;
- } catch (e) {
- summary.local_storage_error = String(e);
- }
-
- try {
- window.sessionStorage.clear();
- summary.session_storage_cleared = true;
- } catch (e) {
- summary.session_storage_error = String(e);
- }
-
- try {
- if (typeof caches !== 'undefined') {
- const cacheKeys = await caches.keys();
- for (const key of cacheKeys) {
- const deleted = await caches.delete(key);
- if (deleted) {
- summary.cache_storage_deleted.push(key);
- }
- }
- }
- } catch (e) {
- summary.cache_storage_error = String(e);
- }
-
- try {
- if (navigator.serviceWorker) {
- const registrations = await navigator.serviceWorker.getRegistrations();
- for (const registration of registrations) {
- const ok = await registration.unregister();
- if (ok) {
- summary.service_worker_unregistered += 1;
- }
- }
- }
- } catch (e) {
- summary.service_worker_error = String(e);
- }
-
- try {
- if (typeof indexedDB !== 'undefined' && typeof indexedDB.databases === 'function') {
- const dbs = await indexedDB.databases();
- const names = Array.from(new Set(
- dbs
- .map((item) => item && item.name)
- .filter((name) => typeof name === 'string' && name)
- ));
- for (const name of names) {
- try {
- await new Promise((resolve) => {
- const request = indexedDB.deleteDatabase(name);
- request.onsuccess = () => resolve(true);
- request.onerror = () => resolve(false);
- request.onblocked = () => resolve(false);
- });
- summary.indexed_db_deleted.push(name);
- } catch (e) {
- summary.indexed_db_errors.push(`${name}: ${String(e)}`);
- }
- }
- } else {
- summary.indexed_db_unsupported = true;
- }
- } catch (e) {
- summary.indexed_db_errors.push(String(e));
- }
-
- return summary;
- })()
- """, label="clear_tab_site_storage", timeout_seconds=15.0)
- return result if isinstance(result, dict) else {}
-
- async def _clear_resident_storage_and_reload(self, project_id: str) -> bool:
- """清理常驻标签页的站点数据并刷新,尝试原地自愈。"""
- async with self._resident_lock:
- slot_id, resident_info = self._resolve_resident_slot_for_project_locked(project_id)
-
- if not resident_info or not resident_info.tab:
- debug_logger.log_warning(f"[BrowserCaptcha] project_id={project_id} 没有可清理的共享标签页")
- return False
-
- try:
- async with resident_info.solve_lock:
- cleanup_summary = await self._clear_tab_site_storage(resident_info.tab)
- debug_logger.log_warning(
- f"[BrowserCaptcha] project_id={project_id}, slot={slot_id} 已清理站点存储,准备刷新恢复: {cleanup_summary}"
- )
-
- resident_info.recaptcha_ready = False
- await self._tab_reload(
- resident_info.tab,
- label=f"clear_resident_reload:{slot_id or project_id}",
- )
-
- if not await self._wait_for_document_ready(resident_info.tab, retries=30, interval_seconds=1.0):
- debug_logger.log_warning(f"[BrowserCaptcha] project_id={project_id}, slot={slot_id} 清理后页面加载超时")
- return False
-
- resident_info.recaptcha_ready = await self._wait_for_recaptcha(resident_info.tab)
- if resident_info.recaptcha_ready:
- resident_info.last_used_at = time.time()
- self._remember_project_affinity(project_id, slot_id, resident_info)
- self._resident_error_streaks.pop(slot_id, None)
- debug_logger.log_warning(f"[BrowserCaptcha] project_id={project_id}, slot={slot_id} 清理后已恢复 reCAPTCHA")
- return True
-
- debug_logger.log_warning(f"[BrowserCaptcha] project_id={project_id}, slot={slot_id} 清理后仍无法恢复 reCAPTCHA")
- return False
- except Exception as e:
- debug_logger.log_warning(f"[BrowserCaptcha] project_id={project_id}, slot={slot_id} 清理或刷新失败: {e}")
- return False
-
- async def _recreate_resident_tab(self, project_id: str) -> bool:
- """关闭并重建常驻标签页。"""
- slot_id, resident_info = await self._rebuild_resident_tab(project_id, return_slot_key=True)
- if resident_info is None:
- debug_logger.log_warning(f"[BrowserCaptcha] project_id={project_id} 重建共享标签页失败")
- return False
- debug_logger.log_warning(f"[BrowserCaptcha] project_id={project_id} 已重建共享标签页 slot={slot_id}")
- return True
-
- async def _restart_browser_for_project(self, project_id: str) -> bool:
- """重启整个 nodriver 浏览器,并恢复共享打码池。"""
- async with self._resident_lock:
- restore_slots = max(1, min(self._max_resident_tabs, len(self._resident_tabs) or 1))
- restore_project_ids: list[str] = []
- seen_projects = set()
- for candidate in [project_id, *self._project_resident_affinity.keys()]:
- normalized_project_id = str(candidate or "").strip()
- if not normalized_project_id or normalized_project_id in seen_projects:
- continue
- seen_projects.add(normalized_project_id)
- restore_project_ids.append(normalized_project_id)
- if len(restore_project_ids) >= restore_slots:
- break
-
- debug_logger.log_warning(f"[BrowserCaptcha] project_id={project_id} 准备重启 nodriver 浏览器以恢复")
- await self._shutdown_browser_runtime(cancel_idle_reaper=False, reason=f"restart_project:{project_id}")
-
- warmed_slots = await self.warmup_resident_tabs(restore_project_ids, limit=restore_slots)
- if not warmed_slots:
- debug_logger.log_warning(f"[BrowserCaptcha] project_id={project_id} 浏览器重启后恢复共享标签页失败")
- return False
-
- slot_id, resident_info = await self._ensure_resident_tab(project_id, return_slot_key=True)
- if resident_info is None or not slot_id:
- debug_logger.log_warning(f"[BrowserCaptcha] project_id={project_id} 浏览器重启后无法定位可用共享标签页")
- return False
-
- self._remember_project_affinity(project_id, slot_id, resident_info)
- self._resident_error_streaks.pop(slot_id, None)
- debug_logger.log_warning(
- f"[BrowserCaptcha] project_id={project_id} 浏览器重启后已恢复共享标签页池 "
- f"(slots={len(warmed_slots)}, active_slot={slot_id})"
- )
- return True
-
- async def report_flow_error(self, project_id: str, error_reason: str, error_message: str = ""):
- """上游生成接口异常时,对常驻标签页执行自愈恢复。"""
- if not project_id:
- return
-
- async with self._resident_lock:
- slot_id, _ = self._resolve_resident_slot_for_project_locked(project_id)
-
- if not slot_id:
- return
-
- streak = self._resident_error_streaks.get(slot_id, 0) + 1
- self._resident_error_streaks[slot_id] = streak
- error_text = f"{error_reason or ''} {error_message or ''}".strip()
- error_lower = error_text.lower()
- debug_logger.log_warning(
- f"[BrowserCaptcha] project_id={project_id}, slot={slot_id} 收到上游异常,streak={streak}, reason={error_reason}, detail={error_message[:200]}"
- )
-
- if not self._initialized or not self.browser:
- return
-
- # 403 错误:先清理缓存再重建
- if "403" in error_text or "forbidden" in error_lower or "recaptcha" in error_lower:
- debug_logger.log_warning(
- f"[BrowserCaptcha] project_id={project_id} 检测到 403/reCAPTCHA 错误,清理缓存并重建"
- )
- healed = await self._clear_resident_storage_and_reload(project_id)
- if not healed:
- await self._recreate_resident_tab(project_id)
- return
-
- # 服务端错误:根据连续失败次数决定恢复策略
- if self._is_server_side_flow_error(error_text):
- recreate_threshold = max(2, int(getattr(config, "browser_personal_recreate_threshold", 2) or 2))
- restart_threshold = max(3, int(getattr(config, "browser_personal_restart_threshold", 3) or 3))
-
- if streak >= restart_threshold:
- await self._restart_browser_for_project(project_id)
- return
- if streak >= recreate_threshold:
- await self._recreate_resident_tab(project_id)
- return
-
- healed = await self._clear_resident_storage_and_reload(project_id)
- if not healed:
- await self._recreate_resident_tab(project_id)
- return
-
- # 其他错误:直接重建标签页
- await self._recreate_resident_tab(project_id)
-
- async def _wait_for_recaptcha(self, tab) -> bool:
- """等待 reCAPTCHA 加载
-
- Returns:
- True if reCAPTCHA loaded successfully
- """
- debug_logger.log_info("[BrowserCaptcha] 注入 reCAPTCHA 脚本...")
-
- # 注入 reCAPTCHA Enterprise 脚本
- await self._tab_evaluate(tab, f"""
- (() => {{
- if (document.querySelector('script[src*="recaptcha"]')) return;
- const script = document.createElement('script');
- script.src = 'https://www.google.com/recaptcha/enterprise.js?render={self.website_key}';
- script.async = true;
- document.head.appendChild(script);
- }})()
- """, label="inject_recaptcha_script", timeout_seconds=5.0)
-
- # 等待 reCAPTCHA 加载(减少等待时间)
- for i in range(15): # 减少到15次,最多7.5秒
- try:
- is_ready = await self._tab_evaluate(
- tab,
- "typeof grecaptcha !== 'undefined' && "
- "typeof grecaptcha.enterprise !== 'undefined' && "
- "typeof grecaptcha.enterprise.execute === 'function'",
- label="check_recaptcha_ready",
- timeout_seconds=2.5,
- )
-
- if is_ready:
- debug_logger.log_info(f"[BrowserCaptcha] reCAPTCHA 已就绪 (等待了 {i * 0.5}s)")
- return True
-
- await tab.sleep(0.5)
- except Exception as e:
- debug_logger.log_warning(f"[BrowserCaptcha] 检查 reCAPTCHA 时异常: {e}")
- await tab.sleep(0.3) # 异常时减少等待时间
-
- debug_logger.log_warning("[BrowserCaptcha] reCAPTCHA 加载超时")
- return False
-
- async def _wait_for_custom_recaptcha(
- self,
- tab,
- website_key: str,
- enterprise: bool = False,
- ) -> bool:
- """等待任意站点的 reCAPTCHA 加载,用于分数测试。"""
- debug_logger.log_info("[BrowserCaptcha] 检测自定义 reCAPTCHA...")
-
- ready_check = (
- "typeof grecaptcha !== 'undefined' && typeof grecaptcha.enterprise !== 'undefined' && "
- "typeof grecaptcha.enterprise.execute === 'function'"
- ) if enterprise else (
- "typeof grecaptcha !== 'undefined' && typeof grecaptcha.execute === 'function'"
- )
- script_path = "recaptcha/enterprise.js" if enterprise else "recaptcha/api.js"
- label = "Enterprise" if enterprise else "V3"
-
- is_ready = await self._tab_evaluate(
- tab,
- ready_check,
- label="check_custom_recaptcha_preloaded",
- timeout_seconds=2.5,
- )
- if is_ready:
- debug_logger.log_info(f"[BrowserCaptcha] 自定义 reCAPTCHA {label} 已加载")
- return True
-
- debug_logger.log_info("[BrowserCaptcha] 未检测到自定义 reCAPTCHA,注入脚本...")
- await self._tab_evaluate(tab, f"""
- (() => {{
- if (document.querySelector('script[src*="recaptcha"]')) return;
- const script = document.createElement('script');
- script.src = 'https://www.google.com/{script_path}?render={website_key}';
- script.async = true;
- document.head.appendChild(script);
- }})()
- """, label="inject_custom_recaptcha_script", timeout_seconds=5.0)
-
- await tab.sleep(3)
- for i in range(20):
- is_ready = await self._tab_evaluate(
- tab,
- ready_check,
- label="check_custom_recaptcha_ready",
- timeout_seconds=2.5,
- )
- if is_ready:
- debug_logger.log_info(f"[BrowserCaptcha] 自定义 reCAPTCHA {label} 已加载(等待了 {i * 0.5} 秒)")
- return True
- await tab.sleep(0.5)
-
- debug_logger.log_warning("[BrowserCaptcha] 自定义 reCAPTCHA 加载超时")
- return False
-
- async def _execute_recaptcha_on_tab(self, tab, action: str = "IMAGE_GENERATION") -> Optional[str]:
- """在指定标签页执行 reCAPTCHA 获取 token
-
- Args:
- tab: nodriver 标签页对象
- action: reCAPTCHA action类型 (IMAGE_GENERATION 或 VIDEO_GENERATION)
-
- Returns:
- reCAPTCHA token 或 None
- """
- # 生成唯一变量名避免冲突
- ts = int(time.time() * 1000)
- token_var = f"_recaptcha_token_{ts}"
- error_var = f"_recaptcha_error_{ts}"
-
- execute_script = f"""
- (() => {{
- window.{token_var} = null;
- window.{error_var} = null;
-
- try {{
- grecaptcha.enterprise.ready(function() {{
- grecaptcha.enterprise.execute('{self.website_key}', {{action: '{action}'}})
- .then(function(token) {{
- window.{token_var} = token;
- }})
- .catch(function(err) {{
- window.{error_var} = err.message || 'execute failed';
- }});
- }});
- }} catch (e) {{
- window.{error_var} = e.message || 'exception';
- }}
- }})()
- """
-
- # 注入执行脚本
- await self._tab_evaluate(
- tab,
- execute_script,
- label=f"execute_recaptcha:{action}",
- timeout_seconds=5.0,
- )
-
- # 轮询等待结果(最多 30 秒)
- token = None
- for i in range(60):
- await tab.sleep(0.5)
- token = await self._tab_evaluate(
- tab,
- f"window.{token_var}",
- label=f"poll_recaptcha_token:{action}",
- timeout_seconds=2.0,
- )
- if token:
- break
- error = await self._tab_evaluate(
- tab,
- f"window.{error_var}",
- label=f"poll_recaptcha_error:{action}",
- timeout_seconds=2.0,
- )
- if error:
- debug_logger.log_error(f"[BrowserCaptcha] reCAPTCHA 错误: {error}")
- break
-
- # 清理临时变量
- try:
- await self._tab_evaluate(
- tab,
- f"delete window.{token_var}; delete window.{error_var};",
- label="cleanup_recaptcha_temp_vars",
- timeout_seconds=5.0,
- )
- except:
- pass
-
- if token:
- debug_logger.log_info(f"[BrowserCaptcha] ✅ Token 获取成功 (长度: {len(token)})")
- else:
- debug_logger.log_warning("[BrowserCaptcha] Token 获取失败,交由上层执行标签页恢复")
-
- return token
-
- async def _execute_custom_recaptcha_on_tab(
- self,
- tab,
- website_key: str,
- action: str = "homepage",
- enterprise: bool = False,
- ) -> Optional[str]:
- """在指定标签页执行任意站点的 reCAPTCHA。"""
- ts = int(time.time() * 1000)
- token_var = f"_custom_recaptcha_token_{ts}"
- error_var = f"_custom_recaptcha_error_{ts}"
- execute_target = "grecaptcha.enterprise.execute" if enterprise else "grecaptcha.execute"
-
- execute_script = f"""
- (() => {{
- window.{token_var} = null;
- window.{error_var} = null;
-
- try {{
- grecaptcha.ready(function() {{
- {execute_target}('{website_key}', {{action: '{action}'}})
- .then(function(token) {{
- window.{token_var} = token;
- }})
- .catch(function(err) {{
- window.{error_var} = err.message || 'execute failed';
- }});
- }});
- }} catch (e) {{
- window.{error_var} = e.message || 'exception';
- }}
- }})()
- """
-
- await self._tab_evaluate(
- tab,
- execute_script,
- label=f"execute_custom_recaptcha:{action}",
- timeout_seconds=5.0,
- )
-
- token = None
- for _ in range(30):
- await tab.sleep(0.5)
- token = await self._tab_evaluate(
- tab,
- f"window.{token_var}",
- label=f"poll_custom_recaptcha_token:{action}",
- timeout_seconds=2.0,
- )
- if token:
- break
- error = await self._tab_evaluate(
- tab,
- f"window.{error_var}",
- label=f"poll_custom_recaptcha_error:{action}",
- timeout_seconds=2.0,
- )
- if error:
- debug_logger.log_error(f"[BrowserCaptcha] 自定义 reCAPTCHA 错误: {error}")
- break
-
- try:
- await self._tab_evaluate(
- tab,
- f"delete window.{token_var}; delete window.{error_var};",
- label="cleanup_custom_recaptcha_temp_vars",
- timeout_seconds=5.0,
- )
- except:
- pass
-
- if token:
- post_wait_seconds = 3
- try:
- post_wait_seconds = float(getattr(config, "browser_recaptcha_settle_seconds", 3) or 3)
- except Exception:
- pass
- if post_wait_seconds > 0:
- debug_logger.log_info(
- f"[BrowserCaptcha] 自定义 reCAPTCHA 已完成,额外等待 {post_wait_seconds:.1f}s 后返回 token"
- )
- await tab.sleep(post_wait_seconds)
-
- return token
-
- async def _verify_score_on_tab(self, tab, token: str, verify_url: str) -> Dict[str, Any]:
- """直接读取测试页面展示的分数,避免 verify.php 与页面显示口径不一致。"""
- _ = token
- _ = verify_url
- started_at = time.time()
- timeout_seconds = 25.0
- refresh_clicked = False
- last_snapshot: Dict[str, Any] = {}
-
- try:
- timeout_seconds = float(getattr(config, "browser_score_dom_wait_seconds", 25) or 25)
- except Exception:
- pass
-
- while (time.time() - started_at) < timeout_seconds:
- try:
- result = await self._tab_evaluate(tab, """
- (() => {
- const bodyText = ((document.body && document.body.innerText) || "")
- .replace(/\\u00a0/g, " ")
- .replace(/\\r/g, "");
- const patterns = [
- { source: "current_score", regex: /Your score is:\\s*([01](?:\\.\\d+)?)/i },
- { source: "selected_score", regex: /Selected Score Test:[\\s\\S]{0,400}?Score:\\s*([01](?:\\.\\d+)?)/i },
- { source: "history_score", regex: /(?:^|\\n)\\s*Score:\\s*([01](?:\\.\\d+)?)\\s*;/i },
- ];
- let score = null;
- let source = "";
- for (const item of patterns) {
- const match = bodyText.match(item.regex);
- if (!match) continue;
- const parsed = Number(match[1]);
- if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 1) {
- score = parsed;
- source = item.source;
- break;
- }
- }
- const uaMatch = bodyText.match(/Current User Agent:\\s*([^\\n]+)/i);
- const ipMatch = bodyText.match(/Current IP Address:\\s*([^\\n]+)/i);
- return {
- score,
- source,
- raw_text: bodyText.slice(0, 4000),
- current_user_agent: uaMatch ? uaMatch[1].trim() : "",
- current_ip_address: ipMatch ? ipMatch[1].trim() : "",
- title: document.title || "",
- url: location.href || "",
- };
- })()
- """, label="verify_score_dom", timeout_seconds=10.0)
- except Exception as e:
- result = {"error": f"{type(e).__name__}: {str(e)[:200]}"}
-
- if isinstance(result, dict):
- last_snapshot = result
- score = result.get("score")
- if isinstance(score, (int, float)):
- elapsed_ms = int((time.time() - started_at) * 1000)
- return {
- "verify_mode": "browser_page_dom",
- "verify_elapsed_ms": elapsed_ms,
- "verify_http_status": None,
- "verify_result": {
- "success": True,
- "score": score,
- "source": result.get("source") or "antcpt_dom",
- "raw_text": result.get("raw_text") or "",
- "current_user_agent": result.get("current_user_agent") or "",
- "current_ip_address": result.get("current_ip_address") or "",
- "page_title": result.get("title") or "",
- "page_url": result.get("url") or "",
- },
- }
-
- if not refresh_clicked and (time.time() - started_at) >= 2:
- refresh_clicked = True
- try:
- await self._tab_evaluate(tab, """
- (() => {
- const nodes = Array.from(
- document.querySelectorAll('button, input[type="button"], input[type="submit"], a')
- );
- const target = nodes.find((node) => {
- const text = (node.innerText || node.textContent || node.value || "").trim();
- return /Refresh score now!?/i.test(text);
- });
- if (target) {
- target.click();
- return true;
- }
- return false;
- })()
- """, label="verify_score_click_refresh", timeout_seconds=5.0)
- except Exception:
- pass
-
- await tab.sleep(0.5)
-
- elapsed_ms = int((time.time() - started_at) * 1000)
- if not isinstance(last_snapshot, dict):
- last_snapshot = {"raw": last_snapshot}
-
- return {
- "verify_mode": "browser_page_dom",
- "verify_elapsed_ms": elapsed_ms,
- "verify_http_status": None,
- "verify_result": {
- "success": False,
- "score": None,
- "source": "antcpt_dom_timeout",
- "raw_text": last_snapshot.get("raw_text") or "",
- "current_user_agent": last_snapshot.get("current_user_agent") or "",
- "current_ip_address": last_snapshot.get("current_ip_address") or "",
- "page_title": last_snapshot.get("title") or "",
- "page_url": last_snapshot.get("url") or "",
- "error": last_snapshot.get("error") or "未在页面中读取到分数",
- },
- }
-
- async def _extract_tab_fingerprint(self, tab) -> Optional[Dict[str, Any]]:
- """从 nodriver 标签页提取浏览器指纹信息。"""
- try:
- fingerprint = await self._tab_evaluate(tab, """
- () => {
- const ua = navigator.userAgent || "";
- const lang = navigator.language || "";
- const uaData = navigator.userAgentData || null;
- let secChUa = "";
- let secChUaMobile = "";
- let secChUaPlatform = "";
-
- if (uaData) {
- if (Array.isArray(uaData.brands) && uaData.brands.length > 0) {
- secChUa = uaData.brands
- .map((item) => `"${item.brand}";v="${item.version}"`)
- .join(", ");
- }
- secChUaMobile = uaData.mobile ? "?1" : "?0";
- if (uaData.platform) {
- secChUaPlatform = `"${uaData.platform}"`;
- }
- }
-
- return {
- user_agent: ua,
- accept_language: lang,
- sec_ch_ua: secChUa,
- sec_ch_ua_mobile: secChUaMobile,
- sec_ch_ua_platform: secChUaPlatform,
- };
- }
- """, label="extract_tab_fingerprint", timeout_seconds=8.0)
- if not isinstance(fingerprint, dict):
- return None
-
- result: Dict[str, Any] = {"proxy_url": self._proxy_url}
- for key in ("user_agent", "accept_language", "sec_ch_ua", "sec_ch_ua_mobile", "sec_ch_ua_platform"):
- value = fingerprint.get(key)
- if isinstance(value, str) and value:
- result[key] = value
- return result
- except Exception as e:
- debug_logger.log_warning(f"[BrowserCaptcha] 提取 nodriver 指纹失败: {e}")
- return None
-
- # ========== 主要 API ==========
-
- async def get_token(self, project_id: str, action: str = "IMAGE_GENERATION") -> Optional[str]:
- """获取 reCAPTCHA token
-
- 使用全局共享打码标签页池。标签页不再按 project_id 一对一绑定,
- 谁拿到空闲 tab 就用谁的;只有 Session Token 刷新/故障恢复会优先参考最近一次映射。
-
- Args:
- project_id: Flow项目ID
- action: reCAPTCHA action类型
- - IMAGE_GENERATION: 图片生成和2K/4K图片放大 (默认)
- - VIDEO_GENERATION: 视频生成和视频放大
-
- Returns:
- reCAPTCHA token字符串,如果获取失败返回None
- """
- debug_logger.log_info(f"[BrowserCaptcha] get_token 开始: project_id={project_id}, action={action}, 当前标签页数={len(self._resident_tabs)}/{self._max_resident_tabs}")
-
- # 确保浏览器已初始化
- await self.initialize()
- self._last_fingerprint = None
-
- debug_logger.log_info(
- f"[BrowserCaptcha] 开始从共享打码池获取标签页 (project: {project_id}, 当前: {len(self._resident_tabs)}/{self._max_resident_tabs})"
- )
- slot_id, resident_info = await self._ensure_resident_tab(project_id, return_slot_key=True)
- if resident_info is None or not slot_id:
- debug_logger.log_warning(
- f"[BrowserCaptcha] 共享标签页池不可用,fallback 到传统模式 (project: {project_id})"
- )
- return await self._get_token_legacy(project_id, action)
-
- debug_logger.log_info(
- f"[BrowserCaptcha] ✅ 共享标签页可用 (slot={slot_id}, project={project_id}, use_count={resident_info.use_count})"
- )
-
- if resident_info and resident_info.tab and not resident_info.recaptcha_ready:
- debug_logger.log_warning(
- f"[BrowserCaptcha] 共享标签页未就绪,准备重建 cold slot={slot_id}, project={project_id}"
- )
- slot_id, resident_info = await self._rebuild_resident_tab(
- project_id,
- slot_id=slot_id,
- return_slot_key=True,
- )
-
- # 使用常驻标签页生成 token(在锁外执行,避免阻塞)
- if resident_info and resident_info.recaptcha_ready and resident_info.tab:
- start_time = time.time()
- debug_logger.log_info(
- f"[BrowserCaptcha] 从共享常驻标签页即时生成 token (slot={slot_id}, project={project_id}, action={action})..."
- )
- try:
- async with resident_info.solve_lock:
- token = await self._run_with_timeout(
- self._execute_recaptcha_on_tab(resident_info.tab, action),
- timeout_seconds=self._solve_timeout_seconds,
- label=f"resident_solve:{slot_id}:{project_id}:{action}",
- )
- duration_ms = (time.time() - start_time) * 1000
- if token:
- # 更新使用时间和计数
- resident_info.last_used_at = time.time()
- resident_info.use_count += 1
- self._remember_project_affinity(project_id, slot_id, resident_info)
- self._resident_error_streaks.pop(slot_id, None)
- self._last_fingerprint = await self._extract_tab_fingerprint(resident_info.tab)
- debug_logger.log_info(
- f"[BrowserCaptcha] ✅ Token生成成功(slot={slot_id}, 耗时 {duration_ms:.0f}ms, 使用次数: {resident_info.use_count})"
- )
- return token
- else:
- debug_logger.log_warning(
- f"[BrowserCaptcha] 共享标签页生成失败 (slot={slot_id}, project={project_id}),尝试重建..."
- )
- except Exception as e:
- debug_logger.log_warning(f"[BrowserCaptcha] 共享标签页异常 (slot={slot_id}): {e},尝试重建...")
-
- # 常驻标签页失效,尝试重建
- debug_logger.log_info(f"[BrowserCaptcha] 开始重建共享标签页 (slot={slot_id}, project={project_id})")
- slot_id, resident_info = await self._rebuild_resident_tab(
- project_id,
- slot_id=slot_id,
- return_slot_key=True,
- )
- debug_logger.log_info(f"[BrowserCaptcha] 共享标签页重建结束 (slot={slot_id}, project={project_id})")
-
- # 重建后立即尝试生成(在锁外执行)
- if resident_info:
- try:
- async with resident_info.solve_lock:
- token = await self._run_with_timeout(
- self._execute_recaptcha_on_tab(resident_info.tab, action),
- timeout_seconds=self._solve_timeout_seconds,
- label=f"resident_resolve_after_rebuild:{slot_id}:{project_id}:{action}",
- )
- if token:
- resident_info.last_used_at = time.time()
- resident_info.use_count += 1
- self._remember_project_affinity(project_id, slot_id, resident_info)
- self._resident_error_streaks.pop(slot_id, None)
- self._last_fingerprint = await self._extract_tab_fingerprint(resident_info.tab)
- debug_logger.log_info(f"[BrowserCaptcha] ✅ 重建后 Token生成成功 (slot={slot_id})")
- return token
- except Exception:
- pass
-
- # 最终 Fallback: 使用传统模式
- debug_logger.log_warning(f"[BrowserCaptcha] 所有常驻方式失败,fallback 到传统模式 (project: {project_id})")
- legacy_token = await self._get_token_legacy(project_id, action)
- if legacy_token:
- if slot_id:
- self._resident_error_streaks.pop(slot_id, None)
- return legacy_token
-
- async def _create_resident_tab(self, slot_id: str, project_id: Optional[str] = None) -> Optional[ResidentTabInfo]:
- """创建一个共享常驻打码标签页
-
- Args:
- slot_id: 共享标签页槽位 ID
- project_id: 触发创建的项目 ID,仅用于日志和最近映射
-
- Returns:
- ResidentTabInfo 对象,或 None(创建失败)
- """
- try:
- # 使用 Flow API 地址作为基础页面
- website_url = "https://labs.google/fx/api/auth/providers"
- debug_logger.log_info(f"[BrowserCaptcha] 创建共享常驻标签页 slot={slot_id}, seed_project={project_id}")
-
- async with self._resident_lock:
- existing_tabs = [info.tab for info in self._resident_tabs.values() if info.tab]
-
- # 获取或创建标签页
- tabs = self.browser.tabs
- available_tab = None
-
- # 查找未被占用的标签页
- for tab in tabs:
- if tab not in existing_tabs:
- available_tab = tab
- break
-
- if available_tab:
- tab = available_tab
- debug_logger.log_info(f"[BrowserCaptcha] 复用未占用的标签页")
- await self._tab_get(
- tab,
- website_url,
- label=f"resident_tab_get:{slot_id}",
- )
- else:
- debug_logger.log_info(f"[BrowserCaptcha] 创建新标签页")
- tab = await self._browser_get(
- website_url,
- label=f"resident_browser_get:{slot_id}",
- new_tab=True,
- )
-
- # 等待页面加载完成(减少等待时间)
- page_loaded = False
- for retry in range(10): # 减少到10次,最多5秒
- try:
- await asyncio.sleep(0.5)
- ready_state = await self._tab_evaluate(
- tab,
- "document.readyState",
- label=f"resident_document_ready:{slot_id}",
- timeout_seconds=2.0,
- )
- if ready_state == "complete":
- page_loaded = True
- debug_logger.log_info(f"[BrowserCaptcha] 页面已加载")
- break
- except Exception as e:
- debug_logger.log_warning(f"[BrowserCaptcha] 等待页面异常: {e},重试 {retry + 1}/10...")
- await asyncio.sleep(0.3) # 减少重试间隔
-
- if not page_loaded:
- debug_logger.log_error(f"[BrowserCaptcha] 页面加载超时 (slot={slot_id}, project={project_id})")
- await self._close_tab_quietly(tab)
- return None
-
- # 等待 reCAPTCHA 加载
- recaptcha_ready = await self._wait_for_recaptcha(tab)
-
- if not recaptcha_ready:
- debug_logger.log_error(f"[BrowserCaptcha] reCAPTCHA 加载失败 (slot={slot_id}, project={project_id})")
- await self._close_tab_quietly(tab)
- return None
-
- # 创建常驻信息对象
- resident_info = ResidentTabInfo(tab, slot_id, project_id=project_id)
- resident_info.recaptcha_ready = True
-
- debug_logger.log_info(f"[BrowserCaptcha] ✅ 共享常驻标签页创建成功 (slot={slot_id}, project={project_id})")
- return resident_info
-
- except Exception as e:
- debug_logger.log_error(f"[BrowserCaptcha] 创建共享常驻标签页异常 (slot={slot_id}, project={project_id}): {e}")
- return None
-
- async def _close_resident_tab(self, slot_id: str):
- """关闭指定 slot 的共享常驻标签页
-
- Args:
- slot_id: 共享标签页槽位 ID
- """
- async with self._resident_lock:
- resident_info = self._resident_tabs.pop(slot_id, None)
- self._forget_project_affinity_for_slot_locked(slot_id)
- self._resident_error_streaks.pop(slot_id, None)
- self._sync_compat_resident_state()
-
- if resident_info and resident_info.tab:
- try:
- await self._close_tab_quietly(resident_info.tab)
- debug_logger.log_info(f"[BrowserCaptcha] 已关闭共享常驻标签页 slot={slot_id}")
- except Exception as e:
- debug_logger.log_warning(f"[BrowserCaptcha] 关闭标签页时异常: {e}")
-
- async def invalidate_token(self, project_id: str):
- """当检测到 token 无效时调用,重建当前项目最近映射的共享标签页。
-
- Args:
- project_id: 项目 ID
- """
- debug_logger.log_warning(
- f"[BrowserCaptcha] Token 被标记为无效 (project: {project_id}),仅重建共享池中的对应标签页,避免清空全局浏览器状态"
- )
-
- # 重建标签页
- slot_id, resident_info = await self._rebuild_resident_tab(project_id, return_slot_key=True)
- if resident_info and slot_id:
- debug_logger.log_info(f"[BrowserCaptcha] ✅ 标签页已重建 (project: {project_id}, slot={slot_id})")
- else:
- debug_logger.log_error(f"[BrowserCaptcha] 标签页重建失败 (project: {project_id})")
-
- async def _get_token_legacy(self, project_id: str, action: str = "IMAGE_GENERATION") -> Optional[str]:
- """传统模式获取 reCAPTCHA token(每次创建新标签页)
-
- Args:
- project_id: Flow项目ID
- action: reCAPTCHA action类型 (IMAGE_GENERATION 或 VIDEO_GENERATION)
-
- Returns:
- reCAPTCHA token字符串,如果获取失败返回None
- """
- # 确保浏览器已启动
- if not self._initialized or not self.browser:
- await self.initialize()
-
- start_time = time.time()
- tab = None
-
- async with self._legacy_lock:
- try:
- website_url = "https://labs.google/fx/api/auth/providers"
- debug_logger.log_info(
- f"[BrowserCaptcha] [Legacy] 创建独立临时标签页执行验证,避免污染 resident/custom 页面: {website_url}"
- )
- tab = await self._browser_get(
- website_url,
- label=f"legacy_browser_get:{project_id}",
- new_tab=True,
- )
-
- # 等待页面完全加载(增加等待时间)
- debug_logger.log_info("[BrowserCaptcha] [Legacy] 等待页面加载...")
- await tab.sleep(3)
-
- # 等待页面 DOM 完成
- for _ in range(10):
- ready_state = await self._tab_evaluate(
- tab,
- "document.readyState",
- label=f"legacy_document_ready:{project_id}",
- timeout_seconds=2.0,
- )
- if ready_state == "complete":
- break
- await tab.sleep(0.5)
-
- # 等待 reCAPTCHA 加载
- recaptcha_ready = await self._wait_for_recaptcha(tab)
-
- if not recaptcha_ready:
- debug_logger.log_error("[BrowserCaptcha] [Legacy] reCAPTCHA 无法加载")
- return None
-
- # 执行 reCAPTCHA
- debug_logger.log_info(f"[BrowserCaptcha] [Legacy] 执行 reCAPTCHA 验证 (action: {action})...")
- token = await self._run_with_timeout(
- self._execute_recaptcha_on_tab(tab, action),
- timeout_seconds=self._solve_timeout_seconds,
- label=f"legacy_solve:{project_id}:{action}",
- )
-
- duration_ms = (time.time() - start_time) * 1000
-
- if token:
- self._last_fingerprint = await self._extract_tab_fingerprint(tab)
- debug_logger.log_info(f"[BrowserCaptcha] [Legacy] ✅ Token获取成功(耗时 {duration_ms:.0f}ms)")
- return token
-
- debug_logger.log_error("[BrowserCaptcha] [Legacy] Token获取失败(返回null)")
- return None
-
- except Exception as e:
- debug_logger.log_error(f"[BrowserCaptcha] [Legacy] 获取token异常: {str(e)}")
- return None
- finally:
- # 关闭 legacy 临时标签页(但保留浏览器)
- if tab:
- await self._close_tab_quietly(tab)
-
- def get_last_fingerprint(self) -> Optional[Dict[str, Any]]:
- """返回最近一次打码时的浏览器指纹快照。"""
- if not self._last_fingerprint:
- return None
- return dict(self._last_fingerprint)
-
- async def _clear_browser_cache(self):
- """清理浏览器全部缓存"""
- if not self.browser:
- return
-
- try:
- debug_logger.log_info("[BrowserCaptcha] 开始清理浏览器缓存...")
-
- # 使用 Chrome DevTools Protocol 清理缓存
- # 清理所有类型的缓存数据
- await self._browser_send_command(
- "Network.clearBrowserCache",
- label="clear_browser_cache",
- )
-
- # 清理 Cookies
- await self._browser_send_command(
- "Network.clearBrowserCookies",
- label="clear_browser_cookies",
- )
-
- # 清理存储数据(localStorage, sessionStorage, IndexedDB 等)
- await self._browser_send_command(
- "Storage.clearDataForOrigin",
- {
- "origin": "https://www.google.com",
- "storageTypes": "all"
- },
- label="clear_browser_origin_storage",
- )
-
- debug_logger.log_info("[BrowserCaptcha] ✅ 浏览器缓存已清理")
-
- except Exception as e:
- debug_logger.log_warning(f"[BrowserCaptcha] 清理缓存时异常: {e}")
-
- async def _shutdown_browser_runtime(self, cancel_idle_reaper: bool = False, reason: str = "shutdown"):
- if cancel_idle_reaper and self._idle_reaper_task and not self._idle_reaper_task.done():
- self._idle_reaper_task.cancel()
- try:
- await self._idle_reaper_task
- except asyncio.CancelledError:
- pass
- finally:
- self._idle_reaper_task = None
-
- async with self._browser_lock:
- try:
- await self._shutdown_browser_runtime_locked(reason=reason)
- debug_logger.log_info(f"[BrowserCaptcha] 浏览器运行态已清理 ({reason})")
- except Exception as e:
- debug_logger.log_error(f"[BrowserCaptcha] 清理浏览器运行态异常 ({reason}): {str(e)}")
-
- async def close(self):
- """关闭浏览器"""
- await self._shutdown_browser_runtime(cancel_idle_reaper=True, reason="service_close")
-
- async def open_login_window(self):
- """打开登录窗口供用户手动登录 Google"""
- await self.initialize()
- tab = await self._browser_get(
- "https://accounts.google.com/",
- label="open_login_window",
- new_tab=True,
- )
- debug_logger.log_info("[BrowserCaptcha] 请在打开的浏览器中登录账号。登录完成后,无需关闭浏览器,脚本下次运行时会自动使用此状态。")
- print("请在打开的浏览器中登录账号。登录完成后,无需关闭浏览器,脚本下次运行时会自动使用此状态。")
-
- # ========== Session Token 刷新 ==========
-
- async def refresh_session_token(self, project_id: str) -> Optional[str]:
- """从常驻标签页获取最新的 Session Token
-
- 复用共享打码标签页,通过刷新页面并从 cookies 中提取
- __Secure-next-auth.session-token
-
- Args:
- project_id: 项目ID,用于定位常驻标签页
-
- Returns:
- 新的 Session Token,如果获取失败返回 None
- """
- # 确保浏览器已初始化
- await self.initialize()
-
- start_time = time.time()
- debug_logger.log_info(f"[BrowserCaptcha] 开始刷新 Session Token (project: {project_id})...")
-
- async with self._resident_lock:
- slot_id = self._resolve_affinity_slot_locked(project_id)
- resident_info = self._resident_tabs.get(slot_id) if slot_id else None
-
- if resident_info is None or not slot_id:
- slot_id, resident_info = await self._ensure_resident_tab(project_id, return_slot_key=True)
-
- if resident_info is None or not slot_id:
- debug_logger.log_warning(f"[BrowserCaptcha] 无法为 project_id={project_id} 获取共享常驻标签页")
- return None
-
- if not resident_info or not resident_info.tab:
- debug_logger.log_error(f"[BrowserCaptcha] 无法获取常驻标签页")
- return None
-
- tab = resident_info.tab
-
- try:
- async with resident_info.solve_lock:
- # 刷新页面以获取最新的 cookies
- debug_logger.log_info(f"[BrowserCaptcha] 刷新常驻标签页以获取最新 cookies...")
- resident_info.recaptcha_ready = False
- await self._run_with_timeout(
- self._tab_reload(
- tab,
- label=f"refresh_session_reload:{slot_id}",
- ),
- timeout_seconds=self._session_refresh_timeout_seconds,
- label=f"refresh_session_reload_total:{slot_id}",
- )
-
- # 等待页面加载完成
- for i in range(30):
- await asyncio.sleep(1)
- try:
- ready_state = await self._tab_evaluate(
- tab,
- "document.readyState",
- label=f"refresh_session_ready_state:{slot_id}",
- timeout_seconds=2.0,
- )
- if ready_state == "complete":
- break
- except Exception:
- pass
-
- resident_info.recaptcha_ready = await self._wait_for_recaptcha(tab)
- if not resident_info.recaptcha_ready:
- debug_logger.log_warning(
- f"[BrowserCaptcha] 刷新 Session Token 后 reCAPTCHA 未恢复就绪 (slot={slot_id})"
- )
-
- # 额外等待确保 cookies 已设置
- await asyncio.sleep(2)
-
- # 从 cookies 中提取 __Secure-next-auth.session-token
- # nodriver 可以通过 browser 获取 cookies
- session_token = None
-
- try:
- # 使用 nodriver 的 cookies API 获取所有 cookies
- cookies = await self._get_browser_cookies(
- label=f"refresh_session_get_cookies:{slot_id}",
- )
-
- for cookie in cookies:
- if cookie.name == "__Secure-next-auth.session-token":
- session_token = cookie.value
- break
-
- except Exception as e:
- debug_logger.log_warning(f"[BrowserCaptcha] 通过 cookies API 获取失败: {e},尝试从 document.cookie 获取...")
-
- # 备选方案:通过 JavaScript 获取 (注意:HttpOnly cookies 可能无法通过此方式获取)
- try:
- all_cookies = await self._tab_evaluate(
- tab,
- "document.cookie",
- label=f"refresh_session_document_cookie:{slot_id}",
- )
- if all_cookies:
- for part in all_cookies.split(";"):
- part = part.strip()
- if part.startswith("__Secure-next-auth.session-token="):
- session_token = part.split("=", 1)[1]
- break
- except Exception as e2:
- debug_logger.log_error(f"[BrowserCaptcha] document.cookie 获取失败: {e2}")
-
- duration_ms = (time.time() - start_time) * 1000
-
- if session_token:
- resident_info.last_used_at = time.time()
- self._remember_project_affinity(project_id, slot_id, resident_info)
- self._resident_error_streaks.pop(slot_id, None)
- debug_logger.log_info(f"[BrowserCaptcha] ✅ Session Token 获取成功(耗时 {duration_ms:.0f}ms)")
- return session_token
- else:
- debug_logger.log_error(f"[BrowserCaptcha] ❌ 未找到 __Secure-next-auth.session-token cookie")
- return None
-
- except Exception as e:
- debug_logger.log_error(f"[BrowserCaptcha] 刷新 Session Token 异常: {str(e)}")
-
- # 共享标签页可能已失效,尝试重建
- slot_id, resident_info = await self._rebuild_resident_tab(project_id, slot_id=slot_id, return_slot_key=True)
- if resident_info and slot_id:
- # 重建后再次尝试获取
- try:
- async with resident_info.solve_lock:
- cookies = await self._get_browser_cookies(
- label=f"refresh_session_get_cookies_after_rebuild:{slot_id}",
- )
- for cookie in cookies:
- if cookie.name == "__Secure-next-auth.session-token":
- resident_info.last_used_at = time.time()
- self._remember_project_affinity(project_id, slot_id, resident_info)
- self._resident_error_streaks.pop(slot_id, None)
- debug_logger.log_info(f"[BrowserCaptcha] ✅ 重建后 Session Token 获取成功")
- return cookie.value
- except Exception:
- pass
-
- return None
-
- # ========== 状态查询 ==========
-
- def is_resident_mode_active(self) -> bool:
- """检查是否有任何常驻标签页激活"""
- return len(self._resident_tabs) > 0 or self._running
-
- def get_resident_count(self) -> int:
- """获取当前常驻标签页数量"""
- return len(self._resident_tabs)
-
- def get_resident_project_ids(self) -> list[str]:
- """获取所有当前共享常驻标签页的 slot_id 列表。"""
- return list(self._resident_tabs.keys())
-
- def get_resident_project_id(self) -> Optional[str]:
- """获取当前共享池中的第一个 slot_id(向后兼容)。"""
- if self._resident_tabs:
- return next(iter(self._resident_tabs.keys()))
- return self.resident_project_id
-
- async def get_custom_token(
- self,
- website_url: str,
- website_key: str,
- action: str = "homepage",
- enterprise: bool = False,
- ) -> Optional[str]:
- """为任意站点执行 reCAPTCHA,用于分数测试等场景。
-
- 与普通 legacy 模式不同,这里会复用同一个常驻标签页,避免每次冷启动新 tab。
- """
- await self.initialize()
- self._last_fingerprint = None
-
- cache_key = f"{website_url}|{website_key}|{1 if enterprise else 0}"
- warmup_seconds = float(getattr(config, "browser_score_test_warmup_seconds", 12) or 12)
- per_request_settle_seconds = float(
- getattr(config, "browser_score_test_settle_seconds", 2.5) or 2.5
- )
- max_retries = 2
-
- async with self._custom_lock:
- for attempt in range(max_retries):
- start_time = time.time()
- custom_info = self._custom_tabs.get(cache_key)
- tab = custom_info.get("tab") if isinstance(custom_info, dict) else None
-
- try:
- if tab is None:
- debug_logger.log_info(f"[BrowserCaptcha] [Custom] 创建常驻测试标签页: {website_url}")
- tab = await self._browser_get(
- website_url,
- label="custom_browser_get",
- new_tab=True,
- )
- custom_info = {
- "tab": tab,
- "recaptcha_ready": False,
- "warmed_up": False,
- "created_at": time.time(),
- }
- self._custom_tabs[cache_key] = custom_info
-
- page_loaded = False
- for _ in range(20):
- ready_state = await self._tab_evaluate(
- tab,
- "document.readyState",
- label="custom_document_ready",
- timeout_seconds=2.0,
- )
- if ready_state == "complete":
- page_loaded = True
- break
- await tab.sleep(0.5)
-
- if not page_loaded:
- raise RuntimeError("自定义页面加载超时")
-
- if not custom_info.get("recaptcha_ready"):
- recaptcha_ready = await self._wait_for_custom_recaptcha(
- tab=tab,
- website_key=website_key,
- enterprise=enterprise,
- )
- if not recaptcha_ready:
- raise RuntimeError("自定义 reCAPTCHA 无法加载")
- custom_info["recaptcha_ready"] = True
-
- try:
- await self._tab_evaluate(tab, """
- (() => {
- try {
- const body = document.body || document.documentElement;
- const width = window.innerWidth || 1280;
- const height = window.innerHeight || 720;
- const x = Math.max(24, Math.floor(width * 0.38));
- const y = Math.max(24, Math.floor(height * 0.32));
- const moveEvent = new MouseEvent('mousemove', {
- bubbles: true,
- clientX: x,
- clientY: y
- });
- const overEvent = new MouseEvent('mouseover', {
- bubbles: true,
- clientX: x,
- clientY: y
- });
- window.focus();
- window.dispatchEvent(new Event('focus'));
- document.dispatchEvent(moveEvent);
- document.dispatchEvent(overEvent);
- if (body) {
- body.dispatchEvent(moveEvent);
- body.dispatchEvent(overEvent);
- }
- window.scrollTo(0, Math.min(320, document.body?.scrollHeight || 320));
- } catch (e) {}
- })()
- """, label="custom_pre_warm_interaction", timeout_seconds=6.0)
- except Exception:
- pass
-
- if not custom_info.get("warmed_up"):
- if warmup_seconds > 0:
- debug_logger.log_info(
- f"[BrowserCaptcha] [Custom] 首次预热测试页面 {warmup_seconds:.1f}s 后再执行 token"
- )
- try:
- await self._tab_evaluate(tab, """
- (() => {
- try {
- window.scrollTo(0, Math.min(240, document.body.scrollHeight || 240));
- window.dispatchEvent(new Event('mousemove'));
- window.dispatchEvent(new Event('focus'));
- } catch (e) {}
- })()
- """, label="custom_warmup_interaction", timeout_seconds=6.0)
- except Exception:
- pass
- await tab.sleep(warmup_seconds)
- custom_info["warmed_up"] = True
- elif per_request_settle_seconds > 0:
- debug_logger.log_info(
- f"[BrowserCaptcha] [Custom] 复用测试标签页,执行前额外等待 {per_request_settle_seconds:.1f}s"
- )
- await tab.sleep(per_request_settle_seconds)
-
- debug_logger.log_info(f"[BrowserCaptcha] [Custom] 使用常驻测试标签页执行验证 (action: {action})...")
- token = await self._execute_custom_recaptcha_on_tab(
- tab=tab,
- website_key=website_key,
- action=action,
- enterprise=enterprise,
- )
-
- duration_ms = (time.time() - start_time) * 1000
- if token:
- extracted_fingerprint = await self._extract_tab_fingerprint(tab)
- if not extracted_fingerprint:
- try:
- fallback_ua = await self._tab_evaluate(
- tab,
- "navigator.userAgent || ''",
- label="custom_fallback_ua",
- )
- fallback_lang = await self._tab_evaluate(
- tab,
- "navigator.language || ''",
- label="custom_fallback_lang",
- )
- extracted_fingerprint = {
- "user_agent": fallback_ua or "",
- "accept_language": fallback_lang or "",
- "proxy_url": self._proxy_url,
- }
- except Exception:
- extracted_fingerprint = None
- self._last_fingerprint = extracted_fingerprint
- debug_logger.log_info(
- f"[BrowserCaptcha] [Custom] ✅ 常驻测试标签页 Token获取成功(耗时 {duration_ms:.0f}ms)"
- )
- return token
-
- raise RuntimeError("自定义 token 获取失败(返回 null)")
- except Exception as e:
- debug_logger.log_warning(
- f"[BrowserCaptcha] [Custom] 尝试 {attempt + 1}/{max_retries} 失败: {str(e)}"
- )
- stale_info = self._custom_tabs.pop(cache_key, None)
- stale_tab = stale_info.get("tab") if isinstance(stale_info, dict) else None
- if stale_tab:
- await self._close_tab_quietly(stale_tab)
- if attempt >= max_retries - 1:
- debug_logger.log_error(f"[BrowserCaptcha] [Custom] 获取token异常: {str(e)}")
- return None
-
- return None
-
- async def get_custom_score(
- self,
- website_url: str,
- website_key: str,
- verify_url: str,
- action: str = "homepage",
- enterprise: bool = False,
- ) -> Dict[str, Any]:
- """在同一个常驻标签页里获取 token 并直接校验页面分数。"""
- token_started_at = time.time()
- token = await self.get_custom_token(
- website_url=website_url,
- website_key=website_key,
- action=action,
- enterprise=enterprise,
- )
- token_elapsed_ms = int((time.time() - token_started_at) * 1000)
-
- if not token:
- return {
- "token": None,
- "token_elapsed_ms": token_elapsed_ms,
- "verify_mode": "browser_page",
- "verify_elapsed_ms": 0,
- "verify_http_status": None,
- "verify_result": {},
- }
-
- cache_key = f"{website_url}|{website_key}|{1 if enterprise else 0}"
- async with self._custom_lock:
- custom_info = self._custom_tabs.get(cache_key)
- tab = custom_info.get("tab") if isinstance(custom_info, dict) else None
- if tab is None:
- raise RuntimeError("页面分数测试标签页不存在")
- verify_payload = await self._verify_score_on_tab(tab, token, verify_url)
-
- return {
- "token": token,
- "token_elapsed_ms": token_elapsed_ms,
- **verify_payload,
- }
+
+ async def warmup_resident_tabs(self, project_ids: Iterable[str], limit: Optional[int] = None) -> list[str]:
+ """预热共享打码标签页池,减少首个请求的冷启动抖动。"""
+ normalized_project_ids: list[str] = []
+ seen_projects = set()
+ for raw_project_id in project_ids:
+ project_id = str(raw_project_id or "").strip()
+ if not project_id or project_id in seen_projects:
+ continue
+ seen_projects.add(project_id)
+ normalized_project_ids.append(project_id)
+
+ await self.initialize()
+
+ try:
+ warm_limit = self._max_resident_tabs if limit is None else max(1, min(self._max_resident_tabs, int(limit)))
+ except Exception:
+ warm_limit = self._max_resident_tabs
+
+ warmed_slots: list[str] = []
+ for index in range(warm_limit):
+ warm_project_id = normalized_project_ids[index] if index < len(normalized_project_ids) else f"warmup-{index + 1}"
+ slot_id, resident_info = await self._ensure_resident_tab(
+ warm_project_id,
+ force_create=True,
+ return_slot_key=True,
+ )
+ if resident_info and resident_info.tab and slot_id:
+ if slot_id not in warmed_slots:
+ warmed_slots.append(slot_id)
+ continue
+ debug_logger.log_warning(f"[BrowserCaptcha] 预热共享标签页失败 (seed={warm_project_id})")
+
+ return warmed_slots
+
+ # ========== 常驻模式 API ==========
+
+ async def start_resident_mode(self, project_id: str):
+ """启动常驻模式
+
+ Args:
+ project_id: 用于常驻的项目 ID
+ """
+ if not str(project_id or "").strip():
+ debug_logger.log_warning("[BrowserCaptcha] 启动常驻模式失败:project_id 为空")
+ return
+
+ warmed_slots = await self.warmup_resident_tabs([project_id], limit=1)
+ if warmed_slots:
+ debug_logger.log_info(f"[BrowserCaptcha] ✅ 共享常驻打码池已启动 (seed_project: {project_id})")
+ return
+
+ debug_logger.log_error(f"[BrowserCaptcha] 常驻模式启动失败 (seed_project: {project_id})")
+
+ async def stop_resident_mode(self, project_id: Optional[str] = None):
+ """停止常驻模式
+
+ Args:
+ project_id: 指定 project_id 或 slot_id;如果为 None 则关闭所有常驻标签页
+ """
+ target_slot_id = None
+ if project_id:
+ async with self._resident_lock:
+ target_slot_id = project_id if project_id in self._resident_tabs else self._resolve_affinity_slot_locked(project_id)
+
+ if target_slot_id:
+ await self._close_resident_tab(target_slot_id)
+ self._resident_error_streaks.pop(target_slot_id, None)
+ debug_logger.log_info(f"[BrowserCaptcha] 已关闭共享标签页 slot={target_slot_id} (request={project_id})")
+ return
+
+ async with self._resident_lock:
+ slot_ids = list(self._resident_tabs.keys())
+ resident_items = list(self._resident_tabs.values())
+ self._resident_tabs.clear()
+ self._project_resident_affinity.clear()
+ self._resident_error_streaks.clear()
+ self._sync_compat_resident_state()
+
+ for resident_info in resident_items:
+ if resident_info and resident_info.tab:
+ await self._close_tab_quietly(resident_info.tab)
+ debug_logger.log_info(f"[BrowserCaptcha] 已关闭所有共享常驻标签页 (共 {len(slot_ids)} 个)")
+
+ async def _wait_for_document_ready(self, tab, retries: int = 30, interval_seconds: float = 1.0) -> bool:
+ """等待页面文档加载完成。"""
+ for _ in range(retries):
+ try:
+ ready_state = await self._tab_evaluate(
+ tab,
+ "document.readyState",
+ label="document.readyState",
+ timeout_seconds=2.0,
+ )
+ if ready_state == "complete":
+ return True
+ except Exception:
+ pass
+ await asyncio.sleep(interval_seconds)
+ return False
+
+ def _is_server_side_flow_error(self, error_text: str) -> bool:
+ error_lower = (error_text or "").lower()
+ return any(keyword in error_lower for keyword in [
+ "http error 500",
+ "public_error",
+ "internal error",
+ "reason=internal",
+ "reason: internal",
+ "\"reason\":\"internal\"",
+ "server error",
+ "upstream error",
+ ])
+
+ async def _clear_tab_site_storage(self, tab) -> Dict[str, Any]:
+ """清理当前站点的本地存储状态,但保留 cookies 登录态。"""
+ result = await self._tab_evaluate(tab, """
+ (async () => {
+ const summary = {
+ local_storage_cleared: false,
+ session_storage_cleared: false,
+ cache_storage_deleted: [],
+ indexed_db_deleted: [],
+ indexed_db_errors: [],
+ service_worker_unregistered: 0,
+ };
+
+ try {
+ window.localStorage.clear();
+ summary.local_storage_cleared = true;
+ } catch (e) {
+ summary.local_storage_error = String(e);
+ }
+
+ try {
+ window.sessionStorage.clear();
+ summary.session_storage_cleared = true;
+ } catch (e) {
+ summary.session_storage_error = String(e);
+ }
+
+ try {
+ if (typeof caches !== 'undefined') {
+ const cacheKeys = await caches.keys();
+ for (const key of cacheKeys) {
+ const deleted = await caches.delete(key);
+ if (deleted) {
+ summary.cache_storage_deleted.push(key);
+ }
+ }
+ }
+ } catch (e) {
+ summary.cache_storage_error = String(e);
+ }
+
+ try {
+ if (navigator.serviceWorker) {
+ const registrations = await navigator.serviceWorker.getRegistrations();
+ for (const registration of registrations) {
+ const ok = await registration.unregister();
+ if (ok) {
+ summary.service_worker_unregistered += 1;
+ }
+ }
+ }
+ } catch (e) {
+ summary.service_worker_error = String(e);
+ }
+
+ try {
+ if (typeof indexedDB !== 'undefined' && typeof indexedDB.databases === 'function') {
+ const dbs = await indexedDB.databases();
+ const names = Array.from(new Set(
+ dbs
+ .map((item) => item && item.name)
+ .filter((name) => typeof name === 'string' && name)
+ ));
+ for (const name of names) {
+ try {
+ await new Promise((resolve) => {
+ const request = indexedDB.deleteDatabase(name);
+ request.onsuccess = () => resolve(true);
+ request.onerror = () => resolve(false);
+ request.onblocked = () => resolve(false);
+ });
+ summary.indexed_db_deleted.push(name);
+ } catch (e) {
+ summary.indexed_db_errors.push(`${name}: ${String(e)}`);
+ }
+ }
+ } else {
+ summary.indexed_db_unsupported = true;
+ }
+ } catch (e) {
+ summary.indexed_db_errors.push(String(e));
+ }
+
+ return summary;
+ })()
+ """, label="clear_tab_site_storage", timeout_seconds=15.0)
+ return result if isinstance(result, dict) else {}
+
+ async def _clear_resident_storage_and_reload(self, project_id: str) -> bool:
+ """清理常驻标签页的站点数据并刷新,尝试原地自愈。"""
+ async with self._resident_lock:
+ slot_id, resident_info = self._resolve_resident_slot_for_project_locked(project_id)
+
+ if not resident_info or not resident_info.tab:
+ debug_logger.log_warning(f"[BrowserCaptcha] project_id={project_id} 没有可清理的共享标签页")
+ return False
+
+ try:
+ async with resident_info.solve_lock:
+ cleanup_summary = await self._clear_tab_site_storage(resident_info.tab)
+ debug_logger.log_warning(
+ f"[BrowserCaptcha] project_id={project_id}, slot={slot_id} 已清理站点存储,准备刷新恢复: {cleanup_summary}"
+ )
+
+ resident_info.recaptcha_ready = False
+ await self._tab_reload(
+ resident_info.tab,
+ label=f"clear_resident_reload:{slot_id or project_id}",
+ )
+
+ if not await self._wait_for_document_ready(resident_info.tab, retries=30, interval_seconds=1.0):
+ debug_logger.log_warning(f"[BrowserCaptcha] project_id={project_id}, slot={slot_id} 清理后页面加载超时")
+ return False
+
+ resident_info.recaptcha_ready = await self._wait_for_recaptcha(resident_info.tab)
+ if resident_info.recaptcha_ready:
+ resident_info.last_used_at = time.time()
+ self._remember_project_affinity(project_id, slot_id, resident_info)
+ self._resident_error_streaks.pop(slot_id, None)
+ debug_logger.log_warning(f"[BrowserCaptcha] project_id={project_id}, slot={slot_id} 清理后已恢复 reCAPTCHA")
+ return True
+
+ debug_logger.log_warning(f"[BrowserCaptcha] project_id={project_id}, slot={slot_id} 清理后仍无法恢复 reCAPTCHA")
+ return False
+ except Exception as e:
+ debug_logger.log_warning(f"[BrowserCaptcha] project_id={project_id}, slot={slot_id} 清理或刷新失败: {e}")
+ return False
+
+ async def _recreate_resident_tab(self, project_id: str) -> bool:
+ """关闭并重建常驻标签页。"""
+ slot_id, resident_info = await self._rebuild_resident_tab(project_id, return_slot_key=True)
+ if resident_info is None:
+ debug_logger.log_warning(f"[BrowserCaptcha] project_id={project_id} 重建共享标签页失败")
+ return False
+ debug_logger.log_warning(f"[BrowserCaptcha] project_id={project_id} 已重建共享标签页 slot={slot_id}")
+ return True
+
+ async def _restart_browser_for_project(self, project_id: str) -> bool:
+ """重启整个 nodriver 浏览器,并恢复共享打码池。"""
+ async with self._resident_lock:
+ restore_slots = max(1, min(self._max_resident_tabs, len(self._resident_tabs) or 1))
+ restore_project_ids: list[str] = []
+ seen_projects = set()
+ for candidate in [project_id, *self._project_resident_affinity.keys()]:
+ normalized_project_id = str(candidate or "").strip()
+ if not normalized_project_id or normalized_project_id in seen_projects:
+ continue
+ seen_projects.add(normalized_project_id)
+ restore_project_ids.append(normalized_project_id)
+ if len(restore_project_ids) >= restore_slots:
+ break
+
+ debug_logger.log_warning(f"[BrowserCaptcha] project_id={project_id} 准备重启 nodriver 浏览器以恢复")
+ await self._shutdown_browser_runtime(cancel_idle_reaper=False, reason=f"restart_project:{project_id}")
+
+ warmed_slots = await self.warmup_resident_tabs(restore_project_ids, limit=restore_slots)
+ if not warmed_slots:
+ debug_logger.log_warning(f"[BrowserCaptcha] project_id={project_id} 浏览器重启后恢复共享标签页失败")
+ return False
+
+ slot_id, resident_info = await self._ensure_resident_tab(project_id, return_slot_key=True)
+ if resident_info is None or not slot_id:
+ debug_logger.log_warning(f"[BrowserCaptcha] project_id={project_id} 浏览器重启后无法定位可用共享标签页")
+ return False
+
+ self._remember_project_affinity(project_id, slot_id, resident_info)
+ self._resident_error_streaks.pop(slot_id, None)
+ debug_logger.log_warning(
+ f"[BrowserCaptcha] project_id={project_id} 浏览器重启后已恢复共享标签页池 "
+ f"(slots={len(warmed_slots)}, active_slot={slot_id})"
+ )
+ return True
+
+ async def report_flow_error(self, project_id: str, error_reason: str, error_message: str = ""):
+ """上游生成接口异常时,对常驻标签页执行自愈恢复。"""
+ if not project_id:
+ return
+
+ async with self._resident_lock:
+ slot_id, _ = self._resolve_resident_slot_for_project_locked(project_id)
+
+ if not slot_id:
+ return
+
+ streak = self._resident_error_streaks.get(slot_id, 0) + 1
+ self._resident_error_streaks[slot_id] = streak
+ error_text = f"{error_reason or ''} {error_message or ''}".strip()
+ error_lower = error_text.lower()
+ debug_logger.log_warning(
+ f"[BrowserCaptcha] project_id={project_id}, slot={slot_id} 收到上游异常,streak={streak}, reason={error_reason}, detail={error_message[:200]}"
+ )
+
+ if not self._initialized or not self.browser:
+ return
+
+ # 403 错误:先清理缓存再重建
+ if "403" in error_text or "forbidden" in error_lower or "recaptcha" in error_lower:
+ debug_logger.log_warning(
+ f"[BrowserCaptcha] project_id={project_id} 检测到 403/reCAPTCHA 错误,清理缓存并重建"
+ )
+ healed = await self._clear_resident_storage_and_reload(project_id)
+ if not healed:
+ await self._recreate_resident_tab(project_id)
+ return
+
+ # 服务端错误:根据连续失败次数决定恢复策略
+ if self._is_server_side_flow_error(error_text):
+ recreate_threshold = max(2, int(getattr(config, "browser_personal_recreate_threshold", 2) or 2))
+ restart_threshold = max(3, int(getattr(config, "browser_personal_restart_threshold", 3) or 3))
+
+ if streak >= restart_threshold:
+ await self._restart_browser_for_project(project_id)
+ return
+ if streak >= recreate_threshold:
+ await self._recreate_resident_tab(project_id)
+ return
+
+ healed = await self._clear_resident_storage_and_reload(project_id)
+ if not healed:
+ await self._recreate_resident_tab(project_id)
+ return
+
+ # 其他错误:直接重建标签页
+ await self._recreate_resident_tab(project_id)
+
+ async def _wait_for_recaptcha(self, tab) -> bool:
+ """等待 reCAPTCHA 加载
+
+ Returns:
+ True if reCAPTCHA loaded successfully
+ """
+ debug_logger.log_info("[BrowserCaptcha] 注入 reCAPTCHA 脚本...")
+
+ # 注入 reCAPTCHA Enterprise 脚本
+ await self._tab_evaluate(tab, f"""
+ (() => {{
+ if (document.querySelector('script[src*="recaptcha"]')) return;
+ const script = document.createElement('script');
+ script.src = 'https://www.google.com/recaptcha/enterprise.js?render={self.website_key}';
+ script.async = true;
+ document.head.appendChild(script);
+ }})()
+ """, label="inject_recaptcha_script", timeout_seconds=5.0)
+
+ # 等待 reCAPTCHA 加载(减少等待时间)
+ for i in range(15): # 减少到15次,最多7.5秒
+ try:
+ is_ready = await self._tab_evaluate(
+ tab,
+ "typeof grecaptcha !== 'undefined' && "
+ "typeof grecaptcha.enterprise !== 'undefined' && "
+ "typeof grecaptcha.enterprise.execute === 'function'",
+ label="check_recaptcha_ready",
+ timeout_seconds=2.5,
+ )
+
+ if is_ready:
+ debug_logger.log_info(f"[BrowserCaptcha] reCAPTCHA 已就绪 (等待了 {i * 0.5}s)")
+ return True
+
+ await tab.sleep(0.5)
+ except Exception as e:
+ debug_logger.log_warning(f"[BrowserCaptcha] 检查 reCAPTCHA 时异常: {e}")
+ await tab.sleep(0.3) # 异常时减少等待时间
+
+ debug_logger.log_warning("[BrowserCaptcha] reCAPTCHA 加载超时")
+ return False
+
+ async def _wait_for_custom_recaptcha(
+ self,
+ tab,
+ website_key: str,
+ enterprise: bool = False,
+ ) -> bool:
+ """等待任意站点的 reCAPTCHA 加载,用于分数测试。"""
+ debug_logger.log_info("[BrowserCaptcha] 检测自定义 reCAPTCHA...")
+
+ ready_check = (
+ "typeof grecaptcha !== 'undefined' && typeof grecaptcha.enterprise !== 'undefined' && "
+ "typeof grecaptcha.enterprise.execute === 'function'"
+ ) if enterprise else (
+ "typeof grecaptcha !== 'undefined' && typeof grecaptcha.execute === 'function'"
+ )
+ script_path = "recaptcha/enterprise.js" if enterprise else "recaptcha/api.js"
+ label = "Enterprise" if enterprise else "V3"
+
+ is_ready = await self._tab_evaluate(
+ tab,
+ ready_check,
+ label="check_custom_recaptcha_preloaded",
+ timeout_seconds=2.5,
+ )
+ if is_ready:
+ debug_logger.log_info(f"[BrowserCaptcha] 自定义 reCAPTCHA {label} 已加载")
+ return True
+
+ debug_logger.log_info("[BrowserCaptcha] 未检测到自定义 reCAPTCHA,注入脚本...")
+ await self._tab_evaluate(tab, f"""
+ (() => {{
+ if (document.querySelector('script[src*="recaptcha"]')) return;
+ const script = document.createElement('script');
+ script.src = 'https://www.google.com/{script_path}?render={website_key}';
+ script.async = true;
+ document.head.appendChild(script);
+ }})()
+ """, label="inject_custom_recaptcha_script", timeout_seconds=5.0)
+
+ await tab.sleep(3)
+ for i in range(20):
+ is_ready = await self._tab_evaluate(
+ tab,
+ ready_check,
+ label="check_custom_recaptcha_ready",
+ timeout_seconds=2.5,
+ )
+ if is_ready:
+ debug_logger.log_info(f"[BrowserCaptcha] 自定义 reCAPTCHA {label} 已加载(等待了 {i * 0.5} 秒)")
+ return True
+ await tab.sleep(0.5)
+
+ debug_logger.log_warning("[BrowserCaptcha] 自定义 reCAPTCHA 加载超时")
+ return False
+
+ async def _execute_recaptcha_on_tab(self, tab, action: str = "IMAGE_GENERATION") -> Optional[str]:
+ """在指定标签页执行 reCAPTCHA 获取 token
+
+ Args:
+ tab: nodriver 标签页对象
+ action: reCAPTCHA action类型 (IMAGE_GENERATION 或 VIDEO_GENERATION)
+
+ Returns:
+ reCAPTCHA token 或 None
+ """
+ # 生成唯一变量名避免冲突
+ ts = int(time.time() * 1000)
+ token_var = f"_recaptcha_token_{ts}"
+ error_var = f"_recaptcha_error_{ts}"
+
+ execute_script = f"""
+ (() => {{
+ window.{token_var} = null;
+ window.{error_var} = null;
+
+ try {{
+ grecaptcha.enterprise.ready(function() {{
+ grecaptcha.enterprise.execute('{self.website_key}', {{action: '{action}'}})
+ .then(function(token) {{
+ window.{token_var} = token;
+ }})
+ .catch(function(err) {{
+ window.{error_var} = err.message || 'execute failed';
+ }});
+ }});
+ }} catch (e) {{
+ window.{error_var} = e.message || 'exception';
+ }}
+ }})()
+ """
+
+ # 注入执行脚本
+ await self._tab_evaluate(
+ tab,
+ execute_script,
+ label=f"execute_recaptcha:{action}",
+ timeout_seconds=5.0,
+ )
+
+ # 轮询等待结果(最多 30 秒)
+ token = None
+ for i in range(60):
+ await tab.sleep(0.5)
+ token = await self._tab_evaluate(
+ tab,
+ f"window.{token_var}",
+ label=f"poll_recaptcha_token:{action}",
+ timeout_seconds=2.0,
+ )
+ if token:
+ break
+ error = await self._tab_evaluate(
+ tab,
+ f"window.{error_var}",
+ label=f"poll_recaptcha_error:{action}",
+ timeout_seconds=2.0,
+ )
+ if error:
+ debug_logger.log_error(f"[BrowserCaptcha] reCAPTCHA 错误: {error}")
+ break
+
+ # 清理临时变量
+ try:
+ await self._tab_evaluate(
+ tab,
+ f"delete window.{token_var}; delete window.{error_var};",
+ label="cleanup_recaptcha_temp_vars",
+ timeout_seconds=5.0,
+ )
+ except:
+ pass
+
+ if token:
+ debug_logger.log_info(f"[BrowserCaptcha] ✅ Token 获取成功 (长度: {len(token)})")
+ else:
+ debug_logger.log_warning("[BrowserCaptcha] Token 获取失败,交由上层执行标签页恢复")
+
+ return token
+
+ async def _execute_custom_recaptcha_on_tab(
+ self,
+ tab,
+ website_key: str,
+ action: str = "homepage",
+ enterprise: bool = False,
+ ) -> Optional[str]:
+ """在指定标签页执行任意站点的 reCAPTCHA。"""
+ ts = int(time.time() * 1000)
+ token_var = f"_custom_recaptcha_token_{ts}"
+ error_var = f"_custom_recaptcha_error_{ts}"
+ execute_target = "grecaptcha.enterprise.execute" if enterprise else "grecaptcha.execute"
+
+ execute_script = f"""
+ (() => {{
+ window.{token_var} = null;
+ window.{error_var} = null;
+
+ try {{
+ grecaptcha.ready(function() {{
+ {execute_target}('{website_key}', {{action: '{action}'}})
+ .then(function(token) {{
+ window.{token_var} = token;
+ }})
+ .catch(function(err) {{
+ window.{error_var} = err.message || 'execute failed';
+ }});
+ }});
+ }} catch (e) {{
+ window.{error_var} = e.message || 'exception';
+ }}
+ }})()
+ """
+
+ await self._tab_evaluate(
+ tab,
+ execute_script,
+ label=f"execute_custom_recaptcha:{action}",
+ timeout_seconds=5.0,
+ )
+
+ token = None
+ for _ in range(30):
+ await tab.sleep(0.5)
+ token = await self._tab_evaluate(
+ tab,
+ f"window.{token_var}",
+ label=f"poll_custom_recaptcha_token:{action}",
+ timeout_seconds=2.0,
+ )
+ if token:
+ break
+ error = await self._tab_evaluate(
+ tab,
+ f"window.{error_var}",
+ label=f"poll_custom_recaptcha_error:{action}",
+ timeout_seconds=2.0,
+ )
+ if error:
+ debug_logger.log_error(f"[BrowserCaptcha] 自定义 reCAPTCHA 错误: {error}")
+ break
+
+ try:
+ await self._tab_evaluate(
+ tab,
+ f"delete window.{token_var}; delete window.{error_var};",
+ label="cleanup_custom_recaptcha_temp_vars",
+ timeout_seconds=5.0,
+ )
+ except:
+ pass
+
+ if token:
+ post_wait_seconds = 3
+ try:
+ post_wait_seconds = float(getattr(config, "browser_recaptcha_settle_seconds", 3) or 3)
+ except Exception:
+ pass
+ if post_wait_seconds > 0:
+ debug_logger.log_info(
+ f"[BrowserCaptcha] 自定义 reCAPTCHA 已完成,额外等待 {post_wait_seconds:.1f}s 后返回 token"
+ )
+ await tab.sleep(post_wait_seconds)
+
+ return token
+
+ async def _verify_score_on_tab(self, tab, token: str, verify_url: str) -> Dict[str, Any]:
+ """直接读取测试页面展示的分数,避免 verify.php 与页面显示口径不一致。"""
+ _ = token
+ _ = verify_url
+ started_at = time.time()
+ timeout_seconds = 25.0
+ refresh_clicked = False
+ last_snapshot: Dict[str, Any] = {}
+
+ try:
+ timeout_seconds = float(getattr(config, "browser_score_dom_wait_seconds", 25) or 25)
+ except Exception:
+ pass
+
+ while (time.time() - started_at) < timeout_seconds:
+ try:
+ result = await self._tab_evaluate(tab, """
+ (() => {
+ const bodyText = ((document.body && document.body.innerText) || "")
+ .replace(/\\u00a0/g, " ")
+ .replace(/\\r/g, "");
+ const patterns = [
+ { source: "current_score", regex: /Your score is:\\s*([01](?:\\.\\d+)?)/i },
+ { source: "selected_score", regex: /Selected Score Test:[\\s\\S]{0,400}?Score:\\s*([01](?:\\.\\d+)?)/i },
+ { source: "history_score", regex: /(?:^|\\n)\\s*Score:\\s*([01](?:\\.\\d+)?)\\s*;/i },
+ ];
+ let score = null;
+ let source = "";
+ for (const item of patterns) {
+ const match = bodyText.match(item.regex);
+ if (!match) continue;
+ const parsed = Number(match[1]);
+ if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 1) {
+ score = parsed;
+ source = item.source;
+ break;
+ }
+ }
+ const uaMatch = bodyText.match(/Current User Agent:\\s*([^\\n]+)/i);
+ const ipMatch = bodyText.match(/Current IP Address:\\s*([^\\n]+)/i);
+ return {
+ score,
+ source,
+ raw_text: bodyText.slice(0, 4000),
+ current_user_agent: uaMatch ? uaMatch[1].trim() : "",
+ current_ip_address: ipMatch ? ipMatch[1].trim() : "",
+ title: document.title || "",
+ url: location.href || "",
+ };
+ })()
+ """, label="verify_score_dom", timeout_seconds=10.0)
+ except Exception as e:
+ result = {"error": f"{type(e).__name__}: {str(e)[:200]}"}
+
+ if isinstance(result, dict):
+ last_snapshot = result
+ score = result.get("score")
+ if isinstance(score, (int, float)):
+ elapsed_ms = int((time.time() - started_at) * 1000)
+ return {
+ "verify_mode": "browser_page_dom",
+ "verify_elapsed_ms": elapsed_ms,
+ "verify_http_status": None,
+ "verify_result": {
+ "success": True,
+ "score": score,
+ "source": result.get("source") or "antcpt_dom",
+ "raw_text": result.get("raw_text") or "",
+ "current_user_agent": result.get("current_user_agent") or "",
+ "current_ip_address": result.get("current_ip_address") or "",
+ "page_title": result.get("title") or "",
+ "page_url": result.get("url") or "",
+ },
+ }
+
+ if not refresh_clicked and (time.time() - started_at) >= 2:
+ refresh_clicked = True
+ try:
+ await self._tab_evaluate(tab, """
+ (() => {
+ const nodes = Array.from(
+ document.querySelectorAll('button, input[type="button"], input[type="submit"], a')
+ );
+ const target = nodes.find((node) => {
+ const text = (node.innerText || node.textContent || node.value || "").trim();
+ return /Refresh score now!?/i.test(text);
+ });
+ if (target) {
+ target.click();
+ return true;
+ }
+ return false;
+ })()
+ """, label="verify_score_click_refresh", timeout_seconds=5.0)
+ except Exception:
+ pass
+
+ await tab.sleep(0.5)
+
+ elapsed_ms = int((time.time() - started_at) * 1000)
+ if not isinstance(last_snapshot, dict):
+ last_snapshot = {"raw": last_snapshot}
+
+ return {
+ "verify_mode": "browser_page_dom",
+ "verify_elapsed_ms": elapsed_ms,
+ "verify_http_status": None,
+ "verify_result": {
+ "success": False,
+ "score": None,
+ "source": "antcpt_dom_timeout",
+ "raw_text": last_snapshot.get("raw_text") or "",
+ "current_user_agent": last_snapshot.get("current_user_agent") or "",
+ "current_ip_address": last_snapshot.get("current_ip_address") or "",
+ "page_title": last_snapshot.get("title") or "",
+ "page_url": last_snapshot.get("url") or "",
+ "error": last_snapshot.get("error") or "未在页面中读取到分数",
+ },
+ }
+
+ async def _extract_tab_fingerprint(self, tab) -> Optional[Dict[str, Any]]:
+ """从 nodriver 标签页提取浏览器指纹信息。"""
+ try:
+ fingerprint = await self._tab_evaluate(tab, """
+ () => {
+ const ua = navigator.userAgent || "";
+ const lang = navigator.language || "";
+ const uaData = navigator.userAgentData || null;
+ let secChUa = "";
+ let secChUaMobile = "";
+ let secChUaPlatform = "";
+
+ if (uaData) {
+ if (Array.isArray(uaData.brands) && uaData.brands.length > 0) {
+ secChUa = uaData.brands
+ .map((item) => `"${item.brand}";v="${item.version}"`)
+ .join(", ");
+ }
+ secChUaMobile = uaData.mobile ? "?1" : "?0";
+ if (uaData.platform) {
+ secChUaPlatform = `"${uaData.platform}"`;
+ }
+ }
+
+ return {
+ user_agent: ua,
+ accept_language: lang,
+ sec_ch_ua: secChUa,
+ sec_ch_ua_mobile: secChUaMobile,
+ sec_ch_ua_platform: secChUaPlatform,
+ };
+ }
+ """, label="extract_tab_fingerprint", timeout_seconds=8.0)
+ if not isinstance(fingerprint, dict):
+ return None
+
+ result: Dict[str, Any] = {"proxy_url": self._proxy_url}
+ for key in ("user_agent", "accept_language", "sec_ch_ua", "sec_ch_ua_mobile", "sec_ch_ua_platform"):
+ value = fingerprint.get(key)
+ if isinstance(value, str) and value:
+ result[key] = value
+ return result
+ except Exception as e:
+ debug_logger.log_warning(f"[BrowserCaptcha] 提取 nodriver 指纹失败: {e}")
+ return None
+
+ # ========== 主要 API ==========
+
+ async def get_token(self, project_id: str, action: str = "IMAGE_GENERATION") -> Optional[str]:
+ """获取 reCAPTCHA token
+
+ 使用全局共享打码标签页池。标签页不再按 project_id 一对一绑定,
+ 谁拿到空闲 tab 就用谁的;只有 Session Token 刷新/故障恢复会优先参考最近一次映射。
+
+ Args:
+ project_id: Flow项目ID
+ action: reCAPTCHA action类型
+ - IMAGE_GENERATION: 图片生成和2K/4K图片放大 (默认)
+ - VIDEO_GENERATION: 视频生成和视频放大
+
+ Returns:
+ reCAPTCHA token字符串,如果获取失败返回None
+ """
+ debug_logger.log_info(f"[BrowserCaptcha] get_token 开始: project_id={project_id}, action={action}, 当前标签页数={len(self._resident_tabs)}/{self._max_resident_tabs}")
+
+ # 确保浏览器已初始化
+ await self.initialize()
+ self._last_fingerprint = None
+
+ debug_logger.log_info(
+ f"[BrowserCaptcha] 开始从共享打码池获取标签页 (project: {project_id}, 当前: {len(self._resident_tabs)}/{self._max_resident_tabs})"
+ )
+ slot_id, resident_info = await self._ensure_resident_tab(project_id, return_slot_key=True)
+ if resident_info is None or not slot_id:
+ debug_logger.log_warning(
+ f"[BrowserCaptcha] 共享标签页池不可用,fallback 到传统模式 (project: {project_id})"
+ )
+ return await self._get_token_legacy(project_id, action)
+
+ debug_logger.log_info(
+ f"[BrowserCaptcha] ✅ 共享标签页可用 (slot={slot_id}, project={project_id}, use_count={resident_info.use_count})"
+ )
+
+ if resident_info and resident_info.tab and not resident_info.recaptcha_ready:
+ debug_logger.log_warning(
+ f"[BrowserCaptcha] 共享标签页未就绪,准备重建 cold slot={slot_id}, project={project_id}"
+ )
+ slot_id, resident_info = await self._rebuild_resident_tab(
+ project_id,
+ slot_id=slot_id,
+ return_slot_key=True,
+ )
+
+ # 使用常驻标签页生成 token(在锁外执行,避免阻塞)
+ if resident_info and resident_info.recaptcha_ready and resident_info.tab:
+ start_time = time.time()
+ debug_logger.log_info(
+ f"[BrowserCaptcha] 从共享常驻标签页即时生成 token (slot={slot_id}, project={project_id}, action={action})..."
+ )
+ try:
+ async with resident_info.solve_lock:
+ token = await self._run_with_timeout(
+ self._execute_recaptcha_on_tab(resident_info.tab, action),
+ timeout_seconds=self._solve_timeout_seconds,
+ label=f"resident_solve:{slot_id}:{project_id}:{action}",
+ )
+ duration_ms = (time.time() - start_time) * 1000
+ if token:
+ # 更新使用时间和计数
+ resident_info.last_used_at = time.time()
+ resident_info.use_count += 1
+ self._remember_project_affinity(project_id, slot_id, resident_info)
+ self._resident_error_streaks.pop(slot_id, None)
+ self._last_fingerprint = await self._extract_tab_fingerprint(resident_info.tab)
+ debug_logger.log_info(
+ f"[BrowserCaptcha] ✅ Token生成成功(slot={slot_id}, 耗时 {duration_ms:.0f}ms, 使用次数: {resident_info.use_count})"
+ )
+ return token
+ else:
+ debug_logger.log_warning(
+ f"[BrowserCaptcha] 共享标签页生成失败 (slot={slot_id}, project={project_id}),尝试重建..."
+ )
+ except Exception as e:
+ debug_logger.log_warning(f"[BrowserCaptcha] 共享标签页异常 (slot={slot_id}): {e},尝试重建...")
+
+ # 常驻标签页失效,尝试重建
+ debug_logger.log_info(f"[BrowserCaptcha] 开始重建共享标签页 (slot={slot_id}, project={project_id})")
+ slot_id, resident_info = await self._rebuild_resident_tab(
+ project_id,
+ slot_id=slot_id,
+ return_slot_key=True,
+ )
+ debug_logger.log_info(f"[BrowserCaptcha] 共享标签页重建结束 (slot={slot_id}, project={project_id})")
+
+ # 重建后立即尝试生成(在锁外执行)
+ if resident_info:
+ try:
+ async with resident_info.solve_lock:
+ token = await self._run_with_timeout(
+ self._execute_recaptcha_on_tab(resident_info.tab, action),
+ timeout_seconds=self._solve_timeout_seconds,
+ label=f"resident_resolve_after_rebuild:{slot_id}:{project_id}:{action}",
+ )
+ if token:
+ resident_info.last_used_at = time.time()
+ resident_info.use_count += 1
+ self._remember_project_affinity(project_id, slot_id, resident_info)
+ self._resident_error_streaks.pop(slot_id, None)
+ self._last_fingerprint = await self._extract_tab_fingerprint(resident_info.tab)
+ debug_logger.log_info(f"[BrowserCaptcha] ✅ 重建后 Token生成成功 (slot={slot_id})")
+ return token
+ except Exception:
+ pass
+
+ # 最终 Fallback: 使用传统模式
+ debug_logger.log_warning(f"[BrowserCaptcha] 所有常驻方式失败,fallback 到传统模式 (project: {project_id})")
+ legacy_token = await self._get_token_legacy(project_id, action)
+ if legacy_token:
+ if slot_id:
+ self._resident_error_streaks.pop(slot_id, None)
+ return legacy_token
+
+ async def _create_resident_tab(self, slot_id: str, project_id: Optional[str] = None) -> Optional[ResidentTabInfo]:
+ """创建一个共享常驻打码标签页
+
+ Args:
+ slot_id: 共享标签页槽位 ID
+ project_id: 触发创建的项目 ID,仅用于日志和最近映射
+
+ Returns:
+ ResidentTabInfo 对象,或 None(创建失败)
+ """
+ try:
+ # 使用 Flow API 地址作为基础页面
+ website_url = "https://labs.google/fx/api/auth/providers"
+ debug_logger.log_info(f"[BrowserCaptcha] 创建共享常驻标签页 slot={slot_id}, seed_project={project_id}")
+
+ async with self._resident_lock:
+ existing_tabs = [info.tab for info in self._resident_tabs.values() if info.tab]
+
+ # 获取或创建标签页
+ tabs = self.browser.tabs
+ available_tab = None
+
+ # 查找未被占用的标签页
+ for tab in tabs:
+ if tab not in existing_tabs:
+ available_tab = tab
+ break
+
+ if available_tab:
+ tab = available_tab
+ debug_logger.log_info(f"[BrowserCaptcha] 复用未占用的标签页")
+ await self._tab_get(
+ tab,
+ website_url,
+ label=f"resident_tab_get:{slot_id}",
+ )
+ else:
+ debug_logger.log_info(f"[BrowserCaptcha] 创建新标签页")
+ tab = await self._browser_get(
+ website_url,
+ label=f"resident_browser_get:{slot_id}",
+ new_tab=True,
+ )
+
+ # 等待页面加载完成(减少等待时间)
+ page_loaded = False
+ for retry in range(10): # 减少到10次,最多5秒
+ try:
+ await asyncio.sleep(0.5)
+ ready_state = await self._tab_evaluate(
+ tab,
+ "document.readyState",
+ label=f"resident_document_ready:{slot_id}",
+ timeout_seconds=2.0,
+ )
+ if ready_state == "complete":
+ page_loaded = True
+ debug_logger.log_info(f"[BrowserCaptcha] 页面已加载")
+ break
+ except Exception as e:
+ debug_logger.log_warning(f"[BrowserCaptcha] 等待页面异常: {e},重试 {retry + 1}/10...")
+ await asyncio.sleep(0.3) # 减少重试间隔
+
+ if not page_loaded:
+ debug_logger.log_error(f"[BrowserCaptcha] 页面加载超时 (slot={slot_id}, project={project_id})")
+ await self._close_tab_quietly(tab)
+ return None
+
+ # 等待 reCAPTCHA 加载
+ recaptcha_ready = await self._wait_for_recaptcha(tab)
+
+ if not recaptcha_ready:
+ debug_logger.log_error(f"[BrowserCaptcha] reCAPTCHA 加载失败 (slot={slot_id}, project={project_id})")
+ await self._close_tab_quietly(tab)
+ return None
+
+ # 创建常驻信息对象
+ resident_info = ResidentTabInfo(tab, slot_id, project_id=project_id)
+ resident_info.recaptcha_ready = True
+
+ debug_logger.log_info(f"[BrowserCaptcha] ✅ 共享常驻标签页创建成功 (slot={slot_id}, project={project_id})")
+ return resident_info
+
+ except Exception as e:
+ debug_logger.log_error(f"[BrowserCaptcha] 创建共享常驻标签页异常 (slot={slot_id}, project={project_id}): {e}")
+ return None
+
+ async def _close_resident_tab(self, slot_id: str):
+ """关闭指定 slot 的共享常驻标签页
+
+ Args:
+ slot_id: 共享标签页槽位 ID
+ """
+ async with self._resident_lock:
+ resident_info = self._resident_tabs.pop(slot_id, None)
+ self._forget_project_affinity_for_slot_locked(slot_id)
+ self._resident_error_streaks.pop(slot_id, None)
+ self._sync_compat_resident_state()
+
+ if resident_info and resident_info.tab:
+ try:
+ await self._close_tab_quietly(resident_info.tab)
+ debug_logger.log_info(f"[BrowserCaptcha] 已关闭共享常驻标签页 slot={slot_id}")
+ except Exception as e:
+ debug_logger.log_warning(f"[BrowserCaptcha] 关闭标签页时异常: {e}")
+
+ async def invalidate_token(self, project_id: str):
+ """当检测到 token 无效时调用,重建当前项目最近映射的共享标签页。
+
+ Args:
+ project_id: 项目 ID
+ """
+ debug_logger.log_warning(
+ f"[BrowserCaptcha] Token 被标记为无效 (project: {project_id}),仅重建共享池中的对应标签页,避免清空全局浏览器状态"
+ )
+
+ # 重建标签页
+ slot_id, resident_info = await self._rebuild_resident_tab(project_id, return_slot_key=True)
+ if resident_info and slot_id:
+ debug_logger.log_info(f"[BrowserCaptcha] ✅ 标签页已重建 (project: {project_id}, slot={slot_id})")
+ else:
+ debug_logger.log_error(f"[BrowserCaptcha] 标签页重建失败 (project: {project_id})")
+
+ async def _get_token_legacy(self, project_id: str, action: str = "IMAGE_GENERATION") -> Optional[str]:
+ """传统模式获取 reCAPTCHA token(每次创建新标签页)
+
+ Args:
+ project_id: Flow项目ID
+ action: reCAPTCHA action类型 (IMAGE_GENERATION 或 VIDEO_GENERATION)
+
+ Returns:
+ reCAPTCHA token字符串,如果获取失败返回None
+ """
+ # 确保浏览器已启动
+ if not self._initialized or not self.browser:
+ await self.initialize()
+
+ start_time = time.time()
+ tab = None
+
+ async with self._legacy_lock:
+ try:
+ website_url = "https://labs.google/fx/api/auth/providers"
+ debug_logger.log_info(
+ f"[BrowserCaptcha] [Legacy] 创建独立临时标签页执行验证,避免污染 resident/custom 页面: {website_url}"
+ )
+ tab = await self._browser_get(
+ website_url,
+ label=f"legacy_browser_get:{project_id}",
+ new_tab=True,
+ )
+
+ # 等待页面完全加载(增加等待时间)
+ debug_logger.log_info("[BrowserCaptcha] [Legacy] 等待页面加载...")
+ await tab.sleep(3)
+
+ # 等待页面 DOM 完成
+ for _ in range(10):
+ ready_state = await self._tab_evaluate(
+ tab,
+ "document.readyState",
+ label=f"legacy_document_ready:{project_id}",
+ timeout_seconds=2.0,
+ )
+ if ready_state == "complete":
+ break
+ await tab.sleep(0.5)
+
+ # 等待 reCAPTCHA 加载
+ recaptcha_ready = await self._wait_for_recaptcha(tab)
+
+ if not recaptcha_ready:
+ debug_logger.log_error("[BrowserCaptcha] [Legacy] reCAPTCHA 无法加载")
+ return None
+
+ # 执行 reCAPTCHA
+ debug_logger.log_info(f"[BrowserCaptcha] [Legacy] 执行 reCAPTCHA 验证 (action: {action})...")
+ token = await self._run_with_timeout(
+ self._execute_recaptcha_on_tab(tab, action),
+ timeout_seconds=self._solve_timeout_seconds,
+ label=f"legacy_solve:{project_id}:{action}",
+ )
+
+ duration_ms = (time.time() - start_time) * 1000
+
+ if token:
+ self._last_fingerprint = await self._extract_tab_fingerprint(tab)
+ debug_logger.log_info(f"[BrowserCaptcha] [Legacy] ✅ Token获取成功(耗时 {duration_ms:.0f}ms)")
+ return token
+
+ debug_logger.log_error("[BrowserCaptcha] [Legacy] Token获取失败(返回null)")
+ return None
+
+ except Exception as e:
+ debug_logger.log_error(f"[BrowserCaptcha] [Legacy] 获取token异常: {str(e)}")
+ return None
+ finally:
+ # 关闭 legacy 临时标签页(但保留浏览器)
+ if tab:
+ await self._close_tab_quietly(tab)
+
+ def get_last_fingerprint(self) -> Optional[Dict[str, Any]]:
+ """返回最近一次打码时的浏览器指纹快照。"""
+ if not self._last_fingerprint:
+ return None
+ return dict(self._last_fingerprint)
+
+ async def _clear_browser_cache(self):
+ """清理浏览器全部缓存"""
+ if not self.browser:
+ return
+
+ try:
+ debug_logger.log_info("[BrowserCaptcha] 开始清理浏览器缓存...")
+
+ # 使用 Chrome DevTools Protocol 清理缓存
+ # 清理所有类型的缓存数据
+ await self._browser_send_command(
+ "Network.clearBrowserCache",
+ label="clear_browser_cache",
+ )
+
+ # 清理 Cookies
+ await self._browser_send_command(
+ "Network.clearBrowserCookies",
+ label="clear_browser_cookies",
+ )
+
+ # 清理存储数据(localStorage, sessionStorage, IndexedDB 等)
+ await self._browser_send_command(
+ "Storage.clearDataForOrigin",
+ {
+ "origin": "https://www.google.com",
+ "storageTypes": "all"
+ },
+ label="clear_browser_origin_storage",
+ )
+
+ debug_logger.log_info("[BrowserCaptcha] ✅ 浏览器缓存已清理")
+
+ except Exception as e:
+ debug_logger.log_warning(f"[BrowserCaptcha] 清理缓存时异常: {e}")
+
+ async def _shutdown_browser_runtime(self, cancel_idle_reaper: bool = False, reason: str = "shutdown"):
+ if cancel_idle_reaper and self._idle_reaper_task and not self._idle_reaper_task.done():
+ self._idle_reaper_task.cancel()
+ try:
+ await self._idle_reaper_task
+ except asyncio.CancelledError:
+ pass
+ finally:
+ self._idle_reaper_task = None
+
+ async with self._browser_lock:
+ try:
+ await self._shutdown_browser_runtime_locked(reason=reason)
+ debug_logger.log_info(f"[BrowserCaptcha] 浏览器运行态已清理 ({reason})")
+ except Exception as e:
+ debug_logger.log_error(f"[BrowserCaptcha] 清理浏览器运行态异常 ({reason}): {str(e)}")
+
+ async def close(self):
+ """关闭浏览器"""
+ await self._shutdown_browser_runtime(cancel_idle_reaper=True, reason="service_close")
+
+ async def open_login_window(self):
+ """打开登录窗口供用户手动登录 Google"""
+ await self.initialize()
+ tab = await self._browser_get(
+ "https://accounts.google.com/",
+ label="open_login_window",
+ new_tab=True,
+ )
+ debug_logger.log_info("[BrowserCaptcha] 请在打开的浏览器中登录账号。登录完成后,无需关闭浏览器,脚本下次运行时会自动使用此状态。")
+ print("请在打开的浏览器中登录账号。登录完成后,无需关闭浏览器,脚本下次运行时会自动使用此状态。")
+
+ # ========== Session Token 刷新 ==========
+
+ async def refresh_session_token(self, project_id: str) -> Optional[str]:
+ """从常驻标签页获取最新的 Session Token
+
+ 复用共享打码标签页,通过刷新页面并从 cookies 中提取
+ __Secure-next-auth.session-token
+
+ Args:
+ project_id: 项目ID,用于定位常驻标签页
+
+ Returns:
+ 新的 Session Token,如果获取失败返回 None
+ """
+ # 确保浏览器已初始化
+ await self.initialize()
+
+ start_time = time.time()
+ debug_logger.log_info(f"[BrowserCaptcha] 开始刷新 Session Token (project: {project_id})...")
+
+ async with self._resident_lock:
+ slot_id = self._resolve_affinity_slot_locked(project_id)
+ resident_info = self._resident_tabs.get(slot_id) if slot_id else None
+
+ if resident_info is None or not slot_id:
+ slot_id, resident_info = await self._ensure_resident_tab(project_id, return_slot_key=True)
+
+ if resident_info is None or not slot_id:
+ debug_logger.log_warning(f"[BrowserCaptcha] 无法为 project_id={project_id} 获取共享常驻标签页")
+ return None
+
+ if not resident_info or not resident_info.tab:
+ debug_logger.log_error(f"[BrowserCaptcha] 无法获取常驻标签页")
+ return None
+
+ tab = resident_info.tab
+
+ try:
+ async with resident_info.solve_lock:
+ # 刷新页面以获取最新的 cookies
+ debug_logger.log_info(f"[BrowserCaptcha] 刷新常驻标签页以获取最新 cookies...")
+ resident_info.recaptcha_ready = False
+ await self._run_with_timeout(
+ self._tab_reload(
+ tab,
+ label=f"refresh_session_reload:{slot_id}",
+ ),
+ timeout_seconds=self._session_refresh_timeout_seconds,
+ label=f"refresh_session_reload_total:{slot_id}",
+ )
+
+ # 等待页面加载完成
+ for i in range(30):
+ await asyncio.sleep(1)
+ try:
+ ready_state = await self._tab_evaluate(
+ tab,
+ "document.readyState",
+ label=f"refresh_session_ready_state:{slot_id}",
+ timeout_seconds=2.0,
+ )
+ if ready_state == "complete":
+ break
+ except Exception:
+ pass
+
+ resident_info.recaptcha_ready = await self._wait_for_recaptcha(tab)
+ if not resident_info.recaptcha_ready:
+ debug_logger.log_warning(
+ f"[BrowserCaptcha] 刷新 Session Token 后 reCAPTCHA 未恢复就绪 (slot={slot_id})"
+ )
+
+ # 额外等待确保 cookies 已设置
+ await asyncio.sleep(2)
+
+ # 从 cookies 中提取 __Secure-next-auth.session-token
+ # nodriver 可以通过 browser 获取 cookies
+ session_token = None
+
+ try:
+ # 使用 nodriver 的 cookies API 获取所有 cookies
+ cookies = await self._get_browser_cookies(
+ label=f"refresh_session_get_cookies:{slot_id}",
+ )
+
+ for cookie in cookies:
+ if cookie.name == "__Secure-next-auth.session-token":
+ session_token = cookie.value
+ break
+
+ except Exception as e:
+ debug_logger.log_warning(f"[BrowserCaptcha] 通过 cookies API 获取失败: {e},尝试从 document.cookie 获取...")
+
+ # 备选方案:通过 JavaScript 获取 (注意:HttpOnly cookies 可能无法通过此方式获取)
+ try:
+ all_cookies = await self._tab_evaluate(
+ tab,
+ "document.cookie",
+ label=f"refresh_session_document_cookie:{slot_id}",
+ )
+ if all_cookies:
+ for part in all_cookies.split(";"):
+ part = part.strip()
+ if part.startswith("__Secure-next-auth.session-token="):
+ session_token = part.split("=", 1)[1]
+ break
+ except Exception as e2:
+ debug_logger.log_error(f"[BrowserCaptcha] document.cookie 获取失败: {e2}")
+
+ duration_ms = (time.time() - start_time) * 1000
+
+ if session_token:
+ resident_info.last_used_at = time.time()
+ self._remember_project_affinity(project_id, slot_id, resident_info)
+ self._resident_error_streaks.pop(slot_id, None)
+ debug_logger.log_info(f"[BrowserCaptcha] ✅ Session Token 获取成功(耗时 {duration_ms:.0f}ms)")
+ return session_token
+ else:
+ debug_logger.log_error(f"[BrowserCaptcha] ❌ 未找到 __Secure-next-auth.session-token cookie")
+ return None
+
+ except Exception as e:
+ debug_logger.log_error(f"[BrowserCaptcha] 刷新 Session Token 异常: {str(e)}")
+
+ # 共享标签页可能已失效,尝试重建
+ slot_id, resident_info = await self._rebuild_resident_tab(project_id, slot_id=slot_id, return_slot_key=True)
+ if resident_info and slot_id:
+ # 重建后再次尝试获取
+ try:
+ async with resident_info.solve_lock:
+ cookies = await self._get_browser_cookies(
+ label=f"refresh_session_get_cookies_after_rebuild:{slot_id}",
+ )
+ for cookie in cookies:
+ if cookie.name == "__Secure-next-auth.session-token":
+ resident_info.last_used_at = time.time()
+ self._remember_project_affinity(project_id, slot_id, resident_info)
+ self._resident_error_streaks.pop(slot_id, None)
+ debug_logger.log_info(f"[BrowserCaptcha] ✅ 重建后 Session Token 获取成功")
+ return cookie.value
+ except Exception:
+ pass
+
+ return None
+
+ # ========== 状态查询 ==========
+
+ def is_resident_mode_active(self) -> bool:
+ """检查是否有任何常驻标签页激活"""
+ return len(self._resident_tabs) > 0 or self._running
+
+ def get_resident_count(self) -> int:
+ """获取当前常驻标签页数量"""
+ return len(self._resident_tabs)
+
+ def get_resident_project_ids(self) -> list[str]:
+ """获取所有当前共享常驻标签页的 slot_id 列表。"""
+ return list(self._resident_tabs.keys())
+
+ def get_resident_project_id(self) -> Optional[str]:
+ """获取当前共享池中的第一个 slot_id(向后兼容)。"""
+ if self._resident_tabs:
+ return next(iter(self._resident_tabs.keys()))
+ return self.resident_project_id
+
+ async def get_custom_token(
+ self,
+ website_url: str,
+ website_key: str,
+ action: str = "homepage",
+ enterprise: bool = False,
+ ) -> Optional[str]:
+ """为任意站点执行 reCAPTCHA,用于分数测试等场景。
+
+ 与普通 legacy 模式不同,这里会复用同一个常驻标签页,避免每次冷启动新 tab。
+ """
+ await self.initialize()
+ self._last_fingerprint = None
+
+ cache_key = f"{website_url}|{website_key}|{1 if enterprise else 0}"
+ warmup_seconds = float(getattr(config, "browser_score_test_warmup_seconds", 12) or 12)
+ per_request_settle_seconds = float(
+ getattr(config, "browser_score_test_settle_seconds", 2.5) or 2.5
+ )
+ max_retries = 2
+
+ async with self._custom_lock:
+ for attempt in range(max_retries):
+ start_time = time.time()
+ custom_info = self._custom_tabs.get(cache_key)
+ tab = custom_info.get("tab") if isinstance(custom_info, dict) else None
+
+ try:
+ if tab is None:
+ debug_logger.log_info(f"[BrowserCaptcha] [Custom] 创建常驻测试标签页: {website_url}")
+ tab = await self._browser_get(
+ website_url,
+ label="custom_browser_get",
+ new_tab=True,
+ )
+ custom_info = {
+ "tab": tab,
+ "recaptcha_ready": False,
+ "warmed_up": False,
+ "created_at": time.time(),
+ }
+ self._custom_tabs[cache_key] = custom_info
+
+ page_loaded = False
+ for _ in range(20):
+ ready_state = await self._tab_evaluate(
+ tab,
+ "document.readyState",
+ label="custom_document_ready",
+ timeout_seconds=2.0,
+ )
+ if ready_state == "complete":
+ page_loaded = True
+ break
+ await tab.sleep(0.5)
+
+ if not page_loaded:
+ raise RuntimeError("自定义页面加载超时")
+
+ if not custom_info.get("recaptcha_ready"):
+ recaptcha_ready = await self._wait_for_custom_recaptcha(
+ tab=tab,
+ website_key=website_key,
+ enterprise=enterprise,
+ )
+ if not recaptcha_ready:
+ raise RuntimeError("自定义 reCAPTCHA 无法加载")
+ custom_info["recaptcha_ready"] = True
+
+ try:
+ await self._tab_evaluate(tab, """
+ (() => {
+ try {
+ const body = document.body || document.documentElement;
+ const width = window.innerWidth || 1280;
+ const height = window.innerHeight || 720;
+ const x = Math.max(24, Math.floor(width * 0.38));
+ const y = Math.max(24, Math.floor(height * 0.32));
+ const moveEvent = new MouseEvent('mousemove', {
+ bubbles: true,
+ clientX: x,
+ clientY: y
+ });
+ const overEvent = new MouseEvent('mouseover', {
+ bubbles: true,
+ clientX: x,
+ clientY: y
+ });
+ window.focus();
+ window.dispatchEvent(new Event('focus'));
+ document.dispatchEvent(moveEvent);
+ document.dispatchEvent(overEvent);
+ if (body) {
+ body.dispatchEvent(moveEvent);
+ body.dispatchEvent(overEvent);
+ }
+ window.scrollTo(0, Math.min(320, document.body?.scrollHeight || 320));
+ } catch (e) {}
+ })()
+ """, label="custom_pre_warm_interaction", timeout_seconds=6.0)
+ except Exception:
+ pass
+
+ if not custom_info.get("warmed_up"):
+ if warmup_seconds > 0:
+ debug_logger.log_info(
+ f"[BrowserCaptcha] [Custom] 首次预热测试页面 {warmup_seconds:.1f}s 后再执行 token"
+ )
+ try:
+ await self._tab_evaluate(tab, """
+ (() => {
+ try {
+ window.scrollTo(0, Math.min(240, document.body.scrollHeight || 240));
+ window.dispatchEvent(new Event('mousemove'));
+ window.dispatchEvent(new Event('focus'));
+ } catch (e) {}
+ })()
+ """, label="custom_warmup_interaction", timeout_seconds=6.0)
+ except Exception:
+ pass
+ await tab.sleep(warmup_seconds)
+ custom_info["warmed_up"] = True
+ elif per_request_settle_seconds > 0:
+ debug_logger.log_info(
+ f"[BrowserCaptcha] [Custom] 复用测试标签页,执行前额外等待 {per_request_settle_seconds:.1f}s"
+ )
+ await tab.sleep(per_request_settle_seconds)
+
+ debug_logger.log_info(f"[BrowserCaptcha] [Custom] 使用常驻测试标签页执行验证 (action: {action})...")
+ token = await self._execute_custom_recaptcha_on_tab(
+ tab=tab,
+ website_key=website_key,
+ action=action,
+ enterprise=enterprise,
+ )
+
+ duration_ms = (time.time() - start_time) * 1000
+ if token:
+ extracted_fingerprint = await self._extract_tab_fingerprint(tab)
+ if not extracted_fingerprint:
+ try:
+ fallback_ua = await self._tab_evaluate(
+ tab,
+ "navigator.userAgent || ''",
+ label="custom_fallback_ua",
+ )
+ fallback_lang = await self._tab_evaluate(
+ tab,
+ "navigator.language || ''",
+ label="custom_fallback_lang",
+ )
+ extracted_fingerprint = {
+ "user_agent": fallback_ua or "",
+ "accept_language": fallback_lang or "",
+ "proxy_url": self._proxy_url,
+ }
+ except Exception:
+ extracted_fingerprint = None
+ self._last_fingerprint = extracted_fingerprint
+ debug_logger.log_info(
+ f"[BrowserCaptcha] [Custom] ✅ 常驻测试标签页 Token获取成功(耗时 {duration_ms:.0f}ms)"
+ )
+ return token
+
+ raise RuntimeError("自定义 token 获取失败(返回 null)")
+ except Exception as e:
+ debug_logger.log_warning(
+ f"[BrowserCaptcha] [Custom] 尝试 {attempt + 1}/{max_retries} 失败: {str(e)}"
+ )
+ stale_info = self._custom_tabs.pop(cache_key, None)
+ stale_tab = stale_info.get("tab") if isinstance(stale_info, dict) else None
+ if stale_tab:
+ await self._close_tab_quietly(stale_tab)
+ if attempt >= max_retries - 1:
+ debug_logger.log_error(f"[BrowserCaptcha] [Custom] 获取token异常: {str(e)}")
+ return None
+
+ return None
+
+ async def get_custom_score(
+ self,
+ website_url: str,
+ website_key: str,
+ verify_url: str,
+ action: str = "homepage",
+ enterprise: bool = False,
+ ) -> Dict[str, Any]:
+ """在同一个常驻标签页里获取 token 并直接校验页面分数。"""
+ token_started_at = time.time()
+ token = await self.get_custom_token(
+ website_url=website_url,
+ website_key=website_key,
+ action=action,
+ enterprise=enterprise,
+ )
+ token_elapsed_ms = int((time.time() - token_started_at) * 1000)
+
+ if not token:
+ return {
+ "token": None,
+ "token_elapsed_ms": token_elapsed_ms,
+ "verify_mode": "browser_page",
+ "verify_elapsed_ms": 0,
+ "verify_http_status": None,
+ "verify_result": {},
+ }
+
+ cache_key = f"{website_url}|{website_key}|{1 if enterprise else 0}"
+ async with self._custom_lock:
+ custom_info = self._custom_tabs.get(cache_key)
+ tab = custom_info.get("tab") if isinstance(custom_info, dict) else None
+ if tab is None:
+ raise RuntimeError("页面分数测试标签页不存在")
+ verify_payload = await self._verify_score_on_tab(tab, token, verify_url)
+
+ return {
+ "token": token,
+ "token_elapsed_ms": token_elapsed_ms,
+ **verify_payload,
+ }
diff --git a/src/services/file_cache.py b/src/services/file_cache.py
index 7bc3bc0..83abcf9 100644
--- a/src/services/file_cache.py
+++ b/src/services/file_cache.py
@@ -1,182 +1,182 @@
-"""File caching service"""
-import os
-import asyncio
-import hashlib
-import time
-import mimetypes
-from pathlib import Path
-from typing import Optional, Dict, Any
-from datetime import datetime, timedelta
-from urllib.parse import urlparse
-from curl_cffi.requests import AsyncSession
-from ..core.config import config
-from ..core.logger import debug_logger
-
-
-class FileCache:
- """File caching service for videos"""
-
- def __init__(
- self,
- cache_dir: str = "tmp",
- default_timeout: int = 7200,
- proxy_manager=None,
- flow_client=None,
- ):
- """
- Initialize file cache
-
- Args:
- cache_dir: Cache directory path
- default_timeout: Default cache timeout in seconds (default: 2 hours)
- proxy_manager: ProxyManager instance for downloading files
- """
- self.cache_dir = Path(cache_dir)
- self.cache_dir.mkdir(exist_ok=True)
- self.default_timeout = max(0, int(default_timeout))
- self.proxy_manager = proxy_manager
- self.flow_client = flow_client
- self._cleanup_task = None
- self._download_locks: Dict[str, asyncio.Lock] = {}
-
- def _is_cleanup_disabled(self) -> bool:
- return self.default_timeout <= 0
-
- def _get_request_fingerprint(self) -> Optional[Dict[str, Any]]:
- """读取当前请求链路里绑定的浏览器指纹。"""
- if not self.flow_client or not hasattr(self.flow_client, "get_request_fingerprint"):
- return None
-
- try:
- fingerprint = self.flow_client.get_request_fingerprint()
- if isinstance(fingerprint, dict) and fingerprint:
- return fingerprint
- except Exception as e:
- debug_logger.log_warning(f"Get request fingerprint failed: {str(e)}")
-
- return None
-
- async def _resolve_download_proxy(
- self,
- media_type: str,
- fingerprint: Optional[Dict[str, Any]] = None,
- ) -> Optional[str]:
- """根据媒体类型解析下载代理地址。"""
- if isinstance(fingerprint, dict):
- fingerprint_proxy = str(fingerprint.get("proxy_url") or "").strip()
- if fingerprint_proxy:
- return fingerprint_proxy
-
- if not self.proxy_manager:
- return None
-
- try:
- # 媒体下载(图片/视频)优先使用独立的上传/下载代理
- if media_type in ("image", "video") and hasattr(self.proxy_manager, "get_media_proxy_url"):
- return await self.proxy_manager.get_media_proxy_url()
-
- # 其他下载走请求代理
- if hasattr(self.proxy_manager, "get_request_proxy_url"):
- return await self.proxy_manager.get_request_proxy_url()
-
- # 向后兼容旧实现
- if hasattr(self.proxy_manager, "get_proxy_url"):
- return await self.proxy_manager.get_proxy_url()
- except Exception as e:
- debug_logger.log_warning(f"Resolve download proxy failed: {str(e)}")
-
- return None
-
- def _guess_extension(self, url: str, media_type: str) -> str:
- """尽量保留原始扩展名,未知时回退到默认值。"""
- path = urlparse(url).path or ""
- guessed, _ = mimetypes.guess_type(path)
- suffix = Path(path).suffix.lower()
-
- if media_type == "video":
- if suffix in {".mp4", ".mov", ".webm", ".mkv", ".m4v"}:
- return suffix
- if guessed == "video/webm":
- return ".webm"
- if guessed == "video/quicktime":
- return ".mov"
- return ".mp4"
-
- if media_type == "image":
- if suffix in {".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif", ".bmp"}:
- return suffix
- if guessed == "image/png":
- return ".png"
- if guessed == "image/webp":
- return ".webp"
- if guessed == "image/gif":
- return ".gif"
- if guessed == "image/avif":
- return ".avif"
- if guessed == "image/bmp":
- return ".bmp"
- return ".jpg"
-
- return suffix
-
- def _build_download_headers(
- self,
- media_type: str,
- fingerprint: Optional[Dict[str, Any]] = None,
- ) -> Dict[str, str]:
- """构建媒体下载请求头,优先复用当前打码浏览器指纹。"""
- headers = {
- "Accept": (
- "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
- if media_type == "image"
- else "*/*"
- ),
- "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
- "Accept-Encoding": "gzip, deflate, br",
- "Connection": "keep-alive",
- "Referer": "https://labs.google/",
- "Sec-Fetch-Site": "same-origin",
- "Sec-Fetch-Mode": "cors",
- }
-
- if media_type == "image":
- headers["Sec-Fetch-Dest"] = "image"
- else:
- headers["Sec-Fetch-Dest"] = "video"
-
- if isinstance(fingerprint, dict):
- if fingerprint.get("user_agent"):
- headers["User-Agent"] = str(fingerprint["user_agent"])
- if fingerprint.get("accept_language"):
- headers["Accept-Language"] = str(fingerprint["accept_language"])
- if fingerprint.get("sec_ch_ua"):
- headers["sec-ch-ua"] = str(fingerprint["sec_ch_ua"])
- if fingerprint.get("sec_ch_ua_mobile"):
- headers["sec-ch-ua-mobile"] = str(fingerprint["sec_ch_ua_mobile"])
- if fingerprint.get("sec_ch_ua_platform"):
- headers["sec-ch-ua-platform"] = str(fingerprint["sec_ch_ua_platform"])
-
- headers.setdefault(
- "User-Agent",
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
- "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
- )
- return headers
-
- def _write_cached_content(self, file_path: Path, content: bytes):
- """先写临时文件,再原子替换,避免并发读到半截文件。"""
- temp_path = file_path.with_suffix(f"{file_path.suffix}.part")
- try:
- with open(temp_path, "wb") as f:
- f.write(content)
- temp_path.replace(file_path)
- finally:
- if temp_path.exists():
- try:
- temp_path.unlink()
- except Exception:
- pass
-
+"""File caching service"""
+import os
+import asyncio
+import hashlib
+import time
+import mimetypes
+from pathlib import Path
+from typing import Optional, Dict, Any
+from datetime import datetime, timedelta
+from urllib.parse import urlparse
+from curl_cffi.requests import AsyncSession
+from ..core.config import config
+from ..core.logger import debug_logger
+
+
+class FileCache:
+ """File caching service for videos"""
+
+ def __init__(
+ self,
+ cache_dir: str = "tmp",
+ default_timeout: int = 7200,
+ proxy_manager=None,
+ flow_client=None,
+ ):
+ """
+ Initialize file cache
+
+ Args:
+ cache_dir: Cache directory path
+ default_timeout: Default cache timeout in seconds (default: 2 hours)
+ proxy_manager: ProxyManager instance for downloading files
+ """
+ self.cache_dir = Path(cache_dir)
+ self.cache_dir.mkdir(exist_ok=True)
+ self.default_timeout = max(0, int(default_timeout))
+ self.proxy_manager = proxy_manager
+ self.flow_client = flow_client
+ self._cleanup_task = None
+ self._download_locks: Dict[str, asyncio.Lock] = {}
+
+ def _is_cleanup_disabled(self) -> bool:
+ return self.default_timeout <= 0
+
+ def _get_request_fingerprint(self) -> Optional[Dict[str, Any]]:
+ """读取当前请求链路里绑定的浏览器指纹。"""
+ if not self.flow_client or not hasattr(self.flow_client, "get_request_fingerprint"):
+ return None
+
+ try:
+ fingerprint = self.flow_client.get_request_fingerprint()
+ if isinstance(fingerprint, dict) and fingerprint:
+ return fingerprint
+ except Exception as e:
+ debug_logger.log_warning(f"Get request fingerprint failed: {str(e)}")
+
+ return None
+
+ async def _resolve_download_proxy(
+ self,
+ media_type: str,
+ fingerprint: Optional[Dict[str, Any]] = None,
+ ) -> Optional[str]:
+ """根据媒体类型解析下载代理地址。"""
+ if isinstance(fingerprint, dict):
+ fingerprint_proxy = str(fingerprint.get("proxy_url") or "").strip()
+ if fingerprint_proxy:
+ return fingerprint_proxy
+
+ if not self.proxy_manager:
+ return None
+
+ try:
+ # 媒体下载(图片/视频)优先使用独立的上传/下载代理
+ if media_type in ("image", "video") and hasattr(self.proxy_manager, "get_media_proxy_url"):
+ return await self.proxy_manager.get_media_proxy_url()
+
+ # 其他下载走请求代理
+ if hasattr(self.proxy_manager, "get_request_proxy_url"):
+ return await self.proxy_manager.get_request_proxy_url()
+
+ # 向后兼容旧实现
+ if hasattr(self.proxy_manager, "get_proxy_url"):
+ return await self.proxy_manager.get_proxy_url()
+ except Exception as e:
+ debug_logger.log_warning(f"Resolve download proxy failed: {str(e)}")
+
+ return None
+
+ def _guess_extension(self, url: str, media_type: str) -> str:
+ """尽量保留原始扩展名,未知时回退到默认值。"""
+ path = urlparse(url).path or ""
+ guessed, _ = mimetypes.guess_type(path)
+ suffix = Path(path).suffix.lower()
+
+ if media_type == "video":
+ if suffix in {".mp4", ".mov", ".webm", ".mkv", ".m4v"}:
+ return suffix
+ if guessed == "video/webm":
+ return ".webm"
+ if guessed == "video/quicktime":
+ return ".mov"
+ return ".mp4"
+
+ if media_type == "image":
+ if suffix in {".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif", ".bmp"}:
+ return suffix
+ if guessed == "image/png":
+ return ".png"
+ if guessed == "image/webp":
+ return ".webp"
+ if guessed == "image/gif":
+ return ".gif"
+ if guessed == "image/avif":
+ return ".avif"
+ if guessed == "image/bmp":
+ return ".bmp"
+ return ".jpg"
+
+ return suffix
+
+ def _build_download_headers(
+ self,
+ media_type: str,
+ fingerprint: Optional[Dict[str, Any]] = None,
+ ) -> Dict[str, str]:
+ """构建媒体下载请求头,优先复用当前打码浏览器指纹。"""
+ headers = {
+ "Accept": (
+ "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
+ if media_type == "image"
+ else "*/*"
+ ),
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+ "Accept-Encoding": "gzip, deflate, br",
+ "Connection": "keep-alive",
+ "Referer": "https://labs.google/",
+ "Sec-Fetch-Site": "same-origin",
+ "Sec-Fetch-Mode": "cors",
+ }
+
+ if media_type == "image":
+ headers["Sec-Fetch-Dest"] = "image"
+ else:
+ headers["Sec-Fetch-Dest"] = "video"
+
+ if isinstance(fingerprint, dict):
+ if fingerprint.get("user_agent"):
+ headers["User-Agent"] = str(fingerprint["user_agent"])
+ if fingerprint.get("accept_language"):
+ headers["Accept-Language"] = str(fingerprint["accept_language"])
+ if fingerprint.get("sec_ch_ua"):
+ headers["sec-ch-ua"] = str(fingerprint["sec_ch_ua"])
+ if fingerprint.get("sec_ch_ua_mobile"):
+ headers["sec-ch-ua-mobile"] = str(fingerprint["sec_ch_ua_mobile"])
+ if fingerprint.get("sec_ch_ua_platform"):
+ headers["sec-ch-ua-platform"] = str(fingerprint["sec_ch_ua_platform"])
+
+ headers.setdefault(
+ "User-Agent",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+ )
+ return headers
+
+ def _write_cached_content(self, file_path: Path, content: bytes):
+ """先写临时文件,再原子替换,避免并发读到半截文件。"""
+ temp_path = file_path.with_suffix(f"{file_path.suffix}.part")
+ try:
+ with open(temp_path, "wb") as f:
+ f.write(content)
+ temp_path.replace(file_path)
+ finally:
+ if temp_path.exists():
+ try:
+ temp_path.unlink()
+ except Exception:
+ pass
+
async def start_cleanup_task(self):
"""Start background cleanup task"""
if self._is_cleanup_disabled():
@@ -191,8 +191,8 @@ class FileCache:
"""Stop background cleanup task"""
if self._cleanup_task:
self._cleanup_task.cancel()
- try:
- await self._cleanup_task
+ try:
+ await self._cleanup_task
except asyncio.CancelledError:
pass
self._cleanup_task = None
@@ -203,22 +203,22 @@ class FileCache:
await self.stop_cleanup_task()
return False
return await self.start_cleanup_task()
-
- async def _cleanup_loop(self):
- """Background task to clean up expired files"""
- while True:
- try:
- await asyncio.sleep(300) # Check every 5 minutes
- await self._cleanup_expired_files()
- except asyncio.CancelledError:
- break
- except Exception as e:
- debug_logger.log_error(
- error_message=f"Cleanup task error: {str(e)}",
- status_code=0,
- response_text=""
- )
-
+
+ async def _cleanup_loop(self):
+ """Background task to clean up expired files"""
+ while True:
+ try:
+ await asyncio.sleep(300) # Check every 5 minutes
+ await self._cleanup_expired_files()
+ except asyncio.CancelledError:
+ break
+ except Exception as e:
+ debug_logger.log_error(
+ error_message=f"Cleanup task error: {str(e)}",
+ status_code=0,
+ response_text=""
+ )
+
async def _cleanup_expired_files(self):
"""Remove expired cache files"""
try:
@@ -242,273 +242,273 @@ class FileCache:
removed_count += 1
except Exception:
pass
-
- if removed_count > 0:
- debug_logger.log_info(f"Cleanup: removed {removed_count} expired cache files")
-
- except Exception as e:
- debug_logger.log_error(
- error_message=f"Failed to cleanup expired files: {str(e)}",
- status_code=0,
- response_text=""
- )
-
- def _generate_cache_filename(self, url: str, media_type: str) -> str:
- """Generate unique filename for cached file"""
- # Use URL hash as filename
- url_hash = hashlib.md5(url.encode()).hexdigest()
- ext = self._guess_extension(url, media_type)
-
- return f"{url_hash}{ext}"
-
- def _normalize_cache_error(self, error: Exception) -> str:
- """整理缓存错误,避免将底层命令异常直接暴露给用户。"""
- if isinstance(error, FileNotFoundError):
- missing_name = Path(getattr(error, "filename", "") or "curl").name or "curl"
- return f"本机未安装 {missing_name}"
-
- message = str(error or "").strip()
- if not message:
- return "未知错误"
-
- if message.startswith("Failed to cache file:"):
- message = message.split(":", 1)[1].strip() or "未知错误"
-
- return message
-
- async def download_and_cache(self, url: str, media_type: str) -> str:
- """
- Download file from URL and cache it locally
-
- Args:
- url: File URL to download
- media_type: 'image' or 'video'
-
- Returns:
- Local cache filename
- """
- filename = self._generate_cache_filename(url, media_type)
- file_path = self.cache_dir / filename
- download_lock = self._download_locks.setdefault(filename, asyncio.Lock())
-
- async with download_lock:
- # Check if already cached and not expired
- if file_path.exists():
- if self._is_cleanup_disabled():
- return filename
- file_age = time.time() - file_path.stat().st_mtime
- if file_age < self.default_timeout:
- debug_logger.log_info(f"Cache hit: {filename}")
- return filename
- try:
- file_path.unlink()
- except Exception:
- pass
-
- # Download file
- debug_logger.log_info(f"Downloading file from: {url}")
-
- fingerprint = self._get_request_fingerprint()
- proxy_url = await self._resolve_download_proxy(media_type, fingerprint=fingerprint)
- headers = self._build_download_headers(media_type, fingerprint=fingerprint)
-
- # Try method 1: curl_cffi with browser impersonation
- try:
- async with AsyncSession() as session:
- response = await session.get(
- url,
- timeout=60,
- proxy=proxy_url,
- headers=headers,
- impersonate="chrome120",
- verify=False
- )
-
- if response.status_code == 200 and response.content:
- self._write_cached_content(file_path, response.content)
- debug_logger.log_info(
- f"File cached (curl_cffi): {filename} ({len(response.content)} bytes)"
- )
- return filename
- debug_logger.log_warning(
- f"curl_cffi failed with HTTP {response.status_code}, trying wget..."
- )
-
- except Exception as e:
- debug_logger.log_warning(f"curl_cffi failed: {str(e)}, trying wget...")
-
- # Try method 2: wget command
- try:
- import subprocess
-
- wget_cmd = [
- "wget",
- "-q",
- "-O", str(file_path),
- "--timeout=60",
- "--tries=3",
- f"--user-agent={headers.get('User-Agent', '')}",
- f"--header=Accept: {headers.get('Accept', '*/*')}",
- f"--header=Accept-Language: {headers.get('Accept-Language', 'zh-CN,zh;q=0.9,en;q=0.8')}",
- f"--header=Connection: {headers.get('Connection', 'keep-alive')}",
- f"--header=Referer: {headers.get('Referer', 'https://labs.google/')}",
- ]
-
- if "sec-ch-ua" in headers:
- wget_cmd.append(f"--header=sec-ch-ua: {headers['sec-ch-ua']}")
- if "sec-ch-ua-mobile" in headers:
- wget_cmd.append(f"--header=sec-ch-ua-mobile: {headers['sec-ch-ua-mobile']}")
- if "sec-ch-ua-platform" in headers:
- wget_cmd.append(f"--header=sec-ch-ua-platform: {headers['sec-ch-ua-platform']}")
-
- if proxy_url:
- env = os.environ.copy()
- env["http_proxy"] = proxy_url
- env["https_proxy"] = proxy_url
- else:
- env = None
-
- wget_cmd.append(url)
- result = subprocess.run(wget_cmd, capture_output=True, timeout=90, env=env)
-
- if result.returncode == 0 and file_path.exists():
- file_size = file_path.stat().st_size
- if file_size > 0:
- debug_logger.log_info(f"File cached (wget): {filename} ({file_size} bytes)")
- return filename
- raise Exception("Downloaded file is empty")
-
- error_msg = result.stderr.decode("utf-8", errors="ignore") if result.stderr else "Unknown error"
- debug_logger.log_warning(f"wget failed: {error_msg}, trying curl...")
-
- except FileNotFoundError:
- debug_logger.log_warning("wget not found, trying curl...")
- except Exception as e:
- debug_logger.log_warning(f"wget failed: {str(e)}, trying curl...")
-
- # Try method 3: system curl command
- try:
- import subprocess
-
- curl_cmd = [
- "curl",
- "-L",
- "-s",
- "-o", str(file_path),
- "--max-time", "60",
- "-H", f"Accept: {headers.get('Accept', '*/*')}",
- "-H", f"Accept-Language: {headers.get('Accept-Language', 'zh-CN,zh;q=0.9,en;q=0.8')}",
- "-H", f"Connection: {headers.get('Connection', 'keep-alive')}",
- "-H", f"Referer: {headers.get('Referer', 'https://labs.google/')}",
- "-A", headers.get("User-Agent", ""),
- ]
-
- if "sec-ch-ua" in headers:
- curl_cmd.extend(["-H", f"sec-ch-ua: {headers['sec-ch-ua']}"])
- if "sec-ch-ua-mobile" in headers:
- curl_cmd.extend(["-H", f"sec-ch-ua-mobile: {headers['sec-ch-ua-mobile']}"])
- if "sec-ch-ua-platform" in headers:
- curl_cmd.extend(["-H", f"sec-ch-ua-platform: {headers['sec-ch-ua-platform']}"])
- if proxy_url:
- curl_cmd.extend(["-x", proxy_url])
-
- curl_cmd.append(url)
- result = subprocess.run(curl_cmd, capture_output=True, timeout=90)
-
- if result.returncode == 0 and file_path.exists():
- file_size = file_path.stat().st_size
- if file_size > 0:
- debug_logger.log_info(f"File cached (curl): {filename} ({file_size} bytes)")
- return filename
- raise Exception("Downloaded file is empty")
-
- error_msg = result.stderr.decode("utf-8", errors="ignore") if result.stderr else "Unknown error"
- raise Exception(f"curl command failed: {error_msg}")
-
- except FileNotFoundError as e:
- normalized_error = self._normalize_cache_error(e)
- debug_logger.log_error(
- error_message=f"Failed to download file: {str(e)}",
- status_code=0,
- response_text=str(e)
- )
- raise Exception(normalized_error) from e
- except Exception as e:
- normalized_error = self._normalize_cache_error(e)
- debug_logger.log_error(
- error_message=f"Failed to download file: {str(e)}",
- status_code=0,
- response_text=str(e)
- )
- raise Exception(normalized_error) from e
-
- async def cache_base64_image(self, base64_data: str, resolution: str = "") -> str:
- """
- Cache base64 encoded image data to local file
-
- Args:
- base64_data: Base64 encoded image data (without data:image/... prefix)
- resolution: Resolution info for filename (e.g., "4K", "2K")
-
- Returns:
- Local cache filename
- """
- import base64
- import uuid
-
- # Generate unique filename
- unique_id = hashlib.md5(f"{uuid.uuid4()}{time.time()}".encode()).hexdigest()
- suffix = f"_{resolution}" if resolution else ""
- filename = f"{unique_id}{suffix}.jpg"
- file_path = self.cache_dir / filename
-
- try:
- # Decode base64 and save to file
- image_data = base64.b64decode(base64_data)
- with open(file_path, 'wb') as f:
- f.write(image_data)
- debug_logger.log_info(f"Base64 image cached: {filename} ({len(image_data)} bytes)")
- return filename
- except Exception as e:
- debug_logger.log_error(
- error_message=f"Failed to cache base64 image: {str(e)}",
- status_code=0,
- response_text=""
- )
- raise Exception(f"Failed to cache base64 image: {str(e)}")
-
- def get_cache_path(self, filename: str) -> Path:
- """Get full path to cached file"""
- return self.cache_dir / filename
-
- def set_timeout(self, timeout: int):
- """Set cache timeout in seconds"""
- self.default_timeout = max(0, int(timeout))
- debug_logger.log_info(f"Cache timeout updated to {timeout} seconds")
-
- def get_timeout(self) -> int:
- """Get current cache timeout"""
- return self.default_timeout
-
- async def clear_all(self):
- """Clear all cached files"""
- try:
- removed_count = 0
- for file_path in self.cache_dir.iterdir():
- if file_path.is_file():
- try:
- file_path.unlink()
- removed_count += 1
- except Exception:
- pass
-
- debug_logger.log_info(f"Cache cleared: removed {removed_count} files")
- return removed_count
-
- except Exception as e:
- debug_logger.log_error(
- error_message=f"Failed to clear cache: {str(e)}",
- status_code=0,
- response_text=""
- )
- raise
+
+ if removed_count > 0:
+ debug_logger.log_info(f"Cleanup: removed {removed_count} expired cache files")
+
+ except Exception as e:
+ debug_logger.log_error(
+ error_message=f"Failed to cleanup expired files: {str(e)}",
+ status_code=0,
+ response_text=""
+ )
+
+ def _generate_cache_filename(self, url: str, media_type: str) -> str:
+ """Generate unique filename for cached file"""
+ # Use URL hash as filename
+ url_hash = hashlib.md5(url.encode()).hexdigest()
+ ext = self._guess_extension(url, media_type)
+
+ return f"{url_hash}{ext}"
+
+ def _normalize_cache_error(self, error: Exception) -> str:
+ """整理缓存错误,避免将底层命令异常直接暴露给用户。"""
+ if isinstance(error, FileNotFoundError):
+ missing_name = Path(getattr(error, "filename", "") or "curl").name or "curl"
+ return f"本机未安装 {missing_name}"
+
+ message = str(error or "").strip()
+ if not message:
+ return "未知错误"
+
+ if message.startswith("Failed to cache file:"):
+ message = message.split(":", 1)[1].strip() or "未知错误"
+
+ return message
+
+ async def download_and_cache(self, url: str, media_type: str) -> str:
+ """
+ Download file from URL and cache it locally
+
+ Args:
+ url: File URL to download
+ media_type: 'image' or 'video'
+
+ Returns:
+ Local cache filename
+ """
+ filename = self._generate_cache_filename(url, media_type)
+ file_path = self.cache_dir / filename
+ download_lock = self._download_locks.setdefault(filename, asyncio.Lock())
+
+ async with download_lock:
+ # Check if already cached and not expired
+ if file_path.exists():
+ if self._is_cleanup_disabled():
+ return filename
+ file_age = time.time() - file_path.stat().st_mtime
+ if file_age < self.default_timeout:
+ debug_logger.log_info(f"Cache hit: {filename}")
+ return filename
+ try:
+ file_path.unlink()
+ except Exception:
+ pass
+
+ # Download file
+ debug_logger.log_info(f"Downloading file from: {url}")
+
+ fingerprint = self._get_request_fingerprint()
+ proxy_url = await self._resolve_download_proxy(media_type, fingerprint=fingerprint)
+ headers = self._build_download_headers(media_type, fingerprint=fingerprint)
+
+ # Try method 1: curl_cffi with browser impersonation
+ try:
+ async with AsyncSession() as session:
+ response = await session.get(
+ url,
+ timeout=60,
+ proxy=proxy_url,
+ headers=headers,
+ impersonate="chrome120",
+ verify=False
+ )
+
+ if response.status_code == 200 and response.content:
+ self._write_cached_content(file_path, response.content)
+ debug_logger.log_info(
+ f"File cached (curl_cffi): {filename} ({len(response.content)} bytes)"
+ )
+ return filename
+ debug_logger.log_warning(
+ f"curl_cffi failed with HTTP {response.status_code}, trying wget..."
+ )
+
+ except Exception as e:
+ debug_logger.log_warning(f"curl_cffi failed: {str(e)}, trying wget...")
+
+ # Try method 2: wget command
+ try:
+ import subprocess
+
+ wget_cmd = [
+ "wget",
+ "-q",
+ "-O", str(file_path),
+ "--timeout=60",
+ "--tries=3",
+ f"--user-agent={headers.get('User-Agent', '')}",
+ f"--header=Accept: {headers.get('Accept', '*/*')}",
+ f"--header=Accept-Language: {headers.get('Accept-Language', 'zh-CN,zh;q=0.9,en;q=0.8')}",
+ f"--header=Connection: {headers.get('Connection', 'keep-alive')}",
+ f"--header=Referer: {headers.get('Referer', 'https://labs.google/')}",
+ ]
+
+ if "sec-ch-ua" in headers:
+ wget_cmd.append(f"--header=sec-ch-ua: {headers['sec-ch-ua']}")
+ if "sec-ch-ua-mobile" in headers:
+ wget_cmd.append(f"--header=sec-ch-ua-mobile: {headers['sec-ch-ua-mobile']}")
+ if "sec-ch-ua-platform" in headers:
+ wget_cmd.append(f"--header=sec-ch-ua-platform: {headers['sec-ch-ua-platform']}")
+
+ if proxy_url:
+ env = os.environ.copy()
+ env["http_proxy"] = proxy_url
+ env["https_proxy"] = proxy_url
+ else:
+ env = None
+
+ wget_cmd.append(url)
+ result = subprocess.run(wget_cmd, capture_output=True, timeout=90, env=env)
+
+ if result.returncode == 0 and file_path.exists():
+ file_size = file_path.stat().st_size
+ if file_size > 0:
+ debug_logger.log_info(f"File cached (wget): {filename} ({file_size} bytes)")
+ return filename
+ raise Exception("Downloaded file is empty")
+
+ error_msg = result.stderr.decode("utf-8", errors="ignore") if result.stderr else "Unknown error"
+ debug_logger.log_warning(f"wget failed: {error_msg}, trying curl...")
+
+ except FileNotFoundError:
+ debug_logger.log_warning("wget not found, trying curl...")
+ except Exception as e:
+ debug_logger.log_warning(f"wget failed: {str(e)}, trying curl...")
+
+ # Try method 3: system curl command
+ try:
+ import subprocess
+
+ curl_cmd = [
+ "curl",
+ "-L",
+ "-s",
+ "-o", str(file_path),
+ "--max-time", "60",
+ "-H", f"Accept: {headers.get('Accept', '*/*')}",
+ "-H", f"Accept-Language: {headers.get('Accept-Language', 'zh-CN,zh;q=0.9,en;q=0.8')}",
+ "-H", f"Connection: {headers.get('Connection', 'keep-alive')}",
+ "-H", f"Referer: {headers.get('Referer', 'https://labs.google/')}",
+ "-A", headers.get("User-Agent", ""),
+ ]
+
+ if "sec-ch-ua" in headers:
+ curl_cmd.extend(["-H", f"sec-ch-ua: {headers['sec-ch-ua']}"])
+ if "sec-ch-ua-mobile" in headers:
+ curl_cmd.extend(["-H", f"sec-ch-ua-mobile: {headers['sec-ch-ua-mobile']}"])
+ if "sec-ch-ua-platform" in headers:
+ curl_cmd.extend(["-H", f"sec-ch-ua-platform: {headers['sec-ch-ua-platform']}"])
+ if proxy_url:
+ curl_cmd.extend(["-x", proxy_url])
+
+ curl_cmd.append(url)
+ result = subprocess.run(curl_cmd, capture_output=True, timeout=90)
+
+ if result.returncode == 0 and file_path.exists():
+ file_size = file_path.stat().st_size
+ if file_size > 0:
+ debug_logger.log_info(f"File cached (curl): {filename} ({file_size} bytes)")
+ return filename
+ raise Exception("Downloaded file is empty")
+
+ error_msg = result.stderr.decode("utf-8", errors="ignore") if result.stderr else "Unknown error"
+ raise Exception(f"curl command failed: {error_msg}")
+
+ except FileNotFoundError as e:
+ normalized_error = self._normalize_cache_error(e)
+ debug_logger.log_error(
+ error_message=f"Failed to download file: {str(e)}",
+ status_code=0,
+ response_text=str(e)
+ )
+ raise Exception(normalized_error) from e
+ except Exception as e:
+ normalized_error = self._normalize_cache_error(e)
+ debug_logger.log_error(
+ error_message=f"Failed to download file: {str(e)}",
+ status_code=0,
+ response_text=str(e)
+ )
+ raise Exception(normalized_error) from e
+
+ async def cache_base64_image(self, base64_data: str, resolution: str = "") -> str:
+ """
+ Cache base64 encoded image data to local file
+
+ Args:
+ base64_data: Base64 encoded image data (without data:image/... prefix)
+ resolution: Resolution info for filename (e.g., "4K", "2K")
+
+ Returns:
+ Local cache filename
+ """
+ import base64
+ import uuid
+
+ # Generate unique filename
+ unique_id = hashlib.md5(f"{uuid.uuid4()}{time.time()}".encode()).hexdigest()
+ suffix = f"_{resolution}" if resolution else ""
+ filename = f"{unique_id}{suffix}.jpg"
+ file_path = self.cache_dir / filename
+
+ try:
+ # Decode base64 and save to file
+ image_data = base64.b64decode(base64_data)
+ with open(file_path, 'wb') as f:
+ f.write(image_data)
+ debug_logger.log_info(f"Base64 image cached: {filename} ({len(image_data)} bytes)")
+ return filename
+ except Exception as e:
+ debug_logger.log_error(
+ error_message=f"Failed to cache base64 image: {str(e)}",
+ status_code=0,
+ response_text=""
+ )
+ raise Exception(f"Failed to cache base64 image: {str(e)}")
+
+ def get_cache_path(self, filename: str) -> Path:
+ """Get full path to cached file"""
+ return self.cache_dir / filename
+
+ def set_timeout(self, timeout: int):
+ """Set cache timeout in seconds"""
+ self.default_timeout = max(0, int(timeout))
+ debug_logger.log_info(f"Cache timeout updated to {timeout} seconds")
+
+ def get_timeout(self) -> int:
+ """Get current cache timeout"""
+ return self.default_timeout
+
+ async def clear_all(self):
+ """Clear all cached files"""
+ try:
+ removed_count = 0
+ for file_path in self.cache_dir.iterdir():
+ if file_path.is_file():
+ try:
+ file_path.unlink()
+ removed_count += 1
+ except Exception:
+ pass
+
+ debug_logger.log_info(f"Cache cleared: removed {removed_count} files")
+ return removed_count
+
+ except Exception as e:
+ debug_logger.log_error(
+ error_message=f"Failed to clear cache: {str(e)}",
+ status_code=0,
+ response_text=""
+ )
+ raise
diff --git a/src/services/generation_handler.py b/src/services/generation_handler.py
index 869f434..c1bd334 100644
--- a/src/services/generation_handler.py
+++ b/src/services/generation_handler.py
@@ -1,4 +1,4 @@
-"""Generation handler for Flow2API"""
+"""Generation handler for Flow2API"""
import asyncio
import base64
import json
@@ -6,671 +6,671 @@ import time
from pathlib import Path
from typing import Optional, AsyncGenerator, List, Dict, Any
from ..core.logger import debug_logger
-from ..core.config import config
-from ..core.models import Task, RequestLog
-from ..core.account_tiers import (
- PAYGATE_TIER_NOT_PAID,
- get_paygate_tier_label,
- get_required_paygate_tier_for_model,
- normalize_user_paygate_tier,
- supports_model_for_tier,
-)
-from .file_cache import FileCache
-
-
-# Model configuration
-MODEL_CONFIG = {
- # 图片生成 - GEM_PIX (Gemini 2.5 Flash)
- "gemini-2.5-flash-image-landscape": {
- "type": "image",
- "model_name": "GEM_PIX",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE"
- },
- "gemini-2.5-flash-image-portrait": {
- "type": "image",
- "model_name": "GEM_PIX",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT"
- },
-
- # 图片生成 - GEM_PIX_2 (Gemini 3.0 Pro)
- "gemini-3.0-pro-image-landscape": {
- "type": "image",
- "model_name": "GEM_PIX_2",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE"
- },
- "gemini-3.0-pro-image-portrait": {
- "type": "image",
- "model_name": "GEM_PIX_2",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT"
- },
- "gemini-3.0-pro-image-square": {
- "type": "image",
- "model_name": "GEM_PIX_2",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_SQUARE"
- },
- "gemini-3.0-pro-image-four-three": {
- "type": "image",
- "model_name": "GEM_PIX_2",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE_FOUR_THREE"
- },
- "gemini-3.0-pro-image-three-four": {
- "type": "image",
- "model_name": "GEM_PIX_2",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT_THREE_FOUR"
- },
-
- # 图片生成 - GEM_PIX_2 (Gemini 3.0 Pro) 2K 放大版
- "gemini-3.0-pro-image-landscape-2k": {
- "type": "image",
- "model_name": "GEM_PIX_2",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE",
- "upsample": "UPSAMPLE_IMAGE_RESOLUTION_2K"
- },
- "gemini-3.0-pro-image-portrait-2k": {
- "type": "image",
- "model_name": "GEM_PIX_2",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT",
- "upsample": "UPSAMPLE_IMAGE_RESOLUTION_2K"
- },
- "gemini-3.0-pro-image-square-2k": {
- "type": "image",
- "model_name": "GEM_PIX_2",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_SQUARE",
- "upsample": "UPSAMPLE_IMAGE_RESOLUTION_2K"
- },
- "gemini-3.0-pro-image-four-three-2k": {
- "type": "image",
- "model_name": "GEM_PIX_2",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE_FOUR_THREE",
- "upsample": "UPSAMPLE_IMAGE_RESOLUTION_2K"
- },
- "gemini-3.0-pro-image-three-four-2k": {
- "type": "image",
- "model_name": "GEM_PIX_2",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT_THREE_FOUR",
- "upsample": "UPSAMPLE_IMAGE_RESOLUTION_2K"
- },
-
- # 图片生成 - GEM_PIX_2 (Gemini 3.0 Pro) 4K 放大版
- "gemini-3.0-pro-image-landscape-4k": {
- "type": "image",
- "model_name": "GEM_PIX_2",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE",
- "upsample": "UPSAMPLE_IMAGE_RESOLUTION_4K"
- },
- "gemini-3.0-pro-image-portrait-4k": {
- "type": "image",
- "model_name": "GEM_PIX_2",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT",
- "upsample": "UPSAMPLE_IMAGE_RESOLUTION_4K"
- },
- "gemini-3.0-pro-image-square-4k": {
- "type": "image",
- "model_name": "GEM_PIX_2",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_SQUARE",
- "upsample": "UPSAMPLE_IMAGE_RESOLUTION_4K"
- },
- "gemini-3.0-pro-image-four-three-4k": {
- "type": "image",
- "model_name": "GEM_PIX_2",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE_FOUR_THREE",
- "upsample": "UPSAMPLE_IMAGE_RESOLUTION_4K"
- },
- "gemini-3.0-pro-image-three-four-4k": {
- "type": "image",
- "model_name": "GEM_PIX_2",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT_THREE_FOUR",
- "upsample": "UPSAMPLE_IMAGE_RESOLUTION_4K"
- },
-
- # 图片生成 - IMAGEN_3_5 (Imagen 4.0)
- "imagen-4.0-generate-preview-landscape": {
- "type": "image",
- "model_name": "IMAGEN_3_5",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE"
- },
- "imagen-4.0-generate-preview-portrait": {
- "type": "image",
- "model_name": "IMAGEN_3_5",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT"
- },
-
- # 图片生成 - NARWHAL (新版)
- "gemini-3.1-flash-image-landscape": {
- "type": "image",
- "model_name": "NARWHAL",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE"
- },
- "gemini-3.1-flash-image-portrait": {
- "type": "image",
- "model_name": "NARWHAL",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT"
- },
- "gemini-3.1-flash-image-square": {
- "type": "image",
- "model_name": "NARWHAL",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_SQUARE"
- },
- "gemini-3.1-flash-image-four-three": {
- "type": "image",
- "model_name": "NARWHAL",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE_FOUR_THREE"
- },
- "gemini-3.1-flash-image-three-four": {
- "type": "image",
- "model_name": "NARWHAL",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT_THREE_FOUR"
- },
- "gemini-3.1-flash-image-landscape-2k": {
- "type": "image",
- "model_name": "NARWHAL",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE",
- "upsample": "UPSAMPLE_IMAGE_RESOLUTION_2K"
- },
- "gemini-3.1-flash-image-portrait-2k": {
- "type": "image",
- "model_name": "NARWHAL",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT",
- "upsample": "UPSAMPLE_IMAGE_RESOLUTION_2K"
- },
- "gemini-3.1-flash-image-square-2k": {
- "type": "image",
- "model_name": "NARWHAL",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_SQUARE",
- "upsample": "UPSAMPLE_IMAGE_RESOLUTION_2K"
- },
- "gemini-3.1-flash-image-four-three-2k": {
- "type": "image",
- "model_name": "NARWHAL",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE_FOUR_THREE",
- "upsample": "UPSAMPLE_IMAGE_RESOLUTION_2K"
- },
- "gemini-3.1-flash-image-three-four-2k": {
- "type": "image",
- "model_name": "NARWHAL",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT_THREE_FOUR",
- "upsample": "UPSAMPLE_IMAGE_RESOLUTION_2K"
- },
- "gemini-3.1-flash-image-landscape-4k": {
- "type": "image",
- "model_name": "NARWHAL",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE",
- "upsample": "UPSAMPLE_IMAGE_RESOLUTION_4K"
- },
- "gemini-3.1-flash-image-portrait-4k": {
- "type": "image",
- "model_name": "NARWHAL",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT",
- "upsample": "UPSAMPLE_IMAGE_RESOLUTION_4K"
- },
- "gemini-3.1-flash-image-square-4k": {
- "type": "image",
- "model_name": "NARWHAL",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_SQUARE",
- "upsample": "UPSAMPLE_IMAGE_RESOLUTION_4K"
- },
- "gemini-3.1-flash-image-four-three-4k": {
- "type": "image",
- "model_name": "NARWHAL",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE_FOUR_THREE",
- "upsample": "UPSAMPLE_IMAGE_RESOLUTION_4K"
- },
- "gemini-3.1-flash-image-three-four-4k": {
- "type": "image",
- "model_name": "NARWHAL",
- "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT_THREE_FOUR",
- "upsample": "UPSAMPLE_IMAGE_RESOLUTION_4K"
- },
-
- # ========== 文生视频 (T2V - Text to Video) ==========
- # 不支持上传图片,只使用文本提示词生成
-
- # veo_3_1_t2v_fast_portrait (竖屏)
- # 上游模型名: veo_3_1_t2v_fast_portrait
- "veo_3_1_t2v_fast_portrait": {
- "type": "video",
- "video_type": "t2v",
- "model_key": "veo_3_1_t2v_fast_portrait",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
- "supports_images": False
- },
- # veo_3_1_t2v_fast_landscape (横屏)
- # 上游模型名: veo_3_1_t2v_fast
- "veo_3_1_t2v_fast_landscape": {
- "type": "video",
- "video_type": "t2v",
- "model_key": "veo_3_1_t2v_fast",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
- "supports_images": False
- },
-
- # veo_2_1_fast_d_15_t2v (需要新增横竖屏)
- "veo_2_1_fast_d_15_t2v_portrait": {
- "type": "video",
- "video_type": "t2v",
- "model_key": "veo_2_1_fast_d_15_t2v",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
- "supports_images": False
- },
- "veo_2_1_fast_d_15_t2v_landscape": {
- "type": "video",
- "video_type": "t2v",
- "model_key": "veo_2_1_fast_d_15_t2v",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
- "supports_images": False
- },
-
- # veo_2_0_t2v (需要新增横竖屏)
- "veo_2_0_t2v_portrait": {
- "type": "video",
- "video_type": "t2v",
- "model_key": "veo_2_0_t2v",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
- "supports_images": False
- },
- "veo_2_0_t2v_landscape": {
- "type": "video",
- "video_type": "t2v",
- "model_key": "veo_2_0_t2v",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
- "supports_images": False
- },
-
- # veo_3_1_t2v_fast_ultra (横竖屏)
- "veo_3_1_t2v_fast_portrait_ultra": {
- "type": "video",
- "video_type": "t2v",
- "model_key": "veo_3_1_t2v_fast_portrait_ultra",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
- "supports_images": False
- },
- "veo_3_1_t2v_fast_ultra": {
- "type": "video",
- "video_type": "t2v",
- "model_key": "veo_3_1_t2v_fast_ultra",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
- "supports_images": False
- },
-
- # veo_3_1_t2v_fast_ultra_relaxed (横竖屏)
- "veo_3_1_t2v_fast_portrait_ultra_relaxed": {
- "type": "video",
- "video_type": "t2v",
- "model_key": "veo_3_1_t2v_fast_portrait_ultra_relaxed",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
- "supports_images": False
- },
- "veo_3_1_t2v_fast_ultra_relaxed": {
- "type": "video",
- "video_type": "t2v",
- "model_key": "veo_3_1_t2v_fast_ultra_relaxed",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
- "supports_images": False
- },
-
- # veo_3_1_t2v (横竖屏)
- "veo_3_1_t2v_portrait": {
- "type": "video",
- "video_type": "t2v",
- "model_key": "veo_3_1_t2v_portrait",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
- "supports_images": False
- },
- "veo_3_1_t2v_landscape": {
- "type": "video",
- "video_type": "t2v",
- "model_key": "veo_3_1_t2v",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
- "supports_images": False
- },
-
- # ========== 首尾帧模型 (I2V - Image to Video) ==========
- # 支持1-2张图片:1张作为首帧,2张作为首尾帧
-
- # veo_3_1_i2v_s_fast_fl (需要新增横竖屏)
- "veo_3_1_i2v_s_fast_portrait_fl": {
- "type": "video",
- "video_type": "i2v",
- "model_key": "veo_3_1_i2v_s_fast_portrait_fl",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
- "supports_images": True,
- "min_images": 1,
- "max_images": 2
- },
- "veo_3_1_i2v_s_fast_fl": {
- "type": "video",
- "video_type": "i2v",
- "model_key": "veo_3_1_i2v_s_fast_fl",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
- "supports_images": True,
- "min_images": 1,
- "max_images": 2
- },
-
- # veo_2_1_fast_d_15_i2v (需要新增横竖屏)
- "veo_2_1_fast_d_15_i2v_portrait": {
- "type": "video",
- "video_type": "i2v",
- "model_key": "veo_2_1_fast_d_15_i2v",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
- "supports_images": True,
- "min_images": 1,
- "max_images": 2
- },
- "veo_2_1_fast_d_15_i2v_landscape": {
- "type": "video",
- "video_type": "i2v",
- "model_key": "veo_2_1_fast_d_15_i2v",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
- "supports_images": True,
- "min_images": 1,
- "max_images": 2
- },
-
- # veo_2_0_i2v (需要新增横竖屏)
- "veo_2_0_i2v_portrait": {
- "type": "video",
- "video_type": "i2v",
- "model_key": "veo_2_0_i2v",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
- "supports_images": True,
- "min_images": 1,
- "max_images": 2
- },
- "veo_2_0_i2v_landscape": {
- "type": "video",
- "video_type": "i2v",
- "model_key": "veo_2_0_i2v",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
- "supports_images": True,
- "min_images": 1,
- "max_images": 2
- },
-
- # veo_3_1_i2v_s_fast_ultra (横竖屏)
- "veo_3_1_i2v_s_fast_portrait_ultra_fl": {
- "type": "video",
- "video_type": "i2v",
- "model_key": "veo_3_1_i2v_s_fast_portrait_ultra_fl",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
- "supports_images": True,
- "min_images": 1,
- "max_images": 2
- },
- "veo_3_1_i2v_s_fast_ultra_fl": {
- "type": "video",
- "video_type": "i2v",
- "model_key": "veo_3_1_i2v_s_fast_ultra_fl",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
- "supports_images": True,
- "min_images": 1,
- "max_images": 2
- },
-
- # veo_3_1_i2v_s_fast_ultra_relaxed (需要新增横竖屏)
- "veo_3_1_i2v_s_fast_portrait_ultra_relaxed": {
- "type": "video",
- "video_type": "i2v",
- "model_key": "veo_3_1_i2v_s_fast_portrait_ultra_relaxed",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
- "supports_images": True,
- "min_images": 1,
- "max_images": 2
- },
- "veo_3_1_i2v_s_fast_ultra_relaxed": {
- "type": "video",
- "video_type": "i2v",
- "model_key": "veo_3_1_i2v_s_fast_ultra_relaxed",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
- "supports_images": True,
- "min_images": 1,
- "max_images": 2
- },
-
- # veo_3_1_i2v_s (需要新增横竖屏)
- "veo_3_1_i2v_s_portrait": {
- "type": "video",
- "video_type": "i2v",
- "model_key": "veo_3_1_i2v_s",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
- "supports_images": True,
- "min_images": 1,
- "max_images": 2
- },
- "veo_3_1_i2v_s_landscape": {
- "type": "video",
- "video_type": "i2v",
- "model_key": "veo_3_1_i2v_s",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
- "supports_images": True,
- "min_images": 1,
- "max_images": 2
- },
-
- # ========== 多图生成 (R2V - Reference Images to Video) ==========
- # 当前上游协议最多支持 3 张参考图
-
- # veo_3_1_r2v_fast (横竖屏)
- "veo_3_1_r2v_fast_portrait": {
- "type": "video",
- "video_type": "r2v",
- "model_key": "veo_3_1_r2v_fast_portrait",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
- "supports_images": True,
- "min_images": 0,
- "max_images": 3
- },
- "veo_3_1_r2v_fast": {
- "type": "video",
- "video_type": "r2v",
- "model_key": "veo_3_1_r2v_fast_landscape",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
- "supports_images": True,
- "min_images": 0,
- "max_images": 3
- },
-
- # veo_3_1_r2v_fast_ultra (横竖屏)
- "veo_3_1_r2v_fast_portrait_ultra": {
- "type": "video",
- "video_type": "r2v",
- "model_key": "veo_3_1_r2v_fast_portrait_ultra",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
- "supports_images": True,
- "min_images": 0,
- "max_images": 3
- },
- "veo_3_1_r2v_fast_ultra": {
- "type": "video",
- "video_type": "r2v",
- "model_key": "veo_3_1_r2v_fast_landscape_ultra",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
- "supports_images": True,
- "min_images": 0,
- "max_images": 3
- },
-
- # veo_3_1_r2v_fast_ultra_relaxed (横竖屏)
- "veo_3_1_r2v_fast_portrait_ultra_relaxed": {
- "type": "video",
- "video_type": "r2v",
- "model_key": "veo_3_1_r2v_fast_portrait_ultra_relaxed",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
- "supports_images": True,
- "min_images": 0,
- "max_images": 3
- },
- "veo_3_1_r2v_fast_ultra_relaxed": {
- "type": "video",
- "video_type": "r2v",
- "model_key": "veo_3_1_r2v_fast_landscape_ultra_relaxed",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
- "supports_images": True,
- "min_images": 0,
- "max_images": 3
- },
-
- # ========== 视频放大 (Video Upsampler) ==========
- # 仅 3.1 支持,需要先生成视频后再放大,可能需要 30 分钟
-
- # T2V 4K 放大版
- "veo_3_1_t2v_fast_portrait_4k": {
- "type": "video",
- "video_type": "t2v",
- "model_key": "veo_3_1_t2v_fast_portrait",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
- "supports_images": False,
- "upsample": {"resolution": "VIDEO_RESOLUTION_4K", "model_key": "veo_3_1_upsampler_4k"}
- },
- "veo_3_1_t2v_fast_4k": {
- "type": "video",
- "video_type": "t2v",
- "model_key": "veo_3_1_t2v_fast",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
- "supports_images": False,
- "upsample": {"resolution": "VIDEO_RESOLUTION_4K", "model_key": "veo_3_1_upsampler_4k"}
- },
- "veo_3_1_t2v_fast_portrait_ultra_4k": {
- "type": "video",
- "video_type": "t2v",
- "model_key": "veo_3_1_t2v_fast_portrait_ultra",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
- "supports_images": False,
- "upsample": {"resolution": "VIDEO_RESOLUTION_4K", "model_key": "veo_3_1_upsampler_4k"}
- },
- "veo_3_1_t2v_fast_ultra_4k": {
- "type": "video",
- "video_type": "t2v",
- "model_key": "veo_3_1_t2v_fast_ultra",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
- "supports_images": False,
- "upsample": {"resolution": "VIDEO_RESOLUTION_4K", "model_key": "veo_3_1_upsampler_4k"}
- },
-
- # T2V 1080P 放大版
- "veo_3_1_t2v_fast_portrait_1080p": {
- "type": "video",
- "video_type": "t2v",
- "model_key": "veo_3_1_t2v_fast_portrait",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
- "supports_images": False,
- "upsample": {"resolution": "VIDEO_RESOLUTION_1080P", "model_key": "veo_3_1_upsampler_1080p"}
- },
- "veo_3_1_t2v_fast_1080p": {
- "type": "video",
- "video_type": "t2v",
- "model_key": "veo_3_1_t2v_fast",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
- "supports_images": False,
- "upsample": {"resolution": "VIDEO_RESOLUTION_1080P", "model_key": "veo_3_1_upsampler_1080p"}
- },
- "veo_3_1_t2v_fast_portrait_ultra_1080p": {
- "type": "video",
- "video_type": "t2v",
- "model_key": "veo_3_1_t2v_fast_portrait_ultra",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
- "supports_images": False,
- "upsample": {"resolution": "VIDEO_RESOLUTION_1080P", "model_key": "veo_3_1_upsampler_1080p"}
- },
- "veo_3_1_t2v_fast_ultra_1080p": {
- "type": "video",
- "video_type": "t2v",
- "model_key": "veo_3_1_t2v_fast_ultra",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
- "supports_images": False,
- "upsample": {"resolution": "VIDEO_RESOLUTION_1080P", "model_key": "veo_3_1_upsampler_1080p"}
- },
-
- # I2V 4K 放大版
- "veo_3_1_i2v_s_fast_portrait_ultra_fl_4k": {
- "type": "video",
- "video_type": "i2v",
- "model_key": "veo_3_1_i2v_s_fast_portrait_ultra_fl",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
- "supports_images": True,
- "min_images": 1,
- "max_images": 2,
- "upsample": {"resolution": "VIDEO_RESOLUTION_4K", "model_key": "veo_3_1_upsampler_4k"}
- },
- "veo_3_1_i2v_s_fast_ultra_fl_4k": {
- "type": "video",
- "video_type": "i2v",
- "model_key": "veo_3_1_i2v_s_fast_ultra_fl",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
- "supports_images": True,
- "min_images": 1,
- "max_images": 2,
- "upsample": {"resolution": "VIDEO_RESOLUTION_4K", "model_key": "veo_3_1_upsampler_4k"}
- },
-
- # I2V 1080P 放大版
- "veo_3_1_i2v_s_fast_portrait_ultra_fl_1080p": {
- "type": "video",
- "video_type": "i2v",
- "model_key": "veo_3_1_i2v_s_fast_portrait_ultra_fl",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
- "supports_images": True,
- "min_images": 1,
- "max_images": 2,
- "upsample": {"resolution": "VIDEO_RESOLUTION_1080P", "model_key": "veo_3_1_upsampler_1080p"}
- },
- "veo_3_1_i2v_s_fast_ultra_fl_1080p": {
- "type": "video",
- "video_type": "i2v",
- "model_key": "veo_3_1_i2v_s_fast_ultra_fl",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
- "supports_images": True,
- "min_images": 1,
- "max_images": 2,
- "upsample": {"resolution": "VIDEO_RESOLUTION_1080P", "model_key": "veo_3_1_upsampler_1080p"}
- },
-
- # R2V 4K 放大版
- "veo_3_1_r2v_fast_portrait_ultra_4k": {
- "type": "video",
- "video_type": "r2v",
- "model_key": "veo_3_1_r2v_fast_portrait_ultra",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
- "supports_images": True,
- "min_images": 0,
- "max_images": 3,
- "upsample": {"resolution": "VIDEO_RESOLUTION_4K", "model_key": "veo_3_1_upsampler_4k"}
- },
- "veo_3_1_r2v_fast_ultra_4k": {
- "type": "video",
- "video_type": "r2v",
- "model_key": "veo_3_1_r2v_fast_landscape_ultra",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
- "supports_images": True,
- "min_images": 0,
- "max_images": 3,
- "upsample": {"resolution": "VIDEO_RESOLUTION_4K", "model_key": "veo_3_1_upsampler_4k"}
- },
-
- # R2V 1080P 放大版
- "veo_3_1_r2v_fast_portrait_ultra_1080p": {
- "type": "video",
- "video_type": "r2v",
- "model_key": "veo_3_1_r2v_fast_portrait_ultra",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
- "supports_images": True,
- "min_images": 0,
- "max_images": 3,
- "upsample": {"resolution": "VIDEO_RESOLUTION_1080P", "model_key": "veo_3_1_upsampler_1080p"}
- },
- "veo_3_1_r2v_fast_ultra_1080p": {
- "type": "video",
- "video_type": "r2v",
- "model_key": "veo_3_1_r2v_fast_landscape_ultra",
- "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
- "supports_images": True,
- "min_images": 0,
- "max_images": 3,
- "upsample": {"resolution": "VIDEO_RESOLUTION_1080P", "model_key": "veo_3_1_upsampler_1080p"}
- }
-}
-
-
+from ..core.config import config
+from ..core.models import Task, RequestLog
+from ..core.account_tiers import (
+ PAYGATE_TIER_NOT_PAID,
+ get_paygate_tier_label,
+ get_required_paygate_tier_for_model,
+ normalize_user_paygate_tier,
+ supports_model_for_tier,
+)
+from .file_cache import FileCache
+
+
+# Model configuration
+MODEL_CONFIG = {
+ # 图片生成 - GEM_PIX (Gemini 2.5 Flash)
+ "gemini-2.5-flash-image-landscape": {
+ "type": "image",
+ "model_name": "GEM_PIX",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE"
+ },
+ "gemini-2.5-flash-image-portrait": {
+ "type": "image",
+ "model_name": "GEM_PIX",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT"
+ },
+
+ # 图片生成 - GEM_PIX_2 (Gemini 3.0 Pro)
+ "gemini-3.0-pro-image-landscape": {
+ "type": "image",
+ "model_name": "GEM_PIX_2",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE"
+ },
+ "gemini-3.0-pro-image-portrait": {
+ "type": "image",
+ "model_name": "GEM_PIX_2",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT"
+ },
+ "gemini-3.0-pro-image-square": {
+ "type": "image",
+ "model_name": "GEM_PIX_2",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_SQUARE"
+ },
+ "gemini-3.0-pro-image-four-three": {
+ "type": "image",
+ "model_name": "GEM_PIX_2",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE_FOUR_THREE"
+ },
+ "gemini-3.0-pro-image-three-four": {
+ "type": "image",
+ "model_name": "GEM_PIX_2",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT_THREE_FOUR"
+ },
+
+ # 图片生成 - GEM_PIX_2 (Gemini 3.0 Pro) 2K 放大版
+ "gemini-3.0-pro-image-landscape-2k": {
+ "type": "image",
+ "model_name": "GEM_PIX_2",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE",
+ "upsample": "UPSAMPLE_IMAGE_RESOLUTION_2K"
+ },
+ "gemini-3.0-pro-image-portrait-2k": {
+ "type": "image",
+ "model_name": "GEM_PIX_2",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT",
+ "upsample": "UPSAMPLE_IMAGE_RESOLUTION_2K"
+ },
+ "gemini-3.0-pro-image-square-2k": {
+ "type": "image",
+ "model_name": "GEM_PIX_2",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_SQUARE",
+ "upsample": "UPSAMPLE_IMAGE_RESOLUTION_2K"
+ },
+ "gemini-3.0-pro-image-four-three-2k": {
+ "type": "image",
+ "model_name": "GEM_PIX_2",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE_FOUR_THREE",
+ "upsample": "UPSAMPLE_IMAGE_RESOLUTION_2K"
+ },
+ "gemini-3.0-pro-image-three-four-2k": {
+ "type": "image",
+ "model_name": "GEM_PIX_2",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT_THREE_FOUR",
+ "upsample": "UPSAMPLE_IMAGE_RESOLUTION_2K"
+ },
+
+ # 图片生成 - GEM_PIX_2 (Gemini 3.0 Pro) 4K 放大版
+ "gemini-3.0-pro-image-landscape-4k": {
+ "type": "image",
+ "model_name": "GEM_PIX_2",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE",
+ "upsample": "UPSAMPLE_IMAGE_RESOLUTION_4K"
+ },
+ "gemini-3.0-pro-image-portrait-4k": {
+ "type": "image",
+ "model_name": "GEM_PIX_2",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT",
+ "upsample": "UPSAMPLE_IMAGE_RESOLUTION_4K"
+ },
+ "gemini-3.0-pro-image-square-4k": {
+ "type": "image",
+ "model_name": "GEM_PIX_2",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_SQUARE",
+ "upsample": "UPSAMPLE_IMAGE_RESOLUTION_4K"
+ },
+ "gemini-3.0-pro-image-four-three-4k": {
+ "type": "image",
+ "model_name": "GEM_PIX_2",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE_FOUR_THREE",
+ "upsample": "UPSAMPLE_IMAGE_RESOLUTION_4K"
+ },
+ "gemini-3.0-pro-image-three-four-4k": {
+ "type": "image",
+ "model_name": "GEM_PIX_2",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT_THREE_FOUR",
+ "upsample": "UPSAMPLE_IMAGE_RESOLUTION_4K"
+ },
+
+ # 图片生成 - IMAGEN_3_5 (Imagen 4.0)
+ "imagen-4.0-generate-preview-landscape": {
+ "type": "image",
+ "model_name": "IMAGEN_3_5",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE"
+ },
+ "imagen-4.0-generate-preview-portrait": {
+ "type": "image",
+ "model_name": "IMAGEN_3_5",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT"
+ },
+
+ # 图片生成 - NARWHAL (新版)
+ "gemini-3.1-flash-image-landscape": {
+ "type": "image",
+ "model_name": "NARWHAL",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE"
+ },
+ "gemini-3.1-flash-image-portrait": {
+ "type": "image",
+ "model_name": "NARWHAL",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT"
+ },
+ "gemini-3.1-flash-image-square": {
+ "type": "image",
+ "model_name": "NARWHAL",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_SQUARE"
+ },
+ "gemini-3.1-flash-image-four-three": {
+ "type": "image",
+ "model_name": "NARWHAL",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE_FOUR_THREE"
+ },
+ "gemini-3.1-flash-image-three-four": {
+ "type": "image",
+ "model_name": "NARWHAL",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT_THREE_FOUR"
+ },
+ "gemini-3.1-flash-image-landscape-2k": {
+ "type": "image",
+ "model_name": "NARWHAL",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE",
+ "upsample": "UPSAMPLE_IMAGE_RESOLUTION_2K"
+ },
+ "gemini-3.1-flash-image-portrait-2k": {
+ "type": "image",
+ "model_name": "NARWHAL",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT",
+ "upsample": "UPSAMPLE_IMAGE_RESOLUTION_2K"
+ },
+ "gemini-3.1-flash-image-square-2k": {
+ "type": "image",
+ "model_name": "NARWHAL",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_SQUARE",
+ "upsample": "UPSAMPLE_IMAGE_RESOLUTION_2K"
+ },
+ "gemini-3.1-flash-image-four-three-2k": {
+ "type": "image",
+ "model_name": "NARWHAL",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE_FOUR_THREE",
+ "upsample": "UPSAMPLE_IMAGE_RESOLUTION_2K"
+ },
+ "gemini-3.1-flash-image-three-four-2k": {
+ "type": "image",
+ "model_name": "NARWHAL",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT_THREE_FOUR",
+ "upsample": "UPSAMPLE_IMAGE_RESOLUTION_2K"
+ },
+ "gemini-3.1-flash-image-landscape-4k": {
+ "type": "image",
+ "model_name": "NARWHAL",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE",
+ "upsample": "UPSAMPLE_IMAGE_RESOLUTION_4K"
+ },
+ "gemini-3.1-flash-image-portrait-4k": {
+ "type": "image",
+ "model_name": "NARWHAL",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT",
+ "upsample": "UPSAMPLE_IMAGE_RESOLUTION_4K"
+ },
+ "gemini-3.1-flash-image-square-4k": {
+ "type": "image",
+ "model_name": "NARWHAL",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_SQUARE",
+ "upsample": "UPSAMPLE_IMAGE_RESOLUTION_4K"
+ },
+ "gemini-3.1-flash-image-four-three-4k": {
+ "type": "image",
+ "model_name": "NARWHAL",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE_FOUR_THREE",
+ "upsample": "UPSAMPLE_IMAGE_RESOLUTION_4K"
+ },
+ "gemini-3.1-flash-image-three-four-4k": {
+ "type": "image",
+ "model_name": "NARWHAL",
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT_THREE_FOUR",
+ "upsample": "UPSAMPLE_IMAGE_RESOLUTION_4K"
+ },
+
+ # ========== 文生视频 (T2V - Text to Video) ==========
+ # 不支持上传图片,只使用文本提示词生成
+
+ # veo_3_1_t2v_fast_portrait (竖屏)
+ # 上游模型名: veo_3_1_t2v_fast_portrait
+ "veo_3_1_t2v_fast_portrait": {
+ "type": "video",
+ "video_type": "t2v",
+ "model_key": "veo_3_1_t2v_fast_portrait",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
+ "supports_images": False
+ },
+ # veo_3_1_t2v_fast_landscape (横屏)
+ # 上游模型名: veo_3_1_t2v_fast
+ "veo_3_1_t2v_fast_landscape": {
+ "type": "video",
+ "video_type": "t2v",
+ "model_key": "veo_3_1_t2v_fast",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
+ "supports_images": False
+ },
+
+ # veo_2_1_fast_d_15_t2v (需要新增横竖屏)
+ "veo_2_1_fast_d_15_t2v_portrait": {
+ "type": "video",
+ "video_type": "t2v",
+ "model_key": "veo_2_1_fast_d_15_t2v",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
+ "supports_images": False
+ },
+ "veo_2_1_fast_d_15_t2v_landscape": {
+ "type": "video",
+ "video_type": "t2v",
+ "model_key": "veo_2_1_fast_d_15_t2v",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
+ "supports_images": False
+ },
+
+ # veo_2_0_t2v (需要新增横竖屏)
+ "veo_2_0_t2v_portrait": {
+ "type": "video",
+ "video_type": "t2v",
+ "model_key": "veo_2_0_t2v",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
+ "supports_images": False
+ },
+ "veo_2_0_t2v_landscape": {
+ "type": "video",
+ "video_type": "t2v",
+ "model_key": "veo_2_0_t2v",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
+ "supports_images": False
+ },
+
+ # veo_3_1_t2v_fast_ultra (横竖屏)
+ "veo_3_1_t2v_fast_portrait_ultra": {
+ "type": "video",
+ "video_type": "t2v",
+ "model_key": "veo_3_1_t2v_fast_portrait_ultra",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
+ "supports_images": False
+ },
+ "veo_3_1_t2v_fast_ultra": {
+ "type": "video",
+ "video_type": "t2v",
+ "model_key": "veo_3_1_t2v_fast_ultra",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
+ "supports_images": False
+ },
+
+ # veo_3_1_t2v_fast_ultra_relaxed (横竖屏)
+ "veo_3_1_t2v_fast_portrait_ultra_relaxed": {
+ "type": "video",
+ "video_type": "t2v",
+ "model_key": "veo_3_1_t2v_fast_portrait_ultra_relaxed",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
+ "supports_images": False
+ },
+ "veo_3_1_t2v_fast_ultra_relaxed": {
+ "type": "video",
+ "video_type": "t2v",
+ "model_key": "veo_3_1_t2v_fast_ultra_relaxed",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
+ "supports_images": False
+ },
+
+ # veo_3_1_t2v (横竖屏)
+ "veo_3_1_t2v_portrait": {
+ "type": "video",
+ "video_type": "t2v",
+ "model_key": "veo_3_1_t2v_portrait",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
+ "supports_images": False
+ },
+ "veo_3_1_t2v_landscape": {
+ "type": "video",
+ "video_type": "t2v",
+ "model_key": "veo_3_1_t2v",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
+ "supports_images": False
+ },
+
+ # ========== 首尾帧模型 (I2V - Image to Video) ==========
+ # 支持1-2张图片:1张作为首帧,2张作为首尾帧
+
+ # veo_3_1_i2v_s_fast_fl (需要新增横竖屏)
+ "veo_3_1_i2v_s_fast_portrait_fl": {
+ "type": "video",
+ "video_type": "i2v",
+ "model_key": "veo_3_1_i2v_s_fast_portrait_fl",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
+ "supports_images": True,
+ "min_images": 1,
+ "max_images": 2
+ },
+ "veo_3_1_i2v_s_fast_fl": {
+ "type": "video",
+ "video_type": "i2v",
+ "model_key": "veo_3_1_i2v_s_fast_fl",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
+ "supports_images": True,
+ "min_images": 1,
+ "max_images": 2
+ },
+
+ # veo_2_1_fast_d_15_i2v (需要新增横竖屏)
+ "veo_2_1_fast_d_15_i2v_portrait": {
+ "type": "video",
+ "video_type": "i2v",
+ "model_key": "veo_2_1_fast_d_15_i2v",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
+ "supports_images": True,
+ "min_images": 1,
+ "max_images": 2
+ },
+ "veo_2_1_fast_d_15_i2v_landscape": {
+ "type": "video",
+ "video_type": "i2v",
+ "model_key": "veo_2_1_fast_d_15_i2v",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
+ "supports_images": True,
+ "min_images": 1,
+ "max_images": 2
+ },
+
+ # veo_2_0_i2v (需要新增横竖屏)
+ "veo_2_0_i2v_portrait": {
+ "type": "video",
+ "video_type": "i2v",
+ "model_key": "veo_2_0_i2v",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
+ "supports_images": True,
+ "min_images": 1,
+ "max_images": 2
+ },
+ "veo_2_0_i2v_landscape": {
+ "type": "video",
+ "video_type": "i2v",
+ "model_key": "veo_2_0_i2v",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
+ "supports_images": True,
+ "min_images": 1,
+ "max_images": 2
+ },
+
+ # veo_3_1_i2v_s_fast_ultra (横竖屏)
+ "veo_3_1_i2v_s_fast_portrait_ultra_fl": {
+ "type": "video",
+ "video_type": "i2v",
+ "model_key": "veo_3_1_i2v_s_fast_portrait_ultra_fl",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
+ "supports_images": True,
+ "min_images": 1,
+ "max_images": 2
+ },
+ "veo_3_1_i2v_s_fast_ultra_fl": {
+ "type": "video",
+ "video_type": "i2v",
+ "model_key": "veo_3_1_i2v_s_fast_ultra_fl",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
+ "supports_images": True,
+ "min_images": 1,
+ "max_images": 2
+ },
+
+ # veo_3_1_i2v_s_fast_ultra_relaxed (需要新增横竖屏)
+ "veo_3_1_i2v_s_fast_portrait_ultra_relaxed": {
+ "type": "video",
+ "video_type": "i2v",
+ "model_key": "veo_3_1_i2v_s_fast_portrait_ultra_relaxed",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
+ "supports_images": True,
+ "min_images": 1,
+ "max_images": 2
+ },
+ "veo_3_1_i2v_s_fast_ultra_relaxed": {
+ "type": "video",
+ "video_type": "i2v",
+ "model_key": "veo_3_1_i2v_s_fast_ultra_relaxed",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
+ "supports_images": True,
+ "min_images": 1,
+ "max_images": 2
+ },
+
+ # veo_3_1_i2v_s (需要新增横竖屏)
+ "veo_3_1_i2v_s_portrait": {
+ "type": "video",
+ "video_type": "i2v",
+ "model_key": "veo_3_1_i2v_s",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
+ "supports_images": True,
+ "min_images": 1,
+ "max_images": 2
+ },
+ "veo_3_1_i2v_s_landscape": {
+ "type": "video",
+ "video_type": "i2v",
+ "model_key": "veo_3_1_i2v_s",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
+ "supports_images": True,
+ "min_images": 1,
+ "max_images": 2
+ },
+
+ # ========== 多图生成 (R2V - Reference Images to Video) ==========
+ # 当前上游协议最多支持 3 张参考图
+
+ # veo_3_1_r2v_fast (横竖屏)
+ "veo_3_1_r2v_fast_portrait": {
+ "type": "video",
+ "video_type": "r2v",
+ "model_key": "veo_3_1_r2v_fast_portrait",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
+ "supports_images": True,
+ "min_images": 0,
+ "max_images": 3
+ },
+ "veo_3_1_r2v_fast": {
+ "type": "video",
+ "video_type": "r2v",
+ "model_key": "veo_3_1_r2v_fast_landscape",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
+ "supports_images": True,
+ "min_images": 0,
+ "max_images": 3
+ },
+
+ # veo_3_1_r2v_fast_ultra (横竖屏)
+ "veo_3_1_r2v_fast_portrait_ultra": {
+ "type": "video",
+ "video_type": "r2v",
+ "model_key": "veo_3_1_r2v_fast_portrait_ultra",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
+ "supports_images": True,
+ "min_images": 0,
+ "max_images": 3
+ },
+ "veo_3_1_r2v_fast_ultra": {
+ "type": "video",
+ "video_type": "r2v",
+ "model_key": "veo_3_1_r2v_fast_landscape_ultra",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
+ "supports_images": True,
+ "min_images": 0,
+ "max_images": 3
+ },
+
+ # veo_3_1_r2v_fast_ultra_relaxed (横竖屏)
+ "veo_3_1_r2v_fast_portrait_ultra_relaxed": {
+ "type": "video",
+ "video_type": "r2v",
+ "model_key": "veo_3_1_r2v_fast_portrait_ultra_relaxed",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
+ "supports_images": True,
+ "min_images": 0,
+ "max_images": 3
+ },
+ "veo_3_1_r2v_fast_ultra_relaxed": {
+ "type": "video",
+ "video_type": "r2v",
+ "model_key": "veo_3_1_r2v_fast_landscape_ultra_relaxed",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
+ "supports_images": True,
+ "min_images": 0,
+ "max_images": 3
+ },
+
+ # ========== 视频放大 (Video Upsampler) ==========
+ # 仅 3.1 支持,需要先生成视频后再放大,可能需要 30 分钟
+
+ # T2V 4K 放大版
+ "veo_3_1_t2v_fast_portrait_4k": {
+ "type": "video",
+ "video_type": "t2v",
+ "model_key": "veo_3_1_t2v_fast_portrait",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
+ "supports_images": False,
+ "upsample": {"resolution": "VIDEO_RESOLUTION_4K", "model_key": "veo_3_1_upsampler_4k"}
+ },
+ "veo_3_1_t2v_fast_4k": {
+ "type": "video",
+ "video_type": "t2v",
+ "model_key": "veo_3_1_t2v_fast",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
+ "supports_images": False,
+ "upsample": {"resolution": "VIDEO_RESOLUTION_4K", "model_key": "veo_3_1_upsampler_4k"}
+ },
+ "veo_3_1_t2v_fast_portrait_ultra_4k": {
+ "type": "video",
+ "video_type": "t2v",
+ "model_key": "veo_3_1_t2v_fast_portrait_ultra",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
+ "supports_images": False,
+ "upsample": {"resolution": "VIDEO_RESOLUTION_4K", "model_key": "veo_3_1_upsampler_4k"}
+ },
+ "veo_3_1_t2v_fast_ultra_4k": {
+ "type": "video",
+ "video_type": "t2v",
+ "model_key": "veo_3_1_t2v_fast_ultra",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
+ "supports_images": False,
+ "upsample": {"resolution": "VIDEO_RESOLUTION_4K", "model_key": "veo_3_1_upsampler_4k"}
+ },
+
+ # T2V 1080P 放大版
+ "veo_3_1_t2v_fast_portrait_1080p": {
+ "type": "video",
+ "video_type": "t2v",
+ "model_key": "veo_3_1_t2v_fast_portrait",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
+ "supports_images": False,
+ "upsample": {"resolution": "VIDEO_RESOLUTION_1080P", "model_key": "veo_3_1_upsampler_1080p"}
+ },
+ "veo_3_1_t2v_fast_1080p": {
+ "type": "video",
+ "video_type": "t2v",
+ "model_key": "veo_3_1_t2v_fast",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
+ "supports_images": False,
+ "upsample": {"resolution": "VIDEO_RESOLUTION_1080P", "model_key": "veo_3_1_upsampler_1080p"}
+ },
+ "veo_3_1_t2v_fast_portrait_ultra_1080p": {
+ "type": "video",
+ "video_type": "t2v",
+ "model_key": "veo_3_1_t2v_fast_portrait_ultra",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
+ "supports_images": False,
+ "upsample": {"resolution": "VIDEO_RESOLUTION_1080P", "model_key": "veo_3_1_upsampler_1080p"}
+ },
+ "veo_3_1_t2v_fast_ultra_1080p": {
+ "type": "video",
+ "video_type": "t2v",
+ "model_key": "veo_3_1_t2v_fast_ultra",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
+ "supports_images": False,
+ "upsample": {"resolution": "VIDEO_RESOLUTION_1080P", "model_key": "veo_3_1_upsampler_1080p"}
+ },
+
+ # I2V 4K 放大版
+ "veo_3_1_i2v_s_fast_portrait_ultra_fl_4k": {
+ "type": "video",
+ "video_type": "i2v",
+ "model_key": "veo_3_1_i2v_s_fast_portrait_ultra_fl",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
+ "supports_images": True,
+ "min_images": 1,
+ "max_images": 2,
+ "upsample": {"resolution": "VIDEO_RESOLUTION_4K", "model_key": "veo_3_1_upsampler_4k"}
+ },
+ "veo_3_1_i2v_s_fast_ultra_fl_4k": {
+ "type": "video",
+ "video_type": "i2v",
+ "model_key": "veo_3_1_i2v_s_fast_ultra_fl",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
+ "supports_images": True,
+ "min_images": 1,
+ "max_images": 2,
+ "upsample": {"resolution": "VIDEO_RESOLUTION_4K", "model_key": "veo_3_1_upsampler_4k"}
+ },
+
+ # I2V 1080P 放大版
+ "veo_3_1_i2v_s_fast_portrait_ultra_fl_1080p": {
+ "type": "video",
+ "video_type": "i2v",
+ "model_key": "veo_3_1_i2v_s_fast_portrait_ultra_fl",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
+ "supports_images": True,
+ "min_images": 1,
+ "max_images": 2,
+ "upsample": {"resolution": "VIDEO_RESOLUTION_1080P", "model_key": "veo_3_1_upsampler_1080p"}
+ },
+ "veo_3_1_i2v_s_fast_ultra_fl_1080p": {
+ "type": "video",
+ "video_type": "i2v",
+ "model_key": "veo_3_1_i2v_s_fast_ultra_fl",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
+ "supports_images": True,
+ "min_images": 1,
+ "max_images": 2,
+ "upsample": {"resolution": "VIDEO_RESOLUTION_1080P", "model_key": "veo_3_1_upsampler_1080p"}
+ },
+
+ # R2V 4K 放大版
+ "veo_3_1_r2v_fast_portrait_ultra_4k": {
+ "type": "video",
+ "video_type": "r2v",
+ "model_key": "veo_3_1_r2v_fast_portrait_ultra",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
+ "supports_images": True,
+ "min_images": 0,
+ "max_images": 3,
+ "upsample": {"resolution": "VIDEO_RESOLUTION_4K", "model_key": "veo_3_1_upsampler_4k"}
+ },
+ "veo_3_1_r2v_fast_ultra_4k": {
+ "type": "video",
+ "video_type": "r2v",
+ "model_key": "veo_3_1_r2v_fast_landscape_ultra",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
+ "supports_images": True,
+ "min_images": 0,
+ "max_images": 3,
+ "upsample": {"resolution": "VIDEO_RESOLUTION_4K", "model_key": "veo_3_1_upsampler_4k"}
+ },
+
+ # R2V 1080P 放大版
+ "veo_3_1_r2v_fast_portrait_ultra_1080p": {
+ "type": "video",
+ "video_type": "r2v",
+ "model_key": "veo_3_1_r2v_fast_portrait_ultra",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
+ "supports_images": True,
+ "min_images": 0,
+ "max_images": 3,
+ "upsample": {"resolution": "VIDEO_RESOLUTION_1080P", "model_key": "veo_3_1_upsampler_1080p"}
+ },
+ "veo_3_1_r2v_fast_ultra_1080p": {
+ "type": "video",
+ "video_type": "r2v",
+ "model_key": "veo_3_1_r2v_fast_landscape_ultra",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
+ "supports_images": True,
+ "min_images": 0,
+ "max_images": 3,
+ "upsample": {"resolution": "VIDEO_RESOLUTION_1080P", "model_key": "veo_3_1_upsampler_1080p"}
+ }
+}
+
+
class GenerationHandler:
"""统一生成处理器"""
@@ -687,1413 +687,1413 @@ class GenerationHandler:
proxy_manager=proxy_manager,
flow_client=flow_client,
)
-
- def _create_generation_result(self) -> Dict[str, Any]:
- """????????????????"""
- return dict(success=False, error_message=None, error_emitted=False)
-
- def _create_response_state(self) -> Dict[str, Any]:
- """为单次请求创建独立的响应状态,避免并发请求互相污染。"""
- return {
- "url": None,
- "generated_assets": None,
- "base_url": None,
- }
-
- def _mark_generation_failed(self, generation_result: Optional[Dict[str, Any]], error_message: str):
- """????????????????????"""
- if isinstance(generation_result, dict):
- generation_result["success"] = False
- generation_result["error_message"] = error_message
- generation_result["error_emitted"] = True
-
- def _mark_generation_succeeded(self, generation_result: Optional[Dict[str, Any]]):
- """???????"""
- if isinstance(generation_result, dict):
- generation_result["success"] = True
- generation_result["error_message"] = None
- generation_result["error_emitted"] = False
-
- def _normalize_error_message(self, error_message: Any, max_length: int = 1000) -> str:
- """归一化错误文本,避免写入超长内容。"""
- text = str(error_message or "").strip() or "未知错误"
- if len(text) <= max_length:
- return text
- return f"{text[:max_length - 3]}..."
-
- async def _fail_video_task(self, operations: Optional[List[Dict[str, Any]]], error_message: str):
- """将视频任务收口到失败态,避免残留 processing。"""
- if not operations:
- return
-
- operation = operations[0] if operations else {}
- task_id = (operation.get("operation") or {}).get("name")
- if not task_id:
- return
-
- try:
- await self.db.update_task(
- task_id,
- status="failed",
- error_message=self._normalize_error_message(error_message),
- completed_at=time.time()
- )
- except Exception as exc:
- debug_logger.log_error(f"[VIDEO] 更新任务失败状态失败: {exc}")
-
- async def check_token_availability(self, is_image: bool, is_video: bool) -> bool:
- """检查Token可用性
-
- Args:
- is_image: 是否检查图片生成Token
- is_video: 是否检查视频生成Token
-
- Returns:
- True表示有可用Token, False表示无可用Token
- """
- token_obj = await self.load_balancer.select_token(
- for_image_generation=is_image,
- for_video_generation=is_video
- )
- return token_obj is not None
-
- async def handle_generation(
- self,
- model: str,
- prompt: str,
- images: Optional[List[bytes]] = None,
- stream: bool = False,
- base_url_override: Optional[str] = None
- ) -> AsyncGenerator:
- """统一生成入口
-
- Args:
- model: 模型名称
- prompt: 提示词
- images: 图片列表 (bytes格式)
- stream: 是否流式输出
- """
- start_time = time.time()
- token = None
- generation_type = None
- pending_token_state = {"active": False}
- request_id = f"gen-{int(start_time * 1000)}-{id(asyncio.current_task())}"
- perf_trace: Dict[str, Any] = {
- "request_id": request_id,
- "model": model,
- "status": "processing",
- }
- generation_result = self._create_generation_result()
- response_state = self._create_response_state()
- response_state["base_url"] = (base_url_override or "").strip().rstrip("/") or None
- request_log_state: Dict[str, Any] = {"id": None, "progress": 0}
-
- # 防止并发链路复用到上一次请求的指纹上下文
- if hasattr(self.flow_client, "clear_request_fingerprint"):
- self.flow_client.clear_request_fingerprint()
-
- # 1. 验证模型
- if model not in MODEL_CONFIG:
- error_msg = f"不支持的模型: {model}"
- debug_logger.log_error(error_msg)
- yield self._create_error_response(error_msg, status_code=400)
- return
-
- model_config = MODEL_CONFIG[model]
- generation_type = model_config["type"]
- request_operation = f"generate_{generation_type}"
- prompt_for_log = prompt if len(prompt) <= 2000 else f"{prompt[:2000]}...(truncated)"
- request_payload = {
- "model": model,
- "prompt": prompt_for_log,
- "has_images": images is not None and len(images) > 0,
- }
- debug_logger.log_info(f"[GENERATION] 开始生成 - 模型: {model}, 类型: {generation_type}, Prompt: {prompt[:50]}...")
-
- # 向用户展示开始信息
- if stream:
- yield self._create_stream_chunk(
- f"✨ {'视频' if generation_type == 'video' else '图片'}生成任务已启动\n",
- role="assistant"
- )
- request_log_state["id"] = await self._log_request(
- token_id=None,
- operation=request_operation,
- request_data=request_payload,
- response_data={"status": "processing", "status_text": "started", "progress": 0, "request_id": request_id},
- status_code=102,
- duration=0,
- status_text="started",
- progress=0,
- )
-
- # 2. 选择Token
- debug_logger.log_info(f"[GENERATION] 正在选择可用Token...")
- token_select_started_at = time.time()
-
- if generation_type == "image":
- token = await self.load_balancer.select_token(
- for_image_generation=True,
- model=model,
- reserve=False,
- enforce_concurrency_filter=False,
- track_pending=True,
- )
- else:
- token = await self.load_balancer.select_token(
- for_video_generation=True,
- model=model,
- reserve=False,
- enforce_concurrency_filter=False,
- track_pending=True,
- )
- perf_trace["token_select_ms"] = int((time.time() - token_select_started_at) * 1000)
-
- if not token:
- error_msg = None
- if self.load_balancer and hasattr(self.load_balancer, "get_unavailable_reason"):
- error_msg = await self.load_balancer.get_unavailable_reason(
- for_image_generation=(generation_type == "image"),
- for_video_generation=(generation_type == "video"),
- model=model,
- )
- if not error_msg:
- error_msg = self._get_no_token_error_message(generation_type)
- debug_logger.log_error(f"[GENERATION] {error_msg}")
- await self._log_request(
- token_id=None,
- operation=request_operation,
- request_data=request_payload,
- response_data={"error": error_msg, "performance": perf_trace},
- status_code=503,
- duration=time.time() - start_time,
- log_id=request_log_state.get("id"),
- status_text="failed",
- progress=request_log_state.get("progress", 0),
- )
- if stream:
- yield self._create_stream_chunk(f"❌ {error_msg}\n")
- yield self._create_error_response(error_msg, status_code=503)
- return
-
- debug_logger.log_info(f"[GENERATION] 已选择Token: {token.id} ({token.email})")
- pending_token_state["active"] = True
- await self._update_request_log_progress(
- request_log_state,
- token_id=token.id,
- status_text="token_selected",
- progress=8,
- response_extra={"token_email": token.email},
- )
-
- try:
- # 3. 确保AT有效
- debug_logger.log_info(f"[GENERATION] 检查Token AT有效性...")
- if stream:
- yield self._create_stream_chunk("初始化生成环境...\n")
-
- await self._update_request_log_progress(
- request_log_state,
- token_id=token.id,
- status_text="token_ready",
- progress=15,
- )
- ensure_at_started_at = time.time()
- token = await self.token_manager.ensure_valid_token(token)
- perf_trace["ensure_at_ms"] = int((time.time() - ensure_at_started_at) * 1000)
- if not token:
- error_msg = "Token AT无效或刷新失败"
- debug_logger.log_error(f"[GENERATION] {error_msg}")
- if stream:
- yield self._create_stream_chunk(f"❌ {error_msg}\n")
- yield self._create_error_response(error_msg, status_code=503)
- return
-
- # 4. 确保Project存在
- debug_logger.log_info(f"[GENERATION] 检查/创建Project...")
-
- if not supports_model_for_tier(model, token.user_paygate_tier):
- required_tier = get_required_paygate_tier_for_model(model)
- error_msg = "当前模型需要 " + get_paygate_tier_label(required_tier) + " 账号: " + model
- debug_logger.log_error(f"[GENERATION] {error_msg}")
- if stream:
- yield self._create_stream_chunk(f"❌ {error_msg}\n")
- yield self._create_error_response(error_msg, status_code=403)
- return
-
- ensure_project_started_at = time.time()
- project_id = await self.token_manager.ensure_project_exists(token.id)
- perf_trace["ensure_project_ms"] = int((time.time() - ensure_project_started_at) * 1000)
- debug_logger.log_info(f"[GENERATION] Project ID: {project_id}")
- await self._update_request_log_progress(
- request_log_state,
- token_id=token.id,
- status_text="project_ready",
- progress=22,
- response_extra={"project_id": project_id},
- )
- prefill_action = "IMAGE_GENERATION" if generation_type == "image" else "VIDEO_GENERATION"
- await self.flow_client.prefill_remote_browser_pool(
- project_id=project_id,
- action=prefill_action,
- token_id=token.id,
- )
-
- # 5. 根据类型处理
- generation_pipeline_started_at = time.time()
- if generation_type == "image":
- debug_logger.log_info(f"[GENERATION] 开始图片生成流程...")
- async for chunk in self._handle_image_generation(
- token, project_id, model_config, prompt, images, stream,
- perf_trace=perf_trace,
- generation_result=generation_result,
- response_state=response_state,
- request_log_state=request_log_state,
- pending_token_state=pending_token_state
- ):
- yield chunk
- else: # video
- debug_logger.log_info(f"[GENERATION] 开始视频生成流程...")
- async for chunk in self._handle_video_generation(
- token, project_id, model_config, prompt, images, stream,
- perf_trace=perf_trace,
- generation_result=generation_result,
- response_state=response_state,
- request_log_state=request_log_state,
- pending_token_state=pending_token_state
- ):
- yield chunk
- perf_trace["generation_pipeline_ms"] = int((time.time() - generation_pipeline_started_at) * 1000)
-
- # 6. 记录使用
- if not generation_result.get("success"):
- error_msg = generation_result.get("error_message") or "生成未成功完成"
- debug_logger.log_warning(f"[GENERATION] 生成未成功,不扣次数: {error_msg}")
- if token:
- await self.token_manager.record_error(token.id)
- duration = time.time() - start_time
- perf_trace["status"] = "failed"
- perf_trace["total_ms"] = int(duration * 1000)
- perf_trace["error"] = error_msg
- prompt_for_log = prompt if len(prompt) <= 2000 else f"{prompt[:2000]}...(truncated)"
- await self._log_request(
- token.id if token else None,
- request_operation,
- request_payload,
- {"error": error_msg, "performance": perf_trace},
- 500,
- duration,
- log_id=request_log_state.get("id"),
- status_text="failed",
- progress=request_log_state.get("progress", 0),
- )
- if not generation_result.get("error_emitted"):
- if stream:
- yield self._create_stream_chunk(f"❌ {error_msg}\n")
- yield self._create_error_response(error_msg, status_code=500)
- return
-
- is_video = (generation_type == "video")
- await self.token_manager.record_usage(token.id, is_video=is_video)
-
- # 重置错误计数 (请求成功时清空连续错误计数)
- await self.token_manager.record_success(token.id)
-
- debug_logger.log_info(f"[GENERATION] ✅ 生成成功完成")
-
- # 7. 记录成功日志
- duration = time.time() - start_time
- perf_trace["status"] = "success"
- perf_trace["total_ms"] = int(duration * 1000)
- # 日志中保留更完整的 prompt,避免管理页只看到过短内容
- prompt_for_log = prompt if len(prompt) <= 2000 else f"{prompt[:2000]}...(truncated)"
-
- # 构建响应数据,包含生成的URL
- response_data = {
- "status": "success",
- "model": model,
- "prompt": prompt_for_log,
- "performance": perf_trace
- }
-
- # 添加生成的URL(如果有)
- if response_state.get("url"):
- response_data["url"] = response_state["url"]
- if response_state.get("generated_assets"):
- response_data["generated_assets"] = response_state["generated_assets"]
- image_perf = perf_trace.get("image_generation", {}) if isinstance(perf_trace, dict) else {}
- video_perf = perf_trace.get("video_generation", {}) if isinstance(perf_trace, dict) else {}
- debug_logger.log_info(
- f"[PERF] [{request_id}] total={perf_trace.get('total_ms', 0)}ms, "
- f"select={perf_trace.get('token_select_ms', 0)}ms, "
- f"ensure_at={perf_trace.get('ensure_at_ms', 0)}ms, "
- f"project={perf_trace.get('ensure_project_ms', 0)}ms, "
- f"pipeline={perf_trace.get('generation_pipeline_ms', 0)}ms, "
- f"slot_wait={image_perf.get('slot_wait_ms', 0)}ms, "
- f"launch_queue={image_perf.get('launch_queue_wait_ms', 0)}ms, "
- f"launch_stagger={image_perf.get('launch_stagger_wait_ms', 0)}ms, "
- f"video_slot_wait={video_perf.get('slot_wait_ms', 0)}ms"
- )
-
- await self._log_request(
- token.id,
- request_operation,
- request_payload,
- response_data,
- 200,
- duration,
- log_id=request_log_state.get("id"),
- status_text="completed",
- progress=100,
- )
-
- except asyncio.CancelledError:
- error_msg = "生成已取消: 客户端连接已断开"
- debug_logger.log_warning(f"[GENERATION] ⚠️ {error_msg}")
- duration = time.time() - start_time
- perf_trace["status"] = "failed"
- perf_trace["total_ms"] = int(duration * 1000)
- perf_trace["error"] = error_msg
- prompt_for_log = prompt if len(prompt) <= 2000 else f"{prompt[:2000]}...(truncated)"
- await self._log_request(
- token.id if token else None,
- request_operation if generation_type else "generate_unknown",
- request_payload if 'request_payload' in locals() else {"model": model},
- {"error": error_msg, "performance": perf_trace},
- 499,
- duration,
- log_id=request_log_state.get("id"),
- status_text="failed",
- progress=request_log_state.get("progress", 0),
- )
- raise
- except Exception as e:
- error_msg = f"生成失败: {str(e)}"
- debug_logger.log_error(f"[GENERATION] ❌ {error_msg}")
- if token:
- # 记录错误(所有错误统一处理,不再特殊处理429)
- await self.token_manager.record_error(token.id)
-
- # 先将最终失败状态落库,再返回错误响应,避免日志停在 102。
- duration = time.time() - start_time
- perf_trace["status"] = "failed"
- perf_trace["total_ms"] = int(duration * 1000)
- perf_trace["error"] = error_msg
- prompt_for_log = prompt if len(prompt) <= 2000 else f"{prompt[:2000]}...(truncated)"
- await self._log_request(
- token.id if token else None,
- request_operation if generation_type else "generate_unknown",
- request_payload if 'request_payload' in locals() else {"model": model},
- {"error": error_msg, "performance": perf_trace},
- 500,
- duration,
- log_id=request_log_state.get("id"),
- status_text="failed",
- progress=request_log_state.get("progress", 0),
- )
- if stream:
- yield self._create_stream_chunk(f"❌ {error_msg}\n")
- yield self._create_error_response(error_msg, status_code=500)
- finally:
- if pending_token_state.get("active") and token and self.load_balancer:
- await self.load_balancer.release_pending(
- token.id,
- for_image_generation=(generation_type == "image"),
- for_video_generation=(generation_type == "video"),
- )
- pending_token_state["active"] = False
-
-
- def _get_no_token_error_message(self, generation_type: str) -> str:
- """获取无可用Token时的详细错误信息"""
- if generation_type == "image":
- return "没有可用的Token进行图片生成。所有Token都处于禁用、冷却、锁定或已过期状态。"
- else:
- return "没有可用的Token进行视频生成。所有Token都处于禁用、冷却、配额耗尽或已过期状态。"
-
- async def _handle_image_generation(
- self,
- token,
- project_id: str,
- model_config: dict,
- prompt: str,
- images: Optional[List[bytes]],
- stream: bool,
- perf_trace: Optional[Dict[str, Any]] = None,
- generation_result: Optional[Dict[str, Any]] = None,
- response_state: Optional[Dict[str, Any]] = None,
- request_log_state: Optional[Dict[str, Any]] = None,
- pending_token_state: Optional[Dict[str, bool]] = None
- ) -> AsyncGenerator:
- """处理图片生成 (同步返回)"""
-
- if response_state is None:
- response_state = self._create_response_state()
-
- image_trace: Optional[Dict[str, Any]] = None
- if isinstance(perf_trace, dict):
- image_trace = perf_trace.setdefault("image_generation", {})
- image_trace["input_image_count"] = len(images) if images else 0
-
- # 不在本地等待图片硬并发槽位;请求一到就直接向上游提交。
- normalized_tier = normalize_user_paygate_tier(token.user_paygate_tier)
-
- if image_trace is not None:
- image_trace["slot_wait_ms"] = 0
-
- if images and len(images) > 0:
- await self._update_request_log_progress(request_log_state, token_id=token.id, status_text="uploading_images", progress=28)
- else:
- await self._update_request_log_progress(request_log_state, token_id=token.id, status_text="submitting_image", progress=28)
-
- try:
- # 上传图片 (如果有)
- upload_started_at = time.time()
- image_inputs = []
- if images and len(images) > 0:
- if stream:
- yield self._create_stream_chunk(f"上传 {len(images)} 张参考图片...\n")
-
- # 支持多图输入
- for idx, image_bytes in enumerate(images):
- media_id = await self.flow_client.upload_image(
- token.at,
- image_bytes,
- model_config["aspect_ratio"],
- project_id=project_id
- )
- image_inputs.append({
- "name": media_id,
- "imageInputType": "IMAGE_INPUT_TYPE_REFERENCE"
- })
- if stream:
- yield self._create_stream_chunk(f"已上传第 {idx + 1}/{len(images)} 张图片\n")
- if image_trace is not None:
- image_trace["upload_images_ms"] = int((time.time() - upload_started_at) * 1000)
-
- # 调用生成API
- if stream:
- if images and len(images) > 0:
- yield self._create_stream_chunk("参考图片上传完成,正在进行打码验证...\n")
- else:
- yield self._create_stream_chunk("正在进行打码验证并提交图片生成请求...\n")
-
- async def _image_progress_callback(status_text: str, progress: int):
- await self._update_request_log_progress(
- request_log_state,
- token_id=token.id,
- status_text=status_text,
- progress=progress,
- )
-
- generate_started_at = time.time()
- result, generation_session_id, upstream_trace = await self.flow_client.generate_image(
- at=token.at,
- project_id=project_id,
- prompt=prompt,
- model_name=model_config["model_name"],
- aspect_ratio=model_config["aspect_ratio"],
- image_inputs=image_inputs,
- token_id=token.id,
- token_image_concurrency=token.image_concurrency,
- progress_callback=_image_progress_callback,
- )
- if image_trace is not None:
- image_trace["generate_api_ms"] = int((time.time() - generate_started_at) * 1000)
- image_trace["upstream_trace"] = upstream_trace
- attempts = upstream_trace.get("generation_attempts") if isinstance(upstream_trace, dict) else None
- if isinstance(attempts, list) and attempts:
- first_attempt = attempts[0] if isinstance(attempts[0], dict) else {}
- image_trace["launch_queue_wait_ms"] = int(first_attempt.get("launch_queue_ms") or 0)
- image_trace["launch_stagger_wait_ms"] = int(first_attempt.get("launch_stagger_ms") or 0)
- await self._update_request_log_progress(
- request_log_state,
- token_id=token.id,
- status_text="image_generated",
- progress=72,
- )
-
- # 提取URL和mediaId
- media = result.get("media", [])
- if not media:
- self._mark_generation_failed(generation_result, "\u751f\u6210\u7ed3\u679c\u4e3a\u7a7a")
- yield self._create_error_response("生成结果为空", status_code=502)
- return
-
- image_url = media[0]["image"]["generatedImage"]["fifeUrl"]
- media_id = media[0].get("name") # 用于 upsample
- response_state["generated_assets"] = {
- "type": "image",
- "origin_image_url": image_url
- }
-
- # 检查是否需要 upsample
- upsample_resolution = model_config.get("upsample")
- if upsample_resolution and media_id:
- upsample_started_at = time.time()
- resolution_name = "4K" if "4K" in upsample_resolution else "2K"
- await self._update_request_log_progress(request_log_state, token_id=token.id, status_text=f"upsampling_{resolution_name.lower()}", progress=82)
- if stream:
- yield self._create_stream_chunk(f"正在放大图片到 {resolution_name}...\n")
-
- # 4K/2K 图片重试逻辑 - 最多重试3次
- max_retries = 3
- for retry_attempt in range(max_retries):
- try:
- # 调用 upsample API
- encoded_image = await self.flow_client.upsample_image(
- at=token.at,
- project_id=project_id,
- media_id=media_id,
- target_resolution=upsample_resolution,
- user_paygate_tier=normalized_tier,
- session_id=generation_session_id,
- token_id=token.id
- )
-
- if encoded_image:
- debug_logger.log_info(f"[UPSAMPLE] 图片已放大到 {resolution_name}")
-
- if stream:
- yield self._create_stream_chunk(f"✅ 图片已放大到 {resolution_name}\n")
-
- # 2K/4K 图片统一落盘为真实文件,日志里只保留链接。
- response_state["generated_assets"] = {
- "type": "image",
- "origin_image_url": image_url,
- "upscaled_image": {
- "resolution": resolution_name
- }
- }
-
- try:
- await self._update_request_log_progress(
- request_log_state,
- token_id=token.id,
- status_text="caching_image",
- progress=90,
- )
- if stream:
- yield self._create_stream_chunk(f"缓存 {resolution_name} 图片中...\n")
- cached_filename = await self.file_cache.cache_base64_image(encoded_image, resolution_name)
- local_url = f"{self._get_base_url(response_state)}/tmp/{cached_filename}"
- response_state["url"] = local_url
- response_state["generated_assets"]["upscaled_image"]["local_url"] = local_url
- response_state["generated_assets"]["upscaled_image"]["url"] = local_url
- self._mark_generation_succeeded(generation_result)
- if stream:
- yield self._create_stream_chunk(f"✅ {resolution_name} 图片缓存成功\n")
- yield self._create_stream_chunk(
- f"",
- finish_reason="stop"
- )
- else:
- yield self._create_completion_response(
- local_url,
- media_type="image"
- )
- if image_trace is not None:
- image_trace["upsample_ms"] = int((time.time() - upsample_started_at) * 1000)
- return
- except Exception as e:
- debug_logger.log_error(f"Failed to cache {resolution_name} image: {str(e)}")
- response_state["url"] = image_url
- response_state["generated_assets"]["upscaled_image"]["local_url"] = None
- response_state["generated_assets"]["upscaled_image"]["url"] = image_url
- response_state["generated_assets"]["upscaled_image"]["delivery_mode"] = "inline_base64_fallback"
- self._mark_generation_succeeded(generation_result)
- base64_url = f"data:image/jpeg;base64,{encoded_image}"
- if stream:
- cache_error = self._normalize_error_message(e, max_length=120)
- yield self._create_stream_chunk(f"⚠️ 缓存失败: {cache_error},返回内联图片...\n")
- yield self._create_stream_chunk(
- f"",
- finish_reason="stop"
- )
- else:
- yield self._create_completion_response(
- base64_url,
- media_type="image"
- )
- if image_trace is not None:
- image_trace["upsample_ms"] = int((time.time() - upsample_started_at) * 1000)
- return
- else:
- debug_logger.log_warning("[UPSAMPLE] 返回结果为空")
- if stream:
- yield self._create_stream_chunk(f"⚠️ 放大失败,返回原图...\n")
- break # 空结果不重试
-
- except Exception as e:
- error_str = str(e)
- debug_logger.log_error(f"[UPSAMPLE] 放大失败 (尝试 {retry_attempt + 1}/{max_retries}): {error_str}")
-
- # 检查是否是可重试错误(403、reCAPTCHA、超时等)
- retry_reason = self.flow_client._get_retry_reason(error_str)
- if retry_reason and retry_attempt < max_retries - 1:
- if stream:
- yield self._create_stream_chunk(f"⚠️ 放大遇到{retry_reason},正在重试 ({retry_attempt + 2}/{max_retries})...\n")
- # 等待一小段时间后重试
- await asyncio.sleep(1)
- continue
- else:
- if stream:
- yield self._create_stream_chunk(f"⚠️ 放大失败: {error_str},返回原图...\n")
- break
- if image_trace is not None:
- image_trace["upsample_ms"] = int((time.time() - upsample_started_at) * 1000)
-
- local_url = image_url
- cache_started_at = time.time()
- if config.cache_enabled:
- await self._update_request_log_progress(
- request_log_state,
- token_id=token.id,
- status_text="caching_image",
- progress=90,
- )
- if stream:
- yield self._create_stream_chunk("正在缓存 1K 图片文件...\n")
- try:
- cached_filename = await self.file_cache.download_and_cache(image_url, "image")
- local_url = f"{self._get_base_url(response_state)}/tmp/{cached_filename}"
- if stream:
- yield self._create_stream_chunk("✅ 1K 图片缓存成功,准备返回缓存地址...\n")
- except Exception as e:
- debug_logger.log_error(f"Failed to cache 1K image: {str(e)}")
- local_url = image_url
- if stream:
- cache_error = self._normalize_error_message(e, max_length=120)
- yield self._create_stream_chunk(f"⚠️ 缓存失败: {cache_error}\n正在返回源链接...\n")
- elif stream:
- yield self._create_stream_chunk("缓存已关闭,正在返回官方图片链接...\n")
- if image_trace is not None:
- image_trace["cache_image_ms"] = int((time.time() - cache_started_at) * 1000)
-
- # 返回结果
- # 存储URL用于日志记录
- response_state["url"] = local_url
- response_state["generated_assets"] = {
- "type": "image",
- "origin_image_url": image_url,
- "final_image_url": local_url
- }
- self._mark_generation_succeeded(generation_result)
-
- if stream:
- yield self._create_stream_chunk(
- f"",
- finish_reason="stop"
- )
- else:
- yield self._create_completion_response(
- local_url, # 直接传URL,让方法内部格式化
- media_type="image"
- )
-
- finally:
- pass
-
- async def _handle_video_generation(
- self,
- token,
- project_id: str,
- model_config: dict,
- prompt: str,
- images: Optional[List[bytes]],
- stream: bool,
- perf_trace: Optional[Dict[str, Any]] = None,
- generation_result: Optional[Dict[str, Any]] = None,
- response_state: Optional[Dict[str, Any]] = None,
- request_log_state: Optional[Dict[str, Any]] = None,
- pending_token_state: Optional[Dict[str, bool]] = None
- ) -> AsyncGenerator:
- """处理视频生成 (异步轮询)"""
-
- if response_state is None:
- response_state = self._create_response_state()
-
- video_trace: Optional[Dict[str, Any]] = None
- if isinstance(perf_trace, dict):
- video_trace = perf_trace.setdefault("video_generation", {})
- video_trace["input_image_count"] = len(images) if images else 0
-
- # 不在本地等待视频硬并发槽位;请求一到就直接向上游提交。
- normalized_tier = normalize_user_paygate_tier(token.user_paygate_tier)
-
- if video_trace is not None:
- video_trace["slot_wait_ms"] = 0
-
- await self._update_request_log_progress(request_log_state, token_id=token.id, status_text="preparing_video", progress=24)
-
- try:
- # 获取模型类型和配置
- video_type = model_config.get("video_type")
- supports_images = model_config.get("supports_images", False)
- min_images = model_config.get("min_images", 0)
- max_images = model_config.get("max_images", 0)
-
- # 根据账号tier自动调整模型 key
- model_key = model_config["model_key"]
- user_tier = normalized_tier
-
- # TIER_TWO 账号需要使用 ultra 版本的模型
- if user_tier == "PAYGATE_TIER_TWO":
- # 如果模型 key 不包含 ultra,自动添加
- if "ultra" not in model_key:
- # veo_3_1_i2v_s_fast_fl -> veo_3_1_i2v_s_fast_ultra_fl
- # veo_3_1_i2v_s_fast_portrait_fl -> veo_3_1_i2v_s_fast_portrait_ultra_fl
- # veo_3_1_t2v_fast -> veo_3_1_t2v_fast_ultra
- # veo_3_1_t2v_fast_portrait -> veo_3_1_t2v_fast_portrait_ultra
- # veo_3_1_r2v_fast_landscape -> veo_3_1_r2v_fast_landscape_ultra
- if "_fl" in model_key:
- model_key = model_key.replace("_fl", "_ultra_fl")
- else:
- # 直接在末尾添加 _ultra
- model_key = model_key + "_ultra"
-
- if stream:
- yield self._create_stream_chunk(f"TIER_TWO 账号自动切换到 ultra 模型: {model_key}\n")
- debug_logger.log_info(f"[VIDEO] TIER_TWO 账号,模型自动调整: {model_config['model_key']} -> {model_key}")
-
- # TIER_ONE 账号需要使用非 ultra 版本
- elif user_tier == "PAYGATE_TIER_ONE":
- # 如果模型 key 包含 ultra,需要移除(避免用户误用)
- if "ultra" in model_key:
- # veo_3_1_i2v_s_fast_ultra_fl -> veo_3_1_i2v_s_fast_fl
- # veo_3_1_t2v_fast_ultra -> veo_3_1_t2v_fast
- # veo_3_1_r2v_fast_landscape_ultra -> veo_3_1_r2v_fast_landscape
- model_key = model_key.replace("_ultra_fl", "_fl").replace("_ultra", "")
-
- if stream:
- yield self._create_stream_chunk(f"TIER_ONE 账号自动切换到标准模型: {model_key}\n")
- debug_logger.log_info(f"[VIDEO] TIER_ONE 账号,模型自动调整: {model_config['model_key']} -> {model_key}")
-
- # 更新 model_config 中的 model_key
- model_config = dict(model_config) # 创建副本避免修改原配置
- model_config["model_key"] = model_key
-
- # 图片数量
- image_count = len(images) if images else 0
-
- # ========== 验证和处理图片 ==========
-
- # T2V: 文生视频 - 不支持图片
- if video_type == "t2v":
- if image_count > 0:
- if stream:
- yield self._create_stream_chunk("⚠️ 文生视频模型不支持上传图片,将忽略图片仅使用文本提示词生成\n")
- debug_logger.log_warning(f"[T2V] 模型 {model_config['model_key']} 不支持图片,已忽略 {image_count} 张图片")
- images = None # 清空图片
- image_count = 0
-
- # I2V: 首尾帧模型 - 需要1-2张图片
- elif video_type == "i2v":
- if image_count < min_images or image_count > max_images:
- error_msg = f"❌ 首尾帧模型需要 {min_images}-{max_images} 张图片,当前提供了 {image_count} 张"
- if stream:
- yield self._create_stream_chunk(f"{error_msg}\n")
- self._mark_generation_failed(generation_result, error_msg)
- yield self._create_error_response(error_msg, status_code=400)
- return
-
- # R2V: 多图生成 - 当前上游协议最多 3 张参考图
- elif video_type == "r2v":
- if max_images is not None and image_count > max_images:
- error_msg = f"❌ 多图视频模型最多支持 {max_images} 张参考图,当前提供了 {image_count} 张"
- if stream:
- yield self._create_stream_chunk(f"{error_msg}\n")
- self._mark_generation_failed(generation_result, error_msg)
- yield self._create_error_response(error_msg, status_code=400)
- return
-
- # ========== 上传图片 ==========
- start_media_id = None
- end_media_id = None
- reference_images = []
-
- # I2V: 首尾帧处理
- if video_type == "i2v" and images:
- if image_count == 1:
- # 只有1张图: 仅作为首帧
- if stream:
- yield self._create_stream_chunk("上传首帧图片...\n")
- start_media_id = await self.flow_client.upload_image(
- token.at, images[0], model_config["aspect_ratio"], project_id=project_id
- )
- debug_logger.log_info(f"[I2V] 仅上传首帧: {start_media_id}")
-
- elif image_count == 2:
- # 2张图: 首帧+尾帧
- if stream:
- yield self._create_stream_chunk("上传首帧和尾帧图片...\n")
- start_media_id = await self.flow_client.upload_image(
- token.at, images[0], model_config["aspect_ratio"], project_id=project_id
- )
- end_media_id = await self.flow_client.upload_image(
- token.at, images[1], model_config["aspect_ratio"], project_id=project_id
- )
- debug_logger.log_info(f"[I2V] 上传首尾帧: {start_media_id}, {end_media_id}")
-
- # R2V: 多图处理
- elif video_type == "r2v" and images:
- if stream:
- yield self._create_stream_chunk(f"上传 {image_count} 张参考图片...\n")
-
- for img in images:
- media_id = await self.flow_client.upload_image(
- token.at, img, model_config["aspect_ratio"], project_id=project_id
- )
- reference_images.append({
- "imageUsageType": "IMAGE_USAGE_TYPE_ASSET",
- "mediaId": media_id
- })
- debug_logger.log_info(f"[R2V] 上传了 {len(reference_images)} 张参考图片")
-
- # ========== 调用生成API ==========
- if stream:
- yield self._create_stream_chunk("提交视频生成任务...\n")
- submit_started_at = time.time()
-
- # I2V: 首尾帧生成
- if video_type == "i2v" and start_media_id:
- if end_media_id:
- # 有首尾帧
- result = await self.flow_client.generate_video_start_end(
- at=token.at,
- project_id=project_id,
- prompt=prompt,
- model_key=model_config["model_key"],
- aspect_ratio=model_config["aspect_ratio"],
- start_media_id=start_media_id,
- end_media_id=end_media_id,
- user_paygate_tier=normalized_tier,
- token_id=token.id,
- token_video_concurrency=token.video_concurrency,
- )
- else:
- # 只有首帧 - 需要去掉 model_key 中的 _fl
- # 情况1: _fl_ 在中间 (如 veo_3_1_i2v_s_fast_fl_ultra_relaxed -> veo_3_1_i2v_s_fast_ultra_relaxed)
- # 情况2: _fl 在结尾 (如 veo_3_1_i2v_s_fast_ultra_fl -> veo_3_1_i2v_s_fast_ultra)
- actual_model_key = model_config["model_key"].replace("_fl_", "_")
- if actual_model_key.endswith("_fl"):
- actual_model_key = actual_model_key[:-3]
- debug_logger.log_info(f"[I2V] 单帧模式,model_key: {model_config['model_key']} -> {actual_model_key}")
- result = await self.flow_client.generate_video_start_image(
- at=token.at,
- project_id=project_id,
- prompt=prompt,
- model_key=actual_model_key,
- aspect_ratio=model_config["aspect_ratio"],
- start_media_id=start_media_id,
- user_paygate_tier=normalized_tier,
- token_id=token.id,
- token_video_concurrency=token.video_concurrency,
- )
-
- # R2V: 多图生成
- elif video_type == "r2v" and reference_images:
- result = await self.flow_client.generate_video_reference_images(
- at=token.at,
- project_id=project_id,
- prompt=prompt,
- model_key=model_config["model_key"],
- aspect_ratio=model_config["aspect_ratio"],
- reference_images=reference_images,
- user_paygate_tier=normalized_tier,
- token_id=token.id,
- token_video_concurrency=token.video_concurrency,
- )
-
- # T2V 或 R2V无图: 纯文本生成
- else:
- result = await self.flow_client.generate_video_text(
- at=token.at,
- project_id=project_id,
- prompt=prompt,
- model_key=model_config["model_key"],
- aspect_ratio=model_config["aspect_ratio"],
- user_paygate_tier=normalized_tier,
- token_id=token.id,
- token_video_concurrency=token.video_concurrency,
- )
- if video_trace is not None:
- video_trace["submit_generation_ms"] = int((time.time() - submit_started_at) * 1000)
-
- # 获取task_id和operations
- operations = result.get("operations", [])
- if not operations:
- self._mark_generation_failed(generation_result, "\u751f\u6210\u4efb\u52a1\u521b\u5efa\u5931\u8d25")
- yield self._create_error_response("生成任务创建失败", status_code=502)
- return
-
- operation = operations[0]
- task_id = operation["operation"]["name"]
- scene_id = operation.get("sceneId")
-
- # 保存Task到数据库
- task = Task(
- task_id=task_id,
- token_id=token.id,
- model=model_config["model_key"],
- prompt=prompt,
- status="processing",
- scene_id=scene_id
- )
- await self.db.create_task(task)
- await self._update_request_log_progress(
- request_log_state,
- token_id=token.id,
- status_text="video_submitted",
- progress=45,
- response_extra={"task_id": task_id, "scene_id": scene_id},
- )
-
- # 轮询结果
- if stream:
- yield self._create_stream_chunk(f"视频生成中...\n")
-
- # 检查是否需要放大
- upsample_config = model_config.get("upsample")
-
- async for chunk in self._poll_video_result(
- token,
- project_id,
- operations,
- stream,
- upsample_config,
- generation_result,
- response_state,
- request_log_state,
- ):
- yield chunk
-
- finally:
- pass
-
- async def _poll_video_result(
- self,
- token,
- project_id: str,
- operations: List[Dict],
- stream: bool,
- upsample_config: Optional[Dict] = None,
- generation_result: Optional[Dict[str, Any]] = None,
- response_state: Optional[Dict[str, Any]] = None,
- request_log_state: Optional[Dict[str, Any]] = None
- ) -> AsyncGenerator:
- """轮询视频生成结果
-
- Args:
- upsample_config: 放大配置 {"resolution": "VIDEO_RESOLUTION_4K", "model_key": "veo_3_1_upsampler_4k"}
- """
-
- if response_state is None:
- response_state = self._create_response_state()
-
- max_attempts = config.max_poll_attempts
- poll_interval = config.poll_interval
-
- # 如果需要放大,轮询次数加倍(放大可能需要 30 分钟)
- if upsample_config:
- max_attempts = max_attempts * 3 # 放大需要更长时间
-
- consecutive_poll_errors = 0
- last_poll_error: Optional[Exception] = None
- max_consecutive_poll_errors = 3
-
- for attempt in range(max_attempts):
- await asyncio.sleep(poll_interval)
-
- try:
- result = await self.flow_client.check_video_status(token.at, operations)
- checked_operations = result.get("operations", [])
- consecutive_poll_errors = 0
- last_poll_error = None
-
- if not checked_operations:
- continue
-
- operation = checked_operations[0]
- status = operation.get("status")
-
- # 状态更新 - 每20秒报告一次 (poll_interval=3秒, 20秒约7次轮询)
- progress_update_interval = 7 # 每7次轮询 = 21秒
- if stream and attempt % progress_update_interval == 0: # 每20秒报告一次
- progress = min(int((attempt / max_attempts) * 100), 95)
- await self._update_request_log_progress(request_log_state, token_id=token.id, status_text="video_polling", progress=max(45, progress), response_extra={"upstream_status": status})
- yield self._create_stream_chunk(f"生成进度: {progress}%\n")
-
- # 检查状态
- if status == "MEDIA_GENERATION_STATUS_SUCCESSFUL":
- # 成功
- metadata = operation["operation"].get("metadata", {})
- video_info = metadata.get("video", {})
- video_url = video_info.get("fifeUrl")
- video_media_id = video_info.get("mediaGenerationId")
- aspect_ratio = video_info.get("aspectRatio", "VIDEO_ASPECT_RATIO_LANDSCAPE")
-
- if not video_url:
- error_msg = "视频生成失败: 视频URL为空"
- await self._fail_video_task(checked_operations, error_msg)
- self._mark_generation_failed(generation_result, error_msg)
- yield self._create_error_response(error_msg, status_code=502)
- return
-
- # ========== 视频放大处理 ==========
- if upsample_config and video_media_id:
- if stream:
- resolution_name = "4K" if "4K" in upsample_config["resolution"] else "1080P"
- yield self._create_stream_chunk(f"\n视频生成完成,开始 {resolution_name} 放大处理...(可能需要 30 分钟)\n")
-
- try:
- # 提交放大任务
- upsample_result = await self.flow_client.upsample_video(
- at=token.at,
- project_id=project_id,
- video_media_id=video_media_id,
- aspect_ratio=aspect_ratio,
- resolution=upsample_config["resolution"],
- model_key=upsample_config["model_key"],
- token_id=token.id,
- token_video_concurrency=token.video_concurrency,
- )
-
- upsample_operations = upsample_result.get("operations", [])
- if upsample_operations:
- if stream:
- yield self._create_stream_chunk("放大任务已提交,继续轮询...\n")
-
- # 递归轮询放大结果(不再放大)
- async for chunk in self._poll_video_result(
- token, project_id, upsample_operations, stream, None, generation_result, response_state, request_log_state
- ):
- yield chunk
- return
- else:
- if stream:
- yield self._create_stream_chunk("⚠️ 放大任务创建失败,返回原始视频\n")
- except Exception as e:
- debug_logger.log_error(f"Video upsample failed: {str(e)}")
- if stream:
- yield self._create_stream_chunk(f"⚠️ 放大失败: {str(e)},返回原始视频\n")
-
- # 缓存视频 (如果启用)
- local_url = video_url
- if config.cache_enabled:
- await self._update_request_log_progress(request_log_state, token_id=token.id, status_text="caching_video", progress=92)
- try:
- if stream:
- yield self._create_stream_chunk("正在缓存视频文件...\n")
- cached_filename = await self.file_cache.download_and_cache(video_url, "video")
- local_url = f"{self._get_base_url(response_state)}/tmp/{cached_filename}"
- if stream:
- yield self._create_stream_chunk("✅ 视频缓存成功,准备返回缓存地址...\n")
- except Exception as e:
- debug_logger.log_error(f"Failed to cache video: {str(e)}")
- # 缓存失败不影响结果返回,使用原始URL
- local_url = video_url
- if stream:
- cache_error = self._normalize_error_message(e, max_length=120)
- yield self._create_stream_chunk(f"⚠️ 缓存失败: {cache_error}\n正在返回源链接...\n")
- else:
- if stream:
- yield self._create_stream_chunk("缓存已关闭,正在返回源链接...\n")
-
- # 更新数据库
- task_id = operation["operation"]["name"]
- await self.db.update_task(
- task_id,
- status="completed",
- progress=100,
- result_urls=[local_url],
- completed_at=time.time()
- )
-
- # 存储URL用于日志记录
- response_state["url"] = local_url
- response_state["generated_assets"] = {
- "type": "video",
- "final_video_url": local_url
- }
-
- # 返回结果
- self._mark_generation_succeeded(generation_result)
-
- if stream:
- yield self._create_stream_chunk(
- f"",
- finish_reason="stop"
- )
- else:
- yield self._create_completion_response(
- local_url, # 直接传URL,让方法内部格式化
- media_type="video"
- )
- return
-
- elif status == "MEDIA_GENERATION_STATUS_FAILED":
- # 生成失败 - 提取错误信息
- error_info = operation.get("operation", {}).get("error", {})
- error_code = error_info.get("code", "unknown")
- error_message = error_info.get("message", "未知错误")
-
- # 更新数据库任务状态
- await self._fail_video_task(
- checked_operations,
- f"{error_message} (code: {error_code})"
- )
-
- # 返回友好的错误消息,提示用户重试
- friendly_error = f"视频生成失败: {error_message},请重试"
- self._mark_generation_failed(generation_result, friendly_error)
- if stream:
- yield self._create_stream_chunk(f"❌ {friendly_error}\n")
- yield self._create_error_response(friendly_error, status_code=502)
- return
-
- elif status.startswith("MEDIA_GENERATION_STATUS_ERROR"):
- # ??????
- error_msg = f"视频生成失败: {status}"
- await self._fail_video_task(checked_operations, error_msg)
- self._mark_generation_failed(generation_result, error_msg)
- yield self._create_error_response(error_msg, status_code=502)
- return
-
- except Exception as e:
- last_poll_error = e
- consecutive_poll_errors += 1
- debug_logger.log_error(f"Poll error: {str(e)}")
- if consecutive_poll_errors >= max_consecutive_poll_errors:
- error_msg = f"视频状态查询失败: {self._normalize_error_message(e)}"
- await self._fail_video_task(operations, error_msg)
- self._mark_generation_failed(generation_result, error_msg)
- if stream:
- yield self._create_stream_chunk(f"❌ {error_msg}\n")
- yield self._create_error_response(error_msg, status_code=502)
- return
- continue
-
- # 超时
- if last_poll_error is not None:
- error_msg = f"视频状态查询持续失败: {self._normalize_error_message(last_poll_error)}"
- else:
- error_msg = f"视频生成超时 (已轮询 {max_attempts} 次)"
- await self._fail_video_task(operations, error_msg)
- self._mark_generation_failed(generation_result, error_msg)
- yield self._create_error_response(error_msg, status_code=504)
-
- # ========== 响应格式化 ==========
-
- def _create_stream_chunk(self, content: str, role: str = None, finish_reason: str = None) -> str:
- """创建流式响应chunk"""
- import json
- import time
-
- chunk = {
- "id": f"chatcmpl-{int(time.time())}",
- "object": "chat.completion.chunk",
- "created": int(time.time()),
- "model": "flow2api",
- "choices": [{
- "index": 0,
- "delta": {},
- "finish_reason": finish_reason
- }]
- }
-
- if role:
- chunk["choices"][0]["delta"]["role"] = role
-
- if finish_reason:
- chunk["choices"][0]["delta"]["content"] = content
- else:
- chunk["choices"][0]["delta"]["reasoning_content"] = content
-
- return f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
-
- def _create_completion_response(self, content: str, media_type: str = "image", is_availability_check: bool = False) -> str:
- """创建非流式响应
-
- Args:
- content: 媒体URL或纯文本消息
- media_type: 媒体类型 ("image" 或 "video")
- is_availability_check: 是否为可用性检查响应 (纯文本消息)
-
- Returns:
- JSON格式的响应
- """
- import json
- import time
-
- # 可用性检查: 返回纯文本消息
- if is_availability_check:
- formatted_content = content
- else:
- # 媒体生成: 根据媒体类型格式化内容为Markdown
- if media_type == "video":
- formatted_content = f"```html\n\n```"
- else: # image
- formatted_content = f""
-
- response = {
- "id": f"chatcmpl-{int(time.time())}",
- "object": "chat.completion",
- "created": int(time.time()),
- "model": "flow2api",
- "choices": [{
- "index": 0,
- "message": {
- "role": "assistant",
- "content": formatted_content
- },
- "finish_reason": "stop"
- }]
- }
-
- return json.dumps(response, ensure_ascii=False)
-
- def _create_error_response(self, error_message: str, status_code: int = 500) -> str:
- """创建错误响应"""
- import json
-
- error = {
- "error": {
- "message": error_message,
- "type": "server_error" if status_code >= 500 else "invalid_request_error",
- "code": "generation_failed",
- "status_code": status_code,
- }
- }
-
- return json.dumps(error, ensure_ascii=False)
-
- def _get_base_url(self, response_state: Optional[Dict[str, Any]] = None) -> str:
- """获取基础URL用于缓存文件访问"""
- # 已配置缓存访问域名时,始终优先使用它,避免被请求 Host/IP 覆盖。
- if config.cache_base_url:
- return config.cache_base_url.rstrip("/")
-
- request_base_url = ""
- if isinstance(response_state, dict):
- request_base_url = (response_state.get("base_url") or "").strip().rstrip("/")
- if request_base_url:
- return request_base_url
-
- # 回退到服务地址,避免把监听地址 0.0.0.0 / :: 直接返回给客户端
- server_host = (config.server_host or "").strip()
- if server_host in {"", "0.0.0.0", "::", "[::]"}:
- server_host = "127.0.0.1"
-
- return f"http://{server_host}:{config.server_port}"
-
- async def _update_request_log_progress(
- self,
- request_log_state: Optional[Dict[str, Any]],
- *,
- token_id: Optional[int] = None,
- status_text: str,
- progress: int,
- response_extra: Optional[Dict[str, Any]] = None,
- ):
- """?????????????"""
- if not isinstance(request_log_state, dict):
- return
- log_id = request_log_state.get("id")
- if not log_id:
- return
-
- safe_progress = max(0, min(100, int(progress)))
- now = time.time()
- last_status_text = str(request_log_state.get("last_status_text") or "").strip()
- last_progress = int(request_log_state.get("last_progress") or 0)
- last_updated_at = float(request_log_state.get("last_progress_update_at") or 0)
-
- request_log_state["progress"] = safe_progress
- request_log_state["last_status_text"] = status_text
- request_log_state["last_progress"] = safe_progress
- payload = {
- "status": "processing",
- "status_text": status_text,
- "progress": safe_progress,
- }
- if isinstance(response_extra, dict):
- payload.update(response_extra)
-
- should_write = (
- safe_progress in (0, 100)
- or status_text != last_status_text
- or safe_progress >= last_progress + 5
- or (now - last_updated_at) >= 1.0
- )
- if not should_write:
- return
-
- request_log_state["last_progress_update_at"] = now
-
- try:
- await self.db.update_request_log(
- log_id,
- token_id=token_id,
- response_body=json.dumps(payload, ensure_ascii=False),
- status_code=102,
- duration=0,
- status_text=status_text,
- progress=safe_progress,
- )
- except Exception as e:
- debug_logger.log_error(f"Failed to update request log progress: {e}")
-
- async def _log_request(
- self,
- token_id: Optional[int],
- operation: str,
- request_data: Dict[str, Any],
- response_data: Dict[str, Any],
- status_code: int,
- duration: float,
- log_id: Optional[int] = None,
- status_text: Optional[str] = None,
- progress: Optional[int] = None,
- ):
- """???????????? log_id ????????"""
- try:
- effective_status_text = status_text or (
- "completed" if status_code == 200 else "failed" if status_code >= 400 else "processing"
- )
- effective_progress = progress
- if effective_progress is None:
- effective_progress = 100 if status_code == 200 else 0 if status_code >= 400 else 0
- effective_progress = max(0, min(100, int(effective_progress)))
-
- request_body = json.dumps(request_data, ensure_ascii=False)
- response_body = json.dumps(response_data, ensure_ascii=False)
-
- if log_id:
- await self.db.update_request_log(
- log_id,
- token_id=token_id,
- operation=operation,
- request_body=request_body,
- response_body=response_body,
- status_code=status_code,
- duration=duration,
- status_text=effective_status_text,
- progress=effective_progress,
- )
- return log_id
-
- log = RequestLog(
- token_id=token_id,
- operation=operation,
- request_body=request_body,
- response_body=response_body,
- status_code=status_code,
- duration=duration,
- status_text=effective_status_text,
- progress=effective_progress,
- )
- return await self.db.add_request_log(log)
- except Exception as e:
- debug_logger.log_error(f"Failed to log request: {e}")
- return None
+
+ def _create_generation_result(self) -> Dict[str, Any]:
+ """????????????????"""
+ return dict(success=False, error_message=None, error_emitted=False)
+
+ def _create_response_state(self) -> Dict[str, Any]:
+ """为单次请求创建独立的响应状态,避免并发请求互相污染。"""
+ return {
+ "url": None,
+ "generated_assets": None,
+ "base_url": None,
+ }
+
+ def _mark_generation_failed(self, generation_result: Optional[Dict[str, Any]], error_message: str):
+ """????????????????????"""
+ if isinstance(generation_result, dict):
+ generation_result["success"] = False
+ generation_result["error_message"] = error_message
+ generation_result["error_emitted"] = True
+
+ def _mark_generation_succeeded(self, generation_result: Optional[Dict[str, Any]]):
+ """???????"""
+ if isinstance(generation_result, dict):
+ generation_result["success"] = True
+ generation_result["error_message"] = None
+ generation_result["error_emitted"] = False
+
+ def _normalize_error_message(self, error_message: Any, max_length: int = 1000) -> str:
+ """归一化错误文本,避免写入超长内容。"""
+ text = str(error_message or "").strip() or "未知错误"
+ if len(text) <= max_length:
+ return text
+ return f"{text[:max_length - 3]}..."
+
+ async def _fail_video_task(self, operations: Optional[List[Dict[str, Any]]], error_message: str):
+ """将视频任务收口到失败态,避免残留 processing。"""
+ if not operations:
+ return
+
+ operation = operations[0] if operations else {}
+ task_id = (operation.get("operation") or {}).get("name")
+ if not task_id:
+ return
+
+ try:
+ await self.db.update_task(
+ task_id,
+ status="failed",
+ error_message=self._normalize_error_message(error_message),
+ completed_at=time.time()
+ )
+ except Exception as exc:
+ debug_logger.log_error(f"[VIDEO] 更新任务失败状态失败: {exc}")
+
+ async def check_token_availability(self, is_image: bool, is_video: bool) -> bool:
+ """检查Token可用性
+
+ Args:
+ is_image: 是否检查图片生成Token
+ is_video: 是否检查视频生成Token
+
+ Returns:
+ True表示有可用Token, False表示无可用Token
+ """
+ token_obj = await self.load_balancer.select_token(
+ for_image_generation=is_image,
+ for_video_generation=is_video
+ )
+ return token_obj is not None
+
+ async def handle_generation(
+ self,
+ model: str,
+ prompt: str,
+ images: Optional[List[bytes]] = None,
+ stream: bool = False,
+ base_url_override: Optional[str] = None
+ ) -> AsyncGenerator:
+ """统一生成入口
+
+ Args:
+ model: 模型名称
+ prompt: 提示词
+ images: 图片列表 (bytes格式)
+ stream: 是否流式输出
+ """
+ start_time = time.time()
+ token = None
+ generation_type = None
+ pending_token_state = {"active": False}
+ request_id = f"gen-{int(start_time * 1000)}-{id(asyncio.current_task())}"
+ perf_trace: Dict[str, Any] = {
+ "request_id": request_id,
+ "model": model,
+ "status": "processing",
+ }
+ generation_result = self._create_generation_result()
+ response_state = self._create_response_state()
+ response_state["base_url"] = (base_url_override or "").strip().rstrip("/") or None
+ request_log_state: Dict[str, Any] = {"id": None, "progress": 0}
+
+ # 防止并发链路复用到上一次请求的指纹上下文
+ if hasattr(self.flow_client, "clear_request_fingerprint"):
+ self.flow_client.clear_request_fingerprint()
+
+ # 1. 验证模型
+ if model not in MODEL_CONFIG:
+ error_msg = f"不支持的模型: {model}"
+ debug_logger.log_error(error_msg)
+ yield self._create_error_response(error_msg, status_code=400)
+ return
+
+ model_config = MODEL_CONFIG[model]
+ generation_type = model_config["type"]
+ request_operation = f"generate_{generation_type}"
+ prompt_for_log = prompt if len(prompt) <= 2000 else f"{prompt[:2000]}...(truncated)"
+ request_payload = {
+ "model": model,
+ "prompt": prompt_for_log,
+ "has_images": images is not None and len(images) > 0,
+ }
+ debug_logger.log_info(f"[GENERATION] 开始生成 - 模型: {model}, 类型: {generation_type}, Prompt: {prompt[:50]}...")
+
+ # 向用户展示开始信息
+ if stream:
+ yield self._create_stream_chunk(
+ f"✨ {'视频' if generation_type == 'video' else '图片'}生成任务已启动\n",
+ role="assistant"
+ )
+ request_log_state["id"] = await self._log_request(
+ token_id=None,
+ operation=request_operation,
+ request_data=request_payload,
+ response_data={"status": "processing", "status_text": "started", "progress": 0, "request_id": request_id},
+ status_code=102,
+ duration=0,
+ status_text="started",
+ progress=0,
+ )
+
+ # 2. 选择Token
+ debug_logger.log_info(f"[GENERATION] 正在选择可用Token...")
+ token_select_started_at = time.time()
+
+ if generation_type == "image":
+ token = await self.load_balancer.select_token(
+ for_image_generation=True,
+ model=model,
+ reserve=False,
+ enforce_concurrency_filter=False,
+ track_pending=True,
+ )
+ else:
+ token = await self.load_balancer.select_token(
+ for_video_generation=True,
+ model=model,
+ reserve=False,
+ enforce_concurrency_filter=False,
+ track_pending=True,
+ )
+ perf_trace["token_select_ms"] = int((time.time() - token_select_started_at) * 1000)
+
+ if not token:
+ error_msg = None
+ if self.load_balancer and hasattr(self.load_balancer, "get_unavailable_reason"):
+ error_msg = await self.load_balancer.get_unavailable_reason(
+ for_image_generation=(generation_type == "image"),
+ for_video_generation=(generation_type == "video"),
+ model=model,
+ )
+ if not error_msg:
+ error_msg = self._get_no_token_error_message(generation_type)
+ debug_logger.log_error(f"[GENERATION] {error_msg}")
+ await self._log_request(
+ token_id=None,
+ operation=request_operation,
+ request_data=request_payload,
+ response_data={"error": error_msg, "performance": perf_trace},
+ status_code=503,
+ duration=time.time() - start_time,
+ log_id=request_log_state.get("id"),
+ status_text="failed",
+ progress=request_log_state.get("progress", 0),
+ )
+ if stream:
+ yield self._create_stream_chunk(f"❌ {error_msg}\n")
+ yield self._create_error_response(error_msg, status_code=503)
+ return
+
+ debug_logger.log_info(f"[GENERATION] 已选择Token: {token.id} ({token.email})")
+ pending_token_state["active"] = True
+ await self._update_request_log_progress(
+ request_log_state,
+ token_id=token.id,
+ status_text="token_selected",
+ progress=8,
+ response_extra={"token_email": token.email},
+ )
+
+ try:
+ # 3. 确保AT有效
+ debug_logger.log_info(f"[GENERATION] 检查Token AT有效性...")
+ if stream:
+ yield self._create_stream_chunk("初始化生成环境...\n")
+
+ await self._update_request_log_progress(
+ request_log_state,
+ token_id=token.id,
+ status_text="token_ready",
+ progress=15,
+ )
+ ensure_at_started_at = time.time()
+ token = await self.token_manager.ensure_valid_token(token)
+ perf_trace["ensure_at_ms"] = int((time.time() - ensure_at_started_at) * 1000)
+ if not token:
+ error_msg = "Token AT无效或刷新失败"
+ debug_logger.log_error(f"[GENERATION] {error_msg}")
+ if stream:
+ yield self._create_stream_chunk(f"❌ {error_msg}\n")
+ yield self._create_error_response(error_msg, status_code=503)
+ return
+
+ # 4. 确保Project存在
+ debug_logger.log_info(f"[GENERATION] 检查/创建Project...")
+
+ if not supports_model_for_tier(model, token.user_paygate_tier):
+ required_tier = get_required_paygate_tier_for_model(model)
+ error_msg = "当前模型需要 " + get_paygate_tier_label(required_tier) + " 账号: " + model
+ debug_logger.log_error(f"[GENERATION] {error_msg}")
+ if stream:
+ yield self._create_stream_chunk(f"❌ {error_msg}\n")
+ yield self._create_error_response(error_msg, status_code=403)
+ return
+
+ ensure_project_started_at = time.time()
+ project_id = await self.token_manager.ensure_project_exists(token.id)
+ perf_trace["ensure_project_ms"] = int((time.time() - ensure_project_started_at) * 1000)
+ debug_logger.log_info(f"[GENERATION] Project ID: {project_id}")
+ await self._update_request_log_progress(
+ request_log_state,
+ token_id=token.id,
+ status_text="project_ready",
+ progress=22,
+ response_extra={"project_id": project_id},
+ )
+ prefill_action = "IMAGE_GENERATION" if generation_type == "image" else "VIDEO_GENERATION"
+ await self.flow_client.prefill_remote_browser_pool(
+ project_id=project_id,
+ action=prefill_action,
+ token_id=token.id,
+ )
+
+ # 5. 根据类型处理
+ generation_pipeline_started_at = time.time()
+ if generation_type == "image":
+ debug_logger.log_info(f"[GENERATION] 开始图片生成流程...")
+ async for chunk in self._handle_image_generation(
+ token, project_id, model_config, prompt, images, stream,
+ perf_trace=perf_trace,
+ generation_result=generation_result,
+ response_state=response_state,
+ request_log_state=request_log_state,
+ pending_token_state=pending_token_state
+ ):
+ yield chunk
+ else: # video
+ debug_logger.log_info(f"[GENERATION] 开始视频生成流程...")
+ async for chunk in self._handle_video_generation(
+ token, project_id, model_config, prompt, images, stream,
+ perf_trace=perf_trace,
+ generation_result=generation_result,
+ response_state=response_state,
+ request_log_state=request_log_state,
+ pending_token_state=pending_token_state
+ ):
+ yield chunk
+ perf_trace["generation_pipeline_ms"] = int((time.time() - generation_pipeline_started_at) * 1000)
+
+ # 6. 记录使用
+ if not generation_result.get("success"):
+ error_msg = generation_result.get("error_message") or "生成未成功完成"
+ debug_logger.log_warning(f"[GENERATION] 生成未成功,不扣次数: {error_msg}")
+ if token:
+ await self.token_manager.record_error(token.id)
+ duration = time.time() - start_time
+ perf_trace["status"] = "failed"
+ perf_trace["total_ms"] = int(duration * 1000)
+ perf_trace["error"] = error_msg
+ prompt_for_log = prompt if len(prompt) <= 2000 else f"{prompt[:2000]}...(truncated)"
+ await self._log_request(
+ token.id if token else None,
+ request_operation,
+ request_payload,
+ {"error": error_msg, "performance": perf_trace},
+ 500,
+ duration,
+ log_id=request_log_state.get("id"),
+ status_text="failed",
+ progress=request_log_state.get("progress", 0),
+ )
+ if not generation_result.get("error_emitted"):
+ if stream:
+ yield self._create_stream_chunk(f"❌ {error_msg}\n")
+ yield self._create_error_response(error_msg, status_code=500)
+ return
+
+ is_video = (generation_type == "video")
+ await self.token_manager.record_usage(token.id, is_video=is_video)
+
+ # 重置错误计数 (请求成功时清空连续错误计数)
+ await self.token_manager.record_success(token.id)
+
+ debug_logger.log_info(f"[GENERATION] ✅ 生成成功完成")
+
+ # 7. 记录成功日志
+ duration = time.time() - start_time
+ perf_trace["status"] = "success"
+ perf_trace["total_ms"] = int(duration * 1000)
+ # 日志中保留更完整的 prompt,避免管理页只看到过短内容
+ prompt_for_log = prompt if len(prompt) <= 2000 else f"{prompt[:2000]}...(truncated)"
+
+ # 构建响应数据,包含生成的URL
+ response_data = {
+ "status": "success",
+ "model": model,
+ "prompt": prompt_for_log,
+ "performance": perf_trace
+ }
+
+ # 添加生成的URL(如果有)
+ if response_state.get("url"):
+ response_data["url"] = response_state["url"]
+ if response_state.get("generated_assets"):
+ response_data["generated_assets"] = response_state["generated_assets"]
+ image_perf = perf_trace.get("image_generation", {}) if isinstance(perf_trace, dict) else {}
+ video_perf = perf_trace.get("video_generation", {}) if isinstance(perf_trace, dict) else {}
+ debug_logger.log_info(
+ f"[PERF] [{request_id}] total={perf_trace.get('total_ms', 0)}ms, "
+ f"select={perf_trace.get('token_select_ms', 0)}ms, "
+ f"ensure_at={perf_trace.get('ensure_at_ms', 0)}ms, "
+ f"project={perf_trace.get('ensure_project_ms', 0)}ms, "
+ f"pipeline={perf_trace.get('generation_pipeline_ms', 0)}ms, "
+ f"slot_wait={image_perf.get('slot_wait_ms', 0)}ms, "
+ f"launch_queue={image_perf.get('launch_queue_wait_ms', 0)}ms, "
+ f"launch_stagger={image_perf.get('launch_stagger_wait_ms', 0)}ms, "
+ f"video_slot_wait={video_perf.get('slot_wait_ms', 0)}ms"
+ )
+
+ await self._log_request(
+ token.id,
+ request_operation,
+ request_payload,
+ response_data,
+ 200,
+ duration,
+ log_id=request_log_state.get("id"),
+ status_text="completed",
+ progress=100,
+ )
+
+ except asyncio.CancelledError:
+ error_msg = "生成已取消: 客户端连接已断开"
+ debug_logger.log_warning(f"[GENERATION] ⚠️ {error_msg}")
+ duration = time.time() - start_time
+ perf_trace["status"] = "failed"
+ perf_trace["total_ms"] = int(duration * 1000)
+ perf_trace["error"] = error_msg
+ prompt_for_log = prompt if len(prompt) <= 2000 else f"{prompt[:2000]}...(truncated)"
+ await self._log_request(
+ token.id if token else None,
+ request_operation if generation_type else "generate_unknown",
+ request_payload if 'request_payload' in locals() else {"model": model},
+ {"error": error_msg, "performance": perf_trace},
+ 499,
+ duration,
+ log_id=request_log_state.get("id"),
+ status_text="failed",
+ progress=request_log_state.get("progress", 0),
+ )
+ raise
+ except Exception as e:
+ error_msg = f"生成失败: {str(e)}"
+ debug_logger.log_error(f"[GENERATION] ❌ {error_msg}")
+ if token:
+ # 记录错误(所有错误统一处理,不再特殊处理429)
+ await self.token_manager.record_error(token.id)
+
+ # 先将最终失败状态落库,再返回错误响应,避免日志停在 102。
+ duration = time.time() - start_time
+ perf_trace["status"] = "failed"
+ perf_trace["total_ms"] = int(duration * 1000)
+ perf_trace["error"] = error_msg
+ prompt_for_log = prompt if len(prompt) <= 2000 else f"{prompt[:2000]}...(truncated)"
+ await self._log_request(
+ token.id if token else None,
+ request_operation if generation_type else "generate_unknown",
+ request_payload if 'request_payload' in locals() else {"model": model},
+ {"error": error_msg, "performance": perf_trace},
+ 500,
+ duration,
+ log_id=request_log_state.get("id"),
+ status_text="failed",
+ progress=request_log_state.get("progress", 0),
+ )
+ if stream:
+ yield self._create_stream_chunk(f"❌ {error_msg}\n")
+ yield self._create_error_response(error_msg, status_code=500)
+ finally:
+ if pending_token_state.get("active") and token and self.load_balancer:
+ await self.load_balancer.release_pending(
+ token.id,
+ for_image_generation=(generation_type == "image"),
+ for_video_generation=(generation_type == "video"),
+ )
+ pending_token_state["active"] = False
+
+
+ def _get_no_token_error_message(self, generation_type: str) -> str:
+ """获取无可用Token时的详细错误信息"""
+ if generation_type == "image":
+ return "没有可用的Token进行图片生成。所有Token都处于禁用、冷却、锁定或已过期状态。"
+ else:
+ return "没有可用的Token进行视频生成。所有Token都处于禁用、冷却、配额耗尽或已过期状态。"
+
+ async def _handle_image_generation(
+ self,
+ token,
+ project_id: str,
+ model_config: dict,
+ prompt: str,
+ images: Optional[List[bytes]],
+ stream: bool,
+ perf_trace: Optional[Dict[str, Any]] = None,
+ generation_result: Optional[Dict[str, Any]] = None,
+ response_state: Optional[Dict[str, Any]] = None,
+ request_log_state: Optional[Dict[str, Any]] = None,
+ pending_token_state: Optional[Dict[str, bool]] = None
+ ) -> AsyncGenerator:
+ """处理图片生成 (同步返回)"""
+
+ if response_state is None:
+ response_state = self._create_response_state()
+
+ image_trace: Optional[Dict[str, Any]] = None
+ if isinstance(perf_trace, dict):
+ image_trace = perf_trace.setdefault("image_generation", {})
+ image_trace["input_image_count"] = len(images) if images else 0
+
+ # 不在本地等待图片硬并发槽位;请求一到就直接向上游提交。
+ normalized_tier = normalize_user_paygate_tier(token.user_paygate_tier)
+
+ if image_trace is not None:
+ image_trace["slot_wait_ms"] = 0
+
+ if images and len(images) > 0:
+ await self._update_request_log_progress(request_log_state, token_id=token.id, status_text="uploading_images", progress=28)
+ else:
+ await self._update_request_log_progress(request_log_state, token_id=token.id, status_text="submitting_image", progress=28)
+
+ try:
+ # 上传图片 (如果有)
+ upload_started_at = time.time()
+ image_inputs = []
+ if images and len(images) > 0:
+ if stream:
+ yield self._create_stream_chunk(f"上传 {len(images)} 张参考图片...\n")
+
+ # 支持多图输入
+ for idx, image_bytes in enumerate(images):
+ media_id = await self.flow_client.upload_image(
+ token.at,
+ image_bytes,
+ model_config["aspect_ratio"],
+ project_id=project_id
+ )
+ image_inputs.append({
+ "name": media_id,
+ "imageInputType": "IMAGE_INPUT_TYPE_REFERENCE"
+ })
+ if stream:
+ yield self._create_stream_chunk(f"已上传第 {idx + 1}/{len(images)} 张图片\n")
+ if image_trace is not None:
+ image_trace["upload_images_ms"] = int((time.time() - upload_started_at) * 1000)
+
+ # 调用生成API
+ if stream:
+ if images and len(images) > 0:
+ yield self._create_stream_chunk("参考图片上传完成,正在进行打码验证...\n")
+ else:
+ yield self._create_stream_chunk("正在进行打码验证并提交图片生成请求...\n")
+
+ async def _image_progress_callback(status_text: str, progress: int):
+ await self._update_request_log_progress(
+ request_log_state,
+ token_id=token.id,
+ status_text=status_text,
+ progress=progress,
+ )
+
+ generate_started_at = time.time()
+ result, generation_session_id, upstream_trace = await self.flow_client.generate_image(
+ at=token.at,
+ project_id=project_id,
+ prompt=prompt,
+ model_name=model_config["model_name"],
+ aspect_ratio=model_config["aspect_ratio"],
+ image_inputs=image_inputs,
+ token_id=token.id,
+ token_image_concurrency=token.image_concurrency,
+ progress_callback=_image_progress_callback,
+ )
+ if image_trace is not None:
+ image_trace["generate_api_ms"] = int((time.time() - generate_started_at) * 1000)
+ image_trace["upstream_trace"] = upstream_trace
+ attempts = upstream_trace.get("generation_attempts") if isinstance(upstream_trace, dict) else None
+ if isinstance(attempts, list) and attempts:
+ first_attempt = attempts[0] if isinstance(attempts[0], dict) else {}
+ image_trace["launch_queue_wait_ms"] = int(first_attempt.get("launch_queue_ms") or 0)
+ image_trace["launch_stagger_wait_ms"] = int(first_attempt.get("launch_stagger_ms") or 0)
+ await self._update_request_log_progress(
+ request_log_state,
+ token_id=token.id,
+ status_text="image_generated",
+ progress=72,
+ )
+
+ # 提取URL和mediaId
+ media = result.get("media", [])
+ if not media:
+ self._mark_generation_failed(generation_result, "\u751f\u6210\u7ed3\u679c\u4e3a\u7a7a")
+ yield self._create_error_response("生成结果为空", status_code=502)
+ return
+
+ image_url = media[0]["image"]["generatedImage"]["fifeUrl"]
+ media_id = media[0].get("name") # 用于 upsample
+ response_state["generated_assets"] = {
+ "type": "image",
+ "origin_image_url": image_url
+ }
+
+ # 检查是否需要 upsample
+ upsample_resolution = model_config.get("upsample")
+ if upsample_resolution and media_id:
+ upsample_started_at = time.time()
+ resolution_name = "4K" if "4K" in upsample_resolution else "2K"
+ await self._update_request_log_progress(request_log_state, token_id=token.id, status_text=f"upsampling_{resolution_name.lower()}", progress=82)
+ if stream:
+ yield self._create_stream_chunk(f"正在放大图片到 {resolution_name}...\n")
+
+ # 4K/2K 图片重试逻辑 - 最多重试3次
+ max_retries = 3
+ for retry_attempt in range(max_retries):
+ try:
+ # 调用 upsample API
+ encoded_image = await self.flow_client.upsample_image(
+ at=token.at,
+ project_id=project_id,
+ media_id=media_id,
+ target_resolution=upsample_resolution,
+ user_paygate_tier=normalized_tier,
+ session_id=generation_session_id,
+ token_id=token.id
+ )
+
+ if encoded_image:
+ debug_logger.log_info(f"[UPSAMPLE] 图片已放大到 {resolution_name}")
+
+ if stream:
+ yield self._create_stream_chunk(f"✅ 图片已放大到 {resolution_name}\n")
+
+ # 2K/4K 图片统一落盘为真实文件,日志里只保留链接。
+ response_state["generated_assets"] = {
+ "type": "image",
+ "origin_image_url": image_url,
+ "upscaled_image": {
+ "resolution": resolution_name
+ }
+ }
+
+ try:
+ await self._update_request_log_progress(
+ request_log_state,
+ token_id=token.id,
+ status_text="caching_image",
+ progress=90,
+ )
+ if stream:
+ yield self._create_stream_chunk(f"缓存 {resolution_name} 图片中...\n")
+ cached_filename = await self.file_cache.cache_base64_image(encoded_image, resolution_name)
+ local_url = f"{self._get_base_url(response_state)}/tmp/{cached_filename}"
+ response_state["url"] = local_url
+ response_state["generated_assets"]["upscaled_image"]["local_url"] = local_url
+ response_state["generated_assets"]["upscaled_image"]["url"] = local_url
+ self._mark_generation_succeeded(generation_result)
+ if stream:
+ yield self._create_stream_chunk(f"✅ {resolution_name} 图片缓存成功\n")
+ yield self._create_stream_chunk(
+ f"",
+ finish_reason="stop"
+ )
+ else:
+ yield self._create_completion_response(
+ local_url,
+ media_type="image"
+ )
+ if image_trace is not None:
+ image_trace["upsample_ms"] = int((time.time() - upsample_started_at) * 1000)
+ return
+ except Exception as e:
+ debug_logger.log_error(f"Failed to cache {resolution_name} image: {str(e)}")
+ response_state["url"] = image_url
+ response_state["generated_assets"]["upscaled_image"]["local_url"] = None
+ response_state["generated_assets"]["upscaled_image"]["url"] = image_url
+ response_state["generated_assets"]["upscaled_image"]["delivery_mode"] = "inline_base64_fallback"
+ self._mark_generation_succeeded(generation_result)
+ base64_url = f"data:image/jpeg;base64,{encoded_image}"
+ if stream:
+ cache_error = self._normalize_error_message(e, max_length=120)
+ yield self._create_stream_chunk(f"⚠️ 缓存失败: {cache_error},返回内联图片...\n")
+ yield self._create_stream_chunk(
+ f"",
+ finish_reason="stop"
+ )
+ else:
+ yield self._create_completion_response(
+ base64_url,
+ media_type="image"
+ )
+ if image_trace is not None:
+ image_trace["upsample_ms"] = int((time.time() - upsample_started_at) * 1000)
+ return
+ else:
+ debug_logger.log_warning("[UPSAMPLE] 返回结果为空")
+ if stream:
+ yield self._create_stream_chunk(f"⚠️ 放大失败,返回原图...\n")
+ break # 空结果不重试
+
+ except Exception as e:
+ error_str = str(e)
+ debug_logger.log_error(f"[UPSAMPLE] 放大失败 (尝试 {retry_attempt + 1}/{max_retries}): {error_str}")
+
+ # 检查是否是可重试错误(403、reCAPTCHA、超时等)
+ retry_reason = self.flow_client._get_retry_reason(error_str)
+ if retry_reason and retry_attempt < max_retries - 1:
+ if stream:
+ yield self._create_stream_chunk(f"⚠️ 放大遇到{retry_reason},正在重试 ({retry_attempt + 2}/{max_retries})...\n")
+ # 等待一小段时间后重试
+ await asyncio.sleep(1)
+ continue
+ else:
+ if stream:
+ yield self._create_stream_chunk(f"⚠️ 放大失败: {error_str},返回原图...\n")
+ break
+ if image_trace is not None:
+ image_trace["upsample_ms"] = int((time.time() - upsample_started_at) * 1000)
+
+ local_url = image_url
+ cache_started_at = time.time()
+ if config.cache_enabled:
+ await self._update_request_log_progress(
+ request_log_state,
+ token_id=token.id,
+ status_text="caching_image",
+ progress=90,
+ )
+ if stream:
+ yield self._create_stream_chunk("正在缓存 1K 图片文件...\n")
+ try:
+ cached_filename = await self.file_cache.download_and_cache(image_url, "image")
+ local_url = f"{self._get_base_url(response_state)}/tmp/{cached_filename}"
+ if stream:
+ yield self._create_stream_chunk("✅ 1K 图片缓存成功,准备返回缓存地址...\n")
+ except Exception as e:
+ debug_logger.log_error(f"Failed to cache 1K image: {str(e)}")
+ local_url = image_url
+ if stream:
+ cache_error = self._normalize_error_message(e, max_length=120)
+ yield self._create_stream_chunk(f"⚠️ 缓存失败: {cache_error}\n正在返回源链接...\n")
+ elif stream:
+ yield self._create_stream_chunk("缓存已关闭,正在返回官方图片链接...\n")
+ if image_trace is not None:
+ image_trace["cache_image_ms"] = int((time.time() - cache_started_at) * 1000)
+
+ # 返回结果
+ # 存储URL用于日志记录
+ response_state["url"] = local_url
+ response_state["generated_assets"] = {
+ "type": "image",
+ "origin_image_url": image_url,
+ "final_image_url": local_url
+ }
+ self._mark_generation_succeeded(generation_result)
+
+ if stream:
+ yield self._create_stream_chunk(
+ f"",
+ finish_reason="stop"
+ )
+ else:
+ yield self._create_completion_response(
+ local_url, # 直接传URL,让方法内部格式化
+ media_type="image"
+ )
+
+ finally:
+ pass
+
+ async def _handle_video_generation(
+ self,
+ token,
+ project_id: str,
+ model_config: dict,
+ prompt: str,
+ images: Optional[List[bytes]],
+ stream: bool,
+ perf_trace: Optional[Dict[str, Any]] = None,
+ generation_result: Optional[Dict[str, Any]] = None,
+ response_state: Optional[Dict[str, Any]] = None,
+ request_log_state: Optional[Dict[str, Any]] = None,
+ pending_token_state: Optional[Dict[str, bool]] = None
+ ) -> AsyncGenerator:
+ """处理视频生成 (异步轮询)"""
+
+ if response_state is None:
+ response_state = self._create_response_state()
+
+ video_trace: Optional[Dict[str, Any]] = None
+ if isinstance(perf_trace, dict):
+ video_trace = perf_trace.setdefault("video_generation", {})
+ video_trace["input_image_count"] = len(images) if images else 0
+
+ # 不在本地等待视频硬并发槽位;请求一到就直接向上游提交。
+ normalized_tier = normalize_user_paygate_tier(token.user_paygate_tier)
+
+ if video_trace is not None:
+ video_trace["slot_wait_ms"] = 0
+
+ await self._update_request_log_progress(request_log_state, token_id=token.id, status_text="preparing_video", progress=24)
+
+ try:
+ # 获取模型类型和配置
+ video_type = model_config.get("video_type")
+ supports_images = model_config.get("supports_images", False)
+ min_images = model_config.get("min_images", 0)
+ max_images = model_config.get("max_images", 0)
+
+ # 根据账号tier自动调整模型 key
+ model_key = model_config["model_key"]
+ user_tier = normalized_tier
+
+ # TIER_TWO 账号需要使用 ultra 版本的模型
+ if user_tier == "PAYGATE_TIER_TWO":
+ # 如果模型 key 不包含 ultra,自动添加
+ if "ultra" not in model_key:
+ # veo_3_1_i2v_s_fast_fl -> veo_3_1_i2v_s_fast_ultra_fl
+ # veo_3_1_i2v_s_fast_portrait_fl -> veo_3_1_i2v_s_fast_portrait_ultra_fl
+ # veo_3_1_t2v_fast -> veo_3_1_t2v_fast_ultra
+ # veo_3_1_t2v_fast_portrait -> veo_3_1_t2v_fast_portrait_ultra
+ # veo_3_1_r2v_fast_landscape -> veo_3_1_r2v_fast_landscape_ultra
+ if "_fl" in model_key:
+ model_key = model_key.replace("_fl", "_ultra_fl")
+ else:
+ # 直接在末尾添加 _ultra
+ model_key = model_key + "_ultra"
+
+ if stream:
+ yield self._create_stream_chunk(f"TIER_TWO 账号自动切换到 ultra 模型: {model_key}\n")
+ debug_logger.log_info(f"[VIDEO] TIER_TWO 账号,模型自动调整: {model_config['model_key']} -> {model_key}")
+
+ # TIER_ONE 账号需要使用非 ultra 版本
+ elif user_tier == "PAYGATE_TIER_ONE":
+ # 如果模型 key 包含 ultra,需要移除(避免用户误用)
+ if "ultra" in model_key:
+ # veo_3_1_i2v_s_fast_ultra_fl -> veo_3_1_i2v_s_fast_fl
+ # veo_3_1_t2v_fast_ultra -> veo_3_1_t2v_fast
+ # veo_3_1_r2v_fast_landscape_ultra -> veo_3_1_r2v_fast_landscape
+ model_key = model_key.replace("_ultra_fl", "_fl").replace("_ultra", "")
+
+ if stream:
+ yield self._create_stream_chunk(f"TIER_ONE 账号自动切换到标准模型: {model_key}\n")
+ debug_logger.log_info(f"[VIDEO] TIER_ONE 账号,模型自动调整: {model_config['model_key']} -> {model_key}")
+
+ # 更新 model_config 中的 model_key
+ model_config = dict(model_config) # 创建副本避免修改原配置
+ model_config["model_key"] = model_key
+
+ # 图片数量
+ image_count = len(images) if images else 0
+
+ # ========== 验证和处理图片 ==========
+
+ # T2V: 文生视频 - 不支持图片
+ if video_type == "t2v":
+ if image_count > 0:
+ if stream:
+ yield self._create_stream_chunk("⚠️ 文生视频模型不支持上传图片,将忽略图片仅使用文本提示词生成\n")
+ debug_logger.log_warning(f"[T2V] 模型 {model_config['model_key']} 不支持图片,已忽略 {image_count} 张图片")
+ images = None # 清空图片
+ image_count = 0
+
+ # I2V: 首尾帧模型 - 需要1-2张图片
+ elif video_type == "i2v":
+ if image_count < min_images or image_count > max_images:
+ error_msg = f"❌ 首尾帧模型需要 {min_images}-{max_images} 张图片,当前提供了 {image_count} 张"
+ if stream:
+ yield self._create_stream_chunk(f"{error_msg}\n")
+ self._mark_generation_failed(generation_result, error_msg)
+ yield self._create_error_response(error_msg, status_code=400)
+ return
+
+ # R2V: 多图生成 - 当前上游协议最多 3 张参考图
+ elif video_type == "r2v":
+ if max_images is not None and image_count > max_images:
+ error_msg = f"❌ 多图视频模型最多支持 {max_images} 张参考图,当前提供了 {image_count} 张"
+ if stream:
+ yield self._create_stream_chunk(f"{error_msg}\n")
+ self._mark_generation_failed(generation_result, error_msg)
+ yield self._create_error_response(error_msg, status_code=400)
+ return
+
+ # ========== 上传图片 ==========
+ start_media_id = None
+ end_media_id = None
+ reference_images = []
+
+ # I2V: 首尾帧处理
+ if video_type == "i2v" and images:
+ if image_count == 1:
+ # 只有1张图: 仅作为首帧
+ if stream:
+ yield self._create_stream_chunk("上传首帧图片...\n")
+ start_media_id = await self.flow_client.upload_image(
+ token.at, images[0], model_config["aspect_ratio"], project_id=project_id
+ )
+ debug_logger.log_info(f"[I2V] 仅上传首帧: {start_media_id}")
+
+ elif image_count == 2:
+ # 2张图: 首帧+尾帧
+ if stream:
+ yield self._create_stream_chunk("上传首帧和尾帧图片...\n")
+ start_media_id = await self.flow_client.upload_image(
+ token.at, images[0], model_config["aspect_ratio"], project_id=project_id
+ )
+ end_media_id = await self.flow_client.upload_image(
+ token.at, images[1], model_config["aspect_ratio"], project_id=project_id
+ )
+ debug_logger.log_info(f"[I2V] 上传首尾帧: {start_media_id}, {end_media_id}")
+
+ # R2V: 多图处理
+ elif video_type == "r2v" and images:
+ if stream:
+ yield self._create_stream_chunk(f"上传 {image_count} 张参考图片...\n")
+
+ for img in images:
+ media_id = await self.flow_client.upload_image(
+ token.at, img, model_config["aspect_ratio"], project_id=project_id
+ )
+ reference_images.append({
+ "imageUsageType": "IMAGE_USAGE_TYPE_ASSET",
+ "mediaId": media_id
+ })
+ debug_logger.log_info(f"[R2V] 上传了 {len(reference_images)} 张参考图片")
+
+ # ========== 调用生成API ==========
+ if stream:
+ yield self._create_stream_chunk("提交视频生成任务...\n")
+ submit_started_at = time.time()
+
+ # I2V: 首尾帧生成
+ if video_type == "i2v" and start_media_id:
+ if end_media_id:
+ # 有首尾帧
+ result = await self.flow_client.generate_video_start_end(
+ at=token.at,
+ project_id=project_id,
+ prompt=prompt,
+ model_key=model_config["model_key"],
+ aspect_ratio=model_config["aspect_ratio"],
+ start_media_id=start_media_id,
+ end_media_id=end_media_id,
+ user_paygate_tier=normalized_tier,
+ token_id=token.id,
+ token_video_concurrency=token.video_concurrency,
+ )
+ else:
+ # 只有首帧 - 需要去掉 model_key 中的 _fl
+ # 情况1: _fl_ 在中间 (如 veo_3_1_i2v_s_fast_fl_ultra_relaxed -> veo_3_1_i2v_s_fast_ultra_relaxed)
+ # 情况2: _fl 在结尾 (如 veo_3_1_i2v_s_fast_ultra_fl -> veo_3_1_i2v_s_fast_ultra)
+ actual_model_key = model_config["model_key"].replace("_fl_", "_")
+ if actual_model_key.endswith("_fl"):
+ actual_model_key = actual_model_key[:-3]
+ debug_logger.log_info(f"[I2V] 单帧模式,model_key: {model_config['model_key']} -> {actual_model_key}")
+ result = await self.flow_client.generate_video_start_image(
+ at=token.at,
+ project_id=project_id,
+ prompt=prompt,
+ model_key=actual_model_key,
+ aspect_ratio=model_config["aspect_ratio"],
+ start_media_id=start_media_id,
+ user_paygate_tier=normalized_tier,
+ token_id=token.id,
+ token_video_concurrency=token.video_concurrency,
+ )
+
+ # R2V: 多图生成
+ elif video_type == "r2v" and reference_images:
+ result = await self.flow_client.generate_video_reference_images(
+ at=token.at,
+ project_id=project_id,
+ prompt=prompt,
+ model_key=model_config["model_key"],
+ aspect_ratio=model_config["aspect_ratio"],
+ reference_images=reference_images,
+ user_paygate_tier=normalized_tier,
+ token_id=token.id,
+ token_video_concurrency=token.video_concurrency,
+ )
+
+ # T2V 或 R2V无图: 纯文本生成
+ else:
+ result = await self.flow_client.generate_video_text(
+ at=token.at,
+ project_id=project_id,
+ prompt=prompt,
+ model_key=model_config["model_key"],
+ aspect_ratio=model_config["aspect_ratio"],
+ user_paygate_tier=normalized_tier,
+ token_id=token.id,
+ token_video_concurrency=token.video_concurrency,
+ )
+ if video_trace is not None:
+ video_trace["submit_generation_ms"] = int((time.time() - submit_started_at) * 1000)
+
+ # 获取task_id和operations
+ operations = result.get("operations", [])
+ if not operations:
+ self._mark_generation_failed(generation_result, "\u751f\u6210\u4efb\u52a1\u521b\u5efa\u5931\u8d25")
+ yield self._create_error_response("生成任务创建失败", status_code=502)
+ return
+
+ operation = operations[0]
+ task_id = operation["operation"]["name"]
+ scene_id = operation.get("sceneId")
+
+ # 保存Task到数据库
+ task = Task(
+ task_id=task_id,
+ token_id=token.id,
+ model=model_config["model_key"],
+ prompt=prompt,
+ status="processing",
+ scene_id=scene_id
+ )
+ await self.db.create_task(task)
+ await self._update_request_log_progress(
+ request_log_state,
+ token_id=token.id,
+ status_text="video_submitted",
+ progress=45,
+ response_extra={"task_id": task_id, "scene_id": scene_id},
+ )
+
+ # 轮询结果
+ if stream:
+ yield self._create_stream_chunk(f"视频生成中...\n")
+
+ # 检查是否需要放大
+ upsample_config = model_config.get("upsample")
+
+ async for chunk in self._poll_video_result(
+ token,
+ project_id,
+ operations,
+ stream,
+ upsample_config,
+ generation_result,
+ response_state,
+ request_log_state,
+ ):
+ yield chunk
+
+ finally:
+ pass
+
+ async def _poll_video_result(
+ self,
+ token,
+ project_id: str,
+ operations: List[Dict],
+ stream: bool,
+ upsample_config: Optional[Dict] = None,
+ generation_result: Optional[Dict[str, Any]] = None,
+ response_state: Optional[Dict[str, Any]] = None,
+ request_log_state: Optional[Dict[str, Any]] = None
+ ) -> AsyncGenerator:
+ """轮询视频生成结果
+
+ Args:
+ upsample_config: 放大配置 {"resolution": "VIDEO_RESOLUTION_4K", "model_key": "veo_3_1_upsampler_4k"}
+ """
+
+ if response_state is None:
+ response_state = self._create_response_state()
+
+ max_attempts = config.max_poll_attempts
+ poll_interval = config.poll_interval
+
+ # 如果需要放大,轮询次数加倍(放大可能需要 30 分钟)
+ if upsample_config:
+ max_attempts = max_attempts * 3 # 放大需要更长时间
+
+ consecutive_poll_errors = 0
+ last_poll_error: Optional[Exception] = None
+ max_consecutive_poll_errors = 3
+
+ for attempt in range(max_attempts):
+ await asyncio.sleep(poll_interval)
+
+ try:
+ result = await self.flow_client.check_video_status(token.at, operations)
+ checked_operations = result.get("operations", [])
+ consecutive_poll_errors = 0
+ last_poll_error = None
+
+ if not checked_operations:
+ continue
+
+ operation = checked_operations[0]
+ status = operation.get("status")
+
+ # 状态更新 - 每20秒报告一次 (poll_interval=3秒, 20秒约7次轮询)
+ progress_update_interval = 7 # 每7次轮询 = 21秒
+ if stream and attempt % progress_update_interval == 0: # 每20秒报告一次
+ progress = min(int((attempt / max_attempts) * 100), 95)
+ await self._update_request_log_progress(request_log_state, token_id=token.id, status_text="video_polling", progress=max(45, progress), response_extra={"upstream_status": status})
+ yield self._create_stream_chunk(f"生成进度: {progress}%\n")
+
+ # 检查状态
+ if status == "MEDIA_GENERATION_STATUS_SUCCESSFUL":
+ # 成功
+ metadata = operation["operation"].get("metadata", {})
+ video_info = metadata.get("video", {})
+ video_url = video_info.get("fifeUrl")
+ video_media_id = video_info.get("mediaGenerationId")
+ aspect_ratio = video_info.get("aspectRatio", "VIDEO_ASPECT_RATIO_LANDSCAPE")
+
+ if not video_url:
+ error_msg = "视频生成失败: 视频URL为空"
+ await self._fail_video_task(checked_operations, error_msg)
+ self._mark_generation_failed(generation_result, error_msg)
+ yield self._create_error_response(error_msg, status_code=502)
+ return
+
+ # ========== 视频放大处理 ==========
+ if upsample_config and video_media_id:
+ if stream:
+ resolution_name = "4K" if "4K" in upsample_config["resolution"] else "1080P"
+ yield self._create_stream_chunk(f"\n视频生成完成,开始 {resolution_name} 放大处理...(可能需要 30 分钟)\n")
+
+ try:
+ # 提交放大任务
+ upsample_result = await self.flow_client.upsample_video(
+ at=token.at,
+ project_id=project_id,
+ video_media_id=video_media_id,
+ aspect_ratio=aspect_ratio,
+ resolution=upsample_config["resolution"],
+ model_key=upsample_config["model_key"],
+ token_id=token.id,
+ token_video_concurrency=token.video_concurrency,
+ )
+
+ upsample_operations = upsample_result.get("operations", [])
+ if upsample_operations:
+ if stream:
+ yield self._create_stream_chunk("放大任务已提交,继续轮询...\n")
+
+ # 递归轮询放大结果(不再放大)
+ async for chunk in self._poll_video_result(
+ token, project_id, upsample_operations, stream, None, generation_result, response_state, request_log_state
+ ):
+ yield chunk
+ return
+ else:
+ if stream:
+ yield self._create_stream_chunk("⚠️ 放大任务创建失败,返回原始视频\n")
+ except Exception as e:
+ debug_logger.log_error(f"Video upsample failed: {str(e)}")
+ if stream:
+ yield self._create_stream_chunk(f"⚠️ 放大失败: {str(e)},返回原始视频\n")
+
+ # 缓存视频 (如果启用)
+ local_url = video_url
+ if config.cache_enabled:
+ await self._update_request_log_progress(request_log_state, token_id=token.id, status_text="caching_video", progress=92)
+ try:
+ if stream:
+ yield self._create_stream_chunk("正在缓存视频文件...\n")
+ cached_filename = await self.file_cache.download_and_cache(video_url, "video")
+ local_url = f"{self._get_base_url(response_state)}/tmp/{cached_filename}"
+ if stream:
+ yield self._create_stream_chunk("✅ 视频缓存成功,准备返回缓存地址...\n")
+ except Exception as e:
+ debug_logger.log_error(f"Failed to cache video: {str(e)}")
+ # 缓存失败不影响结果返回,使用原始URL
+ local_url = video_url
+ if stream:
+ cache_error = self._normalize_error_message(e, max_length=120)
+ yield self._create_stream_chunk(f"⚠️ 缓存失败: {cache_error}\n正在返回源链接...\n")
+ else:
+ if stream:
+ yield self._create_stream_chunk("缓存已关闭,正在返回源链接...\n")
+
+ # 更新数据库
+ task_id = operation["operation"]["name"]
+ await self.db.update_task(
+ task_id,
+ status="completed",
+ progress=100,
+ result_urls=[local_url],
+ completed_at=time.time()
+ )
+
+ # 存储URL用于日志记录
+ response_state["url"] = local_url
+ response_state["generated_assets"] = {
+ "type": "video",
+ "final_video_url": local_url
+ }
+
+ # 返回结果
+ self._mark_generation_succeeded(generation_result)
+
+ if stream:
+ yield self._create_stream_chunk(
+ f"",
+ finish_reason="stop"
+ )
+ else:
+ yield self._create_completion_response(
+ local_url, # 直接传URL,让方法内部格式化
+ media_type="video"
+ )
+ return
+
+ elif status == "MEDIA_GENERATION_STATUS_FAILED":
+ # 生成失败 - 提取错误信息
+ error_info = operation.get("operation", {}).get("error", {})
+ error_code = error_info.get("code", "unknown")
+ error_message = error_info.get("message", "未知错误")
+
+ # 更新数据库任务状态
+ await self._fail_video_task(
+ checked_operations,
+ f"{error_message} (code: {error_code})"
+ )
+
+ # 返回友好的错误消息,提示用户重试
+ friendly_error = f"视频生成失败: {error_message},请重试"
+ self._mark_generation_failed(generation_result, friendly_error)
+ if stream:
+ yield self._create_stream_chunk(f"❌ {friendly_error}\n")
+ yield self._create_error_response(friendly_error, status_code=502)
+ return
+
+ elif status.startswith("MEDIA_GENERATION_STATUS_ERROR"):
+ # ??????
+ error_msg = f"视频生成失败: {status}"
+ await self._fail_video_task(checked_operations, error_msg)
+ self._mark_generation_failed(generation_result, error_msg)
+ yield self._create_error_response(error_msg, status_code=502)
+ return
+
+ except Exception as e:
+ last_poll_error = e
+ consecutive_poll_errors += 1
+ debug_logger.log_error(f"Poll error: {str(e)}")
+ if consecutive_poll_errors >= max_consecutive_poll_errors:
+ error_msg = f"视频状态查询失败: {self._normalize_error_message(e)}"
+ await self._fail_video_task(operations, error_msg)
+ self._mark_generation_failed(generation_result, error_msg)
+ if stream:
+ yield self._create_stream_chunk(f"❌ {error_msg}\n")
+ yield self._create_error_response(error_msg, status_code=502)
+ return
+ continue
+
+ # 超时
+ if last_poll_error is not None:
+ error_msg = f"视频状态查询持续失败: {self._normalize_error_message(last_poll_error)}"
+ else:
+ error_msg = f"视频生成超时 (已轮询 {max_attempts} 次)"
+ await self._fail_video_task(operations, error_msg)
+ self._mark_generation_failed(generation_result, error_msg)
+ yield self._create_error_response(error_msg, status_code=504)
+
+ # ========== 响应格式化 ==========
+
+ def _create_stream_chunk(self, content: str, role: str = None, finish_reason: str = None) -> str:
+ """创建流式响应chunk"""
+ import json
+ import time
+
+ chunk = {
+ "id": f"chatcmpl-{int(time.time())}",
+ "object": "chat.completion.chunk",
+ "created": int(time.time()),
+ "model": "flow2api",
+ "choices": [{
+ "index": 0,
+ "delta": {},
+ "finish_reason": finish_reason
+ }]
+ }
+
+ if role:
+ chunk["choices"][0]["delta"]["role"] = role
+
+ if finish_reason:
+ chunk["choices"][0]["delta"]["content"] = content
+ else:
+ chunk["choices"][0]["delta"]["reasoning_content"] = content
+
+ return f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
+
+ def _create_completion_response(self, content: str, media_type: str = "image", is_availability_check: bool = False) -> str:
+ """创建非流式响应
+
+ Args:
+ content: 媒体URL或纯文本消息
+ media_type: 媒体类型 ("image" 或 "video")
+ is_availability_check: 是否为可用性检查响应 (纯文本消息)
+
+ Returns:
+ JSON格式的响应
+ """
+ import json
+ import time
+
+ # 可用性检查: 返回纯文本消息
+ if is_availability_check:
+ formatted_content = content
+ else:
+ # 媒体生成: 根据媒体类型格式化内容为Markdown
+ if media_type == "video":
+ formatted_content = f"```html\n\n```"
+ else: # image
+ formatted_content = f""
+
+ response = {
+ "id": f"chatcmpl-{int(time.time())}",
+ "object": "chat.completion",
+ "created": int(time.time()),
+ "model": "flow2api",
+ "choices": [{
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": formatted_content
+ },
+ "finish_reason": "stop"
+ }]
+ }
+
+ return json.dumps(response, ensure_ascii=False)
+
+ def _create_error_response(self, error_message: str, status_code: int = 500) -> str:
+ """创建错误响应"""
+ import json
+
+ error = {
+ "error": {
+ "message": error_message,
+ "type": "server_error" if status_code >= 500 else "invalid_request_error",
+ "code": "generation_failed",
+ "status_code": status_code,
+ }
+ }
+
+ return json.dumps(error, ensure_ascii=False)
+
+ def _get_base_url(self, response_state: Optional[Dict[str, Any]] = None) -> str:
+ """获取基础URL用于缓存文件访问"""
+ # 已配置缓存访问域名时,始终优先使用它,避免被请求 Host/IP 覆盖。
+ if config.cache_base_url:
+ return config.cache_base_url.rstrip("/")
+
+ request_base_url = ""
+ if isinstance(response_state, dict):
+ request_base_url = (response_state.get("base_url") or "").strip().rstrip("/")
+ if request_base_url:
+ return request_base_url
+
+ # 回退到服务地址,避免把监听地址 0.0.0.0 / :: 直接返回给客户端
+ server_host = (config.server_host or "").strip()
+ if server_host in {"", "0.0.0.0", "::", "[::]"}:
+ server_host = "127.0.0.1"
+
+ return f"http://{server_host}:{config.server_port}"
+
+ async def _update_request_log_progress(
+ self,
+ request_log_state: Optional[Dict[str, Any]],
+ *,
+ token_id: Optional[int] = None,
+ status_text: str,
+ progress: int,
+ response_extra: Optional[Dict[str, Any]] = None,
+ ):
+ """?????????????"""
+ if not isinstance(request_log_state, dict):
+ return
+ log_id = request_log_state.get("id")
+ if not log_id:
+ return
+
+ safe_progress = max(0, min(100, int(progress)))
+ now = time.time()
+ last_status_text = str(request_log_state.get("last_status_text") or "").strip()
+ last_progress = int(request_log_state.get("last_progress") or 0)
+ last_updated_at = float(request_log_state.get("last_progress_update_at") or 0)
+
+ request_log_state["progress"] = safe_progress
+ request_log_state["last_status_text"] = status_text
+ request_log_state["last_progress"] = safe_progress
+ payload = {
+ "status": "processing",
+ "status_text": status_text,
+ "progress": safe_progress,
+ }
+ if isinstance(response_extra, dict):
+ payload.update(response_extra)
+
+ should_write = (
+ safe_progress in (0, 100)
+ or status_text != last_status_text
+ or safe_progress >= last_progress + 5
+ or (now - last_updated_at) >= 1.0
+ )
+ if not should_write:
+ return
+
+ request_log_state["last_progress_update_at"] = now
+
+ try:
+ await self.db.update_request_log(
+ log_id,
+ token_id=token_id,
+ response_body=json.dumps(payload, ensure_ascii=False),
+ status_code=102,
+ duration=0,
+ status_text=status_text,
+ progress=safe_progress,
+ )
+ except Exception as e:
+ debug_logger.log_error(f"Failed to update request log progress: {e}")
+
+ async def _log_request(
+ self,
+ token_id: Optional[int],
+ operation: str,
+ request_data: Dict[str, Any],
+ response_data: Dict[str, Any],
+ status_code: int,
+ duration: float,
+ log_id: Optional[int] = None,
+ status_text: Optional[str] = None,
+ progress: Optional[int] = None,
+ ):
+ """???????????? log_id ????????"""
+ try:
+ effective_status_text = status_text or (
+ "completed" if status_code == 200 else "failed" if status_code >= 400 else "processing"
+ )
+ effective_progress = progress
+ if effective_progress is None:
+ effective_progress = 100 if status_code == 200 else 0 if status_code >= 400 else 0
+ effective_progress = max(0, min(100, int(effective_progress)))
+
+ request_body = json.dumps(request_data, ensure_ascii=False)
+ response_body = json.dumps(response_data, ensure_ascii=False)
+
+ if log_id:
+ await self.db.update_request_log(
+ log_id,
+ token_id=token_id,
+ operation=operation,
+ request_body=request_body,
+ response_body=response_body,
+ status_code=status_code,
+ duration=duration,
+ status_text=effective_status_text,
+ progress=effective_progress,
+ )
+ return log_id
+
+ log = RequestLog(
+ token_id=token_id,
+ operation=operation,
+ request_body=request_body,
+ response_body=response_body,
+ status_code=status_code,
+ duration=duration,
+ status_text=effective_status_text,
+ progress=effective_progress,
+ )
+ return await self.db.add_request_log(log)
+ except Exception as e:
+ debug_logger.log_error(f"Failed to log request: {e}")
+ return None