Merge pull request #39 from NetLops/sz
Browse files- .gitignore +4 -0
- Dockerfile +11 -2
- README.md +6 -2
- config/setting.toml +3 -3
- config/setting_example.toml +42 -0
- config/setting_warp_example.toml +42 -0
- docker-compose.local.yml +17 -0
- docker-compose.proxy.yml +3 -3
- docker-compose.yml +1 -1
- requirements.txt +1 -0
- src/api/admin.py +28 -4
- src/api/routes.py +13 -1
- src/main.py +21 -3
- src/services/browser_captcha_personal.py +610 -130
- src/services/flow_client.py +5 -4
- src/services/generation_handler.py +33 -7
- src/services/token_manager.py +122 -35
.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 =
|
| 16 |
|
| 17 |
[debug]
|
| 18 |
enabled = false
|
|
@@ -21,8 +21,8 @@ log_responses = true
|
|
| 21 |
mask_token = true
|
| 22 |
|
| 23 |
[proxy]
|
| 24 |
-
proxy_enabled =
|
| 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 |
-
- "
|
| 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 |
-
- "
|
| 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 |
-
- "
|
| 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":
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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)
|
|
@@ -77,8 +78,24 @@ async def lifespan(app: FastAPI):
|
|
| 77 |
if captcha_config.captcha_method == "personal":
|
| 78 |
from .services.browser_captcha_personal import BrowserCaptchaService
|
| 79 |
browser_service = await BrowserCaptchaService.get_instance(db)
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
elif captcha_config.captcha_method == "browser":
|
| 83 |
from .services.browser_captcha import BrowserCaptchaService
|
| 84 |
browser_service = await BrowserCaptchaService.get_instance(db)
|
|
@@ -86,6 +103,7 @@ async def lifespan(app: FastAPI):
|
|
| 86 |
|
| 87 |
# Initialize concurrency manager
|
| 88 |
tokens = await token_manager.get_all_tokens()
|
|
|
|
| 89 |
await concurrency_manager.initialize(tokens)
|
| 90 |
|
| 91 |
# Start file cache cleanup task
|
|
@@ -135,7 +153,7 @@ async def lifespan(app: FastAPI):
|
|
| 135 |
# Initialize components
|
| 136 |
db = Database()
|
| 137 |
proxy_manager = ProxyManager(db)
|
| 138 |
-
flow_client = FlowClient(proxy_manager)
|
| 139 |
token_manager = TokenManager(db, flow_client)
|
| 140 |
concurrency_manager = ConcurrencyManager()
|
| 141 |
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)
|
|
|
|
| 78 |
if captcha_config.captcha_method == "personal":
|
| 79 |
from .services.browser_captcha_personal import BrowserCaptchaService
|
| 80 |
browser_service = await BrowserCaptchaService.get_instance(db)
|
| 81 |
+
print("✓ Browser captcha service initialized (nodriver mode)")
|
| 82 |
+
|
| 83 |
+
# 启动常驻模式:从第一个可用token获取project_id
|
| 84 |
+
tokens = await token_manager.get_all_tokens()
|
| 85 |
+
resident_project_id = None
|
| 86 |
+
for t in tokens:
|
| 87 |
+
if t.current_project_id and t.is_active:
|
| 88 |
+
resident_project_id = t.current_project_id
|
| 89 |
+
break
|
| 90 |
+
|
| 91 |
+
if resident_project_id:
|
| 92 |
+
# 直接启动常驻模式(会自动导航到项目页面,cookie已持久化)
|
| 93 |
+
await browser_service.start_resident_mode(resident_project_id)
|
| 94 |
+
print(f"✓ Browser captcha resident mode started (project: {resident_project_id[:8]}...)")
|
| 95 |
+
else:
|
| 96 |
+
# 没有可用的project_id时,打开登录窗口供用户手动操作
|
| 97 |
+
await browser_service.open_login_window()
|
| 98 |
+
print("⚠ No active token with project_id found, opened login window for manual setup")
|
| 99 |
elif captcha_config.captcha_method == "browser":
|
| 100 |
from .services.browser_captcha import BrowserCaptchaService
|
| 101 |
browser_service = await BrowserCaptchaService.get_instance(db)
|
|
|
|
| 103 |
|
| 104 |
# Initialize concurrency manager
|
| 105 |
tokens = await token_manager.get_all_tokens()
|
| 106 |
+
|
| 107 |
await concurrency_manager.initialize(tokens)
|
| 108 |
|
| 109 |
# Start file cache cleanup task
|
|
|
|
| 153 |
# Initialize components
|
| 154 |
db = Database()
|
| 155 |
proxy_manager = ProxyManager(db)
|
| 156 |
+
flow_client = FlowClient(proxy_manager, db)
|
| 157 |
token_manager = TokenManager(db, flow_client)
|
| 158 |
concurrency_manager = ConcurrencyManager()
|
| 159 |
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
|
| 6 |
-
|
|
|
|
| 7 |
|
| 8 |
from ..core.logger import debug_logger
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
"""
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 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 |
-
|
| 33 |
-
self.
|
| 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.
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
try:
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 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.
|
| 107 |
await self.initialize()
|
| 108 |
|
| 109 |
start_time = time.time()
|
| 110 |
-
|
| 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 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 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 |
-
#
|
| 169 |
-
if
|
| 170 |
try:
|
| 171 |
-
await
|
| 172 |
-
except:
|
| 173 |
pass
|
| 174 |
|
| 175 |
async def close(self):
|
| 176 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 177 |
try:
|
| 178 |
-
if self.
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
self._initialized = False
|
| 187 |
-
|
|
|
|
| 188 |
except Exception as e:
|
| 189 |
-
debug_logger.log_error(f"[BrowserCaptcha] 关闭异常: {str(e)}")
|
| 190 |
|
| 191 |
-
# 增加一个辅助方法,用于手动登录
|
| 192 |
async def open_login_window(self):
|
| 193 |
-
"""
|
| 194 |
await self.initialize()
|
| 195 |
-
|
| 196 |
-
|
| 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,8 +12,9 @@ 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
|
|
@@ -652,7 +653,7 @@ class FlowClient:
|
|
| 652 |
Returns:
|
| 653 |
同 generate_video_text
|
| 654 |
"""
|
| 655 |
-
url = f"{self.api_base_url}/video:
|
| 656 |
|
| 657 |
# 获取 reCAPTCHA token
|
| 658 |
recaptcha_token = await self._get_recaptcha_token(project_id) or ""
|
|
@@ -772,7 +773,7 @@ class FlowClient:
|
|
| 772 |
if captcha_method == "personal":
|
| 773 |
try:
|
| 774 |
from .browser_captcha_personal import BrowserCaptchaService
|
| 775 |
-
service = await BrowserCaptchaService.get_instance(self.
|
| 776 |
return await service.get_token(project_id)
|
| 777 |
except Exception as e:
|
| 778 |
debug_logger.log_error(f"[reCAPTCHA Browser] error: {str(e)}")
|
|
@@ -781,7 +782,7 @@ class FlowClient:
|
|
| 781 |
elif captcha_method == "browser":
|
| 782 |
try:
|
| 783 |
from .browser_captcha import BrowserCaptchaService
|
| 784 |
-
service = await BrowserCaptchaService.get_instance(self.
|
| 785 |
return await service.get_token(project_id)
|
| 786 |
except Exception as e:
|
| 787 |
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
|
|
|
|
| 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": "
|
| 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": "
|
| 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": "
|
| 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": "
|
| 272 |
"aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
|
| 273 |
"supports_images": True,
|
| 274 |
"min_images": 0,
|
|
@@ -772,12 +772,16 @@ class GenerationHandler:
|
|
| 772 |
user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE"
|
| 773 |
)
|
| 774 |
else:
|
| 775 |
-
# 只有首帧
|
|
|
|
|
|
|
|
|
|
|
|
|
| 776 |
result = await self.flow_client.generate_video_start_image(
|
| 777 |
at=token.at,
|
| 778 |
project_id=project_id,
|
| 779 |
prompt=prompt,
|
| 780 |
-
model_key=
|
| 781 |
aspect_ratio=model_config["aspect_ratio"],
|
| 782 |
start_media_id=start_media_id,
|
| 783 |
user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE"
|
|
@@ -926,8 +930,30 @@ class GenerationHandler:
|
|
| 926 |
)
|
| 927 |
return
|
| 928 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 929 |
elif status.startswith("MEDIA_GENERATION_STATUS_ERROR"):
|
| 930 |
-
#
|
| 931 |
yield self._create_error_response(f"视频生成失败: {status}")
|
| 932 |
return
|
| 933 |
|
|
|
|
| 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,
|
|
|
|
| 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 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 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 |
-
|
| 306 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
|
| 308 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
try:
|
| 310 |
-
|
| 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 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|