diff --git a/src/api/admin.py b/src/api/admin.py index fe9db0a..8a6dcb9 100644 --- a/src/api/admin.py +++ b/src/api/admin.py @@ -4,6 +4,8 @@ from fastapi.responses import JSONResponse from pydantic import BaseModel from typing import Optional, List import secrets +import time +from curl_cffi.requests import AsyncSession from ..core.auth import AuthManager from ..core.database import Database from ..core.config import config @@ -61,6 +63,14 @@ class UpdateTokenRequest(BaseModel): 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 GenerationConfigRequest(BaseModel): @@ -522,7 +532,9 @@ async def get_proxy_config(token: str = Depends(verify_admin_token)): "success": True, "config": { "enabled": config.enabled, - "proxy_url": config.proxy_url + "proxy_url": config.proxy_url, + "media_proxy_enabled": config.media_proxy_enabled, + "media_proxy_url": config.media_proxy_url } } @@ -533,7 +545,9 @@ async def get_proxy_config_alias(token: str = Depends(verify_admin_token)): config = await proxy_manager.get_proxy_config() return { "proxy_enabled": config.enabled, # Frontend expects proxy_enabled - "proxy_url": config.proxy_url + "proxy_url": config.proxy_url, + "media_proxy_enabled": config.media_proxy_enabled, + "media_proxy_url": config.media_proxy_url } @@ -543,7 +557,15 @@ async def update_proxy_config_alias( token: str = Depends(verify_admin_token) ): """Update proxy configuration (alias for frontend compatibility)""" - await proxy_manager.update_proxy_config(request.proxy_enabled, request.proxy_url) + 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": "代理配置更新成功"} @@ -553,10 +575,81 @@ async def update_proxy_config( token: str = Depends(verify_admin_token) ): """Update proxy configuration""" - await proxy_manager.update_proxy_config(request.proxy_enabled, request.proxy_url) + 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""" diff --git a/src/core/database.py b/src/core/database.py index f819bf9..a26f584 100644 --- a/src/core/database.py +++ b/src/core/database.py @@ -77,17 +77,28 @@ class Database: if count[0] == 0: proxy_enabled = False proxy_url = None + media_proxy_enabled = False + media_proxy_url = None if config_dict: proxy_config = config_dict.get("proxy", {}) proxy_enabled = proxy_config.get("proxy_enabled", False) proxy_url = proxy_config.get("proxy_url", "") proxy_url = proxy_url if proxy_url else None + media_proxy_enabled = proxy_config.get( + "media_proxy_enabled", + proxy_config.get("image_io_proxy_enabled", False) + ) + media_proxy_url = proxy_config.get( + "media_proxy_url", + proxy_config.get("image_io_proxy_url", "") + ) + media_proxy_url = media_proxy_url if media_proxy_url else None await db.execute(""" - INSERT INTO proxy_config (id, enabled, proxy_url) - VALUES (1, ?, ?) - """, (proxy_enabled, proxy_url)) + INSERT INTO proxy_config (id, enabled, proxy_url, media_proxy_enabled, media_proxy_url) + VALUES (1, ?, ?, ?, ?) + """, (proxy_enabled, proxy_url, media_proxy_enabled, media_proxy_url)) # Ensure generation_config has a row cursor = await db.execute("SELECT COUNT(*) FROM generation_config") @@ -207,6 +218,20 @@ class Database: ) """) + # Check and create proxy_config table if missing + if not await self._table_exists(db, "proxy_config"): + print(" ✓ Creating missing table: proxy_config") + await db.execute(""" + CREATE TABLE proxy_config ( + id INTEGER PRIMARY KEY DEFAULT 1, + enabled BOOLEAN DEFAULT 0, + proxy_url TEXT, + media_proxy_enabled BOOLEAN DEFAULT 0, + media_proxy_url TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + # Check and create captcha_config table if missing if not await self._table_exists(db, "captcha_config"): print(" ✓ Creating missing table: captcha_config") @@ -279,6 +304,21 @@ class Database: except Exception as e: print(f" ✗ Failed to add column 'error_ban_threshold': {e}") + # Check and add missing columns to proxy_config table + if await self._table_exists(db, "proxy_config"): + proxy_columns_to_add = [ + ("media_proxy_enabled", "BOOLEAN DEFAULT 0"), + ("media_proxy_url", "TEXT"), + ] + + for col_name, col_type in proxy_columns_to_add: + if not await self._column_exists(db, "proxy_config", col_name): + try: + await db.execute(f"ALTER TABLE proxy_config ADD COLUMN {col_name} {col_type}") + print(f" ✓ Added column '{col_name}' to proxy_config table") + except Exception as e: + print(f" ✗ Failed to add column '{col_name}': {e}") + # Check and add missing columns to captcha_config table if await self._table_exists(db, "captcha_config"): captcha_columns_to_add = [ @@ -457,6 +497,8 @@ class Database: id INTEGER PRIMARY KEY DEFAULT 1, enabled BOOLEAN DEFAULT 0, proxy_url TEXT, + media_proxy_enabled BOOLEAN DEFAULT 0, + media_proxy_url TEXT, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) @@ -946,14 +988,47 @@ class Database: return ProxyConfig(**dict(row)) return None - async def update_proxy_config(self, enabled: bool, proxy_url: Optional[str] = None): + async def update_proxy_config( + self, + enabled: bool, + proxy_url: Optional[str] = None, + media_proxy_enabled: Optional[bool] = None, + media_proxy_url: Optional[str] = None + ): """Update proxy configuration""" async with aiosqlite.connect(self.db_path) as db: - await db.execute(""" - UPDATE proxy_config - SET enabled = ?, proxy_url = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = 1 - """, (enabled, proxy_url)) + db.row_factory = aiosqlite.Row + cursor = await db.execute("SELECT * FROM proxy_config WHERE id = 1") + row = await cursor.fetchone() + + if row: + current = dict(row) + new_media_proxy_enabled = ( + media_proxy_enabled + if media_proxy_enabled is not None + else current.get("media_proxy_enabled", False) + ) + new_media_proxy_url = ( + media_proxy_url + if media_proxy_url is not None + else current.get("media_proxy_url") + ) + + await db.execute(""" + UPDATE proxy_config + SET enabled = ?, proxy_url = ?, + media_proxy_enabled = ?, media_proxy_url = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + """, (enabled, proxy_url, new_media_proxy_enabled, new_media_proxy_url)) + else: + new_media_proxy_enabled = media_proxy_enabled if media_proxy_enabled is not None else False + new_media_proxy_url = media_proxy_url + await db.execute(""" + INSERT INTO proxy_config (id, enabled, proxy_url, media_proxy_enabled, media_proxy_url) + VALUES (1, ?, ?, ?, ?) + """, (enabled, proxy_url, new_media_proxy_enabled, new_media_proxy_url)) + await db.commit() async def get_generation_config(self) -> Optional[GenerationConfig]: diff --git a/src/core/models.py b/src/core/models.py index b005026..d98e567 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -112,8 +112,10 @@ class AdminConfig(BaseModel): class ProxyConfig(BaseModel): """Proxy configuration""" id: int = 1 - enabled: bool = False - proxy_url: Optional[str] = None + enabled: bool = False # 请求代理开关 + proxy_url: Optional[str] = None # 请求代理地址 + media_proxy_enabled: bool = False # 图片上传/下载代理开关 + media_proxy_url: Optional[str] = None # 图片上传/下载代理地址 class GenerationConfig(BaseModel): diff --git a/src/services/browser_captcha.py b/src/services/browser_captcha.py index 9540a28..4e1e48f 100644 --- a/src/services/browser_captcha.py +++ b/src/services/browser_captcha.py @@ -13,11 +13,12 @@ import time import re import random from pathlib import Path -from typing import Optional, Dict +from typing import Optional, Dict, Any, List from datetime import datetime -from urllib.parse import urlparse, unquote +from urllib.parse import urlparse, unquote, parse_qs from ..core.logger import debug_logger +from ..core.config import config # ==================== Docker 环境检测 ==================== @@ -203,102 +204,36 @@ class TokenBrowser: 每次都是新的随机 UA,避免长时间运行导致的各种问题 """ - # UA 池 + # UA 池(去掉 120-127,加入移动端 UA,并保留一批不常见补丁版本) UA_LIST = [ - # Windows Chrome (120-132) + # Windows Chrome (128-132) "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - # Windows Chrome 完整版本号 - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.83 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.139 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.117 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.100 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.138 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.120 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.127 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.6422.141 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", - # Windows Edge (120-132) + # Windows Chrome 不常见补丁版本 + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.210 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.265 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.172 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.177 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.186 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + + # Windows Edge (128-132) "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 Edg/132.0.0.0", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.0", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.0.0", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.83 Safari/537.36 Edg/132.0.2957.115", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.139 Safari/537.36 Edg/131.0.2903.99", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.117 Safari/537.36 Edg/130.0.2849.80", - # macOS Chrome (120-132) - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_3_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_6_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", - # macOS Safari - "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_3_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_6_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", - # macOS Edge - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 Edg/132.0.0.0", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 Edg/132.0.0.0", - # Linux Chrome - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", - "Mozilla/5.0 (X11; Ubuntu; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36", - "Mozilla/5.0 (X11; Ubuntu; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", - "Mozilla/5.0 (X11; Ubuntu; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", - # Linux Firefox - "Mozilla/5.0 (X11; Linux x86_64; rv:134.0) Gecko/20100101 Firefox/134.0", - "Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0", - "Mozilla/5.0 (X11; Linux x86_64; rv:132.0) Gecko/20100101 Firefox/132.0", - "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0", - "Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0", - "Mozilla/5.0 (X11; Linux x86_64; rv:129.0) Gecko/20100101 Firefox/129.0", - "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0", - "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:134.0) Gecko/20100101 Firefox/134.0", - "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0", - "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:132.0) Gecko/20100101 Firefox/132.0", - "Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:134.0) Gecko/20100101 Firefox/134.0", - # Windows Firefox + # Windows Edge 不常见补丁版本 + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.210 Safari/537.36 Edg/132.0.2957.171", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.265 Safari/537.36 Edg/131.0.2903.146", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.172 Safari/537.36 Edg/130.0.2849.142", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.177 Safari/537.36 Edg/129.0.2792.124", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.186 Safari/537.36 Edg/128.0.2739.111", + + # Windows Firefox (128-134) "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:134.0) Gecko/20100101 Firefox/134.0", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", @@ -306,30 +241,84 @@ class TokenBrowser: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:130.0) Gecko/20100101 Firefox/130.0", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:129.0) Gecko/20100101 Firefox/129.0", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0", - # macOS Firefox + + # macOS Chrome (128-132) + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_3_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_6_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + # macOS Chrome 不常见补丁版本 + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.210 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.265 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.172 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.177 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.186 Safari/537.36", + + # macOS Safari + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_3_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_6_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + + # macOS Edge (128-132) + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 Edg/132.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0", + # macOS Edge 不常见补丁版本 + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.210 Safari/537.36 Edg/132.0.2957.171", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.265 Safari/537.36 Edg/131.0.2903.146", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.172 Safari/537.36 Edg/130.0.2849.142", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.177 Safari/537.36 Edg/129.0.2792.124", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.186 Safari/537.36 Edg/128.0.2739.111", + + # macOS Firefox (128-134) "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.4; rv:134.0) Gecko/20100101 Firefox/134.0", "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.3; rv:133.0) Gecko/20100101 Firefox/133.0", "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.2; rv:132.0) Gecko/20100101 Firefox/132.0", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:134.0) Gecko/20100101 Firefox/134.0", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", - # Opera - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 OPR/116.0.0.0", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 OPR/115.0.0.0", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 OPR/114.0.0.0", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/113.0.0.0", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 OPR/112.0.0.0", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 OPR/116.0.0.0", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 OPR/115.0.0.0", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 OPR/116.0.0.0", - # Brave - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Brave/131", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Brave/130", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Brave/131", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Brave/131", - # Vivaldi - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Vivaldi/6.9.3447.54", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Vivaldi/6.8.3381.55", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Vivaldi/6.9.3447.54", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.1; rv:131.0) Gecko/20100101 Firefox/131.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:130.0) Gecko/20100101 Firefox/130.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:129.0) Gecko/20100101 Firefox/129.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:128.0) Gecko/20100101 Firefox/128.0", + + # Android Chrome Mobile (128-132) + "Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.163 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 14; SM-S9180) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.260 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.172 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 12; M2102J20SG) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.177 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 11; M2012K11AC) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.186 Mobile Safari/537.36", + + # Android Edge Mobile + "Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.163 Mobile Safari/537.36 EdgA/132.0.2957.171", + "Mozilla/5.0 (Linux; Android 14; SM-S9180) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.260 Mobile Safari/537.36 EdgA/131.0.2903.146", + "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.172 Mobile Safari/537.36 EdgA/130.0.2849.142", + "Mozilla/5.0 (Linux; Android 12; M2102J20SG) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.177 Mobile Safari/537.36 EdgA/129.0.2792.124", + "Mozilla/5.0 (Linux; Android 11; M2012K11AC) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.186 Mobile Safari/537.36 EdgA/128.0.2739.111", + + # Android Samsung Browser (相对少见) + "Mozilla/5.0 (Linux; Android 14; SM-S9180) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/28.0 Chrome/132.0.6834.163 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 13; SM-S9110) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/130.0.6723.172 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 12; SM-G9910) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/26.0 Chrome/128.0.6613.186 Mobile Safari/537.36", + + # iOS Safari + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + + # iOS Chrome / Edge + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/132.0.6834.95 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.112 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.90 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/132.2957.171 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/131.2903.146 Mobile/15E148 Safari/604.1", ] # 分辨率池 @@ -351,6 +340,11 @@ class TokenBrowser: self._semaphore = asyncio.Semaphore(1) # 同时只能有一个任务 self._solve_count = 0 self._error_count = 0 + self._last_fingerprint: Optional[Dict[str, Any]] = None + # 打码成功后延迟关闭浏览器:等待上游图片/视频请求完成通知 + self._pending_release_events: List[asyncio.Event] = [] + self._pending_release_tasks: List[asyncio.Task] = [] + self._pending_release_lock = asyncio.Lock() async def _create_browser(self) -> tuple: """创建新浏览器实例(新 UA),返回 (playwright, browser, context)""" @@ -366,16 +360,24 @@ class TokenBrowser: # 代理配置 proxy_option = None + raw_proxy_url = None try: if self.db: captcha_config = await self.db.get_captcha_config() - raw_url = captcha_config.browser_proxy_enabled and captcha_config.browser_proxy_url - if raw_url: - proxy_option = parse_proxy_url(raw_url.strip()) + if captcha_config.browser_proxy_enabled and captcha_config.browser_proxy_url: + candidate_proxy_url = captcha_config.browser_proxy_url.strip() + proxy_option = parse_proxy_url(candidate_proxy_url) if proxy_option: + raw_proxy_url = candidate_proxy_url debug_logger.log_info(f"[BrowserCaptcha] Token-{self.token_id} 使用代理: {proxy_option['server']}") except: pass + # 先记录创建时的指纹,后续会在页面中补齐 sec-ch-* 等信息 + self._last_fingerprint = { + "user_agent": random_ua, + "proxy_url": raw_proxy_url if raw_proxy_url else None, + } + try: browser = await playwright.chromium.launch( headless=False, @@ -405,6 +407,53 @@ class TokenBrowser: await playwright.stop() except: pass raise + + async def _capture_page_fingerprint(self, page): + """从浏览器页面提取 UA 与客户端提示头,确保与打码浏览器一致。""" + try: + fingerprint = await page.evaluate(""" + () => { + 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, + }; + } + """) + + if not isinstance(fingerprint, dict): + return + + if self._last_fingerprint is None: + self._last_fingerprint = {} + + 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: + self._last_fingerprint[key] = value + except Exception as e: + debug_logger.log_warning(f"[BrowserCaptcha] Token-{self.token_id} 提取浏览器指纹失败: {type(e).__name__}: {str(e)[:200]}") async def _close_browser(self, playwright, browser, context): """关闭浏览器实例""" @@ -420,6 +469,104 @@ class TokenBrowser: if playwright: await playwright.stop() except: pass + + async def _wait_and_close_after_request( + self, + release_event: asyncio.Event, + wait_timeout: int, + playwright, + browser, + context, + action: str + ): + """等待上游请求结束后再关闭浏览器(超时兜底)。""" + close_reason = "上游请求完成" + try: + await asyncio.wait_for(release_event.wait(), timeout=wait_timeout) + except asyncio.TimeoutError: + close_reason = f"等待上游请求完成超时({wait_timeout}s)" + debug_logger.log_warning( + f"[BrowserCaptcha] Token-{self.token_id} {close_reason},执行兜底关闭" + ) + except Exception as e: + close_reason = f"等待上游请求完成异常: {type(e).__name__}" + debug_logger.log_warning( + f"[BrowserCaptcha] Token-{self.token_id} {close_reason},执行兜底关闭" + ) + finally: + await self._close_browser(playwright, browser, context) + debug_logger.log_info( + f"[BrowserCaptcha] Token-{self.token_id} {close_reason},浏览器已关闭 (action={action})" + ) + async with self._pending_release_lock: + current_task = asyncio.current_task() + if current_task in self._pending_release_tasks: + self._pending_release_tasks.remove(current_task) + if release_event in self._pending_release_events: + self._pending_release_events.remove(release_event) + + async def _defer_browser_close_until_request_done( + self, + playwright, + browser, + context, + action: str + ): + """打码成功后延迟关闭浏览器,等待 Flow 请求结束通知。""" + flow_timeout = int(getattr(config, "flow_timeout", 300) or 300) + upsample_timeout = int(getattr(config, "upsample_timeout", 300) or 300) + if action == "IMAGE_GENERATION": + # 图片链路可能包含放大请求,等待上限至少覆盖 flow/upsample 超时 + base_timeout = max(flow_timeout, upsample_timeout) + wait_timeout = max(base_timeout + 180, 900) + else: + # 视频请求默认超时更长,给更大的缓冲避免“请求未结束就关闭” + wait_timeout = max(flow_timeout + 300, 1800) + release_event = asyncio.Event() + release_task = asyncio.create_task( + self._wait_and_close_after_request( + release_event=release_event, + wait_timeout=wait_timeout, + playwright=playwright, + browser=browser, + context=context, + action=action, + ) + ) + + async with self._pending_release_lock: + self._pending_release_events.append(release_event) + self._pending_release_tasks.append(release_task) + debug_logger.log_info( + f"[BrowserCaptcha] Token-{self.token_id} 打码成功后进入延迟关闭,等待上游请求完成 (action={action}, timeout={wait_timeout}s)" + ) + + async def notify_generation_request_finished(self): + """通知当前 Token 对应的上游图片/视频请求已结束。""" + async with self._pending_release_lock: + release_event = self._pending_release_events.pop(0) if self._pending_release_events else None + if release_event and not release_event.is_set(): + release_event.set() + debug_logger.log_info( + f"[BrowserCaptcha] Token-{self.token_id} 收到上游请求完成通知,开始关闭浏览器" + ) + + async def force_close_pending_browser(self): + """强制关闭待释放浏览器(服务关闭时调用)。""" + async with self._pending_release_lock: + release_events = list(self._pending_release_events) + release_tasks = list(self._pending_release_tasks) + self._pending_release_events.clear() + self._pending_release_tasks.clear() + + for release_event in release_events: + if not release_event.is_set(): + release_event.set() + for release_task in release_tasks: + try: + await asyncio.wait_for(release_task, timeout=5) + except Exception: + pass async def _execute_captcha(self, context, project_id: str, website_key: str, action: str) -> Optional[str]: """在给定 context 中执行打码逻辑""" @@ -440,6 +587,29 @@ class TokenBrowser: await route.abort() await page.route("**/*", handle_route) + reload_ok_event = asyncio.Event() + clr_ok_event = asyncio.Event() + + def handle_response(response): + try: + if response.status != 200: + return + parsed = urlparse(response.url) + path = parsed.path or "" + if "recaptcha/enterprise/reload" not in path and "recaptcha/enterprise/clr" not in path: + return + query = parse_qs(parsed.query or "") + key = (query.get("k") or [None])[0] + if key != website_key: + return + if "recaptcha/enterprise/reload" in path: + reload_ok_event.set() + elif "recaptcha/enterprise/clr" in path: + clr_ok_event.set() + except Exception: + pass + + page.on("response", handle_response) try: await page.goto(page_url, wait_until="load", timeout=30000) except Exception as e: @@ -451,6 +621,9 @@ class TokenBrowser: except Exception as e: debug_logger.log_warning(f"[BrowserCaptcha] Token-{self.token_id} grecaptcha 未就绪: {type(e).__name__}: {str(e)[:200]}") return None + + # 记录本次打码页面的真实 UA/客户端提示头 + await self._capture_page_fingerprint(page) token = await asyncio.wait_for( page.evaluate(f""" @@ -465,6 +638,32 @@ class TokenBrowser: """, action), timeout=30 ) + + # 按要求:等待 enterprise/reload 与 enterprise/clr 均出现并返回 200 + try: + await asyncio.wait_for(reload_ok_event.wait(), timeout=12) + except asyncio.TimeoutError: + debug_logger.log_warning( + f"[BrowserCaptcha] Token-{self.token_id} 等待 recaptcha enterprise/reload 200 超时" + ) + return None + + try: + await asyncio.wait_for(clr_ok_event.wait(), timeout=12) + except asyncio.TimeoutError: + debug_logger.log_warning( + f"[BrowserCaptcha] Token-{self.token_id} 等待 recaptcha enterprise/clr 200 超时" + ) + return None + + # 即使 reload/clr 都已返回 200,也额外等待几秒,确保 enterprise 请求链路完全稳定。 + post_wait_seconds = float(getattr(config, "browser_recaptcha_settle_seconds", 3) or 3) + if post_wait_seconds > 0: + debug_logger.log_info( + f"[BrowserCaptcha] Token-{self.token_id} reload/clr 已就绪,额外等待 {post_wait_seconds:.1f}s 后返回 token" + ) + await asyncio.sleep(post_wait_seconds) + return token except Exception as e: msg = f"{type(e).__name__}: {str(e)}" @@ -472,8 +671,16 @@ class TokenBrowser: return None finally: if page: - try: await page.close() - except: pass + try: + await page.close() + except: + pass + + def get_last_fingerprint(self) -> Optional[Dict[str, Any]]: + """返回最近一次打码浏览器的指纹快照。""" + if not self._last_fingerprint: + return None + return dict(self._last_fingerprint) async def get_token(self, project_id: str, website_key: str, action: str = "IMAGE_GENERATION") -> Optional[str]: """获取 Token:启动新浏览器 -> 打码 -> 关闭浏览器""" @@ -496,6 +703,16 @@ class TokenBrowser: if token: self._solve_count += 1 debug_logger.log_info(f"[BrowserCaptcha] Token-{self.token_id} 获取成功 ({(time.time()-start_ts)*1000:.0f}ms)") + # 不立即关闭浏览器:等待图片/视频请求结束后再关闭 + await self._defer_browser_close_until_request_done( + playwright=playwright, + browser=browser, + context=context, + action=action, + ) + playwright = None + browser = None + context = None return token self._error_count += 1 @@ -671,6 +888,14 @@ class BrowserCaptchaService: self._log_stats() return token, browser_id + async def get_fingerprint(self, browser_id: int) -> Optional[Dict[str, Any]]: + """获取指定浏览器最近一次打码时的指纹快照。""" + async with self._browsers_lock: + browser = self._browsers.get(browser_id) + if not browser: + return None + return browser.get_last_fingerprint() + async def report_error(self, browser_id: int = None): """上层举报:Token 无效(统计用) @@ -682,6 +907,17 @@ class BrowserCaptchaService: if browser_id is not None: debug_logger.log_info(f"[BrowserCaptcha] 浏览器 {browser_id} 的 token 验证失败") + async def report_request_finished(self, browser_id: int = None): + """上层通知:图片/视频请求已完成,可关闭对应打码浏览器。""" + if browser_id is None: + return + + async with self._browsers_lock: + browser = self._browsers.get(browser_id) + + if browser: + await browser.notify_generation_request_finished() + async def remove_browser(self, browser_id: int): async with self._browsers_lock: if browser_id in self._browsers: @@ -689,7 +925,14 @@ class BrowserCaptchaService: async def close(self): async with self._browsers_lock: + browsers = list(self._browsers.values()) self._browsers.clear() + + for browser in browsers: + try: + await browser.force_close_pending_browser() + except Exception: + pass async def open_login_browser(self): return {"success": False, "error": "Not implemented"} async def create_browser_for_token(self, t, s=None): pass diff --git a/src/services/browser_captcha_personal.py b/src/services/browser_captcha_personal.py index 4933c19..72fcc32 100644 --- a/src/services/browser_captcha_personal.py +++ b/src/services/browser_captcha_personal.py @@ -8,7 +8,7 @@ import time import os import sys import subprocess -from typing import Optional +from typing import Optional, Dict, Any from ..core.logger import debug_logger @@ -155,6 +155,7 @@ class BrowserCaptchaService: self.resident_tab = None # 向后兼容 self._running = False # 向后兼容 self._recaptcha_ready = False # 向后兼容 + self._last_fingerprint: Optional[Dict[str, Any]] = None @classmethod async def get_instance(cls, db=None) -> 'BrowserCaptchaService': @@ -429,6 +430,53 @@ class BrowserCaptchaService: return token + async def _extract_tab_fingerprint(self, tab) -> Optional[Dict[str, Any]]: + """从 nodriver 标签页提取浏览器指纹信息。""" + try: + fingerprint = await tab.evaluate(""" + () => { + 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, + }; + } + """) + if not isinstance(fingerprint, dict): + return None + + # personal 模式当前未单独配置浏览器代理,显式使用直连,避免与全局代理混淆。 + result: Dict[str, Any] = {"proxy_url": None} + 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]: @@ -447,6 +495,7 @@ class BrowserCaptchaService: """ # 确保浏览器已初始化 await self.initialize() + self._last_fingerprint = None # 尝试从常驻标签页获取 token async with self._resident_lock: @@ -470,6 +519,7 @@ class BrowserCaptchaService: token = await self._execute_recaptcha_on_tab(resident_info.tab, action) duration_ms = (time.time() - start_time) * 1000 if token: + self._last_fingerprint = await self._extract_tab_fingerprint(resident_info.tab) debug_logger.log_info(f"[BrowserCaptcha] ✅ Token生成成功(耗时 {duration_ms:.0f}ms)") return token else: @@ -487,6 +537,7 @@ class BrowserCaptchaService: try: token = await self._execute_recaptcha_on_tab(resident_info.tab, action) if token: + self._last_fingerprint = await self._extract_tab_fingerprint(resident_info.tab) debug_logger.log_info(f"[BrowserCaptcha] ✅ 重建后 Token生成成功") return token except Exception: @@ -621,6 +672,7 @@ class BrowserCaptchaService: 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 else: @@ -638,6 +690,12 @@ class BrowserCaptchaService: except Exception: pass + def get_last_fingerprint(self) -> Optional[Dict[str, Any]]: + """返回最近一次打码时的浏览器指纹快照。""" + if not self._last_fingerprint: + return None + return dict(self._last_fingerprint) + async def close(self): """关闭浏览器""" # 先停止所有常驻模式(关闭所有常驻标签页) @@ -798,4 +856,4 @@ class BrowserCaptchaService: """获取当前常驻的 project_id(向后兼容,返回第一个)""" if self._resident_tabs: return next(iter(self._resident_tabs.keys())) - return self.resident_project_id \ No newline at end of file + return self.resident_project_id diff --git a/src/services/file_cache.py b/src/services/file_cache.py index 60a47dc..85eae64 100644 --- a/src/services/file_cache.py +++ b/src/services/file_cache.py @@ -29,6 +29,28 @@ class FileCache: self.proxy_manager = proxy_manager self._cleanup_task = None + async def _resolve_download_proxy(self, media_type: str) -> Optional[str]: + """根据媒体类型解析下载代理地址。""" + 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 + async def start_cleanup_task(self): """Start background cleanup task""" if self._cleanup_task is None: @@ -131,12 +153,8 @@ class FileCache: # Download file debug_logger.log_info(f"Downloading file from: {url}") - # Get proxy if available - proxy_url = None - if self.proxy_manager: - proxy_config = await self.proxy_manager.get_proxy_config() - if proxy_config and proxy_config.enabled and proxy_config.proxy_url: - proxy_url = proxy_config.proxy_url + # Resolve proxy by media type + proxy_url = await self._resolve_download_proxy(media_type) # Try method 1: curl_cffi with browser impersonation try: diff --git a/src/services/flow_client.py b/src/services/flow_client.py index 7a968ba..cf7d177 100644 --- a/src/services/flow_client.py +++ b/src/services/flow_client.py @@ -1,5 +1,6 @@ """Flow API Client for VideoFX (Veo)""" import asyncio +import contextvars import time import uuid import random @@ -21,6 +22,11 @@ class FlowClient: self.timeout = config.flow_timeout # 缓存每个账号的 User-Agent self._user_agent_cache = {} + # 当前请求链路绑定的浏览器指纹(基于 contextvar,避免并发串扰) + self._request_fingerprint_ctx: contextvars.ContextVar[Optional[Dict[str, Any]]] = contextvars.ContextVar( + "flow_request_fingerprint", + default=None + ) # Default "real browser" headers (Android Chrome style) to reduce upstream 4xx/5xx instability. # These will be applied as defaults (won't override caller-provided headers). @@ -109,6 +115,14 @@ class FlowClient: return user_agent + def _set_request_fingerprint(self, fingerprint: Optional[Dict[str, Any]]): + """设置当前请求链路的浏览器指纹上下文。""" + self._request_fingerprint_ctx.set(dict(fingerprint) if fingerprint else None) + + def clear_request_fingerprint(self): + """清理请求链路绑定的浏览器指纹。""" + self._set_request_fingerprint(None) + async def _make_request( self, method: str, @@ -119,7 +133,8 @@ class FlowClient: st_token: Optional[str] = None, use_at: bool = False, at_token: Optional[str] = None, - timeout: Optional[int] = None + timeout: Optional[int] = None, + use_media_proxy: bool = False ) -> Dict[str, Any]: """统一HTTP请求处理 @@ -133,12 +148,29 @@ class FlowClient: use_at: 是否使用AT认证 (Bearer方式) at_token: Access Token timeout: 自定义超时时间(秒),不传则使用默认值 + use_media_proxy: 是否使用图片上传/下载代理 """ - proxy_url = await self.proxy_manager.get_proxy_url() + fingerprint = self._request_fingerprint_ctx.get() + + proxy_url = None + if self.proxy_manager: + if use_media_proxy and hasattr(self.proxy_manager, "get_media_proxy_url"): + proxy_url = await self.proxy_manager.get_media_proxy_url() + elif hasattr(self.proxy_manager, "get_request_proxy_url"): + proxy_url = await self.proxy_manager.get_request_proxy_url() + else: + proxy_url = await self.proxy_manager.get_proxy_url() + + if isinstance(fingerprint, dict) and "proxy_url" in fingerprint: + proxy_url = fingerprint.get("proxy_url") + if proxy_url == "": + proxy_url = None request_timeout = timeout or self.timeout if headers is None: headers = {} + else: + headers = dict(headers) # ST认证 - 使用Cookie if use_st and st_token: @@ -155,18 +187,38 @@ class FlowClient: elif at_token: account_id = at_token[:16] # 使用 AT 的前16个字符 - # 通用请求头 - 基于账号生成固定的 User-Agent + # 通用请求头 - 优先使用打码浏览器指纹中的 UA + fingerprint_user_agent = None + if isinstance(fingerprint, dict): + fingerprint_user_agent = fingerprint.get("user_agent") + headers.update({ "Content-Type": "application/json", - "User-Agent": self._generate_user_agent(account_id) + "User-Agent": fingerprint_user_agent or self._generate_user_agent(account_id) }) + # 若存在打码浏览器指纹,覆盖关键客户端提示头,保证提交请求与打码时一致。 + if isinstance(fingerprint, dict): + if fingerprint.get("accept_language"): + headers.setdefault("Accept-Language", fingerprint["accept_language"]) + if fingerprint.get("sec_ch_ua"): + headers["sec-ch-ua"] = fingerprint["sec_ch_ua"] + if fingerprint.get("sec_ch_ua_mobile"): + headers["sec-ch-ua-mobile"] = fingerprint["sec_ch_ua_mobile"] + if fingerprint.get("sec_ch_ua_platform"): + headers["sec-ch-ua-platform"] = fingerprint["sec_ch_ua_platform"] + # Add default Chromium/Android client headers (do not override explicitly provided values). for key, value in self._default_client_headers.items(): headers.setdefault(key, value) # Log request if config.debug_enabled: + if isinstance(fingerprint, dict): + proxy_for_log = proxy_url if proxy_url else "direct" + debug_logger.log_info( + f"[FINGERPRINT] 使用打码浏览器指纹提交请求: UA={headers.get('User-Agent', '')[:120]}, proxy={proxy_for_log}" + ) debug_logger.log_request( method=method, url=url, @@ -411,17 +463,19 @@ class FlowClient: self, at: str, image_bytes: bytes, - aspect_ratio: str = "IMAGE_ASPECT_RATIO_LANDSCAPE" + aspect_ratio: str = "IMAGE_ASPECT_RATIO_LANDSCAPE", + project_id: Optional[str] = None ) -> str: - """上传图片,返回mediaGenerationId + """上传图片,返回mediaId Args: at: Access Token image_bytes: 图片字节数据 aspect_ratio: 图片或视频宽高比(会自动转换为图片格式) + project_id: 项目ID(新上传接口可使用) Returns: - mediaGenerationId (CAM...) + mediaId """ # 转换视频aspect_ratio为图片aspect_ratio # VIDEO_ASPECT_RATIO_LANDSCAPE -> IMAGE_ASPECT_RATIO_LANDSCAPE @@ -435,8 +489,48 @@ class FlowClient: # 编码为base64 (去掉前缀) image_base64 = base64.b64encode(image_bytes).decode('utf-8') - url = f"{self.api_base_url}:uploadUserImage" - json_data = { + # 优先尝试新版上传接口: /v1/flow/uploadImage + # 若失败则自动回退到旧接口,保证兼容 + ext = "png" if "png" in mime_type else "jpg" + upload_file_name = f"flow2api_upload_{int(time.time() * 1000)}.{ext}" + new_url = f"{self.api_base_url}/flow/uploadImage" + new_client_context = { + "tool": "PINHOLE" + } + if project_id: + new_client_context["projectId"] = project_id + + new_json_data = { + "clientContext": new_client_context, + "fileName": upload_file_name, + "imageBytes": image_base64, + "isHidden": False, + "isUserUploaded": True, + "mimeType": mime_type + } + + try: + new_result = await self._make_request( + method="POST", + url=new_url, + json_data=new_json_data, + use_at=True, + at_token=at, + use_media_proxy=True + ) + media_id = ( + new_result.get("media", {}).get("name") + or new_result.get("mediaGenerationId", {}).get("mediaGenerationId") + ) + if media_id: + return media_id + raise Exception(f"Invalid upload response: missing media id, keys={list(new_result.keys())}") + except Exception as new_upload_error: + debug_logger.log_warning(f"[UPLOAD] New upload API failed, fallback to legacy endpoint: {new_upload_error}") + + # 兼容回退:旧接口 :uploadUserImage + legacy_url = f"{self.api_base_url}:uploadUserImage" + legacy_json_data = { "imageInput": { "rawImageBytes": image_base64, "mimeType": mime_type, @@ -449,16 +543,21 @@ class FlowClient: } } - result = await self._make_request( + legacy_result = await self._make_request( method="POST", - url=url, - json_data=json_data, + url=legacy_url, + json_data=legacy_json_data, use_at=True, - at_token=at + at_token=at, + use_media_proxy=True ) - # 返回mediaGenerationId - media_id = result["mediaGenerationId"]["mediaGenerationId"] + media_id = ( + legacy_result.get("mediaGenerationId", {}).get("mediaGenerationId") + or legacy_result.get("media", {}).get("name") + ) + if not media_id: + raise Exception(f"Legacy upload response missing media id: keys={list(legacy_result.keys())}") return media_id # ========== 图片生成 (使用AT) - 同步返回 ========== @@ -554,6 +653,8 @@ class FlowClient: continue else: raise e + finally: + await self._notify_browser_captcha_request_finished(browser_id) # 所有重试都失败 raise last_error @@ -583,7 +684,7 @@ class FlowClient: url = f"{self.api_base_url}/flow/upsampleImage" # 获取 reCAPTCHA token - 使用 IMAGE_GENERATION action - recaptcha_token, _ = await self._get_recaptcha_token(project_id, action="IMAGE_GENERATION") + recaptcha_token, browser_id = await self._get_recaptcha_token(project_id, action="IMAGE_GENERATION") if not recaptcha_token: raise Exception("Failed to obtain reCAPTCHA token") upsample_session_id = session_id or self._generate_session_id() @@ -604,17 +705,20 @@ class FlowClient: } # 4K/2K 放大使用专用超时,因为返回的 base64 数据量很大 - result = await self._make_request( - method="POST", - url=url, - json_data=json_data, - use_at=True, - at_token=at, - timeout=config.upsample_timeout - ) + try: + result = await self._make_request( + method="POST", + url=url, + json_data=json_data, + use_at=True, + at_token=at, + timeout=config.upsample_timeout + ) - # 返回 base64 编码的图片 - return result.get("encodedImage", "") + # 返回 base64 编码的图片 + return result.get("encodedImage", "") + finally: + await self._notify_browser_captcha_request_finished(browser_id) # ========== 视频生成 (使用AT) - 异步返回 ========== @@ -705,6 +809,8 @@ class FlowClient: continue else: raise e + finally: + await self._notify_browser_captcha_request_finished(browser_id) # 所有重试都失败 raise last_error @@ -792,6 +898,8 @@ class FlowClient: continue else: raise e + finally: + await self._notify_browser_captcha_request_finished(browser_id) # 所有重试都失败 raise last_error @@ -886,6 +994,8 @@ class FlowClient: continue else: raise e + finally: + await self._notify_browser_captcha_request_finished(browser_id) # 所有重试都失败 raise last_error @@ -976,6 +1086,8 @@ class FlowClient: continue else: raise e + finally: + await self._notify_browser_captcha_request_finished(browser_id) # 所有重试都失败 raise last_error @@ -1059,6 +1171,8 @@ class FlowClient: continue else: raise e + finally: + await self._notify_browser_captcha_request_finished(browser_id) raise last_error @@ -1149,6 +1263,16 @@ class FlowClient: except Exception: pass + async def _notify_browser_captcha_request_finished(self, browser_id: int = None): + """通知有头浏览器:上游图片/视频请求已结束,可关闭对应打码浏览器。""" + if config.captcha_method == "browser": + try: + from .browser_captcha import BrowserCaptchaService + service = await BrowserCaptchaService.get_instance(self.db) + await service.report_request_finished(browser_id) + except Exception: + pass + def _generate_session_id(self) -> str: """生成sessionId: ;timestamp""" return f";{int(time.time() * 1000)}" @@ -1177,45 +1301,59 @@ class FlowClient: try: from .browser_captcha_personal import BrowserCaptchaService service = await BrowserCaptchaService.get_instance(self.db) - return await service.get_token(project_id, action), None + token = await service.get_token(project_id, action) + fingerprint = service.get_last_fingerprint() if token else None + self._set_request_fingerprint(fingerprint if token else None) + return token, None except RuntimeError as e: # 捕获 Docker 环境或依赖缺失的明确错误 error_msg = str(e) debug_logger.log_error(f"[reCAPTCHA Personal] {error_msg}") print(f"[reCAPTCHA] ❌ 内置浏览器打码失败: {error_msg}") + self._set_request_fingerprint(None) return None, None except ImportError as e: debug_logger.log_error(f"[reCAPTCHA Personal] 导入失败: {str(e)}") print(f"[reCAPTCHA] ❌ nodriver 未安装,请运行: pip install nodriver") + self._set_request_fingerprint(None) return None, None except Exception as e: debug_logger.log_error(f"[reCAPTCHA Personal] 错误: {str(e)}") + self._set_request_fingerprint(None) return None, None # 有头浏览器打码 (playwright) elif captcha_method == "browser": try: from .browser_captcha import BrowserCaptchaService service = await BrowserCaptchaService.get_instance(self.db) - return await service.get_token(project_id, action) + token, browser_id = await service.get_token(project_id, action) + fingerprint = await service.get_fingerprint(browser_id) if token else None + self._set_request_fingerprint(fingerprint if token else None) + return token, browser_id except RuntimeError as e: # 捕获 Docker 环境或依赖缺失的明确错误 error_msg = str(e) debug_logger.log_error(f"[reCAPTCHA Browser] {error_msg}") print(f"[reCAPTCHA] ❌ 有头浏览器打码失败: {error_msg}") + self._set_request_fingerprint(None) return None, None except ImportError as e: debug_logger.log_error(f"[reCAPTCHA Browser] 导入失败: {str(e)}") print(f"[reCAPTCHA] ❌ playwright 未安装,请运行: pip install playwright && python -m playwright install chromium") + self._set_request_fingerprint(None) return None, None except Exception as e: debug_logger.log_error(f"[reCAPTCHA Browser] 错误: {str(e)}") + self._set_request_fingerprint(None) return None, None # API打码服务 elif captcha_method in ["yescaptcha", "capmonster", "ezcaptcha", "capsolver"]: + self._set_request_fingerprint(None) token = await self._get_api_captcha_token(captcha_method, project_id, action) return token, None else: debug_logger.log_info(f"[reCAPTCHA] 未知的打码方式: {captcha_method}") + self._set_request_fingerprint(None) return None, None async def _get_api_captcha_token(self, method: str, project_id: str, action: str = "IMAGE_GENERATION") -> Optional[str]: diff --git a/src/services/generation_handler.py b/src/services/generation_handler.py index 3d41d3c..00ee7a1 100644 --- a/src/services/generation_handler.py +++ b/src/services/generation_handler.py @@ -677,6 +677,8 @@ class GenerationHandler: default_timeout=config.cache_timeout, proxy_manager=proxy_manager ) + self._last_generated_url = None + self._last_generation_assets = None async def check_token_availability(self, is_image: bool, is_video: bool) -> bool: """检查Token可用性 @@ -711,6 +713,12 @@ class GenerationHandler: """ start_time = time.time() token = None + self._last_generated_url = None + self._last_generation_assets = None + + # 防止并发链路复用到上一次请求的指纹上下文 + if hasattr(self.flow_client, "clear_request_fingerprint"): + self.flow_client.clear_request_fingerprint() # 1. 验证模型 if model not in MODEL_CONFIG: @@ -816,24 +824,30 @@ class GenerationHandler: # 7. 记录成功日志 duration = time.time() - start_time + # 日志中保留更完整的 prompt,避免管理页只看到过短内容 + prompt_for_log = prompt if len(prompt) <= 2000 else f"{prompt[:2000]}...(truncated)" # 构建响应数据,包含生成的URL response_data = { "status": "success", "model": model, - "prompt": prompt[:100] + "prompt": prompt_for_log } # 添加生成的URL(如果有) if hasattr(self, '_last_generated_url') and self._last_generated_url: response_data["url"] = self._last_generated_url - # 清除临时存储 - self._last_generated_url = None + if hasattr(self, "_last_generation_assets") and self._last_generation_assets: + response_data["generated_assets"] = self._last_generation_assets + + # 清除临时存储,避免污染后续请求 + self._last_generated_url = None + self._last_generation_assets = None await self._log_request( token.id, f"generate_{generation_type}", - {"model": model, "prompt": prompt[:100], "has_images": images is not None and len(images) > 0}, + {"model": model, "prompt": prompt_for_log, "has_images": images is not None and len(images) > 0}, response_data, 200, duration @@ -851,10 +865,11 @@ class GenerationHandler: # 记录失败日志 duration = time.time() - start_time + prompt_for_log = prompt if len(prompt) <= 2000 else f"{prompt[:2000]}...(truncated)" await self._log_request( token.id if token else None, f"generate_{generation_type if model_config else 'unknown'}", - {"model": model, "prompt": prompt[:100], "has_images": images is not None and len(images) > 0}, + {"model": model, "prompt": prompt_for_log, "has_images": images is not None and len(images) > 0}, {"error": error_msg}, 500, duration @@ -896,7 +911,8 @@ class GenerationHandler: media_id = await self.flow_client.upload_image( token.at, image_bytes, - model_config["aspect_ratio"] + model_config["aspect_ratio"], + project_id=project_id ) image_inputs.append({ "name": media_id, @@ -926,6 +942,10 @@ class GenerationHandler: image_url = media[0]["image"]["generatedImage"]["fifeUrl"] media_id = media[0].get("name") # 用于 upsample + self._last_generation_assets = { + "type": "image", + "origin_image_url": image_url + } # 检查是否需要 upsample upsample_resolution = model_config.get("upsample") @@ -955,8 +975,16 @@ class GenerationHandler: yield self._create_stream_chunk(f"✅ 图片已放大到 {resolution_name}\n") # 缓存放大后的图片 (如果启用) - # 日志统一记录原图URL (放大后的base64数据太大,不适合存储) + # 日志统一记录原图URL + 2K/4K 信息 self._last_generated_url = image_url + self._last_generation_assets = { + "type": "image", + "origin_image_url": image_url, + "upscaled_image": { + "resolution": resolution_name, + "base64": encoded_image + } + } if config.cache_enabled: try: @@ -964,6 +992,8 @@ class GenerationHandler: 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()}/tmp/{cached_filename}" + self._last_generation_assets["upscaled_image"]["local_url"] = local_url + self._last_generation_assets["upscaled_image"]["url"] = local_url if stream: yield self._create_stream_chunk(f"✅ {resolution_name} 图片缓存成功\n") yield self._create_stream_chunk( @@ -983,6 +1013,8 @@ class GenerationHandler: # 缓存未启用或缓存失败,返回 base64 格式 base64_url = f"data:image/jpeg;base64,{encoded_image}" + self._last_generation_assets["upscaled_image"]["local_url"] = None + self._last_generation_assets["upscaled_image"]["url"] = base64_url if stream: yield self._create_stream_chunk( f"", @@ -1040,6 +1072,11 @@ class GenerationHandler: # 返回结果 # 存储URL用于日志记录 self._last_generated_url = local_url + self._last_generation_assets = { + "type": "image", + "origin_image_url": image_url, + "final_image_url": local_url + } if stream: yield self._create_stream_chunk( @@ -1160,7 +1197,7 @@ class GenerationHandler: 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"] + token.at, images[0], model_config["aspect_ratio"], project_id=project_id ) debug_logger.log_info(f"[I2V] 仅上传首帧: {start_media_id}") @@ -1169,10 +1206,10 @@ class GenerationHandler: 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"] + 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"] + token.at, images[1], model_config["aspect_ratio"], project_id=project_id ) debug_logger.log_info(f"[I2V] 上传首尾帧: {start_media_id}, {end_media_id}") @@ -1183,7 +1220,7 @@ class GenerationHandler: for idx, img in enumerate(images): # 上传所有图片,不限制数量 media_id = await self.flow_client.upload_image( - token.at, img, model_config["aspect_ratio"] + token.at, img, model_config["aspect_ratio"], project_id=project_id ) reference_images.append({ "imageUsageType": "IMAGE_USAGE_TYPE_ASSET", @@ -1407,6 +1444,10 @@ class GenerationHandler: # 存储URL用于日志记录 self._last_generated_url = local_url + self._last_generation_assets = { + "type": "video", + "final_video_url": local_url + } # 返回结果 if stream: diff --git a/src/services/proxy_manager.py b/src/services/proxy_manager.py index eaa6535..6ea5cb6 100644 --- a/src/services/proxy_manager.py +++ b/src/services/proxy_manager.py @@ -1,5 +1,6 @@ """Proxy management module""" from typing import Optional +import re from ..core.database import Database from ..core.models import ProxyConfig @@ -9,16 +10,143 @@ class ProxyManager: def __init__(self, db: Database): self.db = db + def _parse_proxy_line(self, line: str) -> Optional[str]: + """将用户输入代理转换为标准 URL 格式。 + + 支持格式: + - http://user:pass@host:port + - https://user:pass@host:port + - socks5://user:pass@host:port + - socks5h://user:pass@host:port + - socks5://host:port:user:pass + - st5 host:port:user:pass + - host:port + - host:port:user:pass + """ + if not line: + return None + + line = line.strip() + if not line: + return None + + # st5 host:port:user:pass + st5_match = re.match(r"^st5\s+(.+)$", line, re.IGNORECASE) + if st5_match: + rest = st5_match.group(1).strip() + if "@" in rest: + return f"socks5://{rest}" + parts = rest.split(":") + if len(parts) >= 4 and parts[1].isdigit(): + host = parts[0] + port = parts[1] + username = parts[2] + password = ":".join(parts[3:]) + return f"socks5://{username}:{password}@{host}:{port}" + return None + + # 协议前缀格式 + if line.startswith(("http://", "https://", "socks5://", "socks5h://")): + # socks5h 统一转 socks5,便于后续处理 + if line.startswith("socks5h://"): + line = "socks5://" + line[len("socks5h://"):] + + # 已是标准 user:pass@host:port(或 host:port) + if "@" in line: + return line + + # 兼容 protocol://host:port:user:pass + try: + protocol_end = line.index("://") + 3 + protocol = line[:protocol_end] + rest = line[protocol_end:] + parts = rest.split(":") + if len(parts) >= 4 and parts[1].isdigit(): + host = parts[0] + port = parts[1] + username = parts[2] + password = ":".join(parts[3:]) + return f"{protocol}{username}:{password}@{host}:{port}" + if len(parts) == 2 and parts[1].isdigit(): + return line + except Exception: + return None + return None + + # 无协议,带 @:默认按 http 处理 + if "@" in line: + return f"http://{line}" + + # 无协议,按冒号数量判断 + parts = line.split(":") + if len(parts) == 2 and parts[1].isdigit(): + # host:port + return f"http://{parts[0]}:{parts[1]}" + + if len(parts) >= 4 and parts[1].isdigit(): + # host:port:user:pass + host = parts[0] + port = parts[1] + username = parts[2] + password = ":".join(parts[3:]) + return f"http://{username}:{password}@{host}:{port}" + + return None + + def normalize_proxy_url(self, proxy_url: Optional[str]) -> Optional[str]: + """标准化代理地址,空值返回 None,非法格式抛 ValueError。""" + if proxy_url is None: + return None + + raw = proxy_url.strip() + if not raw: + return None + + parsed = self._parse_proxy_line(raw) + if not parsed: + raise ValueError( + "代理地址格式错误,支持示例:" + "http://user:pass@host:port / " + "socks5://user:pass@host:port / " + "host:port:user:pass / st5 host:port:user:pass" + ) + return parsed + async def get_proxy_url(self) -> Optional[str]: - """Get proxy URL if enabled, otherwise return None""" + """兼容旧调用:返回请求代理地址""" + return await self.get_request_proxy_url() + + async def get_request_proxy_url(self) -> Optional[str]: + """Get request proxy URL if enabled, otherwise return None""" config = await self.db.get_proxy_config() if config and config.enabled and config.proxy_url: return config.proxy_url return None - async def update_proxy_config(self, enabled: bool, proxy_url: Optional[str]): + async def get_media_proxy_url(self) -> Optional[str]: + """Get media upload/download proxy URL, fallback to request proxy""" + config = await self.db.get_proxy_config() + if config and config.media_proxy_enabled and config.media_proxy_url: + return config.media_proxy_url + return await self.get_request_proxy_url() + + async def update_proxy_config( + self, + enabled: bool, + proxy_url: Optional[str], + media_proxy_enabled: Optional[bool] = None, + media_proxy_url: Optional[str] = None + ): """Update proxy configuration""" - await self.db.update_proxy_config(enabled, proxy_url) + normalized_proxy_url = self.normalize_proxy_url(proxy_url) + normalized_media_proxy_url = self.normalize_proxy_url(media_proxy_url) + + await self.db.update_proxy_config( + enabled=enabled, + proxy_url=normalized_proxy_url, + media_proxy_enabled=media_proxy_enabled, + media_proxy_url=normalized_media_proxy_url + ) async def get_proxy_config(self) -> ProxyConfig: """Get proxy configuration""" diff --git a/static/manage.html b/static/manage.html index 46e9d80..5b698e2 100644 --- a/static/manage.html +++ b/static/manage.html @@ -201,16 +201,32 @@
支持 HTTP 和 SOCKS5 代理
启用后,图片上传与图片/视频缓存下载可单独走该代理
+支持 HTTP 和 SOCKS5 代理
+测试目标:https://labs.google/
文件URL:
${responseBody.url}文件URL:
${item.url}${JSON.stringify(responseBody,null,2)}${JSON.stringify(responseBody,null,2)}无响应数据
${log.response_body||'无'}${responseBody.error.message||responseBody.error||'未知错误'}
${log.response_body}${log.response_body}${label}: ${safeUrl}
`; + } + + function isVideoUrl(url){ + if(!url) return false; + const text=String(url).toLowerCase(); + if(text.startsWith('data:video/')) return true; + return /(\.mp4|\.webm|\.mov|\.m3u8)(\?|$)/.test(text)||text.includes('/video/'); + } + + function isImageUrl(url){ + if(!url) return false; + const text=String(url).toLowerCase(); + if(text.startsWith('data:image/')) return true; + return /(\.png|\.jpg|\.jpeg|\.webp|\.gif|\.avif|\.bmp)(\?|$)/.test(text)||text.includes('/image/'); + } + + function renderMediaPreview(label,url,withUrl=true){ + if(!url) return ''; + const safeUrl=escapeLogHtml(url); + let previewHtml=''; + if(isVideoUrl(url)){ + previewHtml=``; + }else if(isImageUrl(url)){ + previewHtml=`${escapeLogHtml(label)}
${withUrl?renderLogLink('URL',url):''}${previewHtml}${escapeLogHtml(requestBodyObj?JSON.stringify(requestBodyObj,null,2):(log.request_body||'无'))}放大分辨率: ${escapeLogHtml(upResolution)}
`; + if(upPreviewUrl){ + assetsHtml+=renderMediaPreview(`${upResolution}结果`,upPreviewUrl,false); + } + if(up.base64){ + const preview=String(up.base64).length>600?`${String(up.base64).slice(0,600)}...`:String(up.base64); + assetsHtml+=`Base64长度: ${String(up.base64).length}
${escapeLogHtml(preview)}无资产详情
'}${escapeLogHtml(JSON.stringify(responseBodyObj,null,2))}${escapeLogHtml(log.response_body||'无')}${escapeLogHtml(responseBodyObj.error.message||responseBodyObj.error||'未知错误')}
${escapeLogHtml(responseBodyObj?JSON.stringify(responseBodyObj,null,2):(log.response_body||'无'))}