genz27 Warp commited on
Commit ·
8358864
1
Parent(s): 6e0803d
feat: 添加浏览器打码依赖自动安装和Docker环境检测
Browse files- browser_captcha.py: 添加patchright和chromium自动安装(官方源优先,国内镜像备用)
- browser_captcha_personal.py: 添加nodriver自动安装(官方源优先,国内镜像备用)
- 两个模块都添加Docker环境检测,Docker中禁用本地浏览器打码
- flow_client.py: 更新错误处理,提供更清晰的安装指引
- .gitignore: 添加browser_data_rt目录
Co-Authored-By: Warp <agent@warp.dev>
- .gitignore +1 -0
- .lh/.lhignore +6 -0
- .lh/src/services/generation_handler.py.json +18 -0
- src/services/browser_captcha.py +169 -2
- src/services/browser_captcha_personal.py +121 -2
- src/services/flow_client.py +24 -4
.gitignore
CHANGED
|
@@ -54,6 +54,7 @@ logs.txt
|
|
| 54 |
*.cache
|
| 55 |
|
| 56 |
browser_data
|
|
|
|
| 57 |
|
| 58 |
data
|
| 59 |
config/setting.toml
|
|
|
|
| 54 |
*.cache
|
| 55 |
|
| 56 |
browser_data
|
| 57 |
+
browser_data_rt
|
| 58 |
|
| 59 |
data
|
| 60 |
config/setting.toml
|
.lh/.lhignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# list file to not track by the local-history extension. comment line starts with a '#' character
|
| 2 |
+
# each line describe a regular expression pattern (search for 'Javascript regex')
|
| 3 |
+
# it will relate to the workspace directory root. for example:
|
| 4 |
+
# '.*\.txt' ignores any file with 'txt' extension
|
| 5 |
+
# '/test/.*' ignores all the files under the 'test' directory
|
| 6 |
+
# '.*/test/.*' ignores all the files under any 'test' directory (even under sub-folders)
|
.lh/src/services/generation_handler.py.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"sourceFile": "src/services/generation_handler.py",
|
| 3 |
+
"activeCommit": 0,
|
| 4 |
+
"commits": [
|
| 5 |
+
{
|
| 6 |
+
"activePatchIndex": 0,
|
| 7 |
+
"patches": [
|
| 8 |
+
{
|
| 9 |
+
"date": 1770134305752,
|
| 10 |
+
"content": "Index: \n===================================================================\n--- \n+++ \n"
|
| 11 |
+
}
|
| 12 |
+
],
|
| 13 |
+
"date": 1770134305751,
|
| 14 |
+
"name": "Commit-0",
|
| 15 |
+
"content": "\"\"\"Generation handler for Flow2API\"\"\"\r\nimport asyncio\r\nimport base64\r\nimport json\r\nimport time\r\nfrom typing import Optional, AsyncGenerator, List, Dict, Any\r\nfrom ..core.logger import debug_logger\r\nfrom ..core.config import config\r\nfrom ..core.models import Task, RequestLog\r\nfrom .file_cache import FileCache\r\n\r\n\r\n# Model configuration\r\nMODEL_CONFIG = {\r\n # 图片生成 - GEM_PIX (Gemini 2.5 Flash)\r\n \"gemini-2.5-flash-image-landscape\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_LANDSCAPE\"\r\n },\r\n \"gemini-2.5-flash-image-portrait\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_PORTRAIT\"\r\n },\r\n\r\n # 图片生成 - GEM_PIX_2 (Gemini 3.0 Pro)\r\n \"gemini-3.0-pro-image-landscape\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_LANDSCAPE\"\r\n },\r\n \"gemini-3.0-pro-image-portrait\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_PORTRAIT\"\r\n },\r\n \"gemini-3.0-pro-image-square\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_SQUARE\"\r\n },\r\n \"gemini-3.0-pro-image-four-three\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_LANDSCAPE_FOUR_THREE\"\r\n },\r\n \"gemini-3.0-pro-image-three-four\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_PORTRAIT_THREE_FOUR\"\r\n },\r\n\r\n # 图片生成 - GEM_PIX_2 (Gemini 3.0 Pro) 2K 放大版\r\n \"gemini-3.0-pro-image-landscape-2k\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_LANDSCAPE\",\r\n \"upsample\": \"UPSAMPLE_IMAGE_RESOLUTION_2K\"\r\n },\r\n \"gemini-3.0-pro-image-portrait-2k\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_PORTRAIT\",\r\n \"upsample\": \"UPSAMPLE_IMAGE_RESOLUTION_2K\"\r\n },\r\n \"gemini-3.0-pro-image-square-2k\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_SQUARE\",\r\n \"upsample\": \"UPSAMPLE_IMAGE_RESOLUTION_2K\"\r\n },\r\n \"gemini-3.0-pro-image-four-three-2k\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_LANDSCAPE_FOUR_THREE\",\r\n \"upsample\": \"UPSAMPLE_IMAGE_RESOLUTION_2K\"\r\n },\r\n \"gemini-3.0-pro-image-three-four-2k\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_PORTRAIT_THREE_FOUR\",\r\n \"upsample\": \"UPSAMPLE_IMAGE_RESOLUTION_2K\"\r\n },\r\n\r\n # 图片生成 - GEM_PIX_2 (Gemini 3.0 Pro) 4K 放大版\r\n \"gemini-3.0-pro-image-landscape-4k\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_LANDSCAPE\",\r\n \"upsample\": \"UPSAMPLE_IMAGE_RESOLUTION_4K\"\r\n },\r\n \"gemini-3.0-pro-image-portrait-4k\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_PORTRAIT\",\r\n \"upsample\": \"UPSAMPLE_IMAGE_RESOLUTION_4K\"\r\n },\r\n \"gemini-3.0-pro-image-square-4k\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_SQUARE\",\r\n \"upsample\": \"UPSAMPLE_IMAGE_RESOLUTION_4K\"\r\n },\r\n \"gemini-3.0-pro-image-four-three-4k\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_LANDSCAPE_FOUR_THREE\",\r\n \"upsample\": \"UPSAMPLE_IMAGE_RESOLUTION_4K\"\r\n },\r\n \"gemini-3.0-pro-image-three-four-4k\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_PORTRAIT_THREE_FOUR\",\r\n \"upsample\": \"UPSAMPLE_IMAGE_RESOLUTION_4K\"\r\n },\r\n\r\n # 图片生成 - IMAGEN_3_5 (Imagen 4.0)\r\n \"imagen-4.0-generate-preview-landscape\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"IMAGEN_3_5\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_LANDSCAPE\"\r\n },\r\n \"imagen-4.0-generate-preview-portrait\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"IMAGEN_3_5\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_PORTRAIT\"\r\n },\r\n\r\n # ========== 文生视频 (T2V - Text to Video) ==========\r\n # 不支持上传图片,只使用文本提示词生成\r\n\r\n # veo_3_1_t2v_fast_portrait (竖屏)\r\n # 上游模型名: veo_3_1_t2v_fast_portrait\r\n \"veo_3_1_t2v_fast_portrait\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast_portrait\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": False\r\n },\r\n # veo_3_1_t2v_fast_landscape (横屏)\r\n # 上游模型名: veo_3_1_t2v_fast\r\n \"veo_3_1_t2v_fast_landscape\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": False\r\n },\r\n\r\n # veo_2_1_fast_d_15_t2v (需要新增横竖屏)\r\n \"veo_2_1_fast_d_15_t2v_portrait\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_2_1_fast_d_15_t2v\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": False\r\n },\r\n \"veo_2_1_fast_d_15_t2v_landscape\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_2_1_fast_d_15_t2v\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": False\r\n },\r\n\r\n # veo_2_0_t2v (需要新增横竖屏)\r\n \"veo_2_0_t2v_portrait\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_2_0_t2v\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": False\r\n },\r\n \"veo_2_0_t2v_landscape\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_2_0_t2v\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": False\r\n },\r\n\r\n # veo_3_1_t2v_fast_ultra (横竖屏)\r\n \"veo_3_1_t2v_fast_portrait_ultra\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast_portrait_ultra\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": False\r\n },\r\n \"veo_3_1_t2v_fast_ultra\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast_ultra\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": False\r\n },\r\n\r\n # veo_3_1_t2v_fast_ultra_relaxed (横竖屏)\r\n \"veo_3_1_t2v_fast_portrait_ultra_relaxed\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast_portrait_ultra_relaxed\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": False\r\n },\r\n \"veo_3_1_t2v_fast_ultra_relaxed\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast_ultra_relaxed\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": False\r\n },\r\n\r\n # veo_3_1_t2v (横竖屏)\r\n \"veo_3_1_t2v_portrait\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_portrait\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": False\r\n },\r\n \"veo_3_1_t2v_landscape\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": False\r\n },\r\n\r\n # ========== 首尾帧模型 (I2V - Image to Video) ==========\r\n # 支持1-2张图片:1张作为首帧,2张作为首尾帧\r\n\r\n # veo_3_1_i2v_s_fast_fl (需要新增横竖屏)\r\n \"veo_3_1_i2v_s_fast_portrait_fl\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_3_1_i2v_s_fast_portrait_fl\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2\r\n },\r\n \"veo_3_1_i2v_s_fast_fl\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_3_1_i2v_s_fast_fl\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2\r\n },\r\n\r\n # veo_2_1_fast_d_15_i2v (需要新增横竖屏)\r\n \"veo_2_1_fast_d_15_i2v_portrait\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_2_1_fast_d_15_i2v\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2\r\n },\r\n \"veo_2_1_fast_d_15_i2v_landscape\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_2_1_fast_d_15_i2v\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2\r\n },\r\n\r\n # veo_2_0_i2v (需要新增横竖屏)\r\n \"veo_2_0_i2v_portrait\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_2_0_i2v\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2\r\n },\r\n \"veo_2_0_i2v_landscape\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_2_0_i2v\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2\r\n },\r\n\r\n # veo_3_1_i2v_s_fast_ultra (横竖屏)\r\n \"veo_3_1_i2v_s_fast_portrait_ultra_fl\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_3_1_i2v_s_fast_portrait_ultra_fl\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2\r\n },\r\n \"veo_3_1_i2v_s_fast_ultra_fl\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_3_1_i2v_s_fast_ultra_fl\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2\r\n },\r\n\r\n # veo_3_1_i2v_s_fast_ultra_relaxed (需要新增横竖屏)\r\n \"veo_3_1_i2v_s_fast_portrait_ultra_relaxed\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_3_1_i2v_s_fast_portrait_ultra_relaxed\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2\r\n },\r\n \"veo_3_1_i2v_s_fast_ultra_relaxed\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_3_1_i2v_s_fast_ultra_relaxed\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2\r\n },\r\n\r\n # veo_3_1_i2v_s (需要新增横竖屏)\r\n \"veo_3_1_i2v_s_portrait\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_3_1_i2v_s\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2\r\n },\r\n \"veo_3_1_i2v_s_landscape\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_3_1_i2v_s\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2\r\n },\r\n\r\n # ========== 多图生成 (R2V - Reference Images to Video) ==========\r\n # 支持多张图片,不限制数量\r\n\r\n # veo_3_1_r2v_fast (横竖屏)\r\n \"veo_3_1_r2v_fast_portrait\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"r2v\",\r\n \"model_key\": \"veo_3_1_r2v_fast_portrait\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 0,\r\n \"max_images\": None # 不限制\r\n },\r\n \"veo_3_1_r2v_fast\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"r2v\",\r\n \"model_key\": \"veo_3_1_r2v_fast\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 0,\r\n \"max_images\": None # 不限制\r\n },\r\n\r\n # veo_3_1_r2v_fast_ultra (横竖屏)\r\n \"veo_3_1_r2v_fast_portrait_ultra\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"r2v\",\r\n \"model_key\": \"veo_3_1_r2v_fast_portrait_ultra\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 0,\r\n \"max_images\": None # 不限制\r\n },\r\n \"veo_3_1_r2v_fast_ultra\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"r2v\",\r\n \"model_key\": \"veo_3_1_r2v_fast_ultra\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 0,\r\n \"max_images\": None # 不限制\r\n },\r\n\r\n # veo_3_1_r2v_fast_ultra_relaxed (横竖屏)\r\n \"veo_3_1_r2v_fast_portrait_ultra_relaxed\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"r2v\",\r\n \"model_key\": \"veo_3_1_r2v_fast_portrait_ultra_relaxed\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 0,\r\n \"max_images\": None # 不限制\r\n },\r\n \"veo_3_1_r2v_fast_ultra_relaxed\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"r2v\",\r\n \"model_key\": \"veo_3_1_r2v_fast_ultra_relaxed\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 0,\r\n \"max_images\": None # 不限制\r\n },\r\n\r\n # ========== 视频放大 (Video Upsampler) ==========\r\n # 仅 3.1 支持,需要先生成视频后再放大,可能需要 30 分钟\r\n\r\n # T2V 4K 放大版\r\n \"veo_3_1_t2v_fast_portrait_4k\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast_portrait\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": False,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_4K\", \"model_key\": \"veo_3_1_upsampler_4k\"}\r\n },\r\n \"veo_3_1_t2v_fast_4k\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": False,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_4K\", \"model_key\": \"veo_3_1_upsampler_4k\"}\r\n },\r\n \"veo_3_1_t2v_fast_portrait_ultra_4k\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast_portrait_ultra\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": False,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_4K\", \"model_key\": \"veo_3_1_upsampler_4k\"}\r\n },\r\n \"veo_3_1_t2v_fast_ultra_4k\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast_ultra\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": False,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_4K\", \"model_key\": \"veo_3_1_upsampler_4k\"}\r\n },\r\n\r\n # T2V 1080P 放大版\r\n \"veo_3_1_t2v_fast_portrait_1080p\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast_portrait\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": False,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_1080P\", \"model_key\": \"veo_3_1_upsampler_1080p\"}\r\n },\r\n \"veo_3_1_t2v_fast_1080p\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": False,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_1080P\", \"model_key\": \"veo_3_1_upsampler_1080p\"}\r\n },\r\n \"veo_3_1_t2v_fast_portrait_ultra_1080p\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast_portrait_ultra\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": False,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_1080P\", \"model_key\": \"veo_3_1_upsampler_1080p\"}\r\n },\r\n \"veo_3_1_t2v_fast_ultra_1080p\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast_ultra\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": False,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_1080P\", \"model_key\": \"veo_3_1_upsampler_1080p\"}\r\n },\r\n\r\n # I2V 4K 放大版\r\n \"veo_3_1_i2v_s_fast_portrait_ultra_fl_4k\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_3_1_i2v_s_fast_portrait_ultra_fl\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_4K\", \"model_key\": \"veo_3_1_upsampler_4k\"}\r\n },\r\n \"veo_3_1_i2v_s_fast_ultra_fl_4k\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_3_1_i2v_s_fast_ultra_fl\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_4K\", \"model_key\": \"veo_3_1_upsampler_4k\"}\r\n },\r\n\r\n # I2V 1080P 放大版\r\n \"veo_3_1_i2v_s_fast_portrait_ultra_fl_1080p\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_3_1_i2v_s_fast_portrait_ultra_fl\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_1080P\", \"model_key\": \"veo_3_1_upsampler_1080p\"}\r\n },\r\n \"veo_3_1_i2v_s_fast_ultra_fl_1080p\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_3_1_i2v_s_fast_ultra_fl\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_1080P\", \"model_key\": \"veo_3_1_upsampler_1080p\"}\r\n },\r\n\r\n # R2V 4K 放大版\r\n \"veo_3_1_r2v_fast_portrait_ultra_4k\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"r2v\",\r\n \"model_key\": \"veo_3_1_r2v_fast_portrait_ultra\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 0,\r\n \"max_images\": None,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_4K\", \"model_key\": \"veo_3_1_upsampler_4k\"}\r\n },\r\n \"veo_3_1_r2v_fast_ultra_4k\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"r2v\",\r\n \"model_key\": \"veo_3_1_r2v_fast_ultra\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 0,\r\n \"max_images\": None,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_4K\", \"model_key\": \"veo_3_1_upsampler_4k\"}\r\n },\r\n\r\n # R2V 1080P 放大版\r\n \"veo_3_1_r2v_fast_portrait_ultra_1080p\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"r2v\",\r\n \"model_key\": \"veo_3_1_r2v_fast_portrait_ultra\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 0,\r\n \"max_images\": None,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_1080P\", \"model_key\": \"veo_3_1_upsampler_1080p\"}\r\n },\r\n \"veo_3_1_r2v_fast_ultra_1080p\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"r2v\",\r\n \"model_key\": \"veo_3_1_r2v_fast_ultra\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 0,\r\n \"max_images\": None,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_1080P\", \"model_key\": \"veo_3_1_upsampler_1080p\"}\r\n }\r\n}\r\n\r\n\r\nclass GenerationHandler:\r\n \"\"\"统一生成处理器\"\"\"\r\n\r\n def __init__(self, flow_client, token_manager, load_balancer, db, concurrency_manager, proxy_manager):\r\n self.flow_client = flow_client\r\n self.token_manager = token_manager\r\n self.load_balancer = load_balancer\r\n self.db = db\r\n self.concurrency_manager = concurrency_manager\r\n self.file_cache = FileCache(\r\n cache_dir=\"tmp\",\r\n default_timeout=config.cache_timeout,\r\n proxy_manager=proxy_manager\r\n )\r\n\r\n async def check_token_availability(self, is_image: bool, is_video: bool) -> bool:\r\n \"\"\"检查Token可用性\r\n\r\n Args:\r\n is_image: 是否检查图片生成Token\r\n is_video: 是否检查视频生成Token\r\n\r\n Returns:\r\n True表示有可用Token, False表示无可用Token\r\n \"\"\"\r\n token_obj = await self.load_balancer.select_token(\r\n for_image_generation=is_image,\r\n for_video_generation=is_video\r\n )\r\n return token_obj is not None\r\n\r\n async def handle_generation(\r\n self,\r\n model: str,\r\n prompt: str,\r\n images: Optional[List[bytes]] = None,\r\n stream: bool = False\r\n ) -> AsyncGenerator:\r\n \"\"\"统一生成入口\r\n\r\n Args:\r\n model: 模型名称\r\n prompt: 提示词\r\n images: 图片列表 (bytes格式)\r\n stream: 是否流式输出\r\n \"\"\"\r\n start_time = time.time()\r\n token = None\r\n\r\n # 1. 验证模型\r\n if model not in MODEL_CONFIG:\r\n error_msg = f\"不支持的模型: {model}\"\r\n debug_logger.log_error(error_msg)\r\n yield self._create_error_response(error_msg)\r\n return\r\n\r\n model_config = MODEL_CONFIG[model]\r\n generation_type = model_config[\"type\"]\r\n debug_logger.log_info(f\"[GENERATION] 开始生成 - 模型: {model}, 类型: {generation_type}, Prompt: {prompt[:50]}...\")\r\n\r\n # 非流式��式: 只检查可用性\r\n if not stream:\r\n is_image = (generation_type == \"image\")\r\n is_video = (generation_type == \"video\")\r\n available = await self.check_token_availability(is_image, is_video)\r\n\r\n if available:\r\n if is_image:\r\n message = \"所有Token可用于图片生成。请启用流式模式使用生成功能。\"\r\n else:\r\n message = \"所有Token可用于视频生成。请启用流式模式使用生成功能。\"\r\n else:\r\n if is_image:\r\n message = \"没有可用的Token进行图片生成\"\r\n else:\r\n message = \"没有可用的Token进行视频生成\"\r\n\r\n yield self._create_completion_response(message, is_availability_check=True)\r\n return\r\n\r\n # 向用户展示开始信息\r\n if stream:\r\n yield self._create_stream_chunk(\r\n f\"✨ {'视频' if generation_type == 'video' else '图片'}生成任务已启动\\n\",\r\n role=\"assistant\"\r\n )\r\n\r\n # 2. 选择Token\r\n debug_logger.log_info(f\"[GENERATION] 正在选择可用Token...\")\r\n\r\n if generation_type == \"image\":\r\n token = await self.load_balancer.select_token(for_image_generation=True, model=model)\r\n else:\r\n token = await self.load_balancer.select_token(for_video_generation=True, model=model)\r\n\r\n if not token:\r\n error_msg = self._get_no_token_error_message(generation_type)\r\n debug_logger.log_error(f\"[GENERATION] {error_msg}\")\r\n if stream:\r\n yield self._create_stream_chunk(f\"❌ {error_msg}\\n\")\r\n yield self._create_error_response(error_msg)\r\n return\r\n\r\n debug_logger.log_info(f\"[GENERATION] 已选择Token: {token.id} ({token.email})\")\r\n\r\n try:\r\n # 3. 确保AT有效\r\n debug_logger.log_info(f\"[GENERATION] 检查Token AT有效性...\")\r\n if stream:\r\n yield self._create_stream_chunk(\"初始化生成环境...\\n\")\r\n\r\n if not await self.token_manager.is_at_valid(token.id):\r\n error_msg = \"Token AT无效或刷新失败\"\r\n debug_logger.log_error(f\"[GENERATION] {error_msg}\")\r\n if stream:\r\n yield self._create_stream_chunk(f\"❌ {error_msg}\\n\")\r\n yield self._create_error_response(error_msg)\r\n return\r\n\r\n # 重新获取token (AT可能已刷新)\r\n token = await self.token_manager.get_token(token.id)\r\n\r\n # 4. 确保Project存在\r\n debug_logger.log_info(f\"[GENERATION] 检查/创建Project...\")\r\n\r\n project_id = await self.token_manager.ensure_project_exists(token.id)\r\n debug_logger.log_info(f\"[GENERATION] Project ID: {project_id}\")\r\n\r\n # 5. 根据类型处理\r\n if generation_type == \"image\":\r\n debug_logger.log_info(f\"[GENERATION] 开始图片生成流程...\")\r\n async for chunk in self._handle_image_generation(\r\n token, project_id, model_config, prompt, images, stream\r\n ):\r\n yield chunk\r\n else: # video\r\n debug_logger.log_info(f\"[GENERATION] 开始视频生成流程...\")\r\n async for chunk in self._handle_video_generation(\r\n token, project_id, model_config, prompt, images, stream\r\n ):\r\n yield chunk\r\n\r\n # 6. 记录使用\r\n is_video = (generation_type == \"video\")\r\n await self.token_manager.record_usage(token.id, is_video=is_video)\r\n\r\n # 重置错误计数 (请求成功时清空连续错误计数)\r\n await self.token_manager.record_success(token.id)\r\n\r\n debug_logger.log_info(f\"[GENERATION] ✅ 生成成功完成\")\r\n\r\n # 7. 记录成功日志\r\n duration = time.time() - start_time\r\n\r\n # 构建响应数据,包含生成的URL\r\n response_data = {\r\n \"status\": \"success\",\r\n \"model\": model,\r\n \"prompt\": prompt[:100]\r\n }\r\n\r\n # 添加生成的URL(如果有)\r\n if hasattr(self, '_last_generated_url') and self._last_generated_url:\r\n response_data[\"url\"] = self._last_generated_url\r\n # 清除临时存储\r\n self._last_generated_url = None\r\n\r\n await self._log_request(\r\n token.id,\r\n f\"generate_{generation_type}\",\r\n {\"model\": model, \"prompt\": prompt[:100], \"has_images\": images is not None and len(images) > 0},\r\n response_data,\r\n 200,\r\n duration\r\n )\r\n\r\n except Exception as e:\r\n error_msg = f\"生成失败: {str(e)}\"\r\n debug_logger.log_error(f\"[GENERATION] ❌ {error_msg}\")\r\n if stream:\r\n yield self._create_stream_chunk(f\"❌ {error_msg}\\n\")\r\n if token:\r\n # 记录错误(所有错误统一处理,不再特殊处理429)\r\n await self.token_manager.record_error(token.id)\r\n yield self._create_error_response(error_msg)\r\n\r\n # 记录失败日志\r\n duration = time.time() - start_time\r\n await self._log_request(\r\n token.id if token else None,\r\n f\"generate_{generation_type if model_config else 'unknown'}\",\r\n {\"model\": model, \"prompt\": prompt[:100], \"has_images\": images is not None and len(images) > 0},\r\n {\"error\": error_msg},\r\n 500,\r\n duration\r\n )\r\n\r\n def _get_no_token_error_message(self, generation_type: str) -> str:\r\n \"\"\"获取无可用Token时的详细错误信息\"\"\"\r\n if generation_type == \"image\":\r\n return \"没有可用的Token进行图片生成。所有Token都处于禁用、冷却、锁定或已过期状态。\"\r\n else:\r\n return \"没有可用的Token进行视频生成。所有Token都处于禁用、冷却、配额耗尽或已过期状态。\"\r\n\r\n async def _handle_image_generation(\r\n self,\r\n token,\r\n project_id: str,\r\n model_config: dict,\r\n prompt: str,\r\n images: Optional[List[bytes]],\r\n stream: bool\r\n ) -> AsyncGenerator:\r\n \"\"\"处理图片生成 (同步返回)\"\"\"\r\n\r\n # 获取并发槽位\r\n if self.concurrency_manager:\r\n if not await self.concurrency_manager.acquire_image(token.id):\r\n yield self._create_error_response(\"图片并发限制已达上限\")\r\n return\r\n\r\n try:\r\n # 上传图片 (如果有)\r\n image_inputs = []\r\n if images and len(images) > 0:\r\n if stream:\r\n yield self._create_stream_chunk(f\"上传 {len(images)} 张参考图片...\\n\")\r\n\r\n # 支持多图输入\r\n for idx, image_bytes in enumerate(images):\r\n media_id = await self.flow_client.upload_image(\r\n token.at,\r\n image_bytes,\r\n model_config[\"aspect_ratio\"]\r\n )\r\n image_inputs.append({\r\n \"name\": media_id,\r\n \"imageInputType\": \"IMAGE_INPUT_TYPE_REFERENCE\"\r\n })\r\n if stream:\r\n yield self._create_stream_chunk(f\"已上传第 {idx + 1}/{len(images)} 张图片\\n\")\r\n\r\n # 调用生成API\r\n if stream:\r\n yield self._create_stream_chunk(\"正在生成图片...\\n\")\r\n\r\n result = await self.flow_client.generate_image(\r\n at=token.at,\r\n project_id=project_id,\r\n prompt=prompt,\r\n model_name=model_config[\"model_name\"],\r\n aspect_ratio=model_config[\"aspect_ratio\"],\r\n image_inputs=image_inputs\r\n )\r\n\r\n # 提取URL和mediaId\r\n media = result.get(\"media\", [])\r\n if not media:\r\n yield self._create_error_response(\"生成结果为空\")\r\n return\r\n\r\n image_url = media[0][\"image\"][\"generatedImage\"][\"fifeUrl\"]\r\n media_id = media[0].get(\"name\") # 用于 upsample\r\n\r\n # 检查是否需要 upsample\r\n upsample_resolution = model_config.get(\"upsample\")\r\n if upsample_resolution and media_id:\r\n resolution_name = \"4K\" if \"4K\" in upsample_resolution else \"2K\"\r\n if stream:\r\n yield self._create_stream_chunk(f\"正在放大图片到 {resolution_name}...\\n\")\r\n\r\n # 4K/2K 图片重试逻辑 - 最多重试3次\r\n max_retries = 3\r\n for retry_attempt in range(max_retries):\r\n try:\r\n # 调用 upsample API\r\n encoded_image = await self.flow_client.upsample_image(\r\n at=token.at,\r\n project_id=project_id,\r\n media_id=media_id,\r\n target_resolution=upsample_resolution\r\n )\r\n\r\n if encoded_image:\r\n debug_logger.log_info(f\"[UPSAMPLE] 图片已放大到 {resolution_name}\")\r\n\r\n if stream:\r\n yield self._create_stream_chunk(f\"✅ 图片已放大到 {resolution_name}\\n\")\r\n\r\n # 缓存放大后的图片 (如果启用)\r\n # 日志统一记录原图URL (放大后的base64数据太大,不适合存储)\r\n self._last_generated_url = image_url\r\n\r\n if config.cache_enabled:\r\n try:\r\n if stream:\r\n yield self._create_stream_chunk(f\"缓存 {resolution_name} 图片中...\\n\")\r\n cached_filename = await self.file_cache.cache_base64_image(encoded_image, resolution_name)\r\n local_url = f\"{self._get_base_url()}/tmp/{cached_filename}\"\r\n if stream:\r\n yield self._create_stream_chunk(f\"✅ {resolution_name} 图片缓存成功\\n\")\r\n yield self._create_stream_chunk(\r\n f\"\",\r\n finish_reason=\"stop\"\r\n )\r\n else:\r\n yield self._create_completion_response(\r\n local_url,\r\n media_type=\"image\"\r\n )\r\n return\r\n except Exception as e:\r\n debug_logger.log_error(f\"Failed to cache {resolution_name} image: {str(e)}\")\r\n if stream:\r\n yield self._create_stream_chunk(f\"⚠️ 缓存失败: {str(e)},返回 base64...\\n\")\r\n\r\n # 缓存未启用或缓存失败,返回 base64 格式\r\n base64_url = f\"data:image/jpeg;base64,{encoded_image}\"\r\n if stream:\r\n yield self._create_stream_chunk(\r\n f\"\",\r\n finish_reason=\"stop\"\r\n )\r\n else:\r\n yield self._create_completion_response(\r\n base64_url,\r\n media_type=\"image\"\r\n )\r\n return\r\n else:\r\n debug_logger.log_warning(\"[UPSAMPLE] 返回结果为空\")\r\n if stream:\r\n yield self._create_stream_chunk(f\"⚠️ 放大失败,返回原图...\\n\")\r\n break # 空结果不重试\r\n\r\n except Exception as e:\r\n error_str = str(e)\r\n debug_logger.log_error(f\"[UPSAMPLE] 放大失败 (尝试 {retry_attempt + 1}/{max_retries}): {error_str}\")\r\n \r\n # 检查是否是可重试错误(403、reCAPTCHA、超时等)\r\n retry_reason = self.flow_client._get_retry_reason(error_str)\r\n if retry_reason and retry_attempt < max_retries - 1:\r\n if stream:\r\n yield self._create_stream_chunk(f\"⚠️ 放大遇到{retry_reason},正在重试 ({retry_attempt + 2}/{max_retries})...\\n\")\r\n # 等待一小段时间后重试\r\n await asyncio.sleep(1)\r\n continue\r\n else:\r\n if stream:\r\n yield self._create_stream_chunk(f\"⚠️ 放大失败: {error_str},返回原图...\\n\")\r\n break\r\n\r\n # 缓存图片 (如果启用)\r\n local_url = image_url\r\n if config.cache_enabled:\r\n try:\r\n if stream:\r\n yield self._create_stream_chunk(\"缓存图片中...\\n\")\r\n cached_filename = await self.file_cache.download_and_cache(image_url, \"image\")\r\n local_url = f\"{self._get_base_url()}/tmp/{cached_filename}\"\r\n if stream:\r\n yield self._create_stream_chunk(\"✅ 图片缓存成功,准备返回缓存地址...\\n\")\r\n except Exception as e:\r\n debug_logger.log_error(f\"Failed to cache image: {str(e)}\")\r\n # 缓存失败不影响结果返回,使用原始URL\r\n local_url = image_url\r\n if stream:\r\n yield self._create_stream_chunk(f\"⚠️ 缓存失败: {str(e)}\\n正在返回源链接...\\n\")\r\n else:\r\n if stream:\r\n yield self._create_stream_chunk(\"缓存已关闭,正在返回源链接...\\n\")\r\n\r\n # 返回结果\r\n # 存储URL用于日志记录\r\n self._last_generated_url = local_url\r\n\r\n if stream:\r\n yield self._create_stream_chunk(\r\n f\"\",\r\n finish_reason=\"stop\"\r\n )\r\n else:\r\n yield self._create_completion_response(\r\n local_url, # 直接传URL,让方法内部格式化\r\n media_type=\"image\"\r\n )\r\n\r\n finally:\r\n # 释放并发槽位\r\n if self.concurrency_manager:\r\n await self.concurrency_manager.release_image(token.id)\r\n\r\n async def _handle_video_generation(\r\n self,\r\n token,\r\n project_id: str,\r\n model_config: dict,\r\n prompt: str,\r\n images: Optional[List[bytes]],\r\n stream: bool\r\n ) -> AsyncGenerator:\r\n \"\"\"处理视频生成 (异步轮询)\"\"\"\r\n\r\n # 获取并发槽位\r\n if self.concurrency_manager:\r\n if not await self.concurrency_manager.acquire_video(token.id):\r\n yield self._create_error_response(\"视频并发限制已达上限\")\r\n return\r\n\r\n try:\r\n # 获取模型类型和配置\r\n video_type = model_config.get(\"video_type\")\r\n supports_images = model_config.get(\"supports_images\", False)\r\n min_images = model_config.get(\"min_images\", 0)\r\n max_images = model_config.get(\"max_images\", 0)\r\n\r\n # 根据账号tier自动调整模型 key\r\n model_key = model_config[\"model_key\"]\r\n user_tier = token.user_paygate_tier or \"PAYGATE_TIER_ONE\"\r\n\r\n # TIER_TWO 账号需要使用 ultra 版本的模型\r\n if user_tier == \"PAYGATE_TIER_TWO\":\r\n # 如果模型 key 不包含 ultra,自动添加\r\n if \"ultra\" not in model_key:\r\n # veo_3_1_i2v_s_fast_fl -> veo_3_1_i2v_s_fast_ultra_fl\r\n # veo_3_1_i2v_s_fast_portrait_fl -> veo_3_1_i2v_s_fast_portrait_ultra_fl\r\n # veo_3_1_t2v_fast -> veo_3_1_t2v_fast_ultra\r\n # veo_3_1_t2v_fast_portrait -> veo_3_1_t2v_fast_portrait_ultra\r\n # veo_3_0_r2v_fast -> veo_3_0_r2v_fast_ultra\r\n if \"_fl\" in model_key:\r\n model_key = model_key.replace(\"_fl\", \"_ultra_fl\")\r\n else:\r\n # 直接在末尾添加 _ultra\r\n model_key = model_key + \"_ultra\"\r\n \r\n if stream:\r\n yield self._create_stream_chunk(f\"TIER_TWO 账号自动切换到 ultra 模型: {model_key}\\n\")\r\n debug_logger.log_info(f\"[VIDEO] TIER_TWO 账号,模型自动调整: {model_config['model_key']} -> {model_key}\")\r\n\r\n # TIER_ONE 账号需要使用非 ultra 版本\r\n elif user_tier == \"PAYGATE_TIER_ONE\":\r\n # 如果模型 key 包含 ultra,需要移除(避免用户误用)\r\n if \"ultra\" in model_key:\r\n # veo_3_1_i2v_s_fast_ultra_fl -> veo_3_1_i2v_s_fast_fl\r\n # veo_3_1_t2v_fast_ultra -> veo_3_1_t2v_fast\r\n model_key = model_key.replace(\"_ultra_fl\", \"_fl\").replace(\"_ultra\", \"\")\r\n \r\n if stream:\r\n yield self._create_stream_chunk(f\"TIER_ONE 账号自动切换到标准模型: {model_key}\\n\")\r\n debug_logger.log_info(f\"[VIDEO] TIER_ONE 账号,模型自动调整: {model_config['model_key']} -> {model_key}\")\r\n\r\n # 更新 model_config 中的 model_key\r\n model_config = dict(model_config) # 创建副本避免修改原配置\r\n model_config[\"model_key\"] = model_key\r\n\r\n # 图片数量\r\n image_count = len(images) if images else 0\r\n\r\n # ========== 验证和处理图片 ==========\r\n\r\n # T2V: 文生视频 - 不支持图片\r\n if video_type == \"t2v\":\r\n if image_count > 0:\r\n if stream:\r\n yield self._create_stream_chunk(\"⚠️ 文生视频模型不支持上传图片,将忽略图片仅使用文本提示词生成\\n\")\r\n debug_logger.log_warning(f\"[T2V] 模型 {model_config['model_key']} 不支持图片,已忽略 {image_count} 张图片\")\r\n images = None # 清空图片\r\n image_count = 0\r\n\r\n # I2V: 首尾帧模型 - 需要1-2张图片\r\n elif video_type == \"i2v\":\r\n if image_count < min_images or image_count > max_images:\r\n error_msg = f\"❌ 首尾帧模型需要 {min_images}-{max_images} 张图片,当前提供了 {image_count} 张\"\r\n if stream:\r\n yield self._create_stream_chunk(f\"{error_msg}\\n\")\r\n yield self._create_error_response(error_msg)\r\n return\r\n\r\n # R2V: 多图生成 - 支持多张图片,不限制数量\r\n elif video_type == \"r2v\":\r\n # 不再限制最大图片数量\r\n pass\r\n\r\n # ========== 上传图片 ==========\r\n start_media_id = None\r\n end_media_id = None\r\n reference_images = []\r\n\r\n # I2V: 首尾帧处理\r\n if video_type == \"i2v\" and images:\r\n if image_count == 1:\r\n # 只有1张图: 仅作为首帧\r\n if stream:\r\n yield self._create_stream_chunk(\"上传首帧图片...\\n\")\r\n start_media_id = await self.flow_client.upload_image(\r\n token.at, images[0], model_config[\"aspect_ratio\"]\r\n )\r\n debug_logger.log_info(f\"[I2V] 仅上传首帧: {start_media_id}\")\r\n\r\n elif image_count == 2:\r\n # 2张图: 首帧+尾帧\r\n if stream:\r\n yield self._create_stream_chunk(\"上传首帧和尾帧图片...\\n\")\r\n start_media_id = await self.flow_client.upload_image(\r\n token.at, images[0], model_config[\"aspect_ratio\"]\r\n )\r\n end_media_id = await self.flow_client.upload_image(\r\n token.at, images[1], model_config[\"aspect_ratio\"]\r\n )\r\n debug_logger.log_info(f\"[I2V] 上传首尾帧: {start_media_id}, {end_media_id}\")\r\n\r\n # R2V: 多图处理\r\n elif video_type == \"r2v\" and images:\r\n if stream:\r\n yield self._create_stream_chunk(f\"上传 {image_count} 张参考图片...\\n\")\r\n\r\n for idx, img in enumerate(images): # 上传所有图片,不限制数量\r\n media_id = await self.flow_client.upload_image(\r\n token.at, img, model_config[\"aspect_ratio\"]\r\n )\r\n reference_images.append({\r\n \"imageUsageType\": \"IMAGE_USAGE_TYPE_ASSET\",\r\n \"mediaId\": media_id\r\n })\r\n debug_logger.log_info(f\"[R2V] 上传了 {len(reference_images)} 张参考图片\")\r\n\r\n # ========== 调用生成API ==========\r\n if stream:\r\n yield self._create_stream_chunk(\"提交视频生成任务...\\n\")\r\n\r\n # I2V: 首尾帧生成\r\n if video_type == \"i2v\" and start_media_id:\r\n if end_media_id:\r\n # 有首尾帧\r\n result = await self.flow_client.generate_video_start_end(\r\n at=token.at,\r\n project_id=project_id,\r\n prompt=prompt,\r\n model_key=model_config[\"model_key\"],\r\n aspect_ratio=model_config[\"aspect_ratio\"],\r\n start_media_id=start_media_id,\r\n end_media_id=end_media_id,\r\n user_paygate_tier=token.user_paygate_tier or \"PAYGATE_TIER_ONE\"\r\n )\r\n else:\r\n # 只有首帧 - 需要去掉 model_key 中的 _fl\r\n # 情况1: _fl_ 在中间 (如 veo_3_1_i2v_s_fast_fl_ultra_relaxed -> veo_3_1_i2v_s_fast_ultra_relaxed)\r\n # 情况2: _fl 在结尾 (如 veo_3_1_i2v_s_fast_ultra_fl -> veo_3_1_i2v_s_fast_ultra)\r\n actual_model_key = model_config[\"model_key\"].replace(\"_fl_\", \"_\")\r\n if actual_model_key.endswith(\"_fl\"):\r\n actual_model_key = actual_model_key[:-3]\r\n debug_logger.log_info(f\"[I2V] 单帧模式,model_key: {model_config['model_key']} -> {actual_model_key}\")\r\n result = await self.flow_client.generate_video_start_image(\r\n at=token.at,\r\n project_id=project_id,\r\n prompt=prompt,\r\n model_key=actual_model_key,\r\n aspect_ratio=model_config[\"aspect_ratio\"],\r\n start_media_id=start_media_id,\r\n user_paygate_tier=token.user_paygate_tier or \"PAYGATE_TIER_ONE\"\r\n )\r\n\r\n # R2V: 多图生成\r\n elif video_type == \"r2v\" and reference_images:\r\n result = await self.flow_client.generate_video_reference_images(\r\n at=token.at,\r\n project_id=project_id,\r\n prompt=prompt,\r\n model_key=model_config[\"model_key\"],\r\n aspect_ratio=model_config[\"aspect_ratio\"],\r\n reference_images=reference_images,\r\n user_paygate_tier=token.user_paygate_tier or \"PAYGATE_TIER_ONE\"\r\n )\r\n\r\n # T2V 或 R2V无图: 纯文本生成\r\n else:\r\n result = await self.flow_client.generate_video_text(\r\n at=token.at,\r\n project_id=project_id,\r\n prompt=prompt,\r\n model_key=model_config[\"model_key\"],\r\n aspect_ratio=model_config[\"aspect_ratio\"],\r\n user_paygate_tier=token.user_paygate_tier or \"PAYGATE_TIER_ONE\"\r\n )\r\n\r\n # 获取task_id和operations\r\n operations = result.get(\"operations\", [])\r\n if not operations:\r\n yield self._create_error_response(\"生成任务创建失败\")\r\n return\r\n\r\n operation = operations[0]\r\n task_id = operation[\"operation\"][\"name\"]\r\n scene_id = operation.get(\"sceneId\")\r\n\r\n # 保存Task到数据库\r\n task = Task(\r\n task_id=task_id,\r\n token_id=token.id,\r\n model=model_config[\"model_key\"],\r\n prompt=prompt,\r\n status=\"processing\",\r\n scene_id=scene_id\r\n )\r\n await self.db.create_task(task)\r\n\r\n # 轮询结果\r\n if stream:\r\n yield self._create_stream_chunk(f\"视频生成中...\\n\")\r\n\r\n # 检查是否需要放大\r\n upsample_config = model_config.get(\"upsample\")\r\n\r\n async for chunk in self._poll_video_result(token, project_id, operations, stream, upsample_config):\r\n yield chunk\r\n\r\n finally:\r\n # 释放并发槽位\r\n if self.concurrency_manager:\r\n await self.concurrency_manager.release_video(token.id)\r\n\r\n async def _poll_video_result(\r\n self,\r\n token,\r\n project_id: str,\r\n operations: List[Dict],\r\n stream: bool,\r\n upsample_config: Optional[Dict] = None\r\n ) -> AsyncGenerator:\r\n \"\"\"轮询视频生成结果\r\n \r\n Args:\r\n upsample_config: 放大配置 {\"resolution\": \"VIDEO_RESOLUTION_4K\", \"model_key\": \"veo_3_1_upsampler_4k\"}\r\n \"\"\"\r\n\r\n max_attempts = config.max_poll_attempts\r\n poll_interval = config.poll_interval\r\n \r\n # 如果需要放大,轮询次数加倍(放大可能需要 30 分钟)\r\n if upsample_config:\r\n max_attempts = max_attempts * 3 # 放大需要更长时间\r\n\r\n for attempt in range(max_attempts):\r\n await asyncio.sleep(poll_interval)\r\n\r\n try:\r\n result = await self.flow_client.check_video_status(token.at, operations)\r\n checked_operations = result.get(\"operations\", [])\r\n\r\n if not checked_operations:\r\n continue\r\n\r\n operation = checked_operations[0]\r\n status = operation.get(\"status\")\r\n\r\n # 状态更新 - 每20秒报告一次 (poll_interval=3秒, 20秒约7次轮询)\r\n progress_update_interval = 7 # 每7次轮询 = 21秒\r\n if stream and attempt % progress_update_interval == 0: # 每20秒报告一次\r\n progress = min(int((attempt / max_attempts) * 100), 95)\r\n yield self._create_stream_chunk(f\"生成进度: {progress}%\\n\")\r\n\r\n # 检查状态\r\n if status == \"MEDIA_GENERATION_STATUS_SUCCESSFUL\":\r\n # 成功\r\n metadata = operation[\"operation\"].get(\"metadata\", {})\r\n video_info = metadata.get(\"video\", {})\r\n video_url = video_info.get(\"fifeUrl\")\r\n video_media_id = video_info.get(\"mediaGenerationId\")\r\n aspect_ratio = video_info.get(\"aspectRatio\", \"VIDEO_ASPECT_RATIO_LANDSCAPE\")\r\n\r\n if not video_url:\r\n yield self._create_error_response(\"视频URL为空\")\r\n return\r\n\r\n # ========== 视频放大处理 ==========\r\n if upsample_config and video_media_id:\r\n if stream:\r\n resolution_name = \"4K\" if \"4K\" in upsample_config[\"resolution\"] else \"1080P\"\r\n yield self._create_stream_chunk(f\"\\n视频生成完成,开始 {resolution_name} 放大处理...(可能需要 30 分钟)\\n\")\r\n \r\n try:\r\n # 提交放大任务\r\n upsample_result = await self.flow_client.upsample_video(\r\n at=token.at,\r\n project_id=project_id,\r\n video_media_id=video_media_id,\r\n aspect_ratio=aspect_ratio,\r\n resolution=upsample_config[\"resolution\"],\r\n model_key=upsample_config[\"model_key\"]\r\n )\r\n \r\n upsample_operations = upsample_result.get(\"operations\", [])\r\n if upsample_operations:\r\n if stream:\r\n yield self._create_stream_chunk(\"放大任务已提交,继续轮询...\\n\")\r\n \r\n # 递归轮询放大结果(不再放大)\r\n async for chunk in self._poll_video_result(\r\n token, project_id, upsample_operations, stream, None\r\n ):\r\n yield chunk\r\n return\r\n else:\r\n if stream:\r\n yield self._create_stream_chunk(\"⚠️ 放大任务创建失败,返回原始视频\\n\")\r\n except Exception as e:\r\n debug_logger.log_error(f\"Video upsample failed: {str(e)}\")\r\n if stream:\r\n yield self._create_stream_chunk(f\"⚠️ 放大失败: {str(e)},返回原始视频\\n\")\r\n\r\n # 缓存视频 (如果启用)\r\n local_url = video_url\r\n if config.cache_enabled:\r\n try:\r\n if stream:\r\n yield self._create_stream_chunk(\"正在缓存视频文件...\\n\")\r\n cached_filename = await self.file_cache.download_and_cache(video_url, \"video\")\r\n local_url = f\"{self._get_base_url()}/tmp/{cached_filename}\"\r\n if stream:\r\n yield self._create_stream_chunk(\"✅ 视频缓存成功,准备返回缓存地址...\\n\")\r\n except Exception as e:\r\n debug_logger.log_error(f\"Failed to cache video: {str(e)}\")\r\n # 缓存失败不影响结果返回,使用原始URL\r\n local_url = video_url\r\n if stream:\r\n yield self._create_stream_chunk(f\"⚠️ 缓存失败: {str(e)}\\n正在返回源链接...\\n\")\r\n else:\r\n if stream:\r\n yield self._create_stream_chunk(\"缓存已关闭,正在返回源链接...\\n\")\r\n\r\n # 更新数据库\r\n task_id = operation[\"operation\"][\"name\"]\r\n await self.db.update_task(\r\n task_id,\r\n status=\"completed\",\r\n progress=100,\r\n result_urls=[local_url],\r\n completed_at=time.time()\r\n )\r\n\r\n # 存储URL用于日志记录\r\n self._last_generated_url = local_url\r\n\r\n # 返回结果\r\n if stream:\r\n yield self._create_stream_chunk(\r\n f\"<video src='{local_url}' controls style='max-width:100%'></video>\",\r\n finish_reason=\"stop\"\r\n )\r\n else:\r\n yield self._create_completion_response(\r\n local_url, # 直接传URL,让方法内部格式化\r\n media_type=\"video\"\r\n )\r\n return\r\n\r\n elif status == \"MEDIA_GENERATION_STATUS_FAILED\":\r\n # 生成失败 - 提取错误信息\r\n error_info = operation.get(\"operation\", {}).get(\"error\", {})\r\n error_code = error_info.get(\"code\", \"unknown\")\r\n error_message = error_info.get(\"message\", \"未知错误\")\r\n \r\n # 更新数据库任务状态\r\n task_id = operation[\"operation\"][\"name\"]\r\n await self.db.update_task(\r\n task_id,\r\n status=\"failed\",\r\n error_message=f\"{error_message} (code: {error_code})\",\r\n completed_at=time.time()\r\n )\r\n \r\n # 返回友好的错误消息,提示用户重试\r\n friendly_error = f\"视频生成失败: {error_message},请重试\"\r\n if stream:\r\n yield self._create_stream_chunk(f\"❌ {friendly_error}\\n\")\r\n yield self._create_error_response(friendly_error)\r\n return\r\n\r\n elif status.startswith(\"MEDIA_GENERATION_STATUS_ERROR\"):\r\n # 其他错误状态\r\n yield self._create_error_response(f\"视频生成失败: {status}\")\r\n return\r\n\r\n except Exception as e:\r\n debug_logger.log_error(f\"Poll error: {str(e)}\")\r\n continue\r\n\r\n # 超时\r\n yield self._create_error_response(f\"视频生成超时 (已轮询{max_attempts}次)\")\r\n\r\n # ========== 响应格式化 ==========\r\n\r\n def _create_stream_chunk(self, content: str, role: str = None, finish_reason: str = None) -> str:\r\n \"\"\"创建流式响应chunk\"\"\"\r\n import json\r\n import time\r\n\r\n chunk = {\r\n \"id\": f\"chatcmpl-{int(time.time())}\",\r\n \"object\": \"chat.completion.chunk\",\r\n \"created\": int(time.time()),\r\n \"model\": \"flow2api\",\r\n \"choices\": [{\r\n \"index\": 0,\r\n \"delta\": {},\r\n \"finish_reason\": finish_reason\r\n }]\r\n }\r\n\r\n if role:\r\n chunk[\"choices\"][0][\"delta\"][\"role\"] = role\r\n\r\n if finish_reason:\r\n chunk[\"choices\"][0][\"delta\"][\"content\"] = content\r\n else:\r\n chunk[\"choices\"][0][\"delta\"][\"reasoning_content\"] = content\r\n\r\n return f\"data: {json.dumps(chunk, ensure_ascii=False)}\\n\\n\"\r\n\r\n def _create_completion_response(self, content: str, media_type: str = \"image\", is_availability_check: bool = False) -> str:\r\n \"\"\"创建非流式响应\r\n\r\n Args:\r\n content: 媒体URL或纯文本消息\r\n media_type: 媒体类型 (\"image\" 或 \"video\")\r\n is_availability_check: 是否为可用性检查响应 (纯文本消息)\r\n\r\n Returns:\r\n JSON格式的响应\r\n \"\"\"\r\n import json\r\n import time\r\n\r\n # 可用性检查: 返回纯文本消息\r\n if is_availability_check:\r\n formatted_content = content\r\n else:\r\n # 媒体生成: 根据媒体类型格式化内容为Markdown\r\n if media_type == \"video\":\r\n formatted_content = f\"```html\\n<video src='{content}' controls></video>\\n```\"\r\n else: # image\r\n formatted_content = f\"\"\r\n\r\n response = {\r\n \"id\": f\"chatcmpl-{int(time.time())}\",\r\n \"object\": \"chat.completion\",\r\n \"created\": int(time.time()),\r\n \"model\": \"flow2api\",\r\n \"choices\": [{\r\n \"index\": 0,\r\n \"message\": {\r\n \"role\": \"assistant\",\r\n \"content\": formatted_content\r\n },\r\n \"finish_reason\": \"stop\"\r\n }]\r\n }\r\n\r\n return json.dumps(response, ensure_ascii=False)\r\n\r\n def _create_error_response(self, error_message: str) -> str:\r\n \"\"\"创建错误响应\"\"\"\r\n import json\r\n\r\n error = {\r\n \"error\": {\r\n \"message\": error_message,\r\n \"type\": \"invalid_request_error\",\r\n \"code\": \"generation_failed\"\r\n }\r\n }\r\n\r\n return json.dumps(error, ensure_ascii=False)\r\n\r\n def _get_base_url(self) -> str:\r\n \"\"\"获取基础URL用于缓存文件访问\"\"\"\r\n # 优先使用配置的cache_base_url\r\n if config.cache_base_url:\r\n return config.cache_base_url\r\n # 否则使用服务器地址\r\n return f\"http://{config.server_host}:{config.server_port}\"\r\n\r\n async def _log_request(\r\n self,\r\n token_id: Optional[int],\r\n operation: str,\r\n request_data: Dict[str, Any],\r\n response_data: Dict[str, Any],\r\n status_code: int,\r\n duration: float\r\n ):\r\n \"\"\"记录请求到数据库\"\"\"\r\n try:\r\n log = RequestLog(\r\n token_id=token_id,\r\n operation=operation,\r\n request_body=json.dumps(request_data, ensure_ascii=False),\r\n response_body=json.dumps(response_data, ensure_ascii=False),\r\n status_code=status_code,\r\n duration=duration\r\n )\r\n await self.db.add_request_log(log)\r\n except Exception as e:\r\n # 日志记录失败不影响主流程\r\n debug_logger.log_error(f\"Failed to log request: {e}\")\r\n"
|
| 16 |
+
}
|
| 17 |
+
]
|
| 18 |
+
}
|
src/services/browser_captcha.py
CHANGED
|
@@ -3,6 +3,8 @@
|
|
| 3 |
支持:自动刷新 Session Token、外部触发指纹切换、死磕重试
|
| 4 |
"""
|
| 5 |
import os
|
|
|
|
|
|
|
| 6 |
# 修复 Windows 上 patchright 的 asyncio 兼容性问题
|
| 7 |
os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", "0")
|
| 8 |
|
|
@@ -15,11 +17,160 @@ from typing import Optional, Dict
|
|
| 15 |
from datetime import datetime
|
| 16 |
from urllib.parse import urlparse, unquote
|
| 17 |
|
| 18 |
-
from patchright.async_api import async_playwright, Route, BrowserContext
|
| 19 |
-
|
| 20 |
from ..core.logger import debug_logger
|
| 21 |
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
# 配置
|
| 24 |
LABS_URL = "https://labs.google/fx/tools/flow"
|
| 25 |
|
|
@@ -405,6 +556,19 @@ class BrowserCaptchaService:
|
|
| 405 |
await cls._instance._load_browser_count()
|
| 406 |
return cls._instance
|
| 407 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
async def _load_browser_count(self):
|
| 409 |
"""从数据库加载浏览器数量配置"""
|
| 410 |
if self.db:
|
|
@@ -471,6 +635,9 @@ class BrowserCaptchaService:
|
|
| 471 |
Returns:
|
| 472 |
(token, browser_id) 元组,调用方失败时用 browser_id 调用 report_error
|
| 473 |
"""
|
|
|
|
|
|
|
|
|
|
| 474 |
self._stats["req_total"] += 1
|
| 475 |
|
| 476 |
# 全局并发限制(如果已配置)
|
|
|
|
| 3 |
支持:自动刷新 Session Token、外部触发指纹切换、死磕重试
|
| 4 |
"""
|
| 5 |
import os
|
| 6 |
+
import sys
|
| 7 |
+
import subprocess
|
| 8 |
# 修复 Windows 上 patchright 的 asyncio 兼容性问题
|
| 9 |
os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", "0")
|
| 10 |
|
|
|
|
| 17 |
from datetime import datetime
|
| 18 |
from urllib.parse import urlparse, unquote
|
| 19 |
|
|
|
|
|
|
|
| 20 |
from ..core.logger import debug_logger
|
| 21 |
|
| 22 |
|
| 23 |
+
# ==================== Docker 环境检测 ====================
|
| 24 |
+
def _is_running_in_docker() -> bool:
|
| 25 |
+
"""检测是否在 Docker 容器中运行"""
|
| 26 |
+
# 方法1: 检查 /.dockerenv 文件
|
| 27 |
+
if os.path.exists('/.dockerenv'):
|
| 28 |
+
return True
|
| 29 |
+
# 方法2: 检查 cgroup
|
| 30 |
+
try:
|
| 31 |
+
with open('/proc/1/cgroup', 'r') as f:
|
| 32 |
+
content = f.read()
|
| 33 |
+
if 'docker' in content or 'kubepods' in content or 'containerd' in content:
|
| 34 |
+
return True
|
| 35 |
+
except:
|
| 36 |
+
pass
|
| 37 |
+
# 方法3: 检查环境变量
|
| 38 |
+
if os.environ.get('DOCKER_CONTAINER') or os.environ.get('KUBERNETES_SERVICE_HOST'):
|
| 39 |
+
return True
|
| 40 |
+
return False
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
IS_DOCKER = _is_running_in_docker()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ==================== patchright 自动安装 ====================
|
| 47 |
+
def _run_pip_install(package: str, use_mirror: bool = False) -> bool:
|
| 48 |
+
"""运行 pip install 命令"""
|
| 49 |
+
cmd = [sys.executable, '-m', 'pip', 'install', package]
|
| 50 |
+
if use_mirror:
|
| 51 |
+
cmd.extend(['-i', 'https://pypi.tuna.tsinghua.edu.cn/simple'])
|
| 52 |
+
|
| 53 |
+
try:
|
| 54 |
+
debug_logger.log_info(f"[BrowserCaptcha] 正在安装 {package}...")
|
| 55 |
+
print(f"[BrowserCaptcha] 正在安装 {package}...")
|
| 56 |
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
| 57 |
+
if result.returncode == 0:
|
| 58 |
+
debug_logger.log_info(f"[BrowserCaptcha] ✅ {package} 安装成功")
|
| 59 |
+
print(f"[BrowserCaptcha] ✅ {package} 安装成功")
|
| 60 |
+
return True
|
| 61 |
+
else:
|
| 62 |
+
debug_logger.log_warning(f"[BrowserCaptcha] {package} 安装失败: {result.stderr[:200]}")
|
| 63 |
+
return False
|
| 64 |
+
except Exception as e:
|
| 65 |
+
debug_logger.log_warning(f"[BrowserCaptcha] {package} 安装异常: {e}")
|
| 66 |
+
return False
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _run_playwright_install(use_mirror: bool = False) -> bool:
|
| 70 |
+
"""安装 patchright chromium 浏览器"""
|
| 71 |
+
cmd = [sys.executable, '-m', 'patchright', 'install', 'chromium']
|
| 72 |
+
env = os.environ.copy()
|
| 73 |
+
|
| 74 |
+
if use_mirror:
|
| 75 |
+
# 使用国内镜像
|
| 76 |
+
env['PLAYWRIGHT_DOWNLOAD_HOST'] = 'https://npmmirror.com/mirrors/playwright'
|
| 77 |
+
|
| 78 |
+
try:
|
| 79 |
+
debug_logger.log_info("[BrowserCaptcha] 正在安装 chromium 浏览器...")
|
| 80 |
+
print("[BrowserCaptcha] 正在安装 chromium 浏览器...")
|
| 81 |
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600, env=env)
|
| 82 |
+
if result.returncode == 0:
|
| 83 |
+
debug_logger.log_info("[BrowserCaptcha] ✅ chromium 浏览器安装成功")
|
| 84 |
+
print("[BrowserCaptcha] ✅ chromium 浏览器安装成功")
|
| 85 |
+
return True
|
| 86 |
+
else:
|
| 87 |
+
debug_logger.log_warning(f"[BrowserCaptcha] chromium 安装失败: {result.stderr[:200]}")
|
| 88 |
+
return False
|
| 89 |
+
except Exception as e:
|
| 90 |
+
debug_logger.log_warning(f"[BrowserCaptcha] chromium 安装异常: {e}")
|
| 91 |
+
return False
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _ensure_patchright_installed() -> bool:
|
| 95 |
+
"""确保 patchright 已安装"""
|
| 96 |
+
try:
|
| 97 |
+
import patchright
|
| 98 |
+
debug_logger.log_info("[BrowserCaptcha] patchright 已安装")
|
| 99 |
+
return True
|
| 100 |
+
except ImportError:
|
| 101 |
+
pass
|
| 102 |
+
|
| 103 |
+
debug_logger.log_info("[BrowserCaptcha] patchright 未安装,开始自动安装...")
|
| 104 |
+
print("[BrowserCaptcha] patchright 未安装,开始自动安装...")
|
| 105 |
+
|
| 106 |
+
# 先尝试官方源
|
| 107 |
+
if _run_pip_install('patchright', use_mirror=False):
|
| 108 |
+
return True
|
| 109 |
+
|
| 110 |
+
# 官方源失败,尝试国内镜像
|
| 111 |
+
debug_logger.log_info("[BrowserCaptcha] 官方源安装失败,尝试国内镜像...")
|
| 112 |
+
print("[BrowserCaptcha] 官方源安装失败,尝试国内镜像...")
|
| 113 |
+
if _run_pip_install('patchright', use_mirror=True):
|
| 114 |
+
return True
|
| 115 |
+
|
| 116 |
+
debug_logger.log_error("[BrowserCaptcha] ❌ patchright 自动安装失败,请手动安装: pip install patchright")
|
| 117 |
+
print("[BrowserCaptcha] ❌ patchright 自动安装失败,请手动安装: pip install patchright")
|
| 118 |
+
return False
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _ensure_browser_installed() -> bool:
|
| 122 |
+
"""确保 chromium 浏览器已安装"""
|
| 123 |
+
try:
|
| 124 |
+
from patchright.sync_api import sync_playwright
|
| 125 |
+
with sync_playwright() as p:
|
| 126 |
+
# 尝试获取浏览器路径,如果失败说明未安装
|
| 127 |
+
browser_path = p.chromium.executable_path
|
| 128 |
+
if browser_path and os.path.exists(browser_path):
|
| 129 |
+
debug_logger.log_info(f"[BrowserCaptcha] chromium 浏览器已安装: {browser_path}")
|
| 130 |
+
return True
|
| 131 |
+
except Exception as e:
|
| 132 |
+
debug_logger.log_info(f"[BrowserCaptcha] ��测浏览器时出错: {e}")
|
| 133 |
+
|
| 134 |
+
debug_logger.log_info("[BrowserCaptcha] chromium 浏览器未安装,开始自动安装...")
|
| 135 |
+
print("[BrowserCaptcha] chromium 浏览器未安装,开始自动安装...")
|
| 136 |
+
|
| 137 |
+
# 先尝试官方源
|
| 138 |
+
if _run_playwright_install(use_mirror=False):
|
| 139 |
+
return True
|
| 140 |
+
|
| 141 |
+
# 官方源失败,尝试国内镜像
|
| 142 |
+
debug_logger.log_info("[BrowserCaptcha] 官方源安装失败,尝试国内镜像...")
|
| 143 |
+
print("[BrowserCaptcha] 官方源安装失败,尝试国内镜像...")
|
| 144 |
+
if _run_playwright_install(use_mirror=True):
|
| 145 |
+
return True
|
| 146 |
+
|
| 147 |
+
debug_logger.log_error("[BrowserCaptcha] ❌ chromium 浏览器自动安装失败,请手动安装: python -m patchright install chromium")
|
| 148 |
+
print("[BrowserCaptcha] ❌ chromium 浏览器自动安装失败,请手动安装: python -m patchright install chromium")
|
| 149 |
+
return False
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# 尝试导入 patchright
|
| 153 |
+
async_playwright = None
|
| 154 |
+
Route = None
|
| 155 |
+
BrowserContext = None
|
| 156 |
+
PATCHRIGHT_AVAILABLE = False
|
| 157 |
+
|
| 158 |
+
if IS_DOCKER:
|
| 159 |
+
debug_logger.log_warning("[BrowserCaptcha] 检测到 Docker 环境,有头浏览器打码不可用,请使用第三方打码服务")
|
| 160 |
+
print("[BrowserCaptcha] ⚠️ 检测到 Docker 环境,有头浏览器打码不可用")
|
| 161 |
+
print("[BrowserCaptcha] 请使用第三方打码服务: yescaptcha, capmonster, ezcaptcha, capsolver")
|
| 162 |
+
else:
|
| 163 |
+
if _ensure_patchright_installed():
|
| 164 |
+
try:
|
| 165 |
+
from patchright.async_api import async_playwright, Route, BrowserContext
|
| 166 |
+
PATCHRIGHT_AVAILABLE = True
|
| 167 |
+
# 检查并安装浏览器
|
| 168 |
+
_ensure_browser_installed()
|
| 169 |
+
except ImportError as e:
|
| 170 |
+
debug_logger.log_error(f"[BrowserCaptcha] patchright 导入失败: {e}")
|
| 171 |
+
print(f"[BrowserCaptcha] ❌ patchright 导入失败: {e}")
|
| 172 |
+
|
| 173 |
+
|
| 174 |
# 配置
|
| 175 |
LABS_URL = "https://labs.google/fx/tools/flow"
|
| 176 |
|
|
|
|
| 556 |
await cls._instance._load_browser_count()
|
| 557 |
return cls._instance
|
| 558 |
|
| 559 |
+
def _check_available(self):
|
| 560 |
+
"""检查服务是否可用"""
|
| 561 |
+
if IS_DOCKER:
|
| 562 |
+
raise RuntimeError(
|
| 563 |
+
"有头浏览器打码在 Docker 环境中不可用。"
|
| 564 |
+
"请使用第三方打码服务: yescaptcha, capmonster, ezcaptcha, capsolver"
|
| 565 |
+
)
|
| 566 |
+
if not PATCHRIGHT_AVAILABLE or async_playwright is None:
|
| 567 |
+
raise RuntimeError(
|
| 568 |
+
"patchright 未安装或不可用。"
|
| 569 |
+
"请手动安装: pip install patchright && python -m patchright install chromium"
|
| 570 |
+
)
|
| 571 |
+
|
| 572 |
async def _load_browser_count(self):
|
| 573 |
"""从数据库加载浏览器数量配置"""
|
| 574 |
if self.db:
|
|
|
|
| 635 |
Returns:
|
| 636 |
(token, browser_id) 元组,调用方失败时用 browser_id 调用 report_error
|
| 637 |
"""
|
| 638 |
+
# 检查服务是否可用
|
| 639 |
+
self._check_available()
|
| 640 |
+
|
| 641 |
self._stats["req_total"] += 1
|
| 642 |
|
| 643 |
# 全局并发限制(如果已配置)
|
src/services/browser_captcha_personal.py
CHANGED
|
@@ -6,13 +6,116 @@
|
|
| 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):
|
|
@@ -61,9 +164,25 @@ class BrowserCaptchaService:
|
|
| 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:
|
|
|
|
| 6 |
import asyncio
|
| 7 |
import time
|
| 8 |
import os
|
| 9 |
+
import sys
|
| 10 |
+
import subprocess
|
| 11 |
from typing import Optional
|
| 12 |
|
|
|
|
|
|
|
| 13 |
from ..core.logger import debug_logger
|
| 14 |
|
| 15 |
|
| 16 |
+
# ==================== Docker 环境检测 ====================
|
| 17 |
+
def _is_running_in_docker() -> bool:
|
| 18 |
+
"""检测是否在 Docker 容器中运行"""
|
| 19 |
+
# 方法1: 检查 /.dockerenv 文件
|
| 20 |
+
if os.path.exists('/.dockerenv'):
|
| 21 |
+
return True
|
| 22 |
+
# 方法2: 检查 cgroup
|
| 23 |
+
try:
|
| 24 |
+
with open('/proc/1/cgroup', 'r') as f:
|
| 25 |
+
content = f.read()
|
| 26 |
+
if 'docker' in content or 'kubepods' in content or 'containerd' in content:
|
| 27 |
+
return True
|
| 28 |
+
except:
|
| 29 |
+
pass
|
| 30 |
+
# 方法3: 检查环境变量
|
| 31 |
+
if os.environ.get('DOCKER_CONTAINER') or os.environ.get('KUBERNETES_SERVICE_HOST'):
|
| 32 |
+
return True
|
| 33 |
+
return False
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
IS_DOCKER = _is_running_in_docker()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ==================== nodriver 自动安装 ====================
|
| 40 |
+
def _run_pip_install(package: str, use_mirror: bool = False) -> bool:
|
| 41 |
+
"""运行 pip install 命令
|
| 42 |
+
|
| 43 |
+
Args:
|
| 44 |
+
package: 包名
|
| 45 |
+
use_mirror: 是否使用国内镜像
|
| 46 |
+
|
| 47 |
+
Returns:
|
| 48 |
+
是否安装成功
|
| 49 |
+
"""
|
| 50 |
+
cmd = [sys.executable, '-m', 'pip', 'install', package]
|
| 51 |
+
if use_mirror:
|
| 52 |
+
cmd.extend(['-i', 'https://pypi.tuna.tsinghua.edu.cn/simple'])
|
| 53 |
+
|
| 54 |
+
try:
|
| 55 |
+
debug_logger.log_info(f"[BrowserCaptcha] 正在安装 {package}...")
|
| 56 |
+
print(f"[BrowserCaptcha] 正在安装 {package}...")
|
| 57 |
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
| 58 |
+
if result.returncode == 0:
|
| 59 |
+
debug_logger.log_info(f"[BrowserCaptcha] ✅ {package} 安装成功")
|
| 60 |
+
print(f"[BrowserCaptcha] ✅ {package} 安装成功")
|
| 61 |
+
return True
|
| 62 |
+
else:
|
| 63 |
+
debug_logger.log_warning(f"[BrowserCaptcha] {package} 安装失败: {result.stderr[:200]}")
|
| 64 |
+
return False
|
| 65 |
+
except Exception as e:
|
| 66 |
+
debug_logger.log_warning(f"[BrowserCaptcha] {package} 安装异常: {e}")
|
| 67 |
+
return False
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _ensure_nodriver_installed() -> bool:
|
| 71 |
+
"""确保 nodriver 已安装
|
| 72 |
+
|
| 73 |
+
Returns:
|
| 74 |
+
是否安装成功/已安装
|
| 75 |
+
"""
|
| 76 |
+
try:
|
| 77 |
+
import nodriver
|
| 78 |
+
debug_logger.log_info("[BrowserCaptcha] nodriver 已安装")
|
| 79 |
+
return True
|
| 80 |
+
except ImportError:
|
| 81 |
+
pass
|
| 82 |
+
|
| 83 |
+
debug_logger.log_info("[BrowserCaptcha] nodriver 未安装,开始自动安装...")
|
| 84 |
+
print("[BrowserCaptcha] nodriver 未安装,开始自动安装...")
|
| 85 |
+
|
| 86 |
+
# 先尝试官方源
|
| 87 |
+
if _run_pip_install('nodriver', use_mirror=False):
|
| 88 |
+
return True
|
| 89 |
+
|
| 90 |
+
# 官方源失败,尝试国内镜像
|
| 91 |
+
debug_logger.log_info("[BrowserCaptcha] 官方源安装失败,尝试国内镜像...")
|
| 92 |
+
print("[BrowserCaptcha] 官方源安装失败,尝试国内镜像...")
|
| 93 |
+
if _run_pip_install('nodriver', use_mirror=True):
|
| 94 |
+
return True
|
| 95 |
+
|
| 96 |
+
debug_logger.log_error("[BrowserCaptcha] ❌ nodriver 自动安装失败,请手动安装: pip install nodriver")
|
| 97 |
+
print("[BrowserCaptcha] ❌ nodriver 自动安装失败,请手动安装: pip install nodriver")
|
| 98 |
+
return False
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# 尝试导入 nodriver
|
| 102 |
+
uc = None
|
| 103 |
+
NODRIVER_AVAILABLE = False
|
| 104 |
+
|
| 105 |
+
if IS_DOCKER:
|
| 106 |
+
debug_logger.log_warning("[BrowserCaptcha] 检测到 Docker 环境,内置浏览器打码不可用,请使用第三方打码服务")
|
| 107 |
+
print("[BrowserCaptcha] ⚠️ 检测到 Docker 环境,内置浏览器打码不可用")
|
| 108 |
+
print("[BrowserCaptcha] 请使用第三方打码服务: yescaptcha, capmonster, ezcaptcha, capsolver")
|
| 109 |
+
else:
|
| 110 |
+
if _ensure_nodriver_installed():
|
| 111 |
+
try:
|
| 112 |
+
import nodriver as uc
|
| 113 |
+
NODRIVER_AVAILABLE = True
|
| 114 |
+
except ImportError as e:
|
| 115 |
+
debug_logger.log_error(f"[BrowserCaptcha] nodriver 导入失败: {e}")
|
| 116 |
+
print(f"[BrowserCaptcha] ❌ nodriver 导入失败: {e}")
|
| 117 |
+
|
| 118 |
+
|
| 119 |
class ResidentTabInfo:
|
| 120 |
"""常驻标签页信息结构"""
|
| 121 |
def __init__(self, tab, project_id: str):
|
|
|
|
| 164 |
if cls._instance is None:
|
| 165 |
cls._instance = cls(db)
|
| 166 |
return cls._instance
|
| 167 |
+
|
| 168 |
+
def _check_available(self):
|
| 169 |
+
"""检查服务是否可用"""
|
| 170 |
+
if IS_DOCKER:
|
| 171 |
+
raise RuntimeError(
|
| 172 |
+
"内置浏览器打码在 Docker 环境中不可用。"
|
| 173 |
+
"请使用第三方打码服务: yescaptcha, capmonster, ezcaptcha, capsolver"
|
| 174 |
+
)
|
| 175 |
+
if not NODRIVER_AVAILABLE or uc is None:
|
| 176 |
+
raise RuntimeError(
|
| 177 |
+
"nodriver 未安装或不可用。"
|
| 178 |
+
"请手动安装: pip install nodriver"
|
| 179 |
+
)
|
| 180 |
|
| 181 |
async def initialize(self):
|
| 182 |
"""初始化 nodriver 浏览器"""
|
| 183 |
+
# 检查服务是否可用
|
| 184 |
+
self._check_available()
|
| 185 |
+
|
| 186 |
if self._initialized and self.browser:
|
| 187 |
# 检查浏览器是否仍然存活
|
| 188 |
try:
|
src/services/flow_client.py
CHANGED
|
@@ -1164,23 +1164,43 @@ class FlowClient:
|
|
| 1164 |
"""
|
| 1165 |
captcha_method = config.captcha_method
|
| 1166 |
|
| 1167 |
-
#
|
| 1168 |
if captcha_method == "personal":
|
| 1169 |
try:
|
| 1170 |
from .browser_captcha_personal import BrowserCaptchaService
|
| 1171 |
service = await BrowserCaptchaService.get_instance(self.db)
|
| 1172 |
return await service.get_token(project_id, action), None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1173 |
except Exception as e:
|
| 1174 |
-
debug_logger.log_error(f"[reCAPTCHA
|
| 1175 |
return None, None
|
| 1176 |
-
# 有头浏览器打码
|
| 1177 |
elif captcha_method == "browser":
|
| 1178 |
try:
|
| 1179 |
from .browser_captcha import BrowserCaptchaService
|
| 1180 |
service = await BrowserCaptchaService.get_instance(self.db)
|
| 1181 |
return await service.get_token(project_id, action)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1182 |
except Exception as e:
|
| 1183 |
-
debug_logger.log_error(f"[reCAPTCHA Browser]
|
| 1184 |
return None, None
|
| 1185 |
# API打码服务
|
| 1186 |
elif captcha_method in ["yescaptcha", "capmonster", "ezcaptcha", "capsolver"]:
|
|
|
|
| 1164 |
"""
|
| 1165 |
captcha_method = config.captcha_method
|
| 1166 |
|
| 1167 |
+
# 内置浏览器打码 (nodriver)
|
| 1168 |
if captcha_method == "personal":
|
| 1169 |
try:
|
| 1170 |
from .browser_captcha_personal import BrowserCaptchaService
|
| 1171 |
service = await BrowserCaptchaService.get_instance(self.db)
|
| 1172 |
return await service.get_token(project_id, action), None
|
| 1173 |
+
except RuntimeError as e:
|
| 1174 |
+
# 捕获 Docker 环境或依赖缺失的明确错误
|
| 1175 |
+
error_msg = str(e)
|
| 1176 |
+
debug_logger.log_error(f"[reCAPTCHA Personal] {error_msg}")
|
| 1177 |
+
print(f"[reCAPTCHA] ❌ 内置浏览器打码失败: {error_msg}")
|
| 1178 |
+
return None, None
|
| 1179 |
+
except ImportError as e:
|
| 1180 |
+
debug_logger.log_error(f"[reCAPTCHA Personal] 导入失败: {str(e)}")
|
| 1181 |
+
print(f"[reCAPTCHA] ❌ nodriver 未安装,请运行: pip install nodriver")
|
| 1182 |
+
return None, None
|
| 1183 |
except Exception as e:
|
| 1184 |
+
debug_logger.log_error(f"[reCAPTCHA Personal] 错误: {str(e)}")
|
| 1185 |
return None, None
|
| 1186 |
+
# 有头浏览器打码 (patchright/playwright)
|
| 1187 |
elif captcha_method == "browser":
|
| 1188 |
try:
|
| 1189 |
from .browser_captcha import BrowserCaptchaService
|
| 1190 |
service = await BrowserCaptchaService.get_instance(self.db)
|
| 1191 |
return await service.get_token(project_id, action)
|
| 1192 |
+
except RuntimeError as e:
|
| 1193 |
+
# 捕获 Docker 环境或依赖缺失的明确错误
|
| 1194 |
+
error_msg = str(e)
|
| 1195 |
+
debug_logger.log_error(f"[reCAPTCHA Browser] {error_msg}")
|
| 1196 |
+
print(f"[reCAPTCHA] ❌ 有头浏览器打码失败: {error_msg}")
|
| 1197 |
+
return None, None
|
| 1198 |
+
except ImportError as e:
|
| 1199 |
+
debug_logger.log_error(f"[reCAPTCHA Browser] 导入失败: {str(e)}")
|
| 1200 |
+
print(f"[reCAPTCHA] ❌ patchright 未安装,请运行: pip install patchright && python -m patchright install chromium")
|
| 1201 |
+
return None, None
|
| 1202 |
except Exception as e:
|
| 1203 |
+
debug_logger.log_error(f"[reCAPTCHA Browser] 错误: {str(e)}")
|
| 1204 |
return None, None
|
| 1205 |
# API打码服务
|
| 1206 |
elif captcha_method in ["yescaptcha", "capmonster", "ezcaptcha", "capsolver"]:
|