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"![Generated Image]({local_url})", - 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"![Generated Image]({base64_url})", - 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"![Generated Image]({local_url})", - 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"![Generated Image]({content})" - - 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"![Generated Image]({local_url})", + 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"![Generated Image]({base64_url})", + 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"![Generated Image]({local_url})", + 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"![Generated Image]({content})" + + 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