TheSmallHanCat commited on
Commit
aec69d2
·
2 Parent(s): 49d8e1d ad17d67

Merge branch 'main' of https://github.com/TheSmallHanCat/flow2api

Browse files
.gitignore CHANGED
@@ -54,3 +54,7 @@ logs.txt
54
  *.cache
55
 
56
  browser_data
 
 
 
 
 
54
  *.cache
55
 
56
  browser_data
57
+
58
+ data
59
+ config/setting.toml
60
+ config/setting_warp.toml
Dockerfile CHANGED
@@ -2,6 +2,10 @@ FROM python:3.11-slim
2
 
3
  WORKDIR /app
4
 
 
 
 
 
5
  # 安装 Playwright 所需的系统依赖
6
  RUN apt-get update && apt-get install -y \
7
  libnss3 \
@@ -21,9 +25,14 @@ RUN apt-get update && apt-get install -y \
21
  libcairo2 \
22
  && rm -rf /var/lib/apt/lists/*
23
 
24
- # 安装 Python 依赖
25
  COPY requirements.txt .
26
- RUN pip install --no-cache-dir -r requirements.txt
 
 
 
 
 
27
 
28
  # 安装 Playwright 浏览器
29
  RUN playwright install chromium
 
2
 
3
  WORKDIR /app
4
 
5
+ # 使用清华镜像源加速 apt (Debian bookworm)
6
+ RUN sed -i 's|deb.debian.org|mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list.d/debian.sources \
7
+ && sed -i 's|security.debian.org|mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list.d/debian.sources
8
+
9
  # 安装 Playwright 所需的系统依赖
10
  RUN apt-get update && apt-get install -y \
11
  libnss3 \
 
25
  libcairo2 \
26
  && rm -rf /var/lib/apt/lists/*
27
 
28
+ # 安装 Python 依赖(使用清华 PyPI 镜像)
29
  COPY requirements.txt .
30
+ RUN pip install --no-cache-dir -r requirements.txt \
31
+ -i https://pypi.tuna.tsinghua.edu.cn/simple/ \
32
+ --trusted-host pypi.tuna.tsinghua.edu.cn
33
+
34
+ # 设置 Playwright 下载镜像(使用 npmmirror)
35
+ ENV PLAYWRIGHT_DOWNLOAD_HOST=https://registry.npmmirror.com/-/binary/playwright
36
 
37
  # 安装 Playwright 浏览器
38
  RUN playwright install chromium
README.md CHANGED
@@ -16,7 +16,7 @@
16
  - 🎨 **文生图** / **图生图**
17
  - 🎬 **文生视频** / **图生视频**
18
  - 🎞️ **首尾帧视频**
19
- - 🔄 **AT自动刷新**
20
  - 📊 **余额显示** - 实时查询和显示 VideoFX Credits
21
  - 🚀 **负载均衡** - 多 Token 轮询和并发控制
22
  - 🌐 **代理支持** - 支持 HTTP/SOCKS5 代理
@@ -119,7 +119,11 @@ python main.py
119
  | `veo_2_0_t2v_landscape` | 文生视频 | 横屏 |
120
 
121
  #### 首尾帧模型 (I2V - Image to Video)
122
- 📸 **支持1-2张图片:首尾帧**
 
 
 
 
123
 
124
  | 模型名称 | 说明| 尺寸 |
125
  |---------|---------|--------|
 
16
  - 🎨 **文生图** / **图生图**
17
  - 🎬 **文生视频** / **图生视频**
18
  - 🎞️ **首尾帧视频**
19
+ - 🔄 **AT/ST自动刷新** - AT 过期自动刷新,ST 过期时自动通过浏览器更新(personal 模式)
20
  - 📊 **余额显示** - 实时查询和显示 VideoFX Credits
21
  - 🚀 **负载均衡** - 多 Token 轮询和并发控制
22
  - 🌐 **代理支持** - 支持 HTTP/SOCKS5 代理
 
119
  | `veo_2_0_t2v_landscape` | 文生视频 | 横屏 |
120
 
121
  #### 首尾帧模型 (I2V - Image to Video)
122
+ 📸 **支持1-2张图片:1张作为帧,2张作为首尾帧**
123
+
124
+ > 💡 **自动适配**:系统会根据图片数量自动选择对应的 model_key
125
+ > - **单帧模式**(1张图):使用首帧生成视频
126
+ > - **双帧模式**(2张图):使用首帧+尾帧生成过渡视频
127
 
128
  | 模型名称 | 说明| 尺寸 |
129
  |---------|---------|--------|
config/setting.toml CHANGED
@@ -12,7 +12,7 @@ max_poll_attempts = 200
12
 
13
  [server]
14
  host = "0.0.0.0"
15
- port = 8000
16
 
17
  [debug]
18
  enabled = false
@@ -21,8 +21,8 @@ log_responses = true
21
  mask_token = true
22
 
23
  [proxy]
24
- proxy_enabled = false
25
- proxy_url = ""
26
 
27
  [generation]
28
  image_timeout = 300
 
12
 
13
  [server]
14
  host = "0.0.0.0"
15
+ port = 18282
16
 
17
  [debug]
18
  enabled = false
 
21
  mask_token = true
22
 
23
  [proxy]
24
+ proxy_enabled = true
25
+ proxy_url = "http://localhost:7897"
26
 
27
  [generation]
28
  image_timeout = 300
config/setting_example.toml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [global]
2
+ api_key = "han1234"
3
+ admin_username = "admin"
4
+ admin_password = "admin"
5
+
6
+ [flow]
7
+ labs_base_url = "https://labs.google/fx/api"
8
+ api_base_url = "https://aisandbox-pa.googleapis.com/v1"
9
+ timeout = 120
10
+ poll_interval = 3.0
11
+ max_poll_attempts = 200
12
+
13
+ [server]
14
+ host = "0.0.0.0"
15
+ port = 18282
16
+
17
+ [debug]
18
+ enabled = false
19
+ log_requests = true
20
+ log_responses = true
21
+ mask_token = true
22
+
23
+ [proxy]
24
+ proxy_enabled = true
25
+ proxy_url = "http://localhost:7897"
26
+
27
+ [generation]
28
+ image_timeout = 300
29
+ video_timeout = 1500
30
+
31
+ [admin]
32
+ error_ban_threshold = 3
33
+
34
+ [cache]
35
+ enabled = false
36
+ timeout = 7200 # 缓存超时时间(秒), 默认2小时
37
+ base_url = "" # 缓存文件访问的基础URL, 留空则使用服务器地址
38
+
39
+ [captcha]
40
+ captcha_method = "browser" # 打码方式: yescaptcha 或 browser
41
+ yescaptcha_api_key = "" # YesCaptcha API密钥
42
+ yescaptcha_base_url = "https://api.yescaptcha.com"
config/setting_warp_example.toml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [global]
2
+ api_key = "han1234"
3
+ admin_username = "admin"
4
+ admin_password = "admin"
5
+
6
+ [flow]
7
+ labs_base_url = "https://labs.google/fx/api"
8
+ api_base_url = "https://aisandbox-pa.googleapis.com/v1"
9
+ timeout = 120
10
+ poll_interval = 3.0
11
+ max_poll_attempts = 200
12
+
13
+ [server]
14
+ host = "0.0.0.0"
15
+ port = 8000
16
+
17
+ [debug]
18
+ enabled = false
19
+ log_requests = true
20
+ log_responses = true
21
+ mask_token = true
22
+
23
+ [proxy]
24
+ proxy_enabled = true
25
+ proxy_url = "socks5://warp:1080"
26
+
27
+ [generation]
28
+ image_timeout = 300
29
+ video_timeout = 1500
30
+
31
+ [admin]
32
+ error_ban_threshold = 3
33
+
34
+ [cache]
35
+ enabled = false
36
+ timeout = 7200 # 缓存超时时间(秒), 默认2小时
37
+ base_url = "" # 缓存文件访问的基础URL, 留空则使用服务器地址
38
+
39
+ [captcha]
40
+ captcha_method = "browser" # 打码方式: yescaptcha 或 browser
41
+ yescaptcha_api_key = "" # YesCaptcha API密钥
42
+ yescaptcha_base_url = "https://api.yescaptcha.com"
docker-compose.local.yml ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+
3
+ services:
4
+ flow2api:
5
+ build:
6
+ context: .
7
+ dockerfile: Dockerfile
8
+ image: flow2api:local
9
+ container_name: flow2api
10
+ ports:
11
+ - "38000:8000"
12
+ volumes:
13
+ - ./data:/app/data
14
+ - ./config/setting.toml:/app/config/setting.toml
15
+ environment:
16
+ - PYTHONUNBUFFERED=1
17
+ restart: unless-stopped
docker-compose.proxy.yml CHANGED
@@ -5,7 +5,7 @@ services:
5
  image: thesmallhancat/flow2api:latest
6
  container_name: flow2api
7
  ports:
8
- - "8000:8000"
9
  volumes:
10
  - ./data:/app/data
11
  - ./config/setting_warp.toml:/app/config/setting.toml
@@ -22,7 +22,7 @@ services:
22
  devices:
23
  - /dev/net/tun:/dev/net/tun
24
  ports:
25
- - "1080:1080"
26
  environment:
27
  - WARP_SLEEP=2
28
  cap_add:
@@ -33,4 +33,4 @@ services:
33
  - net.ipv6.conf.all.disable_ipv6=0
34
  - net.ipv4.conf.all.src_valid_mark=1
35
  volumes:
36
- - ./data:/var/lib/cloudflare-warp
 
5
  image: thesmallhancat/flow2api:latest
6
  container_name: flow2api
7
  ports:
8
+ - "38000:8000"
9
  volumes:
10
  - ./data:/app/data
11
  - ./config/setting_warp.toml:/app/config/setting.toml
 
22
  devices:
23
  - /dev/net/tun:/dev/net/tun
24
  ports:
25
+ - "31080:1080"
26
  environment:
27
  - WARP_SLEEP=2
28
  cap_add:
 
33
  - net.ipv6.conf.all.disable_ipv6=0
34
  - net.ipv4.conf.all.src_valid_mark=1
35
  volumes:
36
+ - ./data:/var/lib/cloudflare-warp
docker-compose.yml CHANGED
@@ -5,7 +5,7 @@ services:
5
  image: thesmallhancat/flow2api:latest
6
  container_name: flow2api
7
  ports:
8
- - "8000:8000"
9
  volumes:
10
  - ./data:/app/data
11
  - ./config/setting.toml:/app/config/setting.toml
 
5
  image: thesmallhancat/flow2api:latest
6
  container_name: flow2api
7
  ports:
8
+ - "38000:8000"
9
  volumes:
10
  - ./data:/app/data
11
  - ./config/setting.toml:/app/config/setting.toml
requirements.txt CHANGED
@@ -8,3 +8,4 @@ bcrypt==4.2.1
8
  python-multipart==0.0.20
9
  python-dateutil==2.8.2
10
  playwright==1.53.0
 
 
8
  python-multipart==0.0.20
9
  python-dateutil==2.8.2
10
  playwright==1.53.0
11
+ nodriver>=0.48.0
src/api/admin.py CHANGED
@@ -354,17 +354,32 @@ async def refresh_at(
354
  token_id: int,
355
  token: str = Depends(verify_admin_token)
356
  ):
357
- """手动刷新Token的AT (使用ST转换) 🆕"""
 
 
 
 
 
 
 
 
358
  try:
359
- # 调用token_manager的内部刷新方法
360
  success = await token_manager._refresh_at(token_id)
361
 
362
  if success:
363
  # 获取更新后的token信息
364
  updated_token = await token_manager.get_token(token_id)
 
 
 
 
 
 
 
365
  return {
366
  "success": True,
367
- "message": "AT刷新成功",
368
  "token": {
369
  "id": updated_token.id,
370
  "email": updated_token.email,
@@ -372,8 +387,17 @@ async def refresh_at(
372
  }
373
  }
374
  else:
375
- raise HTTPException(status_code=500, detail="AT刷新失败")
 
 
 
 
 
 
 
 
376
  except Exception as e:
 
377
  raise HTTPException(status_code=500, detail=f"刷新AT失败: {str(e)}")
378
 
379
 
 
354
  token_id: int,
355
  token: str = Depends(verify_admin_token)
356
  ):
357
+ """手动刷新Token的AT (使用ST转换) 🆕
358
+
359
+ 如果 AT 刷新失败且处于 personal 模式,会自动尝试通过浏览器刷新 ST
360
+ """
361
+ from ..core.logger import debug_logger
362
+ from ..core.config import config
363
+
364
+ debug_logger.log_info(f"[API] 手动刷新 AT 请求: token_id={token_id}, captcha_method={config.captcha_method}")
365
+
366
  try:
367
+ # 调用token_manager的内部刷新方法(包含 ST 自动刷新逻辑)
368
  success = await token_manager._refresh_at(token_id)
369
 
370
  if success:
371
  # 获取更新后的token信息
372
  updated_token = await token_manager.get_token(token_id)
373
+
374
+ message = "AT刷新成功"
375
+ if config.captcha_method == "personal":
376
+ message += "(支持ST自动刷新)"
377
+
378
+ debug_logger.log_info(f"[API] AT 刷新成功: token_id={token_id}")
379
+
380
  return {
381
  "success": True,
382
+ "message": message,
383
  "token": {
384
  "id": updated_token.id,
385
  "email": updated_token.email,
 
387
  }
388
  }
389
  else:
390
+ debug_logger.log_error(f"[API] AT 刷新失败: token_id={token_id}")
391
+
392
+ error_detail = "AT刷新失败"
393
+ if config.captcha_method != "personal":
394
+ error_detail += f"(当前打码模式: {config.captcha_method},ST自动刷新仅在 personal 模式下可用)"
395
+
396
+ raise HTTPException(status_code=500, detail=error_detail)
397
+ except HTTPException:
398
+ raise
399
  except Exception as e:
400
+ debug_logger.log_error(f"[API] 刷新AT异常: {str(e)}")
401
  raise HTTPException(status_code=500, detail=f"刷新AT失败: {str(e)}")
402
 
403
 
src/api/routes.py CHANGED
@@ -111,7 +111,7 @@ async def create_chat_completion(
111
  if item.get("type") == "text":
112
  prompt = item.get("text", "")
113
  elif item.get("type") == "image_url":
114
- # Extract base64 image
115
  image_url = item.get("image_url", {}).get("url", "")
116
  if image_url.startswith("data:image"):
117
  # Parse base64
@@ -120,6 +120,18 @@ async def create_chat_completion(
120
  image_base64 = match.group(1)
121
  image_bytes = base64.b64decode(image_base64)
122
  images.append(image_bytes)
 
 
 
 
 
 
 
 
 
 
 
 
123
 
124
  # Fallback to deprecated image parameter
125
  if request.image and not images:
 
111
  if item.get("type") == "text":
112
  prompt = item.get("text", "")
113
  elif item.get("type") == "image_url":
114
+ # Extract image from URL or base64
115
  image_url = item.get("image_url", {}).get("url", "")
116
  if image_url.startswith("data:image"):
117
  # Parse base64
 
120
  image_base64 = match.group(1)
121
  image_bytes = base64.b64decode(image_base64)
122
  images.append(image_bytes)
123
+ elif image_url.startswith("http://") or image_url.startswith("https://"):
124
+ # Download remote image URL
125
+ debug_logger.log_info(f"[IMAGE_URL] 下载远程图片: {image_url}")
126
+ try:
127
+ downloaded_bytes = await retrieve_image_data(image_url)
128
+ if downloaded_bytes and len(downloaded_bytes) > 0:
129
+ images.append(downloaded_bytes)
130
+ debug_logger.log_info(f"[IMAGE_URL] ✅ 远程图片下载成功: {len(downloaded_bytes)} 字节")
131
+ else:
132
+ debug_logger.log_warning(f"[IMAGE_URL] ⚠️ 远程图片下载失败或为空: {image_url}")
133
+ except Exception as e:
134
+ debug_logger.log_error(f"[IMAGE_URL] ❌ 远程图片下载异常: {str(e)}")
135
 
136
  # Fallback to deprecated image parameter
137
  if request.image and not images:
src/main.py CHANGED
@@ -68,6 +68,7 @@ async def lifespan(app: FastAPI):
68
 
69
  # Load captcha configuration from database
70
  captcha_config = await db.get_captcha_config()
 
71
  config.set_captcha_method(captcha_config.captcha_method)
72
  config.set_yescaptcha_api_key(captcha_config.yescaptcha_api_key)
73
  config.set_yescaptcha_base_url(captcha_config.yescaptcha_base_url)
@@ -83,8 +84,24 @@ async def lifespan(app: FastAPI):
83
  if captcha_config.captcha_method == "personal":
84
  from .services.browser_captcha_personal import BrowserCaptchaService
85
  browser_service = await BrowserCaptchaService.get_instance(db)
86
- await browser_service.open_login_window()
87
- print("✓ Browser captcha service initialized (webui mode)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  elif captcha_config.captcha_method == "browser":
89
  from .services.browser_captcha import BrowserCaptchaService
90
  browser_service = await BrowserCaptchaService.get_instance(db)
@@ -92,6 +109,7 @@ async def lifespan(app: FastAPI):
92
 
93
  # Initialize concurrency manager
94
  tokens = await token_manager.get_all_tokens()
 
95
  await concurrency_manager.initialize(tokens)
96
 
97
  # Start file cache cleanup task
@@ -141,7 +159,7 @@ async def lifespan(app: FastAPI):
141
  # Initialize components
142
  db = Database()
143
  proxy_manager = ProxyManager(db)
144
- flow_client = FlowClient(proxy_manager)
145
  token_manager = TokenManager(db, flow_client)
146
  concurrency_manager = ConcurrencyManager()
147
  load_balancer = LoadBalancer(token_manager, concurrency_manager)
 
68
 
69
  # Load captcha configuration from database
70
  captcha_config = await db.get_captcha_config()
71
+
72
  config.set_captcha_method(captcha_config.captcha_method)
73
  config.set_yescaptcha_api_key(captcha_config.yescaptcha_api_key)
74
  config.set_yescaptcha_base_url(captcha_config.yescaptcha_base_url)
 
84
  if captcha_config.captcha_method == "personal":
85
  from .services.browser_captcha_personal import BrowserCaptchaService
86
  browser_service = await BrowserCaptchaService.get_instance(db)
87
+ print("✓ Browser captcha service initialized (nodriver mode)")
88
+
89
+ # 启动常驻模式:从第一个可用token获取project_id
90
+ tokens = await token_manager.get_all_tokens()
91
+ resident_project_id = None
92
+ for t in tokens:
93
+ if t.current_project_id and t.is_active:
94
+ resident_project_id = t.current_project_id
95
+ break
96
+
97
+ if resident_project_id:
98
+ # 直接启动常驻模式(会自动导航到项目页面,cookie已持久化)
99
+ await browser_service.start_resident_mode(resident_project_id)
100
+ print(f"✓ Browser captcha resident mode started (project: {resident_project_id[:8]}...)")
101
+ else:
102
+ # 没有可用的project_id时,打开登录窗口供用户手动操作
103
+ await browser_service.open_login_window()
104
+ print("⚠ No active token with project_id found, opened login window for manual setup")
105
  elif captcha_config.captcha_method == "browser":
106
  from .services.browser_captcha import BrowserCaptchaService
107
  browser_service = await BrowserCaptchaService.get_instance(db)
 
109
 
110
  # Initialize concurrency manager
111
  tokens = await token_manager.get_all_tokens()
112
+
113
  await concurrency_manager.initialize(tokens)
114
 
115
  # Start file cache cleanup task
 
159
  # Initialize components
160
  db = Database()
161
  proxy_manager = ProxyManager(db)
162
+ flow_client = FlowClient(proxy_manager, db)
163
  token_manager = TokenManager(db, flow_client)
164
  concurrency_manager = ConcurrencyManager()
165
  load_balancer = LoadBalancer(token_manager, concurrency_manager)
src/services/browser_captcha_personal.py CHANGED
@@ -1,197 +1,677 @@
 
 
 
 
 
1
  import asyncio
2
  import time
3
- import re
4
  import os
5
- from typing import Optional, Dict
6
- from playwright.async_api import async_playwright, BrowserContext, Page
 
7
 
8
  from ..core.logger import debug_logger
9
 
10
- # ... (保持原来的 parse_proxy_url 和 validate_browser_proxy_url 函数不变) ...
11
- def parse_proxy_url(proxy_url: str) -> Optional[Dict[str, str]]:
12
- """解析代理URL,分离协议、主机、端口、认证信息"""
13
- proxy_pattern = r'^(socks5|http|https)://(?:([^:]+):([^@]+)@)?([^:]+):(\d+)$'
14
- match = re.match(proxy_pattern, proxy_url)
15
- if match:
16
- protocol, username, password, host, port = match.groups()
17
- proxy_config = {'server': f'{protocol}://{host}:{port}'}
18
- if username and password:
19
- proxy_config['username'] = username
20
- proxy_config['password'] = password
21
- return proxy_config
22
- return None
23
 
24
  class BrowserCaptchaService:
25
- """浏览器自动化获取 reCAPTCHA token(持久化有头模式)"""
 
 
 
 
 
26
 
27
  _instance: Optional['BrowserCaptchaService'] = None
28
  _lock = asyncio.Lock()
29
 
30
  def __init__(self, db=None):
31
  """初始化服务"""
32
- # === 修改点 1: 设置为有头模式 ===
33
- self.headless = False
34
- self.playwright = None
35
- # 注意: 持久化模式下,我们操作的是 context 而不是 browser
36
- self.context: Optional[BrowserContext] = None
37
  self._initialized = False
38
  self.website_key = "6LdsFiUsAAAAAIjVDZcuLhaHiDn5nnHVXVRQGeMV"
39
  self.db = db
40
-
41
- # === 修改点 2: 指定本地数据存储目录 ===
42
- # 这会在脚本运行目录下生成 browser_data 文件夹,用于保存你的登录状态
43
  self.user_data_dir = os.path.join(os.getcwd(), "browser_data")
 
 
 
 
 
 
 
 
 
 
44
 
45
  @classmethod
46
  async def get_instance(cls, db=None) -> 'BrowserCaptchaService':
 
47
  if cls._instance is None:
48
  async with cls._lock:
49
  if cls._instance is None:
50
  cls._instance = cls(db)
51
- # 首次调用不强制初始化,等待 get_token 时懒加载,或者可以在这里await
52
  return cls._instance
53
 
54
  async def initialize(self):
55
- """初始化持久化浏览器上下文"""
56
- if self._initialized and self.context:
57
- return
 
 
 
 
 
 
 
 
 
 
58
 
59
  try:
60
- proxy_url = None
61
- if self.db:
62
- captcha_config = await self.db.get_captcha_config()
63
- if captcha_config.browser_proxy_enabled and captcha_config.browser_proxy_url:
64
- proxy_url = captcha_config.browser_proxy_url
65
-
66
- debug_logger.log_info(f"[BrowserCaptcha] 正在启动浏览器 (用户数据目录: {self.user_data_dir})...")
67
- self.playwright = await async_playwright().start()
68
-
69
- # 配置��动参数
70
- launch_options = {
71
- 'headless': self.headless,
72
- 'user_data_dir': self.user_data_dir, # 指定数据目录
73
- 'viewport': {'width': 1280, 'height': 720}, # 设置默认窗口大小
74
- 'args': [
75
- '--disable-blink-features=AutomationControlled',
76
- '--disable-infobars',
77
  '--no-sandbox',
 
78
  '--disable-setuid-sandbox',
 
 
 
79
  ]
80
- }
81
-
82
- # 代理配置
83
- if proxy_url:
84
- proxy_config = parse_proxy_url(proxy_url)
85
- if proxy_config:
86
- launch_options['proxy'] = proxy_config
87
- debug_logger.log_info(f"[BrowserCaptcha] 使用代理: {proxy_config['server']}")
88
-
89
- # === 修改点 3: 使用 launch_persistent_context ===
90
- # 这会启动一个带有状态的浏览器窗口
91
- self.context = await self.playwright.chromium.launch_persistent_context(**launch_options)
92
-
93
- # 设置默认超时
94
- self.context.set_default_timeout(30000)
95
 
96
  self._initialized = True
97
- debug_logger.log_info(f"[BrowserCaptcha] ✅ 浏览器已启动 (Profile: {self.user_data_dir})")
98
-
99
  except Exception as e:
100
  debug_logger.log_error(f"[BrowserCaptcha] ❌ 浏览器启动失败: {str(e)}")
101
  raise
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  async def get_token(self, project_id: str) -> Optional[str]:
104
- """获取 reCAPTCHA token"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  # 确保浏览器已启动
106
- if not self._initialized or not self.context:
107
  await self.initialize()
108
 
109
  start_time = time.time()
110
- page: Optional[Page] = None
111
 
112
  try:
113
- # === 修改点 4: 在现有上下文中新建标签页,而不是新建上下文 ===
114
- # 这样可以复用该上下文中已保存的 Cookie (你的登录状态)
115
- page = await self.context.new_page()
116
-
117
  website_url = f"https://labs.google/fx/tools/flow/project/{project_id}"
118
- debug_logger.log_info(f"[BrowserCaptcha] 访问页面: {website_url}")
119
 
120
- # 访问页面
121
- try:
122
- await page.goto(website_url, wait_until="domcontentloaded")
123
- except Exception as e:
124
- debug_logger.log_warning(f"[BrowserCaptcha] 页面加载警告: {str(e)}")
125
-
126
- # --- 关键点:如果需要人工介入 ---
127
- # 你可以在这里加入一段逻辑,如果是第一次运行,或者检测到未登录,
128
- # 可以暂停脚本,等你手动操作完再继续。
129
- # 例如: await asyncio.sleep(30)
130
-
131
- # ... (中间注入脚本和执行 reCAPTCHA 的代码逻辑与原版完全一致,此处省略以节省篇幅) ...
132
- # ... 请将原代码中从 "检查并注入 reCAPTCHA v3 脚本" 到 token 获取部分的代码复制到这里 ...
133
-
134
- # 这里为了演示,简写注入逻辑(请保留你原有的完整注入逻辑):
135
- script_loaded = await page.evaluate("() => { return !!(window.grecaptcha && window.grecaptcha.execute); }")
136
- if not script_loaded:
137
- await page.evaluate(f"""
138
- () => {{
139
- const script = document.createElement('script');
140
- script.src = 'https://www.google.com/recaptcha/api.js?render={self.website_key}';
141
- script.async = true; script.defer = true;
142
- document.head.appendChild(script);
143
- }}
144
- """)
145
- # 等待加载... (保留你原有的等待循环)
146
- await page.wait_for_timeout(2000)
147
-
148
- # 执行获取 Token (保留你原有的 execute 逻辑)
149
- token = await page.evaluate(f"""
150
- async () => {{
151
- try {{
152
- return await window.grecaptcha.execute('{self.website_key}', {{ action: 'FLOW_GENERATION' }});
153
- }} catch (e) {{ return null; }}
154
- }}
155
- """)
156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  if token:
158
- debug_logger.log_info(f"[BrowserCaptcha] ✅ Token获取成功")
159
  return token
160
  else:
161
- debug_logger.log_error("[BrowserCaptcha] Token获取失败")
162
  return None
163
 
164
  except Exception as e:
165
- debug_logger.log_error(f"[BrowserCaptcha] 异常: {str(e)}")
166
  return None
167
  finally:
168
- # === 修改点 5: 只关闭 Page (标签页),不关闭 Context (浏览器窗口) ===
169
- if page:
170
  try:
171
- await page.close()
172
- except:
173
  pass
174
 
175
  async def close(self):
176
- """完全关闭浏览器(清理资源时调用)"""
 
 
 
177
  try:
178
- if self.context:
179
- await self.context.close() # 这会关闭整个浏览器窗口
180
- self.context = None
181
-
182
- if self.playwright:
183
- await self.playwright.stop()
184
- self.playwright = None
185
-
186
  self._initialized = False
187
- debug_logger.log_info("[BrowserCaptcha] 浏览器服务已关闭")
 
188
  except Exception as e:
189
- debug_logger.log_error(f"[BrowserCaptcha] 关闭异常: {str(e)}")
190
 
191
- # 增加一个辅助方法,用于手动登录
192
  async def open_login_window(self):
193
- """调用此方法打开一个永久窗口供登录Google"""
194
  await self.initialize()
195
- page = await self.context.new_page()
196
- await page.goto("https://accounts.google.com/")
197
- print("请在打开的浏览器中登录账号。登录完成后,无需关闭浏览器,脚本下次运行时会自动使用此状态。")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 浏览器自动化获取 reCAPTCHA token
3
+ 使用 nodriver (undetected-chromedriver 继任者) 实现反检测浏览器
4
+ 支持常驻模式:为每个 project_id 自动创建常驻标签页,即时生成 token
5
+ """
6
  import asyncio
7
  import time
 
8
  import os
9
+ from typing import Optional
10
+
11
+ import nodriver as uc
12
 
13
  from ..core.logger import debug_logger
14
 
15
+
16
+ class ResidentTabInfo:
17
+ """常驻标签页信息结构"""
18
+ def __init__(self, tab, project_id: str):
19
+ self.tab = tab
20
+ self.project_id = project_id
21
+ self.recaptcha_ready = False
22
+ self.created_at = time.time()
23
+
 
 
 
 
24
 
25
  class BrowserCaptchaService:
26
+ """浏览器自动化获取 reCAPTCHA token(nodriver 有头模式)
27
+
28
+ 支持两种模式:
29
+ 1. 常驻模式 (Resident Mode): 为每个 project_id 保持常驻标签页,即时生成 token
30
+ 2. 传统模式 (Legacy Mode): 每次请求创建新标签页 (fallback)
31
+ """
32
 
33
  _instance: Optional['BrowserCaptchaService'] = None
34
  _lock = asyncio.Lock()
35
 
36
  def __init__(self, db=None):
37
  """初始化服务"""
38
+ self.headless = False # nodriver 有头模式
39
+ self.browser = None
 
 
 
40
  self._initialized = False
41
  self.website_key = "6LdsFiUsAAAAAIjVDZcuLhaHiDn5nnHVXVRQGeMV"
42
  self.db = db
43
+ # 持久化 profile 目录
 
 
44
  self.user_data_dir = os.path.join(os.getcwd(), "browser_data")
45
+
46
+ # 常驻模式相关属性 (支持多 project_id)
47
+ self._resident_tabs: dict[str, 'ResidentTabInfo'] = {} # project_id -> 常驻标签页信息
48
+ self._resident_lock = asyncio.Lock() # 保护常驻标签页操作
49
+
50
+ # 兼容旧 API(保留 single resident 属性作为别名)
51
+ self.resident_project_id: Optional[str] = None # 向后兼容
52
+ self.resident_tab = None # 向后兼容
53
+ self._running = False # 向后兼容
54
+ self._recaptcha_ready = False # 向后兼容
55
 
56
  @classmethod
57
  async def get_instance(cls, db=None) -> 'BrowserCaptchaService':
58
+ """获取单例实例"""
59
  if cls._instance is None:
60
  async with cls._lock:
61
  if cls._instance is None:
62
  cls._instance = cls(db)
 
63
  return cls._instance
64
 
65
  async def initialize(self):
66
+ """初始化 nodriver 浏览器"""
67
+ if self._initialized and self.browser:
68
+ # 检查浏览器是否仍然存活
69
+ try:
70
+ # 尝试获取浏览器信息验证存活
71
+ if self.browser.stopped:
72
+ debug_logger.log_warning("[BrowserCaptcha] 浏览器已停止,重新初始化...")
73
+ self._initialized = False
74
+ else:
75
+ return
76
+ except Exception:
77
+ debug_logger.log_warning("[BrowserCaptcha] 浏览器无响应,重新初始化...")
78
+ self._initialized = False
79
 
80
  try:
81
+ debug_logger.log_info(f"[BrowserCaptcha] 正在启动 nodriver 浏览器 (用户数据目录: {self.user_data_dir})...")
82
+
83
+ # 确保 user_data_dir 存在
84
+ os.makedirs(self.user_data_dir, exist_ok=True)
85
+
86
+ # 启动 nodriver 浏览器
87
+ self.browser = await uc.start(
88
+ headless=self.headless,
89
+ user_data_dir=self.user_data_dir,
90
+ sandbox=False, # nodriver 需要此参数来禁用 sandbox
91
+ browser_args=[
 
 
 
 
 
 
92
  '--no-sandbox',
93
+ '--disable-dev-shm-usage',
94
  '--disable-setuid-sandbox',
95
+ '--disable-gpu',
96
+ '--window-size=1280,720',
97
+ '--profile-directory=Default', # 跳过 Profile 选择器页面
98
  ]
99
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
  self._initialized = True
102
+ debug_logger.log_info(f"[BrowserCaptcha] ✅ nodriver 浏览器已启动 (Profile: {self.user_data_dir})")
103
+
104
  except Exception as e:
105
  debug_logger.log_error(f"[BrowserCaptcha] ❌ 浏览器启动失败: {str(e)}")
106
  raise
107
 
108
+ # ========== 常驻模式 API ==========
109
+
110
+ async def start_resident_mode(self, project_id: str):
111
+ """启动常驻模式
112
+
113
+ Args:
114
+ project_id: 用于常驻的项目 ID
115
+ """
116
+ if self._running:
117
+ debug_logger.log_warning("[BrowserCaptcha] 常驻模式已在运行")
118
+ return
119
+
120
+ await self.initialize()
121
+
122
+ self.resident_project_id = project_id
123
+ website_url = f"https://labs.google/fx/tools/flow/project/{project_id}"
124
+
125
+ debug_logger.log_info(f"[BrowserCaptcha] 启动常驻模式,访问页面: {website_url}")
126
+
127
+ # 创建一个独立的新标签页(不使用 main_tab,避免被回收)
128
+ self.resident_tab = await self.browser.get(website_url, new_tab=True)
129
+
130
+ debug_logger.log_info("[BrowserCaptcha] 标签页已创建,等待页面加载...")
131
+
132
+ # 等待页面加载完成(带重试机制)
133
+ page_loaded = False
134
+ for retry in range(60):
135
+ try:
136
+ await asyncio.sleep(1)
137
+ ready_state = await self.resident_tab.evaluate("document.readyState")
138
+ debug_logger.log_info(f"[BrowserCaptcha] 页面状态: {ready_state} (重试 {retry + 1}/60)")
139
+ if ready_state == "complete":
140
+ page_loaded = True
141
+ break
142
+ except ConnectionRefusedError as e:
143
+ debug_logger.log_warning(f"[BrowserCaptcha] 标签页连接丢失: {e},尝试重新获取...")
144
+ # 标签页可能已关闭,尝试重新创建
145
+ try:
146
+ self.resident_tab = await self.browser.get(website_url, new_tab=True)
147
+ debug_logger.log_info("[BrowserCaptcha] 已重新创建标签页")
148
+ except Exception as e2:
149
+ debug_logger.log_error(f"[BrowserCaptcha] 重新创建标签页失败: {e2}")
150
+ await asyncio.sleep(2)
151
+ except Exception as e:
152
+ debug_logger.log_warning(f"[BrowserCaptcha] 等待页面异常: {e},重试 {retry + 1}/15...")
153
+ await asyncio.sleep(2)
154
+
155
+ if not page_loaded:
156
+ debug_logger.log_error("[BrowserCaptcha] 页面加载超时,常驻模式启动失败")
157
+ return
158
+
159
+ # 等待 reCAPTCHA 加载
160
+ self._recaptcha_ready = await self._wait_for_recaptcha(self.resident_tab)
161
+
162
+ if not self._recaptcha_ready:
163
+ debug_logger.log_error("[BrowserCaptcha] reCAPTCHA 加载失败,常驻模式启动失败")
164
+ return
165
+
166
+ self._running = True
167
+ debug_logger.log_info(f"[BrowserCaptcha] ✅ 常驻模式已启动 (project: {project_id})")
168
+
169
+ async def stop_resident_mode(self, project_id: Optional[str] = None):
170
+ """停止常驻模式
171
+
172
+ Args:
173
+ project_id: 指定要关闭的 project_id,如果为 None 则关闭所有常驻标签页
174
+ """
175
+ async with self._resident_lock:
176
+ if project_id:
177
+ # 关闭指定的常驻标签页
178
+ await self._close_resident_tab(project_id)
179
+ debug_logger.log_info(f"[BrowserCaptcha] 已关闭 project_id={project_id} 的常驻模式")
180
+ else:
181
+ # 关闭所有常驻标签页
182
+ project_ids = list(self._resident_tabs.keys())
183
+ for pid in project_ids:
184
+ resident_info = self._resident_tabs.pop(pid, None)
185
+ if resident_info and resident_info.tab:
186
+ try:
187
+ await resident_info.tab.close()
188
+ except Exception:
189
+ pass
190
+ debug_logger.log_info(f"[BrowserCaptcha] 已关闭所有常驻标签页 (共 {len(project_ids)} 个)")
191
+
192
+ # 向后兼容:清理旧属性
193
+ if not self._running:
194
+ return
195
+
196
+ self._running = False
197
+ if self.resident_tab:
198
+ try:
199
+ await self.resident_tab.close()
200
+ except Exception:
201
+ pass
202
+ self.resident_tab = None
203
+
204
+ self.resident_project_id = None
205
+ self._recaptcha_ready = False
206
+
207
+ async def _wait_for_recaptcha(self, tab) -> bool:
208
+ """等待 reCAPTCHA 加载
209
+
210
+ Returns:
211
+ True if reCAPTCHA loaded successfully
212
+ """
213
+ debug_logger.log_info("[BrowserCaptcha] 检测 reCAPTCHA...")
214
+
215
+ # 检查 grecaptcha.enterprise.execute
216
+ is_enterprise = await tab.evaluate(
217
+ "typeof grecaptcha !== 'undefined' && typeof grecaptcha.enterprise !== 'undefined' && typeof grecaptcha.enterprise.execute === 'function'"
218
+ )
219
+
220
+ if is_enterprise:
221
+ debug_logger.log_info("[BrowserCaptcha] reCAPTCHA Enterprise 已加载")
222
+ return True
223
+
224
+ # 尝试注入脚本
225
+ debug_logger.log_info("[BrowserCaptcha] 未检测到 reCAPTCHA,注入脚本...")
226
+
227
+ await tab.evaluate(f"""
228
+ (() => {{
229
+ if (document.querySelector('script[src*="recaptcha"]')) return;
230
+ const script = document.createElement('script');
231
+ script.src = 'https://www.google.com/recaptcha/api.js?render={self.website_key}';
232
+ script.async = true;
233
+ document.head.appendChild(script);
234
+ }})()
235
+ """)
236
+
237
+ # 等待脚本加载
238
+ await tab.sleep(3)
239
+
240
+ # 轮询等待 reCAPTCHA 加载
241
+ for i in range(20):
242
+ is_enterprise = await tab.evaluate(
243
+ "typeof grecaptcha !== 'undefined' && typeof grecaptcha.enterprise !== 'undefined' && typeof grecaptcha.enterprise.execute === 'function'"
244
+ )
245
+
246
+ if is_enterprise:
247
+ debug_logger.log_info(f"[BrowserCaptcha] reCAPTCHA Enterprise 已加载(等待了 {i * 0.5} 秒)")
248
+ return True
249
+ await tab.sleep(0.5)
250
+
251
+ debug_logger.log_warning("[BrowserCaptcha] reCAPTCHA 加载超时")
252
+ return False
253
+
254
+ async def _execute_recaptcha_on_tab(self, tab) -> Optional[str]:
255
+ """在指定标签页执行 reCAPTCHA 获取 token
256
+
257
+ Args:
258
+ tab: nodriver 标签页对象
259
+
260
+ Returns:
261
+ reCAPTCHA token 或 None
262
+ """
263
+ # 生成唯一变量名避免冲突
264
+ ts = int(time.time() * 1000)
265
+ token_var = f"_recaptcha_token_{ts}"
266
+ error_var = f"_recaptcha_error_{ts}"
267
+
268
+ execute_script = f"""
269
+ (() => {{
270
+ window.{token_var} = null;
271
+ window.{error_var} = null;
272
+
273
+ try {{
274
+ grecaptcha.enterprise.ready(function() {{
275
+ grecaptcha.enterprise.execute('{self.website_key}', {{action: 'FLOW_GENERATION'}})
276
+ .then(function(token) {{
277
+ window.{token_var} = token;
278
+ }})
279
+ .catch(function(err) {{
280
+ window.{error_var} = err.message || 'execute failed';
281
+ }});
282
+ }});
283
+ }} catch (e) {{
284
+ window.{error_var} = e.message || 'exception';
285
+ }}
286
+ }})()
287
+ """
288
+
289
+ # 注入执行脚本
290
+ await tab.evaluate(execute_script)
291
+
292
+ # 轮询等待结果(最多 15 秒)
293
+ token = None
294
+ for i in range(30):
295
+ await tab.sleep(0.5)
296
+ token = await tab.evaluate(f"window.{token_var}")
297
+ if token:
298
+ break
299
+ error = await tab.evaluate(f"window.{error_var}")
300
+ if error:
301
+ debug_logger.log_error(f"[BrowserCaptcha] reCAPTCHA 错误: {error}")
302
+ break
303
+
304
+ # 清理临时变量
305
+ try:
306
+ await tab.evaluate(f"delete window.{token_var}; delete window.{error_var};")
307
+ except:
308
+ pass
309
+
310
+ return token
311
+
312
+ # ========== 主要 API ==========
313
+
314
  async def get_token(self, project_id: str) -> Optional[str]:
315
+ """获取 reCAPTCHA token
316
+
317
+ 自动常驻模式:如果该 project_id 没有常驻标签页,则自动创建并常驻
318
+
319
+ Args:
320
+ project_id: Flow项目ID
321
+
322
+ Returns:
323
+ reCAPTCHA token字符串,如果获取失败返回None
324
+ """
325
+ # 确保浏览器已初始化
326
+ await self.initialize()
327
+
328
+ # 尝试从常驻标签页获取 token
329
+ async with self._resident_lock:
330
+ resident_info = self._resident_tabs.get(project_id)
331
+
332
+ # 如果该 project_id 没有常驻标签页,则自动创建
333
+ if resident_info is None:
334
+ debug_logger.log_info(f"[BrowserCaptcha] project_id={project_id} 没有常驻标签页,正在创建...")
335
+ resident_info = await self._create_resident_tab(project_id)
336
+ if resident_info is None:
337
+ debug_logger.log_warning(f"[BrowserCaptcha] 无法为 project_id={project_id} 创建常驻标签页,fallback 到传统模式")
338
+ return await self._get_token_legacy(project_id)
339
+ self._resident_tabs[project_id] = resident_info
340
+ debug_logger.log_info(f"[BrowserCaptcha] ✅ 已为 project_id={project_id} 创建常驻标签页 (当前共 {len(self._resident_tabs)} 个)")
341
+
342
+ # 使用常驻标签页生成 token
343
+ if resident_info and resident_info.recaptcha_ready and resident_info.tab:
344
+ start_time = time.time()
345
+ debug_logger.log_info(f"[BrowserCaptcha] 从常驻标签页即时生成 token (project: {project_id})...")
346
+ try:
347
+ token = await self._execute_recaptcha_on_tab(resident_info.tab)
348
+ duration_ms = (time.time() - start_time) * 1000
349
+ if token:
350
+ debug_logger.log_info(f"[BrowserCaptcha] ✅ Token生成成功(耗时 {duration_ms:.0f}ms)")
351
+ return token
352
+ else:
353
+ debug_logger.log_warning(f"[BrowserCaptcha] 常驻标签页生成失败 (project: {project_id}),尝试重建...")
354
+ except Exception as e:
355
+ debug_logger.log_warning(f"[BrowserCaptcha] 常驻标签页异常: {e},尝试重建...")
356
+
357
+ # 常驻标签页失效,尝试重建
358
+ async with self._resident_lock:
359
+ await self._close_resident_tab(project_id)
360
+ resident_info = await self._create_resident_tab(project_id)
361
+ if resident_info:
362
+ self._resident_tabs[project_id] = resident_info
363
+ # 重建后立即尝试生成
364
+ try:
365
+ token = await self._execute_recaptcha_on_tab(resident_info.tab)
366
+ if token:
367
+ debug_logger.log_info(f"[BrowserCaptcha] ✅ 重建后 Token生成成功")
368
+ return token
369
+ except Exception:
370
+ pass
371
+
372
+ # 最终 Fallback: 使用传统模式
373
+ debug_logger.log_warning(f"[BrowserCaptcha] 所有常驻方式失败,fallback 到传统模式 (project: {project_id})")
374
+ return await self._get_token_legacy(project_id)
375
+
376
+ async def _create_resident_tab(self, project_id: str) -> Optional[ResidentTabInfo]:
377
+ """为指定 project_id 创建常驻标签页
378
+
379
+ Args:
380
+ project_id: 项目 ID
381
+
382
+ Returns:
383
+ ResidentTabInfo 对象,或 None(创建失败)
384
+ """
385
+ try:
386
+ website_url = f"https://labs.google/fx/tools/flow/project/{project_id}"
387
+ debug_logger.log_info(f"[BrowserCaptcha] 为 project_id={project_id} 创建常驻标签页,访问: {website_url}")
388
+
389
+ # 创建新标签页
390
+ tab = await self.browser.get(website_url, new_tab=True)
391
+
392
+ # 等待页面加载完成
393
+ page_loaded = False
394
+ for retry in range(60):
395
+ try:
396
+ await asyncio.sleep(1)
397
+ ready_state = await tab.evaluate("document.readyState")
398
+ if ready_state == "complete":
399
+ page_loaded = True
400
+ break
401
+ except ConnectionRefusedError as e:
402
+ debug_logger.log_warning(f"[BrowserCaptcha] 标签页连接丢失: {e}")
403
+ return None
404
+ except Exception as e:
405
+ debug_logger.log_warning(f"[BrowserCaptcha] 等待页面异常: {e},重试 {retry + 1}/60...")
406
+ await asyncio.sleep(1)
407
+
408
+ if not page_loaded:
409
+ debug_logger.log_error(f"[BrowserCaptcha] 页面加载超时 (project: {project_id})")
410
+ try:
411
+ await tab.close()
412
+ except:
413
+ pass
414
+ return None
415
+
416
+ # 等待 reCAPTCHA 加载
417
+ recaptcha_ready = await self._wait_for_recaptcha(tab)
418
+
419
+ if not recaptcha_ready:
420
+ debug_logger.log_error(f"[BrowserCaptcha] reCAPTCHA 加载失败 (project: {project_id})")
421
+ try:
422
+ await tab.close()
423
+ except:
424
+ pass
425
+ return None
426
+
427
+ # 创建常驻信息对象
428
+ resident_info = ResidentTabInfo(tab, project_id)
429
+ resident_info.recaptcha_ready = True
430
+
431
+ debug_logger.log_info(f"[BrowserCaptcha] ✅ 常驻标签页创建成功 (project: {project_id})")
432
+ return resident_info
433
+
434
+ except Exception as e:
435
+ debug_logger.log_error(f"[BrowserCaptcha] 创建常驻标签页异常: {e}")
436
+ return None
437
+
438
+ async def _close_resident_tab(self, project_id: str):
439
+ """关闭指定 project_id 的常驻标签页
440
+
441
+ Args:
442
+ project_id: 项目 ID
443
+ """
444
+ resident_info = self._resident_tabs.pop(project_id, None)
445
+ if resident_info and resident_info.tab:
446
+ try:
447
+ await resident_info.tab.close()
448
+ debug_logger.log_info(f"[BrowserCaptcha] 已关闭 project_id={project_id} 的常驻标签页")
449
+ except Exception as e:
450
+ debug_logger.log_warning(f"[BrowserCaptcha] 关闭标签页时异常: {e}")
451
+
452
+ async def _get_token_legacy(self, project_id: str) -> Optional[str]:
453
+ """传统模式获取 reCAPTCHA token(每次创建新标签页)
454
+
455
+ Args:
456
+ project_id: Flow项目ID
457
+
458
+ Returns:
459
+ reCAPTCHA token字符串,如果获取失败返回None
460
+ """
461
  # 确保浏览器已启动
462
+ if not self._initialized or not self.browser:
463
  await self.initialize()
464
 
465
  start_time = time.time()
466
+ tab = None
467
 
468
  try:
 
 
 
 
469
  website_url = f"https://labs.google/fx/tools/flow/project/{project_id}"
470
+ debug_logger.log_info(f"[BrowserCaptcha] [Legacy] 访问页面: {website_url}")
471
 
472
+ # 新建标签页并访问页面
473
+ tab = await self.browser.get(website_url)
474
+
475
+ # 等待页面完全加载(增加等待时间)
476
+ debug_logger.log_info("[BrowserCaptcha] [Legacy] 等待页面加载...")
477
+ await tab.sleep(3)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478
 
479
+ # 等待页面 DOM 完成
480
+ for _ in range(10):
481
+ ready_state = await tab.evaluate("document.readyState")
482
+ if ready_state == "complete":
483
+ break
484
+ await tab.sleep(0.5)
485
+
486
+ # 等待 reCAPTCHA 加载
487
+ recaptcha_ready = await self._wait_for_recaptcha(tab)
488
+
489
+ if not recaptcha_ready:
490
+ debug_logger.log_error("[BrowserCaptcha] [Legacy] reCAPTCHA 无法加载")
491
+ return None
492
+
493
+ # 执行 reCAPTCHA
494
+ debug_logger.log_info("[BrowserCaptcha] [Legacy] 执行 reCAPTCHA 验证...")
495
+ token = await self._execute_recaptcha_on_tab(tab)
496
+
497
+ duration_ms = (time.time() - start_time) * 1000
498
+
499
  if token:
500
+ debug_logger.log_info(f"[BrowserCaptcha] [Legacy] ✅ Token获取成功(耗时 {duration_ms:.0f}ms)")
501
  return token
502
  else:
503
+ debug_logger.log_error("[BrowserCaptcha] [Legacy] Token获取失败(返回null)")
504
  return None
505
 
506
  except Exception as e:
507
+ debug_logger.log_error(f"[BrowserCaptcha] [Legacy] 获取token异常: {str(e)}")
508
  return None
509
  finally:
510
+ # 关闭标签页(但保留浏览器
511
+ if tab:
512
  try:
513
+ await tab.close()
514
+ except Exception:
515
  pass
516
 
517
  async def close(self):
518
+ """关闭浏览器"""
519
+ # 先停止所有常驻模式(关闭所有常驻标签页)
520
+ await self.stop_resident_mode()
521
+
522
  try:
523
+ if self.browser:
524
+ try:
525
+ self.browser.stop()
526
+ except Exception as e:
527
+ debug_logger.log_warning(f"[BrowserCaptcha] 关闭浏览器时出现异常: {str(e)}")
528
+ finally:
529
+ self.browser = None
530
+
531
  self._initialized = False
532
+ self._resident_tabs.clear() # 确保清空常驻字典
533
+ debug_logger.log_info("[BrowserCaptcha] 浏览器已关闭")
534
  except Exception as e:
535
+ debug_logger.log_error(f"[BrowserCaptcha] 关闭浏览器异常: {str(e)}")
536
 
 
537
  async def open_login_window(self):
538
+ """打开登录窗口供用户手动登录 Google"""
539
  await self.initialize()
540
+ tab = await self.browser.get("https://accounts.google.com/")
541
+ debug_logger.log_info("[BrowserCaptcha] 请在打开的浏览器中登录账号。登录完成后,无需关闭浏览器,脚本下次运行时会自动使用此状态。")
542
+ print("请在打开的浏览器中登录账号。登录完成后,无需关闭浏览器,脚本下次运行时会自动使用此状态。")
543
+
544
+ # ========== Session Token 刷新 ==========
545
+
546
+ async def refresh_session_token(self, project_id: str) -> Optional[str]:
547
+ """从常驻标签页获取最新的 Session Token
548
+
549
+ 复用 reCAPTCHA 常驻标签页,通过刷新页面并从 cookies 中提取
550
+ __Secure-next-auth.session-token
551
+
552
+ Args:
553
+ project_id: 项目ID,用于定位常驻标签页
554
+
555
+ Returns:
556
+ 新的 Session Token,如果获取失败返回 None
557
+ """
558
+ # 确保浏览器已初始化
559
+ await self.initialize()
560
+
561
+ start_time = time.time()
562
+ debug_logger.log_info(f"[BrowserCaptcha] 开始刷新 Session Token (project: {project_id})...")
563
+
564
+ # 尝试获取或创建常驻标签页
565
+ async with self._resident_lock:
566
+ resident_info = self._resident_tabs.get(project_id)
567
+
568
+ # 如果该 project_id 没有常驻标签页,则创建
569
+ if resident_info is None:
570
+ debug_logger.log_info(f"[BrowserCaptcha] project_id={project_id} 没有常驻标签页,正在创建...")
571
+ resident_info = await self._create_resident_tab(project_id)
572
+ if resident_info is None:
573
+ debug_logger.log_warning(f"[BrowserCaptcha] 无法为 project_id={project_id} 创建常驻标签页")
574
+ return None
575
+ self._resident_tabs[project_id] = resident_info
576
+
577
+ if not resident_info or not resident_info.tab:
578
+ debug_logger.log_error(f"[BrowserCaptcha] 无法获取常驻标签页")
579
+ return None
580
+
581
+ tab = resident_info.tab
582
+
583
+ try:
584
+ # 刷新页面以获取最新的 cookies
585
+ debug_logger.log_info(f"[BrowserCaptcha] 刷新常驻标签页以获取最新 cookies...")
586
+ await tab.reload()
587
+
588
+ # 等待页面加载完成
589
+ for i in range(30):
590
+ await asyncio.sleep(1)
591
+ try:
592
+ ready_state = await tab.evaluate("document.readyState")
593
+ if ready_state == "complete":
594
+ break
595
+ except Exception:
596
+ pass
597
+
598
+ # 额外等待确保 cookies 已设置
599
+ await asyncio.sleep(2)
600
+
601
+ # 从 cookies 中提取 __Secure-next-auth.session-token
602
+ # nodriver 可以通过 browser 获取 cookies
603
+ session_token = None
604
+
605
+ try:
606
+ # 使用 nodriver 的 cookies API 获取所有 cookies
607
+ cookies = await self.browser.cookies.get_all()
608
+
609
+ for cookie in cookies:
610
+ if cookie.name == "__Secure-next-auth.session-token":
611
+ session_token = cookie.value
612
+ break
613
+
614
+ except Exception as e:
615
+ debug_logger.log_warning(f"[BrowserCaptcha] 通过 cookies API 获取失败: {e},尝试从 document.cookie 获取...")
616
+
617
+ # 备选方案:通过 JavaScript 获取 (注意:HttpOnly cookies 可能无法通过此方式获取)
618
+ try:
619
+ all_cookies = await tab.evaluate("document.cookie")
620
+ if all_cookies:
621
+ for part in all_cookies.split(";"):
622
+ part = part.strip()
623
+ if part.startswith("__Secure-next-auth.session-token="):
624
+ session_token = part.split("=", 1)[1]
625
+ break
626
+ except Exception as e2:
627
+ debug_logger.log_error(f"[BrowserCaptcha] document.cookie 获取失败: {e2}")
628
+
629
+ duration_ms = (time.time() - start_time) * 1000
630
+
631
+ if session_token:
632
+ debug_logger.log_info(f"[BrowserCaptcha] ✅ Session Token 获取成功(耗时 {duration_ms:.0f}ms)")
633
+ return session_token
634
+ else:
635
+ debug_logger.log_error(f"[BrowserCaptcha] ❌ 未找到 __Secure-next-auth.session-token cookie")
636
+ return None
637
+
638
+ except Exception as e:
639
+ debug_logger.log_error(f"[BrowserCaptcha] 刷新 Session Token 异常: {str(e)}")
640
+
641
+ # 常驻标签页可能已失效,尝试重建
642
+ async with self._resident_lock:
643
+ await self._close_resident_tab(project_id)
644
+ resident_info = await self._create_resident_tab(project_id)
645
+ if resident_info:
646
+ self._resident_tabs[project_id] = resident_info
647
+ # 重建后再次尝试获取
648
+ try:
649
+ cookies = await self.browser.cookies.get_all()
650
+ for cookie in cookies:
651
+ if cookie.name == "__Secure-next-auth.session-token":
652
+ debug_logger.log_info(f"[BrowserCaptcha] ✅ 重建后 Session Token 获取成功")
653
+ return cookie.value
654
+ except Exception:
655
+ pass
656
+
657
+ return None
658
+
659
+ # ========== 状态查询 ==========
660
+
661
+ def is_resident_mode_active(self) -> bool:
662
+ """检查是否有任何常驻标签页激活"""
663
+ return len(self._resident_tabs) > 0 or self._running
664
+
665
+ def get_resident_count(self) -> int:
666
+ """获取当前常驻标签页数量"""
667
+ return len(self._resident_tabs)
668
+
669
+ def get_resident_project_ids(self) -> list[str]:
670
+ """获取所有当前常驻的 project_id 列表"""
671
+ return list(self._resident_tabs.keys())
672
+
673
+ def get_resident_project_id(self) -> Optional[str]:
674
+ """获取当前常驻的 project_id(向后兼容,返回第一个)"""
675
+ if self._resident_tabs:
676
+ return next(iter(self._resident_tabs.keys()))
677
+ return self.resident_project_id
src/services/flow_client.py CHANGED
@@ -12,11 +12,86 @@ from ..core.config import config
12
  class FlowClient:
13
  """VideoFX API客户端"""
14
 
15
- def __init__(self, proxy_manager):
16
  self.proxy_manager = proxy_manager
 
17
  self.labs_base_url = config.flow_labs_base_url # https://labs.google/fx/api
18
  self.api_base_url = config.flow_api_base_url # https://aisandbox-pa.googleapis.com/v1
19
  self.timeout = config.flow_timeout
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  async def _make_request(
22
  self,
@@ -54,10 +129,17 @@ class FlowClient:
54
  if use_at and at_token:
55
  headers["authorization"] = f"Bearer {at_token}"
56
 
57
- # 请求头
 
 
 
 
 
 
 
58
  headers.update({
59
  "Content-Type": "application/json",
60
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
61
  })
62
 
63
  # Log request
@@ -571,7 +653,7 @@ class FlowClient:
571
  Returns:
572
  同 generate_video_text
573
  """
574
- url = f"{self.api_base_url}/video:batchAsyncGenerateVideoStartAndEndImage"
575
 
576
  # 获取 reCAPTCHA token
577
  recaptcha_token = await self._get_recaptcha_token(project_id) or ""
@@ -691,7 +773,7 @@ class FlowClient:
691
  if captcha_method == "personal":
692
  try:
693
  from .browser_captcha_personal import BrowserCaptchaService
694
- service = await BrowserCaptchaService.get_instance(self.proxy_manager)
695
  return await service.get_token(project_id)
696
  except Exception as e:
697
  debug_logger.log_error(f"[reCAPTCHA Browser] error: {str(e)}")
@@ -700,7 +782,7 @@ class FlowClient:
700
  elif captcha_method == "browser":
701
  try:
702
  from .browser_captcha import BrowserCaptchaService
703
- service = await BrowserCaptchaService.get_instance(self.proxy_manager)
704
  return await service.get_token(project_id)
705
  except Exception as e:
706
  debug_logger.log_error(f"[reCAPTCHA Browser] error: {str(e)}")
 
12
  class FlowClient:
13
  """VideoFX API客户端"""
14
 
15
+ def __init__(self, proxy_manager, db=None):
16
  self.proxy_manager = proxy_manager
17
+ self.db = db # Database instance for captcha config
18
  self.labs_base_url = config.flow_labs_base_url # https://labs.google/fx/api
19
  self.api_base_url = config.flow_api_base_url # https://aisandbox-pa.googleapis.com/v1
20
  self.timeout = config.flow_timeout
21
+ # 缓存每个账号的 User-Agent
22
+ self._user_agent_cache = {}
23
+
24
+ def _generate_user_agent(self, account_id: str = None) -> str:
25
+ """基于账号ID生成固定的 User-Agent
26
+
27
+ Args:
28
+ account_id: 账号标识(如 email 或 token_id),相同账号返回相同 UA
29
+
30
+ Returns:
31
+ User-Agent 字符串
32
+ """
33
+ # 如果没有提供账号ID,生成随机UA
34
+ if not account_id:
35
+ account_id = f"random_{random.randint(1, 999999)}"
36
+
37
+ # 如果已缓存,直接返回
38
+ if account_id in self._user_agent_cache:
39
+ return self._user_agent_cache[account_id]
40
+
41
+ # 使用账号ID作为随机种子,确保同一账号生成相同的UA
42
+ import hashlib
43
+ seed = int(hashlib.md5(account_id.encode()).hexdigest()[:8], 16)
44
+ rng = random.Random(seed)
45
+
46
+ # Chrome 版本池
47
+ chrome_versions = ["130.0.0.0", "131.0.0.0", "132.0.0.0", "129.0.0.0"]
48
+ # Firefox 版本池
49
+ firefox_versions = ["133.0", "132.0", "131.0", "134.0"]
50
+ # Safari 版本池
51
+ safari_versions = ["18.2", "18.1", "18.0", "17.6"]
52
+ # Edge 版本池
53
+ edge_versions = ["130.0.0.0", "131.0.0.0", "132.0.0.0"]
54
+
55
+ # 操作系统配置
56
+ os_configs = [
57
+ # Windows
58
+ {
59
+ "platform": "Windows NT 10.0; Win64; x64",
60
+ "browsers": [
61
+ lambda r: f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{r.choice(chrome_versions)} Safari/537.36",
62
+ lambda r: f"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:{r.choice(firefox_versions).split('.')[0]}.0) Gecko/20100101 Firefox/{r.choice(firefox_versions)}",
63
+ lambda r: f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{r.choice(chrome_versions)} Safari/537.36 Edg/{r.choice(edge_versions)}",
64
+ ]
65
+ },
66
+ # macOS
67
+ {
68
+ "platform": "Macintosh; Intel Mac OS X 10_15_7",
69
+ "browsers": [
70
+ lambda r: f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{r.choice(chrome_versions)} Safari/537.36",
71
+ lambda r: f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/{r.choice(safari_versions)} Safari/605.1.15",
72
+ lambda r: f"Mozilla/5.0 (Macintosh; Intel Mac OS X 14.{r.randint(0, 7)}; rv:{r.choice(firefox_versions).split('.')[0]}.0) Gecko/20100101 Firefox/{r.choice(firefox_versions)}",
73
+ ]
74
+ },
75
+ # Linux
76
+ {
77
+ "platform": "X11; Linux x86_64",
78
+ "browsers": [
79
+ lambda r: f"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{r.choice(chrome_versions)} Safari/537.36",
80
+ lambda r: f"Mozilla/5.0 (X11; Linux x86_64; rv:{r.choice(firefox_versions).split('.')[0]}.0) Gecko/20100101 Firefox/{r.choice(firefox_versions)}",
81
+ lambda r: f"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:{r.choice(firefox_versions).split('.')[0]}.0) Gecko/20100101 Firefox/{r.choice(firefox_versions)}",
82
+ ]
83
+ }
84
+ ]
85
+
86
+ # 使用固定种子随机选择操作系统和浏览器
87
+ os_config = rng.choice(os_configs)
88
+ browser_generator = rng.choice(os_config["browsers"])
89
+ user_agent = browser_generator(rng)
90
+
91
+ # 缓存结果
92
+ self._user_agent_cache[account_id] = user_agent
93
+
94
+ return user_agent
95
 
96
  async def _make_request(
97
  self,
 
129
  if use_at and at_token:
130
  headers["authorization"] = f"Bearer {at_token}"
131
 
132
+ # 确定账号标识(优先使 token 的前16个字符作为标识)
133
+ account_id = None
134
+ if st_token:
135
+ account_id = st_token[:16] # 使用 ST 的前16个字符
136
+ elif at_token:
137
+ account_id = at_token[:16] # 使用 AT 的前16个字符
138
+
139
+ # 通用请求头 - 基于账号生成固定的 User-Agent
140
  headers.update({
141
  "Content-Type": "application/json",
142
+ "User-Agent": self._generate_user_agent(account_id)
143
  })
144
 
145
  # Log request
 
653
  Returns:
654
  同 generate_video_text
655
  """
656
+ url = f"{self.api_base_url}/video:batchAsyncGenerateVideoStartImage"
657
 
658
  # 获取 reCAPTCHA token
659
  recaptcha_token = await self._get_recaptcha_token(project_id) or ""
 
773
  if captcha_method == "personal":
774
  try:
775
  from .browser_captcha_personal import BrowserCaptchaService
776
+ service = await BrowserCaptchaService.get_instance(self.db)
777
  return await service.get_token(project_id)
778
  except Exception as e:
779
  debug_logger.log_error(f"[reCAPTCHA Browser] error: {str(e)}")
 
782
  elif captcha_method == "browser":
783
  try:
784
  from .browser_captcha import BrowserCaptchaService
785
+ service = await BrowserCaptchaService.get_instance(self.db)
786
  return await service.get_token(project_id)
787
  except Exception as e:
788
  debug_logger.log_error(f"[reCAPTCHA Browser] error: {str(e)}")
src/services/generation_handler.py CHANGED
@@ -136,7 +136,7 @@ MODEL_CONFIG = {
136
  "veo_3_1_i2v_s_fast_fl_portrait": {
137
  "type": "video",
138
  "video_type": "i2v",
139
- "model_key": "veo_3_1_i2v_s_fast_fl",
140
  "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
141
  "supports_images": True,
142
  "min_images": 1,
@@ -145,7 +145,7 @@ MODEL_CONFIG = {
145
  "veo_3_1_i2v_s_fast_fl_landscape": {
146
  "type": "video",
147
  "video_type": "i2v",
148
- "model_key": "veo_3_1_i2v_s_fast_fl",
149
  "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
150
  "supports_images": True,
151
  "min_images": 1,
@@ -259,7 +259,7 @@ MODEL_CONFIG = {
259
  "veo_3_0_r2v_fast_portrait": {
260
  "type": "video",
261
  "video_type": "r2v",
262
- "model_key": "veo_3_0_r2v_fast",
263
  "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
264
  "supports_images": True,
265
  "min_images": 0,
@@ -268,7 +268,7 @@ MODEL_CONFIG = {
268
  "veo_3_0_r2v_fast_landscape": {
269
  "type": "video",
270
  "video_type": "r2v",
271
- "model_key": "veo_3_0_r2v_fast",
272
  "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
273
  "supports_images": True,
274
  "min_images": 0,
@@ -644,6 +644,44 @@ class GenerationHandler:
644
  min_images = model_config.get("min_images", 0)
645
  max_images = model_config.get("max_images", 0)
646
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
647
  # 图片数量
648
  image_count = len(images) if images else 0
649
 
@@ -734,12 +772,16 @@ class GenerationHandler:
734
  user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE"
735
  )
736
  else:
737
- # 只有首帧
 
 
 
 
738
  result = await self.flow_client.generate_video_start_image(
739
  at=token.at,
740
  project_id=project_id,
741
  prompt=prompt,
742
- model_key=model_config["model_key"],
743
  aspect_ratio=model_config["aspect_ratio"],
744
  start_media_id=start_media_id,
745
  user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE"
@@ -888,8 +930,30 @@ class GenerationHandler:
888
  )
889
  return
890
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
891
  elif status.startswith("MEDIA_GENERATION_STATUS_ERROR"):
892
- # 失败
893
  yield self._create_error_response(f"视频生成失败: {status}")
894
  return
895
 
 
136
  "veo_3_1_i2v_s_fast_fl_portrait": {
137
  "type": "video",
138
  "video_type": "i2v",
139
+ "model_key": "veo_3_1_i2v_s_fast_portrait_fl_ultra_relaxed",
140
  "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
141
  "supports_images": True,
142
  "min_images": 1,
 
145
  "veo_3_1_i2v_s_fast_fl_landscape": {
146
  "type": "video",
147
  "video_type": "i2v",
148
+ "model_key": "veo_3_1_i2v_s_fast_fl_ultra_relaxed",
149
  "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
150
  "supports_images": True,
151
  "min_images": 1,
 
259
  "veo_3_0_r2v_fast_portrait": {
260
  "type": "video",
261
  "video_type": "r2v",
262
+ "model_key": "veo_3_0_r2v_fast_portrait_ultra_relaxed",
263
  "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
264
  "supports_images": True,
265
  "min_images": 0,
 
268
  "veo_3_0_r2v_fast_landscape": {
269
  "type": "video",
270
  "video_type": "r2v",
271
+ "model_key": "veo_3_0_r2v_fast_ultra_relaxed",
272
  "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
273
  "supports_images": True,
274
  "min_images": 0,
 
644
  min_images = model_config.get("min_images", 0)
645
  max_images = model_config.get("max_images", 0)
646
 
647
+ # 根据账号tier自动调整模型 key
648
+ model_key = model_config["model_key"]
649
+ user_tier = token.user_paygate_tier or "PAYGATE_TIER_ONE"
650
+
651
+ # TIER_TWO 账号需要使用 ultra 版本的模型
652
+ if user_tier == "PAYGATE_TIER_TWO":
653
+ # 如果模型 key 不包含 ultra,自动添加
654
+ if "ultra" not in model_key:
655
+ # veo_3_1_i2v_s_fast_fl -> veo_3_1_i2v_s_fast_ultra_fl
656
+ # veo_3_1_t2v_fast -> veo_3_1_t2v_fast_ultra
657
+ # veo_3_0_r2v_fast -> veo_3_0_r2v_fast_ultra
658
+ if "_fl" in model_key:
659
+ model_key = model_key.replace("_fl", "_ultra_fl")
660
+ elif model_key.endswith("_fast"):
661
+ model_key = model_key + "_ultra"
662
+ elif "_fast_" in model_key:
663
+ model_key = model_key.replace("_fast_", "_fast_ultra_")
664
+
665
+ if stream:
666
+ yield self._create_stream_chunk(f"TIER_TWO 账号自动切换到 ultra 模型: {model_key}\n")
667
+ debug_logger.log_info(f"[VIDEO] TIER_TWO 账号,模型自动调整: {model_config['model_key']} -> {model_key}")
668
+
669
+ # TIER_ONE 账号需要使用非 ultra 版本
670
+ elif user_tier == "PAYGATE_TIER_ONE":
671
+ # 如果模型 key 包含 ultra,需要移除(避免用户误用)
672
+ if "ultra" in model_key:
673
+ # veo_3_1_i2v_s_fast_ultra_fl -> veo_3_1_i2v_s_fast_fl
674
+ # veo_3_1_t2v_fast_ultra -> veo_3_1_t2v_fast
675
+ model_key = model_key.replace("_ultra_fl", "_fl").replace("_ultra", "")
676
+
677
+ if stream:
678
+ yield self._create_stream_chunk(f"TIER_ONE 账号自动切换到标准模型: {model_key}\n")
679
+ debug_logger.log_info(f"[VIDEO] TIER_ONE 账号,模型自动调整: {model_config['model_key']} -> {model_key}")
680
+
681
+ # 更新 model_config 中的 model_key
682
+ model_config = dict(model_config) # 创建副本避免修改原配置
683
+ model_config["model_key"] = model_key
684
+
685
  # 图片数量
686
  image_count = len(images) if images else 0
687
 
 
772
  user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE"
773
  )
774
  else:
775
+ # 只有首帧 - 需要将 model_key 中的 _fl_ 替换为 _
776
+ # 例如: veo_3_1_i2v_s_fast_fl_ultra_relaxed -> veo_3_1_i2v_s_fast_ultra_relaxed
777
+ # veo_3_1_i2v_s_fast_portrait_fl_ultra_relaxed -> veo_3_1_i2v_s_fast_portrait_ultra_relaxed
778
+ actual_model_key = model_config["model_key"].replace("_fl_", "_")
779
+ debug_logger.log_info(f"[I2V] 单帧模式,model_key: {model_config['model_key']} -> {actual_model_key}")
780
  result = await self.flow_client.generate_video_start_image(
781
  at=token.at,
782
  project_id=project_id,
783
  prompt=prompt,
784
+ model_key=actual_model_key,
785
  aspect_ratio=model_config["aspect_ratio"],
786
  start_media_id=start_media_id,
787
  user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE"
 
930
  )
931
  return
932
 
933
+ elif status == "MEDIA_GENERATION_STATUS_FAILED":
934
+ # 生成失败 - 提取错误信息
935
+ error_info = operation.get("operation", {}).get("error", {})
936
+ error_code = error_info.get("code", "unknown")
937
+ error_message = error_info.get("message", "未知错误")
938
+
939
+ # 更新数据库任务状态
940
+ task_id = operation["operation"]["name"]
941
+ await self.db.update_task(
942
+ task_id,
943
+ status="failed",
944
+ error_message=f"{error_message} (code: {error_code})",
945
+ completed_at=time.time()
946
+ )
947
+
948
+ # 返回友好的错误消息,提示用户重试
949
+ friendly_error = f"视频生成失败: {error_message},请重试"
950
+ if stream:
951
+ yield self._create_stream_chunk(f"❌ {friendly_error}\n")
952
+ yield self._create_error_response(friendly_error)
953
+ return
954
+
955
  elif status.startswith("MEDIA_GENERATION_STATUS_ERROR"):
956
+ # 其他错误状态
957
  yield self._create_error_response(f"视频生成失败: {status}")
958
  return
959
 
src/services/token_manager.py CHANGED
@@ -268,9 +268,13 @@ class TokenManager:
268
  # AT有效
269
  return True
270
 
 
271
  async def _refresh_at(self, token_id: int) -> bool:
272
  """内部方法: 刷新AT
273
 
 
 
 
274
  Returns:
275
  True if refresh successful, False otherwise
276
  """
@@ -279,49 +283,132 @@ class TokenManager:
279
  if not token:
280
  return False
281
 
282
- try:
283
- debug_logger.log_info(f"[AT_REFRESH] Token {token_id}: 开始刷新AT...")
284
-
285
- # 使用ST转AT
286
- result = await self.flow_client.st_to_at(token.st)
287
- new_at = result["access_token"]
288
- expires = result.get("expires")
289
-
290
- # 解析过期时间
291
- new_at_expires = None
292
- if expires:
293
- try:
294
- new_at_expires = datetime.fromisoformat(expires.replace('Z', '+00:00'))
295
- except:
296
- pass
297
-
298
- # 更新数据库
299
- await self.db.update_token(
300
- token_id,
301
- at=new_at,
302
- at_expires=new_at_expires
303
- )
304
 
305
- debug_logger.log_info(f"[AT_REFRESH] Token {token_id}: AT刷新成功")
306
- debug_logger.log_info(f" - 新过期时间: {new_at_expires}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
 
308
- # 同时刷新credits
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  try:
310
- credits_result = await self.flow_client.get_credits(new_at)
311
- await self.db.update_token(
312
- token_id,
313
- credits=credits_result.get("credits", 0)
314
- )
315
  except:
316
  pass
317
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  return True
 
 
 
 
 
 
 
 
 
 
319
 
320
- except Exception as e:
321
- debug_logger.log_error(f"[AT_REFRESH] Token {token_id}: AT刷新失败 - {str(e)}")
322
- # 刷新失败,禁用Token
323
- await self.disable_token(token_id)
324
- return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
 
326
  async def ensure_project_exists(self, token_id: int) -> str:
327
  """确保Token有可用的Project
 
268
  # AT有效
269
  return True
270
 
271
+
272
  async def _refresh_at(self, token_id: int) -> bool:
273
  """内部方法: 刷新AT
274
 
275
+ 如果 AT 刷新失败(ST 可能过期),会尝试通过浏览器自动刷新 ST,
276
+ 然后重试 AT 刷新。
277
+
278
  Returns:
279
  True if refresh successful, False otherwise
280
  """
 
283
  if not token:
284
  return False
285
 
286
+ # 第一次尝试刷新 AT
287
+ result = await self._do_refresh_at(token_id, token.st)
288
+ if result:
289
+ return True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
 
291
+ # AT 刷新失败,尝试自动更新 ST
292
+ debug_logger.log_info(f"[AT_REFRESH] Token {token_id}: 第一次 AT 刷新失败,尝试自动更新 ST...")
293
+
294
+ new_st = await self._try_refresh_st(token_id, token)
295
+ if new_st:
296
+ # ST 更新成功,重试 AT 刷新
297
+ debug_logger.log_info(f"[AT_REFRESH] Token {token_id}: ST 已更新,重试 AT 刷新...")
298
+ result = await self._do_refresh_at(token_id, new_st)
299
+ if result:
300
+ return True
301
+
302
+ # 所有刷新尝试都失败,禁用 Token
303
+ debug_logger.log_error(f"[AT_REFRESH] Token {token_id}: 所有刷新尝试失败,禁用 Token")
304
+ await self.disable_token(token_id)
305
+ return False
306
+
307
+ async def _do_refresh_at(self, token_id: int, st: str) -> bool:
308
+ """执行 AT 刷新的核心逻辑
309
+
310
+ Args:
311
+ token_id: Token ID
312
+ st: Session Token
313
 
314
+ Returns:
315
+ True if refresh successful AND AT is valid, False otherwise
316
+ """
317
+ try:
318
+ debug_logger.log_info(f"[AT_REFRESH] Token {token_id}: 开始刷新AT...")
319
+
320
+ # 使用ST转AT
321
+ result = await self.flow_client.st_to_at(st)
322
+ new_at = result["access_token"]
323
+ expires = result.get("expires")
324
+
325
+ # 解析过期时间
326
+ new_at_expires = None
327
+ if expires:
328
  try:
329
+ new_at_expires = datetime.fromisoformat(expires.replace('Z', '+00:00'))
 
 
 
 
330
  except:
331
  pass
332
 
333
+ # 更新数据库
334
+ await self.db.update_token(
335
+ token_id,
336
+ at=new_at,
337
+ at_expires=new_at_expires
338
+ )
339
+
340
+ debug_logger.log_info(f"[AT_REFRESH] Token {token_id}: AT刷新成功")
341
+ debug_logger.log_info(f" - 新过期时间: {new_at_expires}")
342
+
343
+ # 验证 AT 有效性:通过 get_credits 测试
344
+ try:
345
+ credits_result = await self.flow_client.get_credits(new_at)
346
+ await self.db.update_token(
347
+ token_id,
348
+ credits=credits_result.get("credits", 0)
349
+ )
350
+ debug_logger.log_info(f"[AT_REFRESH] Token {token_id}: AT 验证成功(余额: {credits_result.get('credits', 0)})")
351
  return True
352
+ except Exception as verify_err:
353
+ # AT 验证失败(可能返回 401),说明 ST 已过期
354
+ error_msg = str(verify_err)
355
+ if "401" in error_msg or "UNAUTHENTICATED" in error_msg:
356
+ debug_logger.log_warning(f"[AT_REFRESH] Token {token_id}: AT 验证失败 (401),ST 可能已过期")
357
+ return False
358
+ else:
359
+ # 其他错误(如网络问题),仍视为成功
360
+ debug_logger.log_warning(f"[AT_REFRESH] Token {token_id}: AT 验证时发生非认证错误: {error_msg}")
361
+ return True
362
 
363
+ except Exception as e:
364
+ debug_logger.log_error(f"[AT_REFRESH] Token {token_id}: AT刷新失败 - {str(e)}")
365
+ return False
366
+
367
+ async def _try_refresh_st(self, token_id: int, token) -> Optional[str]:
368
+ """尝试通过浏览器刷新 Session Token
369
+
370
+ 使用常驻 tab 获取新的 __Secure-next-auth.session-token
371
+
372
+ Args:
373
+ token_id: Token ID
374
+ token: Token 对象
375
+
376
+ Returns:
377
+ 新的 ST 字符串,如果失败返回 None
378
+ """
379
+ try:
380
+ from ..core.config import config
381
+
382
+ # 仅在 personal 模式下支持 ST 自动刷新
383
+ if config.captcha_method != "personal":
384
+ debug_logger.log_info(f"[ST_REFRESH] 非 personal 模式,跳过 ST 自动刷新")
385
+ return None
386
+
387
+ if not token.current_project_id:
388
+ debug_logger.log_warning(f"[ST_REFRESH] Token {token_id} 没有 project_id,无法刷新 ST")
389
+ return None
390
+
391
+ debug_logger.log_info(f"[ST_REFRESH] Token {token_id}: 尝试通过浏览器刷新 ST...")
392
+
393
+ from .browser_captcha_personal import BrowserCaptchaService
394
+ service = await BrowserCaptchaService.get_instance(self.db)
395
+
396
+ new_st = await service.refresh_session_token(token.current_project_id)
397
+ if new_st and new_st != token.st:
398
+ # 更新数据库中的 ST
399
+ await self.db.update_token(token_id, st=new_st)
400
+ debug_logger.log_info(f"[ST_REFRESH] Token {token_id}: ST 已自动更新")
401
+ return new_st
402
+ elif new_st == token.st:
403
+ debug_logger.log_warning(f"[ST_REFRESH] Token {token_id}: 获取到的 ST 与原 ST 相同,可能登录已失效")
404
+ return None
405
+ else:
406
+ debug_logger.log_warning(f"[ST_REFRESH] Token {token_id}: 无法获取新 ST")
407
+ return None
408
+
409
+ except Exception as e:
410
+ debug_logger.log_error(f"[ST_REFRESH] Token {token_id}: 刷新 ST 失败 - {str(e)}")
411
+ return None
412
 
413
  async def ensure_project_exists(self, token_id: int) -> str:
414
  """确保Token有可用的Project