Sync from GitHub: 71b4e35efd6a40202ad9904ea8e1cdf66d0504ac
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +5 -0
- CHANGELOG.md +254 -0
- Dockerfile +41 -0
- HUGGINGFACE_SPACE.md +198 -0
- LICENSE +21 -0
- PERSISTENCE_SOLUTIONS.md +247 -0
- QUICK_START_PERSISTENCE.md +273 -0
- QUICK_START_WEBDAV.md +286 -0
- README.md +491 -5
- README_EN.md +398 -0
- config.example.yaml +172 -0
- config.hf.yaml +54 -0
- docker-compose.yaml +11 -0
- package.json +39 -0
- patches/camoufox-js@0.8.3.locale.patched.js +289 -0
- patches/camoufox-js@0.8.3.pkgman.patched.js +351 -0
- pnpm-lock.yaml +2132 -0
- pnpm-workspace.yaml +5 -0
- public/index.html +206 -0
- scripts/clear-data.js +92 -0
- scripts/genkey.js +21 -0
- scripts/init.js +747 -0
- scripts/postinstall.js +63 -0
- scripts/restore-data-webdav.js +149 -0
- scripts/restore-data.js +139 -0
- scripts/save-data-webdav.js +148 -0
- scripts/save-data.js +117 -0
- src/backend/adapter/chatgpt.js +208 -0
- src/backend/adapter/chatgpt_text.js +290 -0
- src/backend/adapter/deepseek_text.js +297 -0
- src/backend/adapter/doubao.js +253 -0
- src/backend/adapter/doubao_text.js +273 -0
- src/backend/adapter/gemini.js +430 -0
- src/backend/adapter/gemini_biz.js +317 -0
- src/backend/adapter/gemini_biz_text.js +348 -0
- src/backend/adapter/gemini_text.js +429 -0
- src/backend/adapter/google_flow.js +286 -0
- src/backend/adapter/lmarena.js +238 -0
- src/backend/adapter/lmarena_text.js +319 -0
- src/backend/adapter/nanobananafree_ai.js +156 -0
- src/backend/adapter/sora.js +250 -0
- src/backend/adapter/test.js +191 -0
- src/backend/adapter/zai_is.js +411 -0
- src/backend/adapter/zai_is_text.js +396 -0
- src/backend/adapter/zenmux_ai_text.js +302 -0
- src/backend/engine/launcher.js +363 -0
- src/backend/engine/utils.js +607 -0
- src/backend/index.js +140 -0
- src/backend/pool/PoolManager.js +316 -0
- src/backend/pool/Worker.js +494 -0
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
| 2 |
+
data/
|
| 3 |
+
test/
|
| 4 |
+
config.yaml
|
| 5 |
+
camoufox/
|
CHANGELOG.md
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Changelog
|
| 2 |
+
|
| 3 |
+
All notable changes to this project will be documented in this file.
|
| 4 |
+
|
| 5 |
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
| 6 |
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
| 7 |
+
|
| 8 |
+
## [3.4.5] - 2026-01-11
|
| 9 |
+
|
| 10 |
+
### ✨ Added
|
| 11 |
+
- **增加计数功能**
|
| 12 |
+
- 支持在 WebUI 记录与查看成功次数
|
| 13 |
+
- **FireFox 参数**
|
| 14 |
+
- 增加 FireFox 站点隔离机制开关
|
| 15 |
+
- **提示词违规提示**
|
| 16 |
+
- 提示词违规时提示内容被阻止
|
| 17 |
+
|
| 18 |
+
### 🐛 Fixed
|
| 19 |
+
- **修复图片上传**
|
| 20 |
+
- 修复 LMArena 因模型选择与图片上传的顺序错误导致的图片上传失败
|
| 21 |
+
|
| 22 |
+
## [3.4.4] - 2026-01-10
|
| 23 |
+
|
| 24 |
+
### ✨ Added
|
| 25 |
+
- **新增适配器**
|
| 26 |
+
- 支持豆包图片生成与文本生成适配器
|
| 27 |
+
|
| 28 |
+
### 🐛 Fixed
|
| 29 |
+
- **未捕获的超时错误**
|
| 30 |
+
- 修复因未捕获的超时错误导致的程序崩溃
|
| 31 |
+
- **模型选择**
|
| 32 |
+
- 修复 LMArena 模型选择的问题并同步模型列表
|
| 33 |
+
|
| 34 |
+
## [3.4.3] - 2025-12-26
|
| 35 |
+
|
| 36 |
+
### ✨ Added
|
| 37 |
+
- **适配器描述**
|
| 38 |
+
- 为每个适配器添加描述,可以在 WebUI 中的适配器设置页面点击查看每个适配器的描述和使用方法。
|
| 39 |
+
- **适配器模型管理**
|
| 40 |
+
- 为每个适配器添加模型列表管理,支持黑名单和白名单,可用于禁用网站出现问题的模型
|
| 41 |
+
- **调试适配器**
|
| 42 |
+
- 多种检测网站聚合,IP 纯净度查询等,并初步测试自动过盾
|
| 43 |
+
|
| 44 |
+
## [3.4.3] - 2025-12-26
|
| 45 |
+
|
| 46 |
+
### 🐛 Fixed
|
| 47 |
+
- **Gemini**:修复因懒加载导致的等待图片超时问题
|
| 48 |
+
|
| 49 |
+
## [3.4.2] - 2025-12-25
|
| 50 |
+
|
| 51 |
+
### 🔄 Changed
|
| 52 |
+
- **浏览器指纹**
|
| 53 |
+
- 增加 WebGL 和 Canvas 噪点的持久化,防止频繁变化
|
| 54 |
+
- 清洗插件列表,防止出现 FireFox 中有 Chrome 内置的 PDF 阅读器插件
|
| 55 |
+
- 清洗 UA 标识,防止出现未来浏览器版本,导致某些网站报错403 (如:aistudio)
|
| 56 |
+
- **关闭动画**
|
| 57 |
+
- 通过 about:config 中的设置禁用背景高斯模糊 CSS 和减少动画,节省资源占用
|
| 58 |
+
|
| 59 |
+
## [3.4.1] - 2025-12-24
|
| 60 |
+
|
| 61 |
+
### ✨ Added
|
| 62 |
+
- **新增适配器**
|
| 63 |
+
- 支持 Google Flow 图片生成适配器
|
| 64 |
+
|
| 65 |
+
### 🐛 Fixed
|
| 66 |
+
- **Gemini Business**:修复因懒加载导致的等待图片超时问题
|
| 67 |
+
|
| 68 |
+
## [3.4.0] - 2025-12-23
|
| 69 |
+
|
| 70 |
+
### ✨ Added
|
| 71 |
+
- **新增适配器**
|
| 72 |
+
- 支持 ChatGPT 文本生成适配器
|
| 73 |
+
- 支持 zAI 文本生成适配器
|
| 74 |
+
- 支持 DeepSeek 文本生成适配器
|
| 75 |
+
- 支持 Sora 视频生成适配器
|
| 76 |
+
|
| 77 |
+
### 🔄 Changed
|
| 78 |
+
- **适配器实现更改**
|
| 79 |
+
- zAI 图片生成适配器不再使用拦截请求修改响应体的方式,改为UI选择模型列表,并且Nano Banana Pro 支持选择1K、2K、4K
|
| 80 |
+
|
| 81 |
+
## [3.3.2] - 2025-12-22
|
| 82 |
+
|
| 83 |
+
### 🔄 Changed
|
| 84 |
+
- **配置文件**
|
| 85 |
+
- 自动复制初始化配置文件,并放进`data/config.yaml`,Docker友好化
|
| 86 |
+
- 优化 Dockerfile
|
| 87 |
+
- 初始化脚本不再依赖配置文件,支持交互式和参数传入式配置代理
|
| 88 |
+
- 优化 WebUI 文案和日志排列
|
| 89 |
+
|
| 90 |
+
### ❌ Removed
|
| 91 |
+
- **删除测试脚本**
|
| 92 |
+
- 现在有 WebUI 测试了,已经无需 test 脚本了
|
| 93 |
+
|
| 94 |
+
## [3.3.1] - 2025-12-21
|
| 95 |
+
|
| 96 |
+
### ✨ Added
|
| 97 |
+
- **新增适配器**
|
| 98 |
+
- 支持 Gemini 网页版文本生成
|
| 99 |
+
- 支持 ChatGPT 图片生成
|
| 100 |
+
- **支持视频生成**
|
| 101 |
+
- 支持在 Gemini 网页版和 Gemini Enterprise Business 图片生成适配器中生成视频
|
| 102 |
+
|
| 103 |
+
### 🔄 Changed
|
| 104 |
+
- **优化图片下载方式**
|
| 105 |
+
- 让文件下载步骤直接继承浏览器上下文减少特征
|
| 106 |
+
|
| 107 |
+
## [3.3.0] - 2025-12-20
|
| 108 |
+
|
| 109 |
+
### ✨ Added
|
| 110 |
+
- **新增适配器**
|
| 111 |
+
- 支持 ZenMux
|
| 112 |
+
|
| 113 |
+
### 🔄 Changed
|
| 114 |
+
- **清理历史遗留**
|
| 115 |
+
- 清除历史遗留的多余的逻辑
|
| 116 |
+
|
| 117 |
+
## [3.2.1] - 2025-12-20
|
| 118 |
+
|
| 119 |
+
### ✨ Added
|
| 120 |
+
- **WebUI**
|
| 121 |
+
- 完善 WebUI 功能,添加接口测试和日志查看器,优化部分布局
|
| 122 |
+
- **日志记录**
|
| 123 |
+
- 会在 data/temp 文件夹下记录日志(最大5MB轮转)
|
| 124 |
+
|
| 125 |
+
### 🔄 Changed
|
| 126 |
+
- **初始化失败逻辑**
|
| 127 |
+
- 程序初始化失败后不会直接推出,以便利用 WebUI 修改错误的配置
|
| 128 |
+
- **LMArena 图片适配器**
|
| 129 |
+
- 支持通过配置直接返回图片URL (但其他不支持该选项的适配器仍然会返回 Base64)
|
| 130 |
+
|
| 131 |
+
## [3.2.0] - 2025-12-19
|
| 132 |
+
|
| 133 |
+
### ✨ Added
|
| 134 |
+
- **WebUI**
|
| 135 |
+
- 为项目添加了网页版管理工具,便于修改配置文件(可能会有问题,可随时反馈)
|
| 136 |
+
|
| 137 |
+
- **增加看门狗**
|
| 138 |
+
- 增加看门狗机制(Supervisor),保证程序失败重载和利于利用 WebUI 完整重启程序
|
| 139 |
+
- 同时将 Linux 上的虚拟显示器和 VNC 服务器启动程序也迁移至看门狗机制
|
| 140 |
+
|
| 141 |
+
## [3.1.0] - 2025-12-17
|
| 142 |
+
|
| 143 |
+
### ✨ Added
|
| 144 |
+
- **支持文本模型**
|
| 145 |
+
- 添加专门的文本模型适配器(目前仅支持 LMArena 和 Gemini Busineess)
|
| 146 |
+
- 支持网络搜索模型,例如 gemini-3-pro-grounding、grok-4-1-fast-search
|
| 147 |
+
- **图片调度**
|
| 148 |
+
- 若有适配器同时支持同一个模型,但是图片策略不同,将会优先将带图片的请求分发给支持图片的适配器
|
| 149 |
+
- **为自动通过验证码做准备**
|
| 150 |
+
- 新增测试适配器 turnstile_test ,为将来需要自动过 CloudFlare 验证码做准备
|
| 151 |
+
|
| 152 |
+
### 🔄 Changed
|
| 153 |
+
- **项目名称更新**
|
| 154 |
+
- 因支持的功能越来越多,决定为项目改名为 WebAI2API
|
| 155 |
+
|
| 156 |
+
## [3.0.1] - 2025-12-16
|
| 157 |
+
|
| 158 |
+
### ✨ Added
|
| 159 |
+
- **故障转移系统**
|
| 160 |
+
- 实现了基于 Pool 的自动故障转移:当某个 Worker 执行任务失败(如 API 超时、页面崩溃、被限流)时,系统会自动寻找下一个支持该模型的 Worker 进行重试。
|
| 161 |
+
- **Merge 模式增强**:Merge Worker 内部也会在不同的适配器之间进行故障转移。
|
| 162 |
+
|
| 163 |
+
## [3.0.0] - 2025-12-14
|
| 164 |
+
|
| 165 |
+
### ✨ Added
|
| 166 |
+
- **多窗口多账号支持**
|
| 167 |
+
- 架构升级,支持同时管理多个浏览器实例和多个标签页。
|
| 168 |
+
- 实现了浏览器实例间的数据(Cookies/Storage)完全隔离。
|
| 169 |
+
- **Cookies 管理**
|
| 170 |
+
- 新增 `/v1/cookies` 接口,支持获取指定 browser instance 的 Cookies。
|
| 171 |
+
|
| 172 |
+
### 🔄 Changed
|
| 173 |
+
- **配置系统重构**
|
| 174 |
+
- 配置文件结构大幅调整,采用更清晰的 `backend.pool` 结构配置 Worker。
|
| 175 |
+
|
| 176 |
+
## [2.4.0] - 2025-12-13
|
| 177 |
+
|
| 178 |
+
### ✨ Added
|
| 179 |
+
- **浏览器伪装增强**
|
| 180 |
+
- 集成 GEOIP 数据库,实现基于 IP 的自动时区伪装。
|
| 181 |
+
- **初始化脚本 (init.js)**
|
| 182 |
+
- 支持 `npm run init -- -custom` 自定义初始化。
|
| 183 |
+
- 自动下载 GeoLite2 sum数据库。
|
| 184 |
+
- **服务器自检**
|
| 185 |
+
- 启动时自动检查依赖完整性和环境补丁。
|
| 186 |
+
- **Merge 模式监控**
|
| 187 |
+
- 闲时自动跳转到指定网站以维持会话活跃(保活)。
|
| 188 |
+
|
| 189 |
+
### 🔄 Changed
|
| 190 |
+
- **代码重构**
|
| 191 |
+
- 服务器代码模块化 (`src/server/`).
|
| 192 |
+
- 目录结构重新整理。
|
| 193 |
+
|
| 194 |
+
## [2.3.0] - 2025-12-12
|
| 195 |
+
|
| 196 |
+
### ✨ Added
|
| 197 |
+
- **新适配器支持**
|
| 198 |
+
- 初步支持 Gemini 网页版 (`gemini.js`).
|
| 199 |
+
|
| 200 |
+
### 🔄 Changed
|
| 201 |
+
- **流式接口优化**
|
| 202 |
+
- 移除了全局开关,改为由请求体参数 `stream: true` 动态控制。
|
| 203 |
+
- **保活机制**:流式模式下支持无限排队,并通过 SSE 心跳包防止连接超时。
|
| 204 |
+
- **拒绝策略**:非流式请求在队列满时立即拒绝,避免无限等待。
|
| 205 |
+
|
| 206 |
+
## [2.2.3] - 2025-12-12
|
| 207 |
+
|
| 208 |
+
### ✨ Added
|
| 209 |
+
- **后端聚合**
|
| 210 |
+
- 实现了根据模型 ID 自动路由到对应适配器的逻辑。
|
| 211 |
+
|
| 212 |
+
### 🐛 Fixed
|
| 213 |
+
- **Mac 兼容性**
|
| 214 |
+
- 修复了 MacOS 初始化步骤缺失导致的启动失败。
|
| 215 |
+
|
| 216 |
+
## [2.2.2] - 2025-12-12
|
| 217 |
+
|
| 218 |
+
### ✨ Added
|
| 219 |
+
- **Docker 支持**
|
| 220 |
+
- 发布 Docker 镜像
|
| 221 |
+
|
| 222 |
+
## [2.2.1] - 2025-12-12
|
| 223 |
+
|
| 224 |
+
### ✨ Added
|
| 225 |
+
- **Cookie 导出**
|
| 226 |
+
- 利用自动续登机制获取最新 Cookie,供外部工具使用。
|
| 227 |
+
|
| 228 |
+
### 🐛 Fixed
|
| 229 |
+
- **自动续登修复**:改为全局监听,修复了部分场景下不触发的问题。
|
| 230 |
+
- **杂项修复**:VNC 端口冲突、启动参数优化、zAI 错误反馈优化。
|
| 231 |
+
|
| 232 |
+
## [2.2.0] - 2025-12-11
|
| 233 |
+
|
| 234 |
+
### ✨ Added
|
| 235 |
+
- **新适配器支持**
|
| 236 |
+
- 支持 zAI (zai.is),含自动 Discord 登录处理。
|
| 237 |
+
|
| 238 |
+
### 🐛 Fixed
|
| 239 |
+
- **Gemini Business**:修复监听器重复触发问题。
|
| 240 |
+
- **Mac 输入法**:修复拟人输入无法全选的问题。
|
| 241 |
+
|
| 242 |
+
## [2.0.0] - 2025-12-06
|
| 243 |
+
|
| 244 |
+
### 💥 Breaking Changes
|
| 245 |
+
- **核心迁移**
|
| 246 |
+
- 从 Puppeteer 迁移至 **Playwright + Camoufox**。
|
| 247 |
+
- 旧版代码归档至 `puppeteer-edition` 分支。
|
| 248 |
+
|
| 249 |
+
### ✨ Added
|
| 250 |
+
- **新适配器支持**
|
| 251 |
+
- 支持 Nano Banana Free。
|
| 252 |
+
- **功能特性**
|
| 253 |
+
- 内置 XVFB/VNC 支持命令。
|
| 254 |
+
- 支持 Gemini Business 过期自动续登。
|
Dockerfile
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:22-bookworm
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
ENV DEBIAN_FRONTEND=noninteractive
|
| 6 |
+
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=true
|
| 7 |
+
|
| 8 |
+
# 1. 安装系统依赖
|
| 9 |
+
RUN apt-get update && apt-get install -y \
|
| 10 |
+
xvfb \
|
| 11 |
+
x11vnc \
|
| 12 |
+
libasound2 \
|
| 13 |
+
libatk-bridge2.0-0 \
|
| 14 |
+
libgtk-3-0 \
|
| 15 |
+
libnss3 \
|
| 16 |
+
libx11-xcb1 \
|
| 17 |
+
libxss1 \
|
| 18 |
+
libxtst6 \
|
| 19 |
+
libgbm1 \
|
| 20 |
+
libdbus-glib-1-2 \
|
| 21 |
+
python3 \
|
| 22 |
+
build-essential \
|
| 23 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 24 |
+
|
| 25 |
+
# 2. 复制依赖文件、脚本和补丁目录,然后安装
|
| 26 |
+
COPY package.json pnpm-lock.yaml ./
|
| 27 |
+
COPY scripts/ ./scripts/
|
| 28 |
+
COPY patches/ ./patches/
|
| 29 |
+
RUN npm install -g pnpm && pnpm install --no-frozen-lockfile
|
| 30 |
+
|
| 31 |
+
# 3. 复制源码并初始化
|
| 32 |
+
COPY . .
|
| 33 |
+
RUN npm run init
|
| 34 |
+
|
| 35 |
+
# 4. 设置启动脚本权限
|
| 36 |
+
RUN chmod +x start_hf.sh
|
| 37 |
+
|
| 38 |
+
EXPOSE 3000 5900
|
| 39 |
+
|
| 40 |
+
# 5. 使用 Hugging Face Space 启动脚本
|
| 41 |
+
CMD ["./start_hf.sh"]
|
HUGGINGFACE_SPACE.md
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Space 部署指南
|
| 2 |
+
|
| 3 |
+
## 📋 前置要求
|
| 4 |
+
|
| 5 |
+
- Hugging Face 账号
|
| 6 |
+
- 推荐使用 **CPU Basic** 或更高级别的 Space(免费版可能资源不足)
|
| 7 |
+
|
| 8 |
+
## 🚀 部署步骤
|
| 9 |
+
|
| 10 |
+
### 1. 创建 Space
|
| 11 |
+
|
| 12 |
+
1. 访问 https://huggingface.co/spaces
|
| 13 |
+
2. 点击 "Create new Space"
|
| 14 |
+
3. 配置:
|
| 15 |
+
- **Owner**: 选择您的账号
|
| 16 |
+
- **Space name**: 输入名称(如 `webai2api`)
|
| 17 |
+
- **SDK**: 选择 **Docker**
|
| 18 |
+
- **Hardware**: 推荐 **CPU Basic**($0.10/小时)或更高
|
| 19 |
+
- **Visibility**: Public 或 Private
|
| 20 |
+
|
| 21 |
+
### 2. 推送代码
|
| 22 |
+
|
| 23 |
+
```bash
|
| 24 |
+
# 克隆 Space
|
| 25 |
+
git clone https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME
|
| 26 |
+
cd YOUR_SPACE_NAME
|
| 27 |
+
|
| 28 |
+
# 添加远程仓库
|
| 29 |
+
git remote add origin https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME
|
| 30 |
+
|
| 31 |
+
# 推送 webai2hf 分支
|
| 32 |
+
git push origin webai2hf:main
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
### 3. 配置环境变量(可选)
|
| 36 |
+
|
| 37 |
+
在 Space 的 **Settings** → **Variables** 中添加:
|
| 38 |
+
|
| 39 |
+
- `AUTH_TOKEN`: API 鉴权密钥(可选,不设置则使用配置文件中的值)
|
| 40 |
+
|
| 41 |
+
### 4. 等待构建
|
| 42 |
+
|
| 43 |
+
- 首次构建需要 5-10 分钟
|
| 44 |
+
- 构建完成后服务自动启动
|
| 45 |
+
- 浏览器初始化需要 30-60 秒
|
| 46 |
+
|
| 47 |
+
## ⚠️ 重要提示
|
| 48 |
+
|
| 49 |
+
### 资源限制
|
| 50 |
+
|
| 51 |
+
| 硬件类型 | CPU | 内存 | 价格 | 推荐度 |
|
| 52 |
+
|---------|-----|------|------|--------|
|
| 53 |
+
| CPU Basic | 2 vCPU | 16 GB | $0.10/小时 | ✅ 推荐 |
|
| 54 |
+
| CPU Upgrade | 4 vCPU | 32 GB | $0.30/小时 | ✅✅ 最佳 |
|
| 55 |
+
| CPU XL | 8 vCPU | 64 GB | $0.80/小时 | ✅✅✅ 极佳 |
|
| 56 |
+
|
| 57 |
+
**免费版**(CPU tiny)资源严重不足,**不推荐**使用。
|
| 58 |
+
|
| 59 |
+
### 内存优化
|
| 60 |
+
|
| 61 |
+
已针对 Hugging Face Space 进行了以下优化:
|
| 62 |
+
|
| 63 |
+
1. **关闭 Fission**:`fission: false`(内存从 ~2GB 降至 ~1GB)
|
| 64 |
+
2. **使用无头模式**:`headless: true`(节省显示资源)
|
| 65 |
+
3. **减少队列缓冲**:`queueBuffer: 1`
|
| 66 |
+
4. **限制图片数量**:`imageLimit: 3`
|
| 67 |
+
|
| 68 |
+
### 首次使用
|
| 69 |
+
|
| 70 |
+
1. **访问 Space URL**:`https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME`
|
| 71 |
+
2. **点击 "🎨 访问 WebUI"**
|
| 72 |
+
3. **完成账号登录**:
|
| 73 |
+
- 在 WebUI 中找到适配器管理
|
| 74 |
+
- 手动登录所需的 AI 网站
|
| 75 |
+
- 发送测试消息验证
|
| 76 |
+
|
| 77 |
+
## 🔧 故障排除
|
| 78 |
+
|
| 79 |
+
### 问题 1:服务器被暂停(SIGTERM)
|
| 80 |
+
|
| 81 |
+
**原因**:内存超限或启动超时
|
| 82 |
+
|
| 83 |
+
**解决方案**:
|
| 84 |
+
1. 升级到 **CPU Basic** 或更高级别的 Space
|
| 85 |
+
2. 检查日志查看具体错误
|
| 86 |
+
3. 确保使用优化配置 `config.hf.yaml`
|
| 87 |
+
|
| 88 |
+
### 问题 2:浏览器启动失败
|
| 89 |
+
|
| 90 |
+
**原因**:资源不足或配置错误
|
| 91 |
+
|
| 92 |
+
**解决方案**:
|
| 93 |
+
1. 检查 Space 硬件配置
|
| 94 |
+
2. 查看 Space Logs 中的错误信息
|
| 95 |
+
3. 确认配置文件正确
|
| 96 |
+
|
| 97 |
+
### 问题 3:无法访问虚拟显示器
|
| 98 |
+
|
| 99 |
+
**原因**:Hugging Face Space 网络限制
|
| 100 |
+
|
| 101 |
+
**解决方案**:
|
| 102 |
+
1. 通过 WebUI 的 VNC 功能访问
|
| 103 |
+
2. 不支持直接 VNC 客户端连接
|
| 104 |
+
3. 使用 WebUI 进行所有操作
|
| 105 |
+
|
| 106 |
+
### 问题 4:API 返回 503
|
| 107 |
+
|
| 108 |
+
**原因**:浏览器初始化未完成或配置错误
|
| 109 |
+
|
| 110 |
+
**解决方案**:
|
| 111 |
+
1. 等待 30-60 秒让浏览器完全启动
|
| 112 |
+
2. 检查 `/v1/models` 端点是否正常
|
| 113 |
+
3. 查看 Space Logs 确认浏览器已启动
|
| 114 |
+
|
| 115 |
+
## 📊 监控和日志
|
| 116 |
+
|
| 117 |
+
### 查看日志
|
| 118 |
+
|
| 119 |
+
1. 访问 Space 页面
|
| 120 |
+
2. 点击 **"Logs"** 标签
|
| 121 |
+
3. 查看实时日志输出
|
| 122 |
+
|
| 123 |
+
### 健康检查
|
| 124 |
+
|
| 125 |
+
访问以下端点检查服务状态:
|
| 126 |
+
|
| 127 |
+
- `GET /` - 首页(显示服务状态)
|
| 128 |
+
- `GET /v1/models` - 模型列表(API 是否正常)
|
| 129 |
+
|
| 130 |
+
## 💰 成本估算
|
| 131 |
+
|
| 132 |
+
以 **CPU Basic** 为例:
|
| 133 |
+
|
| 134 |
+
- **空闲时**:$0.10/小时 = $2.40/天 = $72/月
|
| 135 |
+
- **使用时**:相同费用(按小时计费)
|
| 136 |
+
|
| 137 |
+
**建议**:
|
| 138 |
+
- 只在需要时启动 Space
|
| 139 |
+
- 使用后及时暂停或删除
|
| 140 |
+
- 考虑使用其他更便宜的云服务
|
| 141 |
+
|
| 142 |
+
## 🔄 更新部署
|
| 143 |
+
|
| 144 |
+
```bash
|
| 145 |
+
# 拉取最新代码
|
| 146 |
+
git pull origin main
|
| 147 |
+
|
| 148 |
+
# 推送更新
|
| 149 |
+
git push origin main
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
Space 会自动重新构建和部署。
|
| 153 |
+
|
| 154 |
+
## 📞 获取帮助
|
| 155 |
+
|
| 156 |
+
- **GitHub Issues**: https://github.com/foxhui/WebAI2API/issues
|
| 157 |
+
- **文档**: https://foxhui.github.io/WebAI2API/
|
| 158 |
+
- **Hugging Face Discord**: https://discord.gg/huggingface
|
| 159 |
+
|
| 160 |
+
## 📝 配置说明
|
| 161 |
+
|
| 162 |
+
### config.hf.yaml 优化配置
|
| 163 |
+
|
| 164 |
+
```yaml
|
| 165 |
+
server:
|
| 166 |
+
port: 7860
|
| 167 |
+
|
| 168 |
+
browser:
|
| 169 |
+
headless: true # 无头模式节省资源
|
| 170 |
+
fission: false # 关闭站点隔离降低内存
|
| 171 |
+
|
| 172 |
+
queue:
|
| 173 |
+
queueBuffer: 1 # 减少队列缓冲
|
| 174 |
+
imageLimit: 3 # 限制图片数量
|
| 175 |
+
```
|
| 176 |
+
|
| 177 |
+
## ⚡ 性能优化建议
|
| 178 |
+
|
| 179 |
+
1. **使用付费版 Space**:免费版资源严重不足
|
| 180 |
+
2. **按需启动**:不需要时暂停 Space
|
| 181 |
+
3. **监控资源使用**:定期查看 CPU 和内存使用情况
|
| 182 |
+
4. **优化配置**:根据实际需求调整配置参数
|
| 183 |
+
|
| 184 |
+
## 🎯 使用场景
|
| 185 |
+
|
| 186 |
+
### 适合
|
| 187 |
+
|
| 188 |
+
- ✅ 测试和演示
|
| 189 |
+
- ✅ 临时使用
|
| 190 |
+
- ✅ 小规模应用
|
| 191 |
+
|
| 192 |
+
### 不适合
|
| 193 |
+
|
| 194 |
+
- ❌ 高并发生产环境
|
| 195 |
+
- ❌ 长时间运行
|
| 196 |
+
- ❌ 大规模应用
|
| 197 |
+
|
| 198 |
+
**建议**:生产环境使用自己的服务器或云服务(如 AWS、阿里云等)。
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2025 foxhui
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
PERSISTENCE_SOLUTIONS.md
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Space 数据持久化方案
|
| 2 |
+
|
| 3 |
+
## 📋 问题
|
| 4 |
+
|
| 5 |
+
Hugging Face Space 的存储不是持久化的,每次重启后:
|
| 6 |
+
- `data/` 目录会被清空
|
| 7 |
+
- 浏览器登录状态会丢失
|
| 8 |
+
- 需要重新登录
|
| 9 |
+
|
| 10 |
+
## 🎯 需求
|
| 11 |
+
|
| 12 |
+
1. **首次启动**:登录模式,保存登录信息
|
| 13 |
+
2. **再次启动**:正常模式,使用保存的登录信息
|
| 14 |
+
|
| 15 |
+
## 💡 解决方案
|
| 16 |
+
|
| 17 |
+
### 方案一:使用 Hugging Face Datasets(推荐)
|
| 18 |
+
|
| 19 |
+
**优点**:
|
| 20 |
+
- ✅ 免费
|
| 21 |
+
- ✅ 完全集成在 Hugging Face 生态中
|
| 22 |
+
- ✅ 支持大文件
|
| 23 |
+
- ✅ 自动版本控制
|
| 24 |
+
|
| 25 |
+
**缺点**:
|
| 26 |
+
- ❌ 需要额外的 API Token
|
| 27 |
+
- ❌ 上传/下载需要时间
|
| 28 |
+
|
| 29 |
+
**实现步骤**:
|
| 30 |
+
|
| 31 |
+
1. **创建 Dataset**:
|
| 32 |
+
- 访问 https://huggingface.co/datasets
|
| 33 |
+
- 创建新的 Dataset(如 `YOUR_USERNAME/webai2api-data`)
|
| 34 |
+
- 设置为 Private(保护隐私)
|
| 35 |
+
|
| 36 |
+
2. **配置环境变量**:
|
| 37 |
+
在 Space Settings 中添加:
|
| 38 |
+
- `HF_DATASET_REPO`: `YOUR_USERNAME/webai2api-data`
|
| 39 |
+
- `HF_TOKEN`: 您的 Hugging Face Token(需要 write 权限)
|
| 40 |
+
|
| 41 |
+
3. **自动保存脚本**:
|
| 42 |
+
```bash
|
| 43 |
+
# 登录完成后,手动执行
|
| 44 |
+
npm run save-data
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
4. **启动时自动恢复**:
|
| 48 |
+
启动脚本会自动检查并恢复数据
|
| 49 |
+
|
| 50 |
+
---
|
| 51 |
+
|
| 52 |
+
### 方案二:使用外部云存储(S3/OneDrive)
|
| 53 |
+
|
| 54 |
+
**优点**:
|
| 55 |
+
- ✅ 灵活性高
|
| 56 |
+
- ✅ 可以使用任何云服务
|
| 57 |
+
- ✅ 成本可控
|
| 58 |
+
|
| 59 |
+
**缺点**:
|
| 60 |
+
- ❌ 需要额外的云服务账号
|
| 61 |
+
- ❌ 需要配置 API 密钥
|
| 62 |
+
|
| 63 |
+
**支持的云服务**:
|
| 64 |
+
- AWS S3
|
| 65 |
+
- 阿里云 OSS
|
| 66 |
+
- 腾讯云 COS
|
| 67 |
+
- OneDrive
|
| 68 |
+
- Google Drive
|
| 69 |
+
|
| 70 |
+
---
|
| 71 |
+
|
| 72 |
+
### 方案三:使用 Cookie 导出/导入(轻量级)
|
| 73 |
+
|
| 74 |
+
**优点**:
|
| 75 |
+
- ✅ 文件小,上传快
|
| 76 |
+
- ✅ 只保存必要信息
|
| 77 |
+
- ✅ 安全性高
|
| 78 |
+
|
| 79 |
+
**缺点**:
|
| 80 |
+
- ❌ 可能需要重新登录(Cookie 过期)
|
| 81 |
+
- ❌ 功能有限
|
| 82 |
+
|
| 83 |
+
**实现**:
|
| 84 |
+
```bash
|
| 85 |
+
# 导出 Cookie
|
| 86 |
+
npm run export-cookies
|
| 87 |
+
|
| 88 |
+
# 导入 Cookie
|
| 89 |
+
npm run import-cookies
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
---
|
| 93 |
+
|
| 94 |
+
### 方案四:使用 Hugging Face Space 持久化存储(付费)
|
| 95 |
+
|
| 96 |
+
**优点**:
|
| 97 |
+
- ✅ 最简单
|
| 98 |
+
- ✅ 自动持久化
|
| 99 |
+
- ✅ 无需额外配置
|
| 100 |
+
|
| 101 |
+
**缺点**:
|
| 102 |
+
- ❌ 需要付费($5/月 起)
|
| 103 |
+
- ❌ 需要升级 Space
|
| 104 |
+
|
| 105 |
+
**步骤**:
|
| 106 |
+
1. 在 Space Settings 中启用持久化存储
|
| 107 |
+
2. 选择存储大小(10GB 起)
|
| 108 |
+
3. 每月 $5
|
| 109 |
+
|
| 110 |
+
---
|
| 111 |
+
|
| 112 |
+
## 🚀 推荐方案对比
|
| 113 |
+
|
| 114 |
+
| 方案 | 成本 | 复杂度 | 可靠性 | 推荐度 |
|
| 115 |
+
|------|------|--------|--------|--------|
|
| 116 |
+
| Datasets | 免费 | 中 | 高 | ✅✅✅ |
|
| 117 |
+
| 外部云存储 | 低 | 高 | 高 | ✅✅ |
|
| 118 |
+
| Cookie 导出 | 免费 | 低 | 中 | ✅ |
|
| 119 |
+
| 持久化存储 | $5/月 | 低 | 最高 | ✅✅✅✅ |
|
| 120 |
+
|
| 121 |
+
## 📖 详细实现
|
| 122 |
+
|
| 123 |
+
### 方案一:使用 Hugging Face Datasets(完整实现)
|
| 124 |
+
|
| 125 |
+
#### 1. 创建 Dataset
|
| 126 |
+
|
| 127 |
+
1. 访问 https://huggingface.co/datasets/new
|
| 128 |
+
2. 输入名称:`YOUR_USERNAME/webai2api-data`
|
| 129 |
+
3. 选择:Private
|
| 130 |
+
4. 点击 Create
|
| 131 |
+
|
| 132 |
+
#### 2. 获取 Token
|
| 133 |
+
|
| 134 |
+
1. 访问 https://huggingface.co/settings/tokens
|
| 135 |
+
2. 创建新 Token
|
| 136 |
+
3. 权限选择:`write`
|
| 137 |
+
4. 复制 Token
|
| 138 |
+
|
| 139 |
+
#### 3. 配置 Space
|
| 140 |
+
|
| 141 |
+
在 Space Settings → Variables 中添加:
|
| 142 |
+
```
|
| 143 |
+
HF_DATASET_REPO=YOUR_USERNAME/webai2api-data
|
| 144 |
+
HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
#### 4. 使用脚本
|
| 148 |
+
|
| 149 |
+
项目已包含以下脚本:
|
| 150 |
+
|
| 151 |
+
**保存数据**:
|
| 152 |
+
```bash
|
| 153 |
+
# 登录完成后执行
|
| 154 |
+
npm run save-data
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
**恢复数据**:
|
| 158 |
+
```bash
|
| 159 |
+
# 启动时自动执行
|
| 160 |
+
# 或手动执行
|
| 161 |
+
npm run restore-data
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
**删除数据**:
|
| 165 |
+
```bash
|
| 166 |
+
npm run clear-data
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
+
#### 5. 工作流程
|
| 170 |
+
|
| 171 |
+
**首次使用**:
|
| 172 |
+
1. 启动 Space(无数据)
|
| 173 |
+
2. 通过 WebUI 登录
|
| 174 |
+
3. 执行 `npm run save-data` 保存数据
|
| 175 |
+
4. 数据上传到 Hugging Face Dataset
|
| 176 |
+
|
| 177 |
+
**后续使用**:
|
| 178 |
+
1. 启动 Space
|
| 179 |
+
2. 自动从 Dataset 恢复数据
|
| 180 |
+
3. 直接使用,无需重新登录
|
| 181 |
+
|
| 182 |
+
---
|
| 183 |
+
|
| 184 |
+
## 🔧 故障排除
|
| 185 |
+
|
| 186 |
+
### 问题 1:上传失败
|
| 187 |
+
|
| 188 |
+
**原因**:Token 权限不足
|
| 189 |
+
|
| 190 |
+
**解决**:
|
| 191 |
+
1. 检查 Token 是否有 `write` 权限
|
| 192 |
+
2. 检查 Dataset 名称是否正确
|
| 193 |
+
3. 检查网络连接
|
| 194 |
+
|
| 195 |
+
### 问题 2:恢复失败
|
| 196 |
+
|
| 197 |
+
**原因**:数据损坏或不完整
|
| 198 |
+
|
| 199 |
+
**解决**:
|
| 200 |
+
1. 删除本地数据:`npm run clear-data`
|
| 201 |
+
2. 重新上传:`npm run save-data`
|
| 202 |
+
3. 检查 Dataset 中的文件
|
| 203 |
+
|
| 204 |
+
### 问题 3:Cookie 过期
|
| 205 |
+
|
| 206 |
+
**原因**:Cookie 有效期已过
|
| 207 |
+
|
| 208 |
+
**解决**:
|
| 209 |
+
1. 重新登录
|
| 210 |
+
2. 重新保存数据
|
| 211 |
+
|
| 212 |
+
---
|
| 213 |
+
|
| 214 |
+
## 💰 成本估算
|
| 215 |
+
|
| 216 |
+
### 方案一:Hugging Face Datasets
|
| 217 |
+
- **存储**:免费(有限制)
|
| 218 |
+
- **流量**:免费
|
| 219 |
+
- **总计**:$0/月
|
| 220 |
+
|
| 221 |
+
### 方案二:外部云存储
|
| 222 |
+
- **AWS S3**: $0.023/GB/月
|
| 223 |
+
- **阿里云 OSS**: ¥0.12/GB/月
|
| 224 |
+
- **腾讯云 COS**: ¥0.118/GB/月
|
| 225 |
+
|
| 226 |
+
### 方案三:Cookie 导出
|
| 227 |
+
- **存储**:免费(文件很小)
|
| 228 |
+
- **总计**:$0/月
|
| 229 |
+
|
| 230 |
+
### 方案四:持久化存储
|
| 231 |
+
- **10GB**: $5/月
|
| 232 |
+
- **20GB**: $10/月
|
| 233 |
+
- **50GB**: $20/月
|
| 234 |
+
|
| 235 |
+
---
|
| 236 |
+
|
| 237 |
+
## 📝 总结
|
| 238 |
+
|
| 239 |
+
**推荐方案**:
|
| 240 |
+
- **测试/个人使用**:方案一(Hugging Face Datasets)
|
| 241 |
+
- **生产环境**:方案四(持久化存储)
|
| 242 |
+
- **成本敏感**:方案三(Cookie 导出)
|
| 243 |
+
|
| 244 |
+
**注意事项**:
|
| 245 |
+
- ⚠️ 定期备份数据
|
| 246 |
+
- ⚠️ 不要将敏感信息提交到公开仓库
|
| 247 |
+
- ⚠️ 使用 HTTPS 保护数据传输
|
QUICK_START_PERSISTENCE.md
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Space 数据持久化快速指南
|
| 2 |
+
|
| 3 |
+
## 🚀 快速开始(3 步完成)
|
| 4 |
+
|
| 5 |
+
### 步骤 1:创建 Dataset
|
| 6 |
+
|
| 7 |
+
1. 访问 https://huggingface.co/datasets/new
|
| 8 |
+
2. 输入名称:`YOUR_USERNAME/webai2api-data`
|
| 9 |
+
3. 选择:**Private**(保护隐私)
|
| 10 |
+
4. 点击 **Create**
|
| 11 |
+
|
| 12 |
+
### 步骤 2:获取 Token
|
| 13 |
+
|
| 14 |
+
1. 访问 https://huggingface.co/settings/tokens
|
| 15 |
+
2. 点击 **New token**
|
| 16 |
+
3. Token name: `webai2api-space`
|
| 17 |
+
4. 权限选择:**write**
|
| 18 |
+
5. 点击 **Create token**
|
| 19 |
+
6. 复制 Token(格式:`hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`)
|
| 20 |
+
|
| 21 |
+
### 步骤 3:配置 Space
|
| 22 |
+
|
| 23 |
+
在 Space Settings → **Variables** 中添加:
|
| 24 |
+
|
| 25 |
+
```
|
| 26 |
+
HF_DATASET_REPO=YOUR_USERNAME/webai2api-data
|
| 27 |
+
HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
例如:
|
| 31 |
+
```
|
| 32 |
+
HF_DATASET_REPO=iudd/webai2api-data
|
| 33 |
+
HF_TOKEN=hf_abc123xyz456...
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
保存后,Space 会自动重启。
|
| 37 |
+
|
| 38 |
+
---
|
| 39 |
+
|
| 40 |
+
## 📖 使用流程
|
| 41 |
+
|
| 42 |
+
### 首次使用(登录模式)
|
| 43 |
+
|
| 44 |
+
1. **启动 Space**(首次,无数据)
|
| 45 |
+
```
|
| 46 |
+
Space 自动启动,检测到无数据
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
2. **访问 WebUI**
|
| 50 |
+
```
|
| 51 |
+
https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
3. **连接虚拟显示器**
|
| 55 |
+
- 点击 "🎨 访问 WebUI"
|
| 56 |
+
- 在 WebUI 中找到 "虚拟显示器" 板块
|
| 57 |
+
- 点击连接
|
| 58 |
+
|
| 59 |
+
4. **完成登录**
|
| 60 |
+
- 在虚拟显示器中登录所需的 AI 网站
|
| 61 |
+
- 发送测试消息验证
|
| 62 |
+
- 确保登录状态正常
|
| 63 |
+
|
| 64 |
+
5. **保存数据**
|
| 65 |
+
- 在 Space 的终端中执行:
|
| 66 |
+
```bash
|
| 67 |
+
npm run save-data
|
| 68 |
+
```
|
| 69 |
+
- 等待上传完成(约 1-2 分钟)
|
| 70 |
+
- 看到 "✅ 数据保存成功" 即可
|
| 71 |
+
|
| 72 |
+
### 后续使用(自动恢复)
|
| 73 |
+
|
| 74 |
+
1. **启动 Space**
|
| 75 |
+
```
|
| 76 |
+
Space 自动启动
|
| 77 |
+
检测到环境变量
|
| 78 |
+
自动从 Dataset 恢复数据
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
2. **直接使用**
|
| 82 |
+
- 浏览器已恢复登录状态
|
| 83 |
+
- 无需重新登录
|
| 84 |
+
- 直接调用 API
|
| 85 |
+
|
| 86 |
+
---
|
| 87 |
+
|
| 88 |
+
## 🛠️ 常用命令
|
| 89 |
+
|
| 90 |
+
### 保存数据
|
| 91 |
+
```bash
|
| 92 |
+
npm run save-data
|
| 93 |
+
```
|
| 94 |
+
**使用场景**:
|
| 95 |
+
- 首次登录完成后
|
| 96 |
+
- 更新登录状态后
|
| 97 |
+
- 定期备份
|
| 98 |
+
|
| 99 |
+
### 恢复数据
|
| 100 |
+
```bash
|
| 101 |
+
npm run restore-data
|
| 102 |
+
```
|
| 103 |
+
**使用场景**:
|
| 104 |
+
- 手动恢复数据
|
| 105 |
+
- 数据丢失后恢复
|
| 106 |
+
|
| 107 |
+
### 清除数据
|
| 108 |
+
```bash
|
| 109 |
+
npm run clear-data
|
| 110 |
+
```
|
| 111 |
+
**使用场景**:
|
| 112 |
+
- 清除本地数据
|
| 113 |
+
- 重新登录前
|
| 114 |
+
- 5 秒倒计时,可取消
|
| 115 |
+
|
| 116 |
+
---
|
| 117 |
+
|
| 118 |
+
## ⚠️ 注意事项
|
| 119 |
+
|
| 120 |
+
1. **首次使用必须手动保存**
|
| 121 |
+
- 自动恢复只在有数据时生效
|
| 122 |
+
- 首次登录后必须手动执行 `npm run save-data`
|
| 123 |
+
|
| 124 |
+
2. **Cookie 过期问题**
|
| 125 |
+
- 如果 Cookie 过期,需要重新登录
|
| 126 |
+
- 重新登录后执行 `npm run save-data`
|
| 127 |
+
|
| 128 |
+
3. **数据隐私**
|
| 129 |
+
- Dataset 设置为 **Private**
|
| 130 |
+
- 不要将 Token 泄露
|
| 131 |
+
- 定期更换 Token
|
| 132 |
+
|
| 133 |
+
4. **网络问题**
|
| 134 |
+
- 上传/下载需要时间
|
| 135 |
+
- 如果失败,重试即可
|
| 136 |
+
- 检查网络连接
|
| 137 |
+
|
| 138 |
+
---
|
| 139 |
+
|
| 140 |
+
## 🔧 故障排除
|
| 141 |
+
|
| 142 |
+
### 问题 1:自动恢复失败
|
| 143 |
+
|
| 144 |
+
**症状**:
|
| 145 |
+
```
|
| 146 |
+
⚠️ 数据恢复失败,将使用新的浏览器实例
|
| 147 |
+
```
|
| 148 |
+
|
| 149 |
+
**解决方案**:
|
| 150 |
+
1. 检查环境变量是否正确
|
| 151 |
+
2. 检查 Dataset 是否存在
|
| 152 |
+
3. 检查 Token 权限(需要 read)
|
| 153 |
+
4. 手动执行 `npm run restore-data`
|
| 154 |
+
|
| 155 |
+
### 问题 2:保存失败
|
| 156 |
+
|
| 157 |
+
**症状**:
|
| 158 |
+
```
|
| 159 |
+
❌ 保存失败: ...
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
**解决方案**:
|
| 163 |
+
1. 检查 Token 权限(需要 write)
|
| 164 |
+
2. 检查 Dataset 名称
|
| 165 |
+
3. 检查网络连接
|
| 166 |
+
4. 确保已登录
|
| 167 |
+
|
| 168 |
+
### 问题 3:需要重新登录
|
| 169 |
+
|
| 170 |
+
**原因**:Cookie 过期
|
| 171 |
+
|
| 172 |
+
**解决方案**:
|
| 173 |
+
1. 清除数据:`npm run clear-data`
|
| 174 |
+
2. 重新登录
|
| 175 |
+
3. 保存数据:`npm run save-data`
|
| 176 |
+
|
| 177 |
+
---
|
| 178 |
+
|
| 179 |
+
## 💡 最佳实践
|
| 180 |
+
|
| 181 |
+
1. **定期保存**
|
| 182 |
+
- 每次登录后立即保存
|
| 183 |
+
- 定期检查数据状态
|
| 184 |
+
|
| 185 |
+
2. **备份策略**
|
| 186 |
+
- 保留多个版本(Dataset 自动版本控制)
|
| 187 |
+
- 定期导出关键数据
|
| 188 |
+
|
| 189 |
+
3. **安全建议**
|
| 190 |
+
- 使用强密码
|
| 191 |
+
- 定期更换 Token
|
| 192 |
+
- 监控访问日志
|
| 193 |
+
|
| 194 |
+
4. **成本控制**
|
| 195 |
+
- Dataset 存储免费
|
| 196 |
+
- 流量免费
|
| 197 |
+
- 无额外成本
|
| 198 |
+
|
| 199 |
+
---
|
| 200 |
+
|
| 201 |
+
## 📊 数据大小
|
| 202 |
+
|
| 203 |
+
浏览器数据通常包含:
|
| 204 |
+
- Cookies: ~10 KB
|
| 205 |
+
- LocalStorage: ~50 KB
|
| 206 |
+
- SessionStorage: ~10 KB
|
| 207 |
+
- Cache: ~100-500 KB
|
| 208 |
+
- 其他: ~50-100 KB
|
| 209 |
+
|
| 210 |
+
**总计**: 约 200-700 KB
|
| 211 |
+
|
| 212 |
+
上传/下载时间:
|
| 213 |
+
- 上传: ~10-30 秒
|
| 214 |
+
- 下载: ~5-15 秒
|
| 215 |
+
|
| 216 |
+
---
|
| 217 |
+
|
| 218 |
+
## 🎯 完整示例
|
| 219 |
+
|
| 220 |
+
### 场景:首次部署
|
| 221 |
+
|
| 222 |
+
```bash
|
| 223 |
+
# 1. 创建 Dataset(在网页上操作)
|
| 224 |
+
# 访问:https://huggingface.co/datasets/new
|
| 225 |
+
# 名称:iudd/webai2api-data
|
| 226 |
+
# 类型:Private
|
| 227 |
+
|
| 228 |
+
# 2. 获取 Token(在网页上操作)
|
| 229 |
+
# 访问:https://huggingface.co/settings/tokens
|
| 230 |
+
# 权限:write
|
| 231 |
+
# 复制 Token
|
| 232 |
+
|
| 233 |
+
# 3. 配置环境变量(在 Space Settings 中)
|
| 234 |
+
# HF_DATASET_REPO=iudd/webai2api-data
|
| 235 |
+
# HF_TOKEN=hf_abc123...
|
| 236 |
+
|
| 237 |
+
# 4. Space 自动重启
|
| 238 |
+
|
| 239 |
+
# 5. 访问 WebUI 并登录
|
| 240 |
+
# URL: https://huggingface.co/spaces/iudd/webai2api
|
| 241 |
+
# 在虚拟显示器中登录
|
| 242 |
+
|
| 243 |
+
# 6. 保存数据
|
| 244 |
+
npm run save-data
|
| 245 |
+
|
| 246 |
+
# ✅ 完成!下次启动自动恢复
|
| 247 |
+
```
|
| 248 |
+
|
| 249 |
+
### 场景:日常使用
|
| 250 |
+
|
| 251 |
+
```bash
|
| 252 |
+
# 1. 启动 Space
|
| 253 |
+
# 自动恢复数据(无需操作)
|
| 254 |
+
|
| 255 |
+
# 2. 直接使用 API
|
| 256 |
+
curl https://huggingface.co/spaces/iudd/webai2api/v1/models
|
| 257 |
+
|
| 258 |
+
# 3. 如果需要更新登录状态
|
| 259 |
+
# 在 WebUI 中重新登录
|
| 260 |
+
npm run save-data
|
| 261 |
+
```
|
| 262 |
+
|
| 263 |
+
---
|
| 264 |
+
|
| 265 |
+
## 📞 获取帮助
|
| 266 |
+
|
| 267 |
+
- **文档**: [PERSISTENCE_SOLUTIONS.md](PERSISTENCE_SOLUTIONS.md)
|
| 268 |
+
- **Issues**: https://github.com/foxhui/WebAI2API/issues
|
| 269 |
+
- **Hugging Face Discord**: https://discord.gg/huggingface
|
| 270 |
+
|
| 271 |
+
---
|
| 272 |
+
|
| 273 |
+
**提示**:首次使用建议先在本地测试,确认流程后再部署到 Space。
|
QUICK_START_WEBDAV.md
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# WebDAV 数据持久化快速指南
|
| 2 |
+
|
| 3 |
+
## 🚀 快速开始(2 步完成)
|
| 4 |
+
|
| 5 |
+
### 步骤 1:配置环境变量
|
| 6 |
+
|
| 7 |
+
在 Hugging Face Space Settings → **Variables** 中添加以下 3 个环境变量:
|
| 8 |
+
|
| 9 |
+
```
|
| 10 |
+
WEBDAV_URL=https://rebun.infini-cloud.net/dav
|
| 11 |
+
WEBDAV_USER=iyougame
|
| 12 |
+
WEBDAV_PASS=exzgmqInkoFADbjOx1ak_reGVIf_ptIZxYUtBFp3mLw
|
| 13 |
+
```
|
| 14 |
+
|
| 15 |
+
保存后,Space 会自动重启。
|
| 16 |
+
|
| 17 |
+
### 步骤 2:完成
|
| 18 |
+
|
| 19 |
+
✅ 配置完成!现在可以使用 WebDAV 保存和恢复浏览器数据了。
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
## 📖 使用流程
|
| 24 |
+
|
| 25 |
+
### 首次使用(登录模式)
|
| 26 |
+
|
| 27 |
+
1. **启动 Space**(首次,无数据)
|
| 28 |
+
```
|
| 29 |
+
Space 自动启动,检测到无数据
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
2. **访问 WebUI**
|
| 33 |
+
```
|
| 34 |
+
https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
3. **连接虚拟显示器**
|
| 38 |
+
- 点击 "🎨 访问 WebUI"
|
| 39 |
+
- 在 WebUI 中找到 "虚拟显示器" 板块
|
| 40 |
+
- 点击连接
|
| 41 |
+
|
| 42 |
+
4. **完成登录**
|
| 43 |
+
- 在虚拟显示器中登录所需的 AI 网站
|
| 44 |
+
- 发送测试消息验证
|
| 45 |
+
- 确保登录状态正常
|
| 46 |
+
|
| 47 |
+
5. **保存数据到 WebDAV**
|
| 48 |
+
- 在 Space 的终端中执行:
|
| 49 |
+
```bash
|
| 50 |
+
npm run save-data-webdav
|
| 51 |
+
```
|
| 52 |
+
- 等待上传完成(约 10-30 秒)
|
| 53 |
+
- 看到 "✅ 数据保存成功" 即可
|
| 54 |
+
|
| 55 |
+
### 后续使用(自动恢复)
|
| 56 |
+
|
| 57 |
+
1. **启动 Space**
|
| 58 |
+
```
|
| 59 |
+
Space 自动启动
|
| 60 |
+
检测到 WebDAV 环境变量
|
| 61 |
+
自动从 WebDAV 恢复数据
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
2. **直接使用**
|
| 65 |
+
- 浏览器已恢复登录状态
|
| 66 |
+
- 无需重新登录
|
| 67 |
+
- 直接调用 API
|
| 68 |
+
|
| 69 |
+
---
|
| 70 |
+
|
| 71 |
+
## 🛠️ 常用命令
|
| 72 |
+
|
| 73 |
+
### 保存数据到 WebDAV
|
| 74 |
+
```bash
|
| 75 |
+
npm run save-data-webdav
|
| 76 |
+
```
|
| 77 |
+
**使用场景**:
|
| 78 |
+
- 首次登录完成后
|
| 79 |
+
- 更新登录状态后
|
| 80 |
+
- 定期备份
|
| 81 |
+
|
| 82 |
+
### 从 WebDAV 恢复数据
|
| 83 |
+
```bash
|
| 84 |
+
npm run restore-data-webdav
|
| 85 |
+
```
|
| 86 |
+
**使用场景**:
|
| 87 |
+
- 手动恢复数据
|
| 88 |
+
- 数据丢失后恢复
|
| 89 |
+
|
| 90 |
+
### 清除本地数据
|
| 91 |
+
```bash
|
| 92 |
+
npm run clear-data
|
| 93 |
+
```
|
| 94 |
+
**使用场景**:
|
| 95 |
+
- 清除本地数据
|
| 96 |
+
- 重新登录前
|
| 97 |
+
- 5 秒倒计时,可取消
|
| 98 |
+
|
| 99 |
+
---
|
| 100 |
+
|
| 101 |
+
## ⚠️ 注意事项
|
| 102 |
+
|
| 103 |
+
1. **首次使用必须手动保存**
|
| 104 |
+
- 自动恢复只在有数据时生效
|
| 105 |
+
- 首次登录后必须手动执行 `npm run save-data-webdav`
|
| 106 |
+
|
| 107 |
+
2. **Cookie 过期问题**
|
| 108 |
+
- 如果 Cookie 过期,需要重新登录
|
| 109 |
+
- 重新登录后执行 `npm run save-data-webdav`
|
| 110 |
+
|
| 111 |
+
3. **WebDAV 连接**
|
| 112 |
+
- 确保 WebDAV 服务器可访问
|
| 113 |
+
- 检查用户名和密码
|
| 114 |
+
- 网络问题可能导致上传/下载失败
|
| 115 |
+
|
| 116 |
+
4. **数据隐私**
|
| 117 |
+
- WebDAV 服务器由您自己控制
|
| 118 |
+
- 确保密码安全
|
| 119 |
+
- 定期更换密码
|
| 120 |
+
|
| 121 |
+
---
|
| 122 |
+
|
| 123 |
+
## 🔧 故障排除
|
| 124 |
+
|
| 125 |
+
### 问题 1:自动恢复失败
|
| 126 |
+
|
| 127 |
+
**症状**:
|
| 128 |
+
```
|
| 129 |
+
⚠️ 数据恢复失败,将使用新的浏览器实例
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
**解决方案**:
|
| 133 |
+
1. 检查环境变量是否正确
|
| 134 |
+
2. 检查 WebDAV 服务器是否可访问
|
| 135 |
+
3. 检查用户名和密码
|
| 136 |
+
4. 手动执行 `npm run restore-data-webdav`
|
| 137 |
+
|
| 138 |
+
### 问题 2:保存失败
|
| 139 |
+
|
| 140 |
+
**症状**:
|
| 141 |
+
```
|
| 142 |
+
❌ 保存失败: ...
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
**解决方案**:
|
| 146 |
+
1. 检查 WebDAV 服务器连接
|
| 147 |
+
2. 检查用户名和密码
|
| 148 |
+
3. 检查网络连接
|
| 149 |
+
4. 确保已登录
|
| 150 |
+
|
| 151 |
+
### 问题 3:需要重新登录
|
| 152 |
+
|
| 153 |
+
**原因**:Cookie 过期
|
| 154 |
+
|
| 155 |
+
**解决方案**:
|
| 156 |
+
1. 清除数据:`npm run clear-data`
|
| 157 |
+
2. 重新登录
|
| 158 |
+
3. 保存数据:`npm run save-data-webdav`
|
| 159 |
+
|
| 160 |
+
### 问题 4:连接超时
|
| 161 |
+
|
| 162 |
+
**原因**:WebDAV 服务器响应慢或网络问题
|
| 163 |
+
|
| 164 |
+
**解决方案**:
|
| 165 |
+
1. 检查网络连接
|
| 166 |
+
2. 检查 WebDAV 服务器状态
|
| 167 |
+
3. 重试保存/恢复操作
|
| 168 |
+
|
| 169 |
+
---
|
| 170 |
+
|
| 171 |
+
## 💡 最佳实践
|
| 172 |
+
|
| 173 |
+
1. **定期保存**
|
| 174 |
+
- 每次登录后立即保存
|
| 175 |
+
- 定期检查数据状态
|
| 176 |
+
|
| 177 |
+
2. **备份策略**
|
| 178 |
+
- WebDAV 服务器可以配置备份
|
| 179 |
+
- 定期导出关键数据
|
| 180 |
+
|
| 181 |
+
3. **安全建议**
|
| 182 |
+
- 使用强密码
|
| 183 |
+
- 定期更换密码
|
| 184 |
+
- 监控访问日志
|
| 185 |
+
|
| 186 |
+
4. **成本控制**
|
| 187 |
+
- WebDAV 完全免费
|
| 188 |
+
- 无流量限制
|
| 189 |
+
- 无存储限制(取决于您的 WebDAV 服务)
|
| 190 |
+
|
| 191 |
+
---
|
| 192 |
+
|
| 193 |
+
## 📊 数据大小
|
| 194 |
+
|
| 195 |
+
浏览器数据通常包含:
|
| 196 |
+
- Cookies: ~10 KB
|
| 197 |
+
- LocalStorage: ~50 KB
|
| 198 |
+
- SessionStorage: ~10 KB
|
| 199 |
+
- Cache: ~100-500 KB
|
| 200 |
+
- 其他: ~50-100 KB
|
| 201 |
+
|
| 202 |
+
**总计**: 约 200-700 KB
|
| 203 |
+
|
| 204 |
+
上传/下载时间:
|
| 205 |
+
- 上传: ~10-30 秒
|
| 206 |
+
- 下载: ~5-15 秒
|
| 207 |
+
|
| 208 |
+
---
|
| 209 |
+
|
| 210 |
+
## 🎯 完整示例
|
| 211 |
+
|
| 212 |
+
### 场景:首次部署
|
| 213 |
+
|
| 214 |
+
```bash
|
| 215 |
+
# 1. 配置环境变量(在 Space Settings 中)
|
| 216 |
+
# WEBDAV_URL=https://rebun.infini-cloud.net/dav
|
| 217 |
+
# WEBDAV_USER=iyougame
|
| 218 |
+
# WEBDAV_PASS=exzgmqInkoFADbjOx1ak_reGVIf_ptIZxYUtBFp3mLw
|
| 219 |
+
|
| 220 |
+
# 2. Space 自动重启
|
| 221 |
+
|
| 222 |
+
# 3. 访问 WebUI 并登录
|
| 223 |
+
# URL: https://huggingface.co/spaces/iudd/webai2api
|
| 224 |
+
# 在虚拟显示器中登录
|
| 225 |
+
|
| 226 |
+
# 4. 保存数据到 WebDAV
|
| 227 |
+
npm run save-data-webdav
|
| 228 |
+
|
| 229 |
+
# ✅ 完成!下次启动自动恢复
|
| 230 |
+
```
|
| 231 |
+
|
| 232 |
+
### 场景:日常使用
|
| 233 |
+
|
| 234 |
+
```bash
|
| 235 |
+
# 1. 启动 Space
|
| 236 |
+
# 自动恢复数据(无需操作)
|
| 237 |
+
|
| 238 |
+
# 2. 直接使用 API
|
| 239 |
+
curl https://huggingface.co/spaces/iudd/webai2api/v1/models
|
| 240 |
+
|
| 241 |
+
# 3. 如果需要更新登录状态
|
| 242 |
+
# 在 WebUI 中重新登录
|
| 243 |
+
npm run save-data-webdav
|
| 244 |
+
```
|
| 245 |
+
|
| 246 |
+
---
|
| 247 |
+
|
| 248 |
+
## 🔐 安全提示
|
| 249 |
+
|
| 250 |
+
1. **不要公开密码**
|
| 251 |
+
- 密���保存在 Space Settings 中(私密)
|
| 252 |
+
- 不要在代码中硬编码密码
|
| 253 |
+
- 不要在公开仓库中提交密码
|
| 254 |
+
|
| 255 |
+
2. **定期更换密码**
|
| 256 |
+
- 建议每月更换一次
|
| 257 |
+
- 更换后更新 Space Settings
|
| 258 |
+
|
| 259 |
+
3. **监控访问**
|
| 260 |
+
- 定期检查 WebDAV 访问日志
|
| 261 |
+
- 发现异常及时更换密码
|
| 262 |
+
|
| 263 |
+
---
|
| 264 |
+
|
| 265 |
+
## 📞 获取帮助
|
| 266 |
+
|
| 267 |
+
- **WebDAV 服务器**: https://rebun.infini-cloud.net
|
| 268 |
+
- **文档**: [PERSISTENCE_SOLUTIONS.md](PERSISTENCE_SOLUTIONS.md)
|
| 269 |
+
- **Issues**: https://github.com/foxhui/WebAI2API/issues
|
| 270 |
+
- **Hugging Face Discord**: https://discord.gg/huggingface
|
| 271 |
+
|
| 272 |
+
---
|
| 273 |
+
|
| 274 |
+
## 💰 成本对比
|
| 275 |
+
|
| 276 |
+
| 方案 | 成本 | 限制 | 推荐度 |
|
| 277 |
+
|------|------|------|--------|
|
| 278 |
+
| **WebDAV** | 免费 | 无 | ✅✅✅✅ |
|
| 279 |
+
| Hugging Face Datasets | 免费 | 有流量限制 | ✅✅✅ |
|
| 280 |
+
| 持久化存储 | $5/月 | 10GB | ✅✅ |
|
| 281 |
+
|
| 282 |
+
**推荐**:使用 WebDAV,完全免费且无限制!
|
| 283 |
+
|
| 284 |
+
---
|
| 285 |
+
|
| 286 |
+
**提示**:首次使用建议先在本地测试,确认流程后再部署到 Space。
|
README.md
CHANGED
|
@@ -1,10 +1,496 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: WebAI2API
|
| 3 |
+
emoji: 🤖
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
tags:
|
| 10 |
+
- web-automation
|
| 11 |
+
- openai-api
|
| 12 |
+
- playwright
|
| 13 |
+
- camoufox
|
| 14 |
+
- ai-api
|
| 15 |
+
- lm-arena
|
| 16 |
+
- gemini
|
| 17 |
+
short_description: 将网页版 AI 服务转换为 OpenAI 兼容 API 的自动化工具
|
| 18 |
---
|
| 19 |
|
| 20 |
+
# WebAI2API
|
| 21 |
+
|
| 22 |
+
简体中文 | [English](README_EN.md)
|
| 23 |
+
|
| 24 |
+
<p align="center">
|
| 25 |
+
<img src="https://github.com/user-attachments/assets/296a518e-c42b-4e39-8ff6-9b4381ed4f6e" width="49%" />
|
| 26 |
+
<img src="https://github.com/user-attachments/assets/bfa30ece-6947-4f18-b2c9-ccc8087b7e89" width="49%" />
|
| 27 |
+
</p>
|
| 28 |
+
<p align="center">
|
| 29 |
+
<img src="https://github.com/user-attachments/assets/5b15ebd2-7593-4f0e-8561-83d6ba5d88ab" width="49%" />
|
| 30 |
+
<img src="https://github.com/user-attachments/assets/53deea29-4071-4a07-8a61-211761c5f2f7" width="49%" />
|
| 31 |
+
</p>
|
| 32 |
+
|
| 33 |
+
## 📑 目录
|
| 34 |
+
|
| 35 |
+
- [Space 部署说明](#-space-部署说明)
|
| 36 |
+
- [数据持久化配置](#-数据持久化配置)
|
| 37 |
+
- [快速部署](#-快速部署)
|
| 38 |
+
- [快速开始](#-快速开始)
|
| 39 |
+
- [使用方法](#-使用方法)
|
| 40 |
+
- [API 接口](#-api-接口)
|
| 41 |
+
- [设备配置参考](#-设备配置参考)
|
| 42 |
+
|
| 43 |
+
---
|
| 44 |
+
|
| 45 |
+
## 📝 项目简介
|
| 46 |
+
|
| 47 |
+
**WebAI2API** 是一个基于 **Camoufox (Playwright)** 的网页版 AI 服务转通用 API 的工具。通过模拟人类操作与 LMArena、Gemini 等网站交互, 提供兼容 **OpenAI 格式** 的接口服务, 同时支持 **多窗口并发** 与 **多账号管理**(浏览器实例数据隔离)。
|
| 48 |
+
|
| 49 |
+
### ✨ 主要特性
|
| 50 |
+
|
| 51 |
+
- 🤖 **拟人交互**: 模拟人类打字与鼠标轨迹, 通过特征伪装规避自动化检测
|
| 52 |
+
- 🔄 **接口兼容**: 提供标准 OpenAI 格式接口, 支持流式响应与心跳保活
|
| 53 |
+
- 🚀 **并发隔离**: 支持多窗口并发执行, 可配置独立代理,实现多账号浏览器实例级数据隔离
|
| 54 |
+
- 🛡️ **稳定防护**: 内置任务队列、负载均衡、故障转移、错误重试等基础功能
|
| 55 |
+
- 🎨 **网页管理**: 提供可视化管理界面, 支持实时日志查看、VNC 连接、适配器管理等
|
| 56 |
+
|
| 57 |
+
### 📋 支持列表
|
| 58 |
+
|
| 59 |
+
| 网站名称 | 文本生成 | 图片生成 | 视频生成 |
|
| 60 |
+
| :--- | :---: | :---: | :---: |
|
| 61 |
+
| [**LMArena**](https://lmarena.ai/) | ✅ | ✅ | 🚫 |
|
| 62 |
+
| [**Gemini Enterprise Business**](https://business.gemini.google/) | ✅ | ✅ | ✅ |
|
| 63 |
+
| [**Nano Banana Free**](https://nanobananafree.ai/) | 🚫 | ✅ | 🚫 |
|
| 64 |
+
| [**zAI**](https://zai.is/) | ✅ | ✅ | 🚫 |
|
| 65 |
+
| [**Google Gemini**](https://gemini.google.com/) | ✅ | ✅💧 | ✅💧 |
|
| 66 |
+
| [**ZenMux**](https://zenmux.ai/) | ✅ | ❌ | 🚫 |
|
| 67 |
+
| [**ChatGPT**](https://chatgpt.com/) | ✅ | ✅ | 🚫 |
|
| 68 |
+
| [**DeepSeek**](https://chat.deepseek.com/) | ✅ | 🚫 | 🚫 |
|
| 69 |
+
| [**Sora**](https://sora.chatgpt.com/) | 🚫 | 🚫 | ✅💧 |
|
| 70 |
+
| [**Google Flow**](https://labs.google/fx/zh/tools/flow) | 🚫 | ✅ | ❌ |
|
| 71 |
+
| [**豆包**](https://www.doubao.com/) | ✅ | ✅ | ❌ |
|
| 72 |
+
| 待续... | - | - | - |
|
| 73 |
+
|
| 74 |
+
> [!NOTE]
|
| 75 |
+
> **获取完整模型列表**: 通过 `GET /v1/models` 接口查看当前配置下所有可用模型及其详细信息。
|
| 76 |
+
>
|
| 77 |
+
> ✅目前支持;❌目前不支持,但未来可能会支持;🚫网站不支持, 未来是否在支持看网站具体情况;💧结果带水印且无法去除;
|
| 78 |
+
|
| 79 |
+
---
|
| 80 |
+
|
| 81 |
+
## 🚀 Space 部署说明
|
| 82 |
+
|
| 83 |
+
### ⚠️ 重要提示
|
| 84 |
+
|
| 85 |
+
**Hugging Face Space 免费版(CPU tiny)资源严重不足,不推荐使用!**
|
| 86 |
+
|
| 87 |
+
**推荐硬件配置:**
|
| 88 |
+
|
| 89 |
+
| 硬件类型 | CPU | 内存 | 价格 | 推荐度 |
|
| 90 |
+
|---------|-----|------|------|--------|
|
| 91 |
+
| CPU Basic | 2 vCPU | 16 GB | $0.10/小时 | ✅ 推荐 |
|
| 92 |
+
| CPU Upgrade | 4 vCPU | 32 GB | $0.30/小时 | ✅✅ 最佳 |
|
| 93 |
+
| CPU XL | 8 vCPU | 64 GB | $0.80/小时 | ✅✅✅ 极佳 |
|
| 94 |
+
|
| 95 |
+
**为什么需要这么多资源?**
|
| 96 |
+
|
| 97 |
+
- 浏览器启动需要约 1-2 GB 内存
|
| 98 |
+
- 每个浏览器实例需要额外 500 MB - 1 GB
|
| 99 |
+
- 虚拟显示器(Xvfb)需要约 100 MB
|
| 100 |
+
- VNC 服务需要约 50 MB
|
| 101 |
+
|
| 102 |
+
**免费版限制:**
|
| 103 |
+
- CPU: 2 vCPU(共享)
|
| 104 |
+
- 内存: 16 GB(共享,实际可用更少)
|
| 105 |
+
- 经常因资源不足导致服务被暂停
|
| 106 |
+
|
| 107 |
+
---
|
| 108 |
+
|
| 109 |
+
## 💾 数据持久化配置
|
| 110 |
+
|
| 111 |
+
**Hugging Face Space 的存储不是持久化的,每次重启后数据会丢失。**
|
| 112 |
+
|
| 113 |
+
### 方案:使用 WebDAV(免费,推荐)
|
| 114 |
+
|
| 115 |
+
**优势:**
|
| 116 |
+
- ✅ 完全免费
|
| 117 |
+
- ✅ 无流量限制
|
| 118 |
+
- ✅ 无存储限制
|
| 119 |
+
- ✅ 自动恢复
|
| 120 |
+
|
| 121 |
+
#### 配置步骤(2 步)
|
| 122 |
+
|
| 123 |
+
1. **在 Space Settings 中添加环境变量**:
|
| 124 |
+
|
| 125 |
+
```
|
| 126 |
+
WEBDAV_URL=https://rebun.infini-cloud.net/dav
|
| 127 |
+
WEBDAV_USER=iyougame
|
| 128 |
+
WEBDAV_PASS=exzgmqInkoFADbjOx1ak_reGVIf_ptIZxYUtBFp3mLw
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
2. **保存并等待 Space 重启**
|
| 132 |
+
|
| 133 |
+
✅ 配置完成!
|
| 134 |
+
|
| 135 |
+
#### 使用流程
|
| 136 |
+
|
| 137 |
+
**首次使用:**
|
| 138 |
+
1. 启动 Space(无数据)
|
| 139 |
+
2. 访问 WebUI 并登录
|
| 140 |
+
3. 保存数据:`npm run save-data-webdav`
|
| 141 |
+
|
| 142 |
+
**后续使用:**
|
| 143 |
+
1. 启动 Space
|
| 144 |
+
2. 自动从 WebDAV 恢复数据
|
| 145 |
+
3. 直接使用,无需重新登录
|
| 146 |
+
|
| 147 |
+
#### 常用命令
|
| 148 |
+
|
| 149 |
+
```bash
|
| 150 |
+
# 保存数据到 WebDAV
|
| 151 |
+
npm run save-data-webdav
|
| 152 |
+
|
| 153 |
+
# 从 WebDAV 恢复数据
|
| 154 |
+
npm run restore-data-webdav
|
| 155 |
+
|
| 156 |
+
# 清除本地数据
|
| 157 |
+
npm run clear-data
|
| 158 |
+
```
|
| 159 |
+
|
| 160 |
+
**详细文档**: [QUICK_START_WEBDAV.md](QUICK_START_WEBDAV.md)
|
| 161 |
+
|
| 162 |
+
---
|
| 163 |
+
|
| 164 |
+
## 🛠️ 部署步骤
|
| 165 |
+
|
| 166 |
+
### 1. 创建 Space
|
| 167 |
+
|
| 168 |
+
1. 访问 https://huggingface.co/spaces
|
| 169 |
+
2. 点击 "Create new Space"
|
| 170 |
+
3. 配置:
|
| 171 |
+
- **Owner**: 选择您的账号
|
| 172 |
+
- **Space name**: 输入名称(如 `webai2api`)
|
| 173 |
+
- **SDK**: 选择 **Docker**
|
| 174 |
+
- **Hardware**: 推荐 **CPU Basic**($0.10/小时)或更高
|
| 175 |
+
- **Visibility**: Public 或 Private
|
| 176 |
+
|
| 177 |
+
### 2. 配置环境变量
|
| 178 |
+
|
| 179 |
+
**数据持久化(必需):**
|
| 180 |
+
```
|
| 181 |
+
WEBDAV_URL=https://rebun.infini-cloud.net/dav
|
| 182 |
+
WEBDAV_USER=iyougame
|
| 183 |
+
WEBDAV_PASS=exzgmqInkoFADbjOx1ak_reGVIf_ptIZxYUtBFp3mLw
|
| 184 |
+
```
|
| 185 |
+
|
| 186 |
+
**可选配置:**
|
| 187 |
+
- `AUTH_TOKEN`: API 鉴权密钥(建议使用强密码)
|
| 188 |
+
|
| 189 |
+
### 3. 推送代码
|
| 190 |
+
|
| 191 |
+
```bash
|
| 192 |
+
# 克隆 Space
|
| 193 |
+
git clone https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME
|
| 194 |
+
cd YOUR_SPACE_NAME
|
| 195 |
+
|
| 196 |
+
# 添加远程仓库
|
| 197 |
+
git remote add origin https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME
|
| 198 |
+
|
| 199 |
+
# 推送 webai2hf 分支
|
| 200 |
+
git push origin webai2hf:main
|
| 201 |
+
```
|
| 202 |
+
|
| 203 |
+
### 4. 等待构建
|
| 204 |
+
|
| 205 |
+
- 首次构建需要 5-10 分钟
|
| 206 |
+
- 构建完成后服务自动启动
|
| 207 |
+
- 浏览器初始化需要 30-60 秒
|
| 208 |
+
|
| 209 |
+
### 5. 访问服务
|
| 210 |
+
|
| 211 |
+
- 主页:`https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME`
|
| 212 |
+
- WebUI:点击页面上的 "🎨 访问 WebUI"
|
| 213 |
+
- API:`https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME/v1/models`
|
| 214 |
+
|
| 215 |
+
---
|
| 216 |
+
|
| 217 |
+
## ⚠️ Space 限制说明
|
| 218 |
+
|
| 219 |
+
- **资源限制**: 免费版 Space 有 CPU 和内存限制,**强烈建议使用付费版**
|
| 220 |
+
- **构建时间**: 首次构建需要下载浏览器,时间较长(5-10 分钟)
|
| 221 |
+
- **启动时间**: 浏览器初始化需要 30-60 秒
|
| 222 |
+
- **持久化存储**: Space 重启后数据会丢失(使用 WebDAV 解决)
|
| 223 |
+
|
| 224 |
+
---
|
| 225 |
+
|
| 226 |
+
## 🚀 快速部署
|
| 227 |
+
|
| 228 |
+
本项目支持 **源码直接运行** 和 **Docker 容器化部署** 两种方式。
|
| 229 |
+
|
| 230 |
+
### 📋 环境要求
|
| 231 |
+
|
| 232 |
+
- **Node.js**: v20.0.0+ (ABI 115+)
|
| 233 |
+
- **操作系统**: Windows / Linux / macOS
|
| 234 |
+
- **核心依赖**: Camoufox (安装过程中自动获取)
|
| 235 |
+
|
| 236 |
+
### 🛠️ 方式一:手动部署
|
| 237 |
+
|
| 238 |
+
1. **安装与配置**
|
| 239 |
+
```bash
|
| 240 |
+
# 1. 安装 NPM 依赖
|
| 241 |
+
pnpm install
|
| 242 |
+
# 2. 安装浏览器等预编译依赖
|
| 243 |
+
# ⚠️ 该脚本需连接 GitHub 下载资源。若网络受限,请使用代理
|
| 244 |
+
npm run init
|
| 245 |
+
# 使用代理
|
| 246 |
+
# 直接使用 -proxy 可交互式输入代理配置
|
| 247 |
+
npm run init -- -proxy=http://username:passwd@host:port
|
| 248 |
+
```
|
| 249 |
+
|
| 250 |
+
2. **启动服务**
|
| 251 |
+
```bash
|
| 252 |
+
# 标准启动
|
| 253 |
+
npm start
|
| 254 |
+
|
| 255 |
+
# Linux 系统 - 虚拟显示启动
|
| 256 |
+
npm start -- -xvfb -vnc
|
| 257 |
+
|
| 258 |
+
# 登录模式 (会临时强行禁用无头模式和自动化)
|
| 259 |
+
npm start -- -login (-xvfb -vnc)
|
| 260 |
+
```
|
| 261 |
+
|
| 262 |
+
### 🐳 方式二:Docker 部署
|
| 263 |
+
|
| 264 |
+
> [!WARNING]
|
| 265 |
+
> **安全提醒**:
|
| 266 |
+
> - Docker 镜像默认开启虚拟显示器 (Xvfb) 和 VNC 服务
|
| 267 |
+
> - 可通过 WebUI 的虚拟显示器板块连接
|
| 268 |
+
> - **WebUI 传输过程未加密, 公网环境请使用 SSH 隧道或 HTTPS**
|
| 269 |
+
|
| 270 |
+
**Docker CLI 启动**
|
| 271 |
+
```bash
|
| 272 |
+
docker run -d --name webai-2api \
|
| 273 |
+
-p 3000:3000 \
|
| 274 |
+
-v "$(pwd)/data:/app/data" \
|
| 275 |
+
--shm-size=2gb \
|
| 276 |
+
foxhui/webai-2api:latest
|
| 277 |
+
```
|
| 278 |
+
|
| 279 |
+
**Docker Compose 启动**
|
| 280 |
+
```bash
|
| 281 |
+
docker-compose up -d
|
| 282 |
+
```
|
| 283 |
+
|
| 284 |
+
---
|
| 285 |
+
|
| 286 |
+
## ⚡ 快速开始
|
| 287 |
+
|
| 288 |
+
### 1. 调整配置文件
|
| 289 |
+
|
| 290 |
+
程序初次运行会从`config.example.yaml`复制配置文件到`data/config.yaml`
|
| 291 |
+
|
| 292 |
+
**配置文件的生效需要重启程序!**
|
| 293 |
+
|
| 294 |
+
```yaml
|
| 295 |
+
server:
|
| 296 |
+
# 监听端口
|
| 297 |
+
port: 3000
|
| 298 |
+
# 鉴权 API Token (可使用 npm run genkey 生成)
|
| 299 |
+
# 该配置会对 API 接口和 WebUI 生效
|
| 300 |
+
auth: sk-change-me-to-your-secure-key
|
| 301 |
+
```
|
| 302 |
+
|
| 303 |
+
> [!TIP]
|
| 304 |
+
> **完整配置说明**: 请参考 [config.example.yaml](config.example.yaml) 文件中的详细注释,或访问 [WebAI2API 文档中心](https://foxhui.github.io/WebAI2API/) 查看完整配置指南。
|
| 305 |
+
|
| 306 |
+
### 2. 访问 Web 管理界面
|
| 307 |
+
|
| 308 |
+
服务启动后, 打开浏览器访问:
|
| 309 |
+
```
|
| 310 |
+
http://localhost:3000
|
| 311 |
+
```
|
| 312 |
+
|
| 313 |
+
> [!TIP]
|
| 314 |
+
> **远程访问**: 将 `localhost` 替换为服务器 IP 地址即可远程访问。
|
| 315 |
+
> **API Token**: 配置文件中的`auth`所配置的鉴权密钥。
|
| 316 |
+
> **安全建议**: 公网环境建议使用 Nginx/Caddy 配置 HTTPS 或通过 SSH 隧道访问。
|
| 317 |
+
|
| 318 |
+
### 3. 初始化账号登录
|
| 319 |
+
|
| 320 |
+
> [!IMPORTANT]
|
| 321 |
+
> **首次使用必须完成以下初始化步骤**:
|
| 322 |
+
|
| 323 |
+
1. **连接虚拟显示器**:
|
| 324 |
+
- Linux/Docker: 在 WebUI 的"虚拟显示器"板块连接
|
| 325 |
+
- Windows: 直接在弹出的浏览器窗口中操作
|
| 326 |
+
|
| 327 |
+
2. **完成账号登录**:
|
| 328 |
+
- 手动登录所需的 AI 网站账号 (账号要求可进入 WebUI 的适配器管理中查看)
|
| 329 |
+
- 在输入框发送任意消息, 触发并完成人机验证 (如需要)
|
| 330 |
+
- 同意服务条款或者新手指引 (如需要)
|
| 331 |
+
- 确保不再有初次使用相关内容的阻拦
|
| 332 |
+
|
| 333 |
+
3. **保存数据到 WebDAV**:
|
| 334 |
+
- 在 Space 的终端中执行:`npm run save-data-webdav`
|
| 335 |
+
- 等待上传完��
|
| 336 |
+
|
| 337 |
+
4. **SSH 隧道连接示例**(公网服务器推荐):
|
| 338 |
+
```bash
|
| 339 |
+
# 在本地终端运行,将服务器的 WebUI 映射到本地
|
| 340 |
+
ssh -L 3000:127.0.0.1:3000 root@服务器IP
|
| 341 |
+
|
| 342 |
+
# 然后在本地访问
|
| 343 |
+
# WebUI: http://localhost:3000
|
| 344 |
+
```
|
| 345 |
+
|
| 346 |
+
---
|
| 347 |
+
|
| 348 |
+
## 📖 使用方法
|
| 349 |
+
|
| 350 |
+
### 运行模式说明
|
| 351 |
+
|
| 352 |
+
> [!NOTE]
|
| 353 |
+
> **关于有头/无头模式**:
|
| 354 |
+
> - **有头模式**(默认): 显示浏览器窗口, 便于调试和人工干预
|
| 355 |
+
> - **无头模式**: 后台运行, 节省资源但无法查看浏览器界面, 且可能会被网站检测
|
| 356 |
+
>
|
| 357 |
+
> **建议**: 为降低风控, **强烈建议长期保持非无头模式运行**(或使用虚拟显示器 Xvfb)。
|
| 358 |
+
|
| 359 |
+
---
|
| 360 |
+
|
| 361 |
+
## 🔌 API 接口
|
| 362 |
+
|
| 363 |
+
> [!TIP]
|
| 364 |
+
> **详细文档**: 请访问 [WebAI2API 文档中心](https://foxhui.github.io/WebAI2API/) 获取更全面的配置指南与接口说明。
|
| 365 |
+
|
| 366 |
+
### 1. OpenAI 兼容接口
|
| 367 |
+
|
| 368 |
+
> [!WARNING]
|
| 369 |
+
> **并发限制与流式保活建议**
|
| 370 |
+
>
|
| 371 |
+
> 本项目通过模拟真实浏览器操作实现, 处理过程根据实际情况时间可能有所变化, 当积压的任务超过设置的数量时会直接拒绝非流式模式的请求。
|
| 372 |
+
>
|
| 373 |
+
> **💡 强烈建议开启流式模式**: 服务器将发送保活心跳包, 可无限排队避免超时。
|
| 374 |
+
|
| 375 |
+
#### 文本对话
|
| 376 |
+
|
| 377 |
+
**端点**: `POST /v1/chat/completions`
|
| 378 |
+
|
| 379 |
+
**请求示例**:
|
| 380 |
+
```bash
|
| 381 |
+
curl http://localhost:3000/v1/chat/completions \
|
| 382 |
+
-H "Content-Type: application/json" \
|
| 383 |
+
-H "Authorization: Bearer YOUR_API_KEY" \
|
| 384 |
+
-d '{
|
| 385 |
+
"model": "gemini-3-pro",
|
| 386 |
+
"messages": [
|
| 387 |
+
{"role": "user", "content": "你好,请介绍一下你自己"}
|
| 388 |
+
],
|
| 389 |
+
"stream": true
|
| 390 |
+
}'
|
| 391 |
+
```
|
| 392 |
+
|
| 393 |
+
#### 多模态请求(文生图/图生图)
|
| 394 |
+
|
| 395 |
+
**支持的图片格式**:
|
| 396 |
+
- **格式**: PNG, JPEG, GIF, WebP
|
| 397 |
+
- **数量**: 最大 10 张(具体限制因网站而异)
|
| 398 |
+
- **数据格式**: 必须使用 Base64 Data URL 格式
|
| 399 |
+
- **自动转换**: 服务器会自动将所有图片转换为 JPG 格式以保证兼容性
|
| 400 |
+
|
| 401 |
+
#### 参数说明
|
| 402 |
+
|
| 403 |
+
| 参数 | 类型 | 必填 | 说明 |
|
| 404 |
+
| :--- | :--- | :---: | :--- |
|
| 405 |
+
| `model` | string | ✅ | 模型名称, 可通过 `/v1/models` 获取可用列表 |
|
| 406 |
+
| `stream` | boolean | 推荐 | 是否开启流式响应, 包含心跳保活机制 |
|
| 407 |
+
|
| 408 |
+
> [!NOTE]
|
| 409 |
+
> **关于流式保活 (Heartbeat)**
|
| 410 |
+
>
|
| 411 |
+
> 为防止长连接超时, 系统提供两种保活模式 (可在配置中切换):
|
| 412 |
+
> 1. **Comment 模式 (默认/推荐)**: 发送 `:keepalive` 注释, 符合 SSE 标准,兼容性最好
|
| 413 |
+
> 2. **Content 模式**: 发送空内容的 data 包, 仅用于必须收到 JSON 数据才重置超时的特殊客户端
|
| 414 |
+
|
| 415 |
+
### 2. 获取模型列表
|
| 416 |
+
|
| 417 |
+
**端点**: `GET /v1/models`
|
| 418 |
+
|
| 419 |
+
**请求示例**:
|
| 420 |
+
```bash
|
| 421 |
+
curl http://localhost:3000/v1/models \
|
| 422 |
+
-H "Authorization: Bearer YOUR_API_KEY"
|
| 423 |
+
```
|
| 424 |
+
|
| 425 |
+
### 3. 获取 Cookies
|
| 426 |
+
|
| 427 |
+
**功能说明**: 利用本项目的自动续登功能获取最新 Cookie 供其他工具使用。
|
| 428 |
+
|
| 429 |
+
**端点**: `GET /v1/cookies`
|
| 430 |
+
|
| 431 |
+
**参数**:
|
| 432 |
+
- `name` (可选): 浏览器实例名称,默认为 `default`
|
| 433 |
+
- `domain` (可选): 过滤指定域名的 Cookie
|
| 434 |
+
|
| 435 |
+
**请求示例**:
|
| 436 |
+
```bash
|
| 437 |
+
# 获取指定实例和域名的 Cookie
|
| 438 |
+
curl "http://localhost:3000/v1/cookies?name=browser_default&domain=lmarena.ai" \
|
| 439 |
+
-H "Authorization: Bearer YOUR_API_KEY"
|
| 440 |
+
```
|
| 441 |
+
|
| 442 |
+
---
|
| 443 |
+
|
| 444 |
+
## 📊 设备配置参考
|
| 445 |
+
|
| 446 |
+
| 资源 | 最低配置 | 推荐配置 (单实例) | 推荐配置 (多实例) |
|
| 447 |
+
| :--- | :--- | :--- | :--- |
|
| 448 |
+
| **CPU** | 1 核 | 2 核及以上 | 2 核及以上 |
|
| 449 |
+
| **内存** | 1 GB | 2 GB 及以上 | 4 GB 及以上 |
|
| 450 |
+
| **磁盘** | 2 GB 可用空间 | 5 GB 及以上 | 7 GB 及以上 |
|
| 451 |
+
|
| 452 |
+
**实测环境表现** (均为单浏览器实例):
|
| 453 |
+
- **Oracle 免费机** (1C1G, Debian 12): 资源紧张, 比较卡顿, 仅供尝鲜或轻度使用
|
| 454 |
+
- **阿里云轻量云** (2C2G, Debian 11): 运行流畅但实例也会卡顿, 项目开发测试所用机型
|
| 455 |
+
|
| 456 |
+
---
|
| 457 |
+
|
| 458 |
+
## 📄 许可证和免责声明
|
| 459 |
+
|
| 460 |
+
本项目采用 [MIT License](LICENSE) 开源。
|
| 461 |
+
|
| 462 |
+
> [!CAUTION]
|
| 463 |
+
> **免责声明**
|
| 464 |
+
>
|
| 465 |
+
> 本项目仅供学习交流使用。如果因使用该项目造成的任何后果 (包括但不限于账号被禁用),作者和项目均不承担任何责任。请遵守相关网站和服务的使用条款 (ToS),并做好相关数据的备份工作。
|
| 466 |
+
|
| 467 |
+
---
|
| 468 |
+
|
| 469 |
+
## 📋 更新日志
|
| 470 |
+
|
| 471 |
+
查看完整的版本历史和更新内容, 请访问 [CHANGELOG.md](CHANGELOG.md)。
|
| 472 |
+
|
| 473 |
+
### 🕰️ 历史版本说明
|
| 474 |
+
|
| 475 |
+
本项目已从 Puppeteer 迁移至 Camoufox, 以应对日益复杂的反机器人检测机制。基于 Puppeteer 的旧版本代码已归档至 `puppeteer-edition` 分支, 仅作留存, **不再提供更新与维护**。
|
| 476 |
+
|
| 477 |
+
---
|
| 478 |
+
|
| 479 |
+
## 🙏 致谢
|
| 480 |
+
|
| 481 |
+
感谢 LMArena、Gemini 等网站提供 AI 服务! 🎉
|
| 482 |
+
|
| 483 |
+
感谢 Hugging Face 提供免费的 Space 托管服务! 🙌
|
| 484 |
+
|
| 485 |
+
感谢 Infini Cloud 提供免费的 WebDAV 存储! ☁️
|
| 486 |
+
|
| 487 |
+
---
|
| 488 |
+
|
| 489 |
+
## 📞 联系方式
|
| 490 |
+
|
| 491 |
+
- **作者**: foxhui
|
| 492 |
+
- **GitHub**: [https://github.com/foxhui](https://github.com/foxhui)
|
| 493 |
+
- **文档**: [WebAI2API 文档中心](https://foxhui.github.io/WebAI2API/)
|
| 494 |
+
- **Issues**: [提交问题](https://github.com/foxhui/WebAI2API/issues)
|
| 495 |
+
- **Hugging Face Space 部署指南**: [查看详细文档](HUGGINGFACE_SPACE.md)
|
| 496 |
+
- **WebDAV 数据持久化指南**: [查看详细文档](QUICK_START_WEBDAV.md)
|
README_EN.md
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: WebAI2API
|
| 3 |
+
emoji: 🤖
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
tags:
|
| 10 |
+
- web-automation
|
| 11 |
+
- openai-api
|
| 12 |
+
- playwright
|
| 13 |
+
- camoufox
|
| 14 |
+
- ai-api
|
| 15 |
+
- lm-arena
|
| 16 |
+
- gemini
|
| 17 |
+
short_description: Automated tool to convert web-based AI services to OpenAI-compatible APIs
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
# WebAI2API
|
| 21 |
+
|
| 22 |
+
[简体中文](README.md) | English
|
| 23 |
+
|
| 24 |
+
> [!NOTE]
|
| 25 |
+
> This English version is translated by **Gemini 3 Flash**.
|
| 26 |
+
|
| 27 |
+
<p align="center">
|
| 28 |
+
<img src="https://github.com/user-attachments/assets/296a518e-c42b-4e39-8ff6-9b4381ed4f6e" width="49%" />
|
| 29 |
+
<img src="https://github.com/user-attachments/assets/bfa30ece-6947-4f18-b2c9-ccc8087b7e89" width="49%" />
|
| 30 |
+
</p>
|
| 31 |
+
<p align="center">
|
| 32 |
+
<img src="https://github.com/user-attachments/assets/5b15ebd2-7593-4f0e-8561-83d6ba5d88ab" width="49%" />
|
| 33 |
+
<img src="https://github.com/user-attachments/assets/53deea29-4071-4a07-8a61-211761c5f2f7" width="49%" />
|
| 34 |
+
</p>
|
| 35 |
+
|
| 36 |
+
## 📑 Table of Contents
|
| 37 |
+
|
| 38 |
+
- [Space Deployment Guide](#-space-deployment-guide)
|
| 39 |
+
- [Quick Deployment](#-quick-deployment)
|
| 40 |
+
- [Quick Start](#-quick-start)
|
| 41 |
+
- [Usage](#-usage)
|
| 42 |
+
- [API Reference](#-api-reference)
|
| 43 |
+
- [Hardware Configuration Reference](#-hardware-configuration-reference)
|
| 44 |
+
|
| 45 |
+
---
|
| 46 |
+
|
| 47 |
+
## 📝 Project Introduction
|
| 48 |
+
|
| 49 |
+
**WebAI2API** is a tool that converts web-based AI services into general APIs based on **Camoufox (Playwright)**. It interacts with websites like LMArena and Gemini by simulating human operations, providing interfaces compatible with the **OpenAI format**, while supporting **multi-window concurrency** and **multi-account management** (browser instance data isolation).
|
| 50 |
+
|
| 51 |
+
### ✨ Key Features
|
| 52 |
+
|
| 53 |
+
- 🤖 **Human-like Interaction**: Simulates human typing and mouse trajectories, evading automation detection through feature camouflage.
|
| 54 |
+
- 🔄 **API Compatibility**: Provides standard OpenAI format interfaces, supporting streaming responses and heartbeat persistence.
|
| 55 |
+
- 🚀 **Concurrency & Isolation**: Supports multi-window concurrent execution with independent proxy configurations, achieving browser-level data isolation for multiple accounts.
|
| 56 |
+
- 🛡️ **Stable Protection**: Built-in task queue, load balancing, failover, error retry, and other essential functions.
|
| 57 |
+
- 🎨 **Web Management**: Provides a visual management interface supporting real-time log viewing, VNC connection, adapter management, etc.
|
| 58 |
+
|
| 59 |
+
### 📋 Supported Platforms
|
| 60 |
+
|
| 61 |
+
| Website | Text Gen | Image Gen | Video Gen |
|
| 62 |
+
| :--- | :---: | :---: | :---: |
|
| 63 |
+
| [**LMArena**](https://lmarena.ai/) | ✅ | ✅ | 🚫 |
|
| 64 |
+
| [**Gemini Enterprise Business**](https://business.gemini.google/) | ✅ | ✅ | ✅ |
|
| 65 |
+
| [**Nano Banana Free**](https://nanobananafree.ai/) | 🚫 | ✅ | 🚫 |
|
| 66 |
+
| [**zAI**](https://zai.is/) | ✅ | ✅ | 🚫 |
|
| 67 |
+
| [**Google Gemini**](https://gemini.google.com/) | ✅ | ✅💧 | ✅💧 |
|
| 68 |
+
| [**ZenMux**](https://zenmux.ai/) | ✅ | ❌ | 🚫 |
|
| 69 |
+
| [**ChatGPT**](https://chatgpt.com/) | ✅ | ✅ | 🚫 |
|
| 70 |
+
| [**DeepSeek**](https://chat.deepseek.com/) | ✅ | 🚫 | 🚫 |
|
| 71 |
+
| [**Sora**](https://sora.chatgpt.com/) | 🚫 | 🚫 | ✅💧 |
|
| 72 |
+
| [**Google Flow**](https://labs.google/fx/zh/tools/flow) | 🚫 | ✅ | ❌ |
|
| 73 |
+
| [**Doubao**](https://www.doubao.com/) | ✅ | ✅ | ❌ |
|
| 74 |
+
| To be continued... | - | - | - |
|
| 75 |
+
|
| 76 |
+
> [!NOTE]
|
| 77 |
+
> **Get full model list**: Use the `GET /v1/models` endpoint to view all available models and their details under the current configuration.
|
| 78 |
+
>
|
| 79 |
+
> ✅ Supported; ❌ Not currently supported, but may be in the future; 🚫 Website does not support, future support depends on the website's status; 💧 Results contain watermarks that cannot be removed.
|
| 80 |
+
|
| 81 |
+
---
|
| 82 |
+
|
| 83 |
+
## 🚀 Space Deployment Guide
|
| 84 |
+
|
| 85 |
+
### 📋 Prerequisites
|
| 86 |
+
|
| 87 |
+
- Hugging Face account
|
| 88 |
+
- Created Space (Docker SDK)
|
| 89 |
+
- Space type set to **Docker**
|
| 90 |
+
|
| 91 |
+
### 🛠️ Deployment Steps
|
| 92 |
+
|
| 93 |
+
1. **Clone or Upload Code to Space**
|
| 94 |
+
```bash
|
| 95 |
+
git clone https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME
|
| 96 |
+
cd YOUR_SPACE_NAME
|
| 97 |
+
git remote add origin https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME
|
| 98 |
+
git push origin main
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
2. **Configure Space Settings**
|
| 102 |
+
- Ensure **SDK** is set to **Docker** in Space settings
|
| 103 |
+
- Ensure public/private settings meet your requirements
|
| 104 |
+
|
| 105 |
+
3. **Configure Environment Variables** (Optional)
|
| 106 |
+
Add the following environment variables in Space settings (if customization is needed):
|
| 107 |
+
- `PORT`: Service port (default 3000)
|
| 108 |
+
- `AUTH_TOKEN`: API authentication key (recommended to use a strong password)
|
| 109 |
+
|
| 110 |
+
4. **Wait for Build to Complete**
|
| 111 |
+
- Space will automatically build the Docker image
|
| 112 |
+
- Build time is approximately 5-10 minutes
|
| 113 |
+
- Service starts automatically after build completion
|
| 114 |
+
|
| 115 |
+
5. **Access Service**
|
| 116 |
+
- Space URL: `https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME`
|
| 117 |
+
- WebUI: Access Space URL directly
|
| 118 |
+
- API: `https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME/v1/...`
|
| 119 |
+
|
| 120 |
+
### ⚠️ Space Limitations
|
| 121 |
+
|
| 122 |
+
- **Resource Limits**: Free tier Spaces have CPU and memory limitations
|
| 123 |
+
- **Build Time**: First build requires downloading browser, takes longer
|
| 124 |
+
- **Persistent Storage**: Data is lost after Space restart (recommend using Secrets for configuration)
|
| 125 |
+
- **Network Access**: Spaces can access external websites but may have rate limits
|
| 126 |
+
|
| 127 |
+
### 💡 Usage Recommendations
|
| 128 |
+
|
| 129 |
+
1. **First Use**: Access WebUI to complete account login initialization
|
| 130 |
+
2. **Authentication**: Set `AUTH_TOKEN` environment variable in Space Settings
|
| 131 |
+
3. **Monitor Logs**: Check running status via Space's Logs page
|
| 132 |
+
4. **Performance**: Upgrade Space to paid tier for better performance if needed
|
| 133 |
+
|
| 134 |
+
---
|
| 135 |
+
|
| 136 |
+
## 🚀 Quick Deployment
|
| 137 |
+
|
| 138 |
+
This project supports both **source code execution** and **Docker containerized deployment**.
|
| 139 |
+
|
| 140 |
+
### 📋 Environment Requirements
|
| 141 |
+
|
| 142 |
+
- **Node.js**: v20.0.0+ (ABI 115+)
|
| 143 |
+
- **OS**: Windows / Linux / macOS
|
| 144 |
+
- **Core Dependency**: Camoufox (automatically downloaded during installation)
|
| 145 |
+
|
| 146 |
+
### 🛠️ Method 1: Manual Deployment
|
| 147 |
+
|
| 148 |
+
1. **Installation & Configuration**
|
| 149 |
+
```bash
|
| 150 |
+
# 1. Install NPM dependencies
|
| 151 |
+
pnpm install
|
| 152 |
+
# 2. Install precompiled dependencies like the browser
|
| 153 |
+
# ⚠️ This script requires connecting to GitHub to download resources. Use a proxy if network access is limited.
|
| 154 |
+
npm run init
|
| 155 |
+
# Using a proxy
|
| 156 |
+
# Use -proxy to interactively input proxy configuration
|
| 157 |
+
npm run init -- -proxy=http://username:passwd@host:port
|
| 158 |
+
```
|
| 159 |
+
|
| 160 |
+
2. **Start Service**
|
| 161 |
+
```bash
|
| 162 |
+
# Standard start
|
| 163 |
+
npm start
|
| 164 |
+
|
| 165 |
+
# Linux - Start with virtual display
|
| 166 |
+
npm start -- -xvfb -vnc
|
| 167 |
+
|
| 168 |
+
# Login mode (Temporarily forces disabling headless mode and automation)
|
| 169 |
+
npm start -- -login (-xvfb -vnc)
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
### 🐳 Method 2: Docker Deployment
|
| 173 |
+
|
| 174 |
+
> [!WARNING]
|
| 175 |
+
> **Security Reminder**:
|
| 176 |
+
> - The Docker image enables the virtual display (Xvfb) and VNC service by default.
|
| 177 |
+
> - Connection is possible via the virtual display section of the WebUI.
|
| 178 |
+
> - **WebUI transmission is unencrypted. Please use SSH tunneling or HTTPS in public network environments.**
|
| 179 |
+
|
| 180 |
+
**Start with Docker CLI**
|
| 181 |
+
```bash
|
| 182 |
+
docker run -d --name webai-2api \
|
| 183 |
+
-p 3000:3000 \
|
| 184 |
+
-v "$(pwd)/data:/app/data" \
|
| 185 |
+
--shm-size=2gb \
|
| 186 |
+
foxhui/webai-2api:latest
|
| 187 |
+
```
|
| 188 |
+
|
| 189 |
+
**Start with Docker Compose**
|
| 190 |
+
```bash
|
| 191 |
+
docker-compose up -d
|
| 192 |
+
```
|
| 193 |
+
|
| 194 |
+
---
|
| 195 |
+
|
| 196 |
+
## ⚡ Quick Start
|
| 197 |
+
|
| 198 |
+
### 1. Adjust Configuration File
|
| 199 |
+
|
| 200 |
+
On first run, the program will copy the configuration file from `config.example.yaml` to `data/config.yaml`.
|
| 201 |
+
|
| 202 |
+
**Changes to the configuration file require a program restart to take effect!**
|
| 203 |
+
|
| 204 |
+
```yaml
|
| 205 |
+
server:
|
| 206 |
+
# Listening port
|
| 207 |
+
port: 3000
|
| 208 |
+
# Authentication API Token (can be generated using npm run genkey)
|
| 209 |
+
# This configuration applies to both API endpoints and the WebUI
|
| 210 |
+
auth: sk-change-me-to-your-secure-key
|
| 211 |
+
```
|
| 212 |
+
|
| 213 |
+
> [!TIP]
|
| 214 |
+
> **Full Configuration Details**: Please refer to the detailed comments in [config.example.yaml](config.example.yaml), or visit the [WebAI2API Documentation Center](https://foxhui.github.io/WebAI2API/en/) for a complete configuration guide.
|
| 215 |
+
|
| 216 |
+
### 2. Access Web Management Interface
|
| 217 |
+
|
| 218 |
+
After the service starts, open your browser and visit:
|
| 219 |
+
```
|
| 220 |
+
http://localhost:3000
|
| 221 |
+
```
|
| 222 |
+
|
| 223 |
+
> [!TIP]
|
| 224 |
+
> **Remote Access**: Replace `localhost` with your server's IP address.
|
| 225 |
+
> **API Token**: The authentication key configured in `auth` of the configuration file.
|
| 226 |
+
> **Security Suggestion**: For public network environments, it is recommended to configure HTTPS using Nginx/Caddy or access via SSH tunnel.
|
| 227 |
+
|
| 228 |
+
### 3. Initial Account Login
|
| 229 |
+
|
| 230 |
+
> [!IMPORTANT]
|
| 231 |
+
> **The following initialization steps must be completed on first use**:
|
| 232 |
+
|
| 233 |
+
1. **Connect to Virtual Display**:
|
| 234 |
+
- Linux/Docker: Connect in the "Virtual Display" section of the WebUI.
|
| 235 |
+
- Windows: Operate directly in the browser window that pops up.
|
| 236 |
+
|
| 237 |
+
2. **Complete Account Login**:
|
| 238 |
+
- Manually log in to the required AI website account (account requirements can be found in the WebUI's adapter management).
|
| 239 |
+
- Send any message in the input box to trigger and complete human-machine verification (if required).
|
| 240 |
+
- Agree to terms of service or新手 guides (if required).
|
| 241 |
+
- Ensure there are no more initial use related obstructions.
|
| 242 |
+
|
| 243 |
+
3. **SSH Tunnel Connection Example** (Recommended for public servers):
|
| 244 |
+
```bash
|
| 245 |
+
# Run in your local terminal to map the server's WebUI to local
|
| 246 |
+
ssh -L 3000:127.0.0.1:3000 root@Server_IP
|
| 247 |
+
|
| 248 |
+
# Then access locally
|
| 249 |
+
# WebUI: http://localhost:3000
|
| 250 |
+
```
|
| 251 |
+
|
| 252 |
+
---
|
| 253 |
+
|
| 254 |
+
## 📖 Usage
|
| 255 |
+
|
| 256 |
+
### Running Mode Description
|
| 257 |
+
|
| 258 |
+
> [!NOTE]
|
| 259 |
+
> **Regarding Headful/Headless Mode**:
|
| 260 |
+
> - **Headful Mode** (Default): Displays the browser window, convenient for debugging and manual intervention.
|
| 261 |
+
> - **Headless Mode**: Runs in the background, saves resources but interfaces cannot be viewed, and may be detected by websites.
|
| 262 |
+
>
|
| 263 |
+
> **Recommendation**: To reduce risk, **it is strongly recommended to run in non-headless mode for the long term** (or use virtual display Xvfb).
|
| 264 |
+
|
| 265 |
+
---
|
| 266 |
+
|
| 267 |
+
## 🔌 API Reference
|
| 268 |
+
|
| 269 |
+
> [!TIP]
|
| 270 |
+
> **Detailed Documentation**: Please visit the [WebAI2API Documentation Center](https://foxhui.github.io/WebAI2API/en/) for a more comprehensive configuration guide and interface description.
|
| 271 |
+
|
| 272 |
+
### 1. OpenAI Compatible API
|
| 273 |
+
|
| 274 |
+
> [!WARNING]
|
| 275 |
+
> **Concurrency Limits and Streaming Keep-alive Recommendations**
|
| 276 |
+
>
|
| 277 |
+
> This project is implemented by simulating real browser operations, and processing time may vary. When the backlog of tasks exceeds the configured amount, non-streaming requests will be rejected directly.
|
| 278 |
+
>
|
| 279 |
+
> **💡 Highly Recommended to enable Streaming Mode**: The server will send keep-alive heartbeat packets, allowing for infinite queuing to avoid timeouts.
|
| 280 |
+
|
| 281 |
+
#### Text Chat
|
| 282 |
+
|
| 283 |
+
**Endpoint**: `POST /v1/chat/completions`
|
| 284 |
+
|
| 285 |
+
**Request Example**:
|
| 286 |
+
```bash
|
| 287 |
+
curl http://localhost:3000/v1/chat/completions \
|
| 288 |
+
-H "Content-Type: application/json" \
|
| 289 |
+
-H "Authorization: Bearer YOUR_API_KEY" \
|
| 290 |
+
-d '{
|
| 291 |
+
"model": "gemini-3-pro",
|
| 292 |
+
"messages": [
|
| 293 |
+
{"role": "user", "content": "Hello, please introduce yourself"}
|
| 294 |
+
],
|
| 295 |
+
"stream": true
|
| 296 |
+
}'
|
| 297 |
+
```
|
| 298 |
+
|
| 299 |
+
#### Multimodal Requests (Text-to-Image / Image-to-Image)
|
| 300 |
+
|
| 301 |
+
**Supported Image Formats**:
|
| 302 |
+
- **Formats**: PNG, JPEG, GIF, WebP
|
| 303 |
+
- **Quantity**: Max 10 images (specific limits vary by website)
|
| 304 |
+
- **Data Format**: Must use Base64 Data URL format
|
| 305 |
+
- **Auto Conversion**: The server automatically converts all images to JPG to ensure compatibility.
|
| 306 |
+
|
| 307 |
+
#### Parameter Description
|
| 308 |
+
|
| 309 |
+
| Parameter | Type | Required | Description |
|
| 310 |
+
| :--- | :--- | :---: | :--- |
|
| 311 |
+
| `model` | string | ✅ | Model name, available list can be retrieved via `/v1/models` |
|
| 312 |
+
| `stream` | boolean | Rec. | Whether to enable streaming response, includes heartbeat keep-alive mechanism |
|
| 313 |
+
|
| 314 |
+
> [!NOTE]
|
| 315 |
+
> **Regarding Streaming Keep-alive (Heartbeat)**
|
| 316 |
+
>
|
| 317 |
+
> To prevent long connection timeouts, the system provides two keep-alive modes (configurable):
|
| 318 |
+
> 1. **Comment Mode (Default/Recommended)**: Sends `:keepalive` comments, compliant with SSE standards, best compatibility.
|
| 319 |
+
> 2. **Content Mode**: Sends data packets with empty content, only for special clients that must receive JSON data to reset timeouts.
|
| 320 |
+
|
| 321 |
+
### 2. Get Model List
|
| 322 |
+
|
| 323 |
+
**Endpoint**: `GET /v1/models`
|
| 324 |
+
|
| 325 |
+
**Request Example**:
|
| 326 |
+
```bash
|
| 327 |
+
curl http://localhost:3000/v1/models \
|
| 328 |
+
-H "Authorization: Bearer YOUR_API_KEY"
|
| 329 |
+
```
|
| 330 |
+
|
| 331 |
+
### 3. Get Cookies
|
| 332 |
+
|
| 333 |
+
**Description**: Utilize the project's automatic renewal feature to get the latest Cookies for use with other tools.
|
| 334 |
+
|
| 335 |
+
**Endpoint**: `GET /v1/cookies`
|
| 336 |
+
|
| 337 |
+
**Parameters**:
|
| 338 |
+
- `name` (Optional): Browser instance name, defaults to `default`.
|
| 339 |
+
- `domain` (Optional): Filter Cookies for a specific domain.
|
| 340 |
+
|
| 341 |
+
**Request Example**:
|
| 342 |
+
```bash
|
| 343 |
+
# Get cookies for a specific instance and domain
|
| 344 |
+
curl "http://localhost:3000/v1/cookies?name=browser_default&domain=lmarena.ai" \
|
| 345 |
+
-H "Authorization: Bearer YOUR_API_KEY"
|
| 346 |
+
```
|
| 347 |
+
|
| 348 |
+
---
|
| 349 |
+
|
| 350 |
+
## 📊 Hardware Configuration Reference
|
| 351 |
+
|
| 352 |
+
| Resource | Minimum | Recommended (Single Instance) | Recommended (Multi-Instance) |
|
| 353 |
+
| :--- | :--- | :--- | :--- |
|
| 354 |
+
| **CPU** | 1 Core | 2 Cores+ | 2 Cores+ |
|
| 355 |
+
| **RAM** | 1 GB | 2 GB+ | 4 GB+ |
|
| 356 |
+
| **Disk** | 2 GB available | 5 GB+ | 7 GB+ |
|
| 357 |
+
|
| 358 |
+
**Measured Environment Performance** (All with single browser instance):
|
| 359 |
+
- **Oracle Free Tier** (1C1G, Debian 12): Resource-intensive, quite laggy, only for trial or light use.
|
| 360 |
+
- **Aliyun Lightweight Cloud** (2C2G, Debian 11): Runs smoothly but instances may still lag; used for project development and testing.
|
| 361 |
+
|
| 362 |
+
---
|
| 363 |
+
|
| 364 |
+
## 📄 License and Disclaimer
|
| 365 |
+
|
| 366 |
+
This project is open-sourced under the [MIT License](LICENSE).
|
| 367 |
+
|
| 368 |
+
> [!CAUTION]
|
| 369 |
+
> **Disclaimer**
|
| 370 |
+
>
|
| 371 |
+
> This project is for educational and exchange purposes only. The author and the project are not responsible for any consequences (including but not limited to account suspension) caused by using this project. Please comply with the Terms of Service (ToS) of the relevant websites and services, and ensure proper backup of relevant data.
|
| 372 |
+
|
| 373 |
+
---
|
| 374 |
+
|
| 375 |
+
## 📋 Changelog
|
| 376 |
+
|
| 377 |
+
View the full version history and update details at [CHANGELOG.md](CHANGELOG.md).
|
| 378 |
+
|
| 379 |
+
### 🕰️ Historical Version Note
|
| 380 |
+
|
| 381 |
+
This project has migrated from Puppeteer to Camoufox to handle increasingly complex anti-bot detection mechanisms. Older code based on Puppeteer has been archived to the `puppeteer-edition` branch for reference only and is **no longer updated or maintained**.
|
| 382 |
+
|
| 383 |
+
---
|
| 384 |
+
|
| 385 |
+
## 🙏 Acknowledgments
|
| 386 |
+
|
| 387 |
+
Thanks to sites like LMArena and Gemini for providing AI services! 🎉
|
| 388 |
+
|
| 389 |
+
Thanks to Hugging Face for providing free Space hosting! 🙌
|
| 390 |
+
|
| 391 |
+
---
|
| 392 |
+
|
| 393 |
+
## 📞 Contact
|
| 394 |
+
|
| 395 |
+
- **Author**: foxhui
|
| 396 |
+
- **GitHub**: [https://github.com/foxhui](https://github.com/foxhui)
|
| 397 |
+
- **Documentation**: [WebAI2API Documentation Center](https://foxhui.github.io/WebAI2API/en/)
|
| 398 |
+
- **Issues**: [Submit Issue](https://github.com/foxhui/WebAI2API/issues)
|
config.example.yaml
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 日志等级: debug | info | warn | error
|
| 2 |
+
logLevel: info
|
| 3 |
+
|
| 4 |
+
server:
|
| 5 |
+
# 监听端口
|
| 6 |
+
port: 3000
|
| 7 |
+
# 鉴权 API Token (可使用 npm run genkey 生成)
|
| 8 |
+
# 该配置会对 API 接口和 WebUI 生效
|
| 9 |
+
auth: sk-change-me-to-your-secure-key
|
| 10 |
+
# 流式请求心跳设置 (自动对 stream: true 的请求发送心跳防止超时)
|
| 11 |
+
keepalive:
|
| 12 |
+
# 心跳模式
|
| 13 |
+
# "comment": (推荐) 发送 :keepalive 注释,不污染数据
|
| 14 |
+
# "content": (备用) 发送空 delta,仅当客户端必须收到 JSON 包才重置超时时使用
|
| 15 |
+
mode: "comment"
|
| 16 |
+
|
| 17 |
+
backend:
|
| 18 |
+
# ========================================
|
| 19 |
+
# Pool 配置
|
| 20 |
+
# ========================================
|
| 21 |
+
pool:
|
| 22 |
+
# 全局调度策略:
|
| 23 |
+
# - least_busy (推荐): 优先分配给当前任务最少的 Worker
|
| 24 |
+
# - round_robin: 轮询分配 (A -> B -> C -> A)
|
| 25 |
+
# - random: 随机分配
|
| 26 |
+
# 任务分发时,会把所有 Instance 下的所有 Worker 扁平化看待
|
| 27 |
+
strategy: least_busy
|
| 28 |
+
|
| 29 |
+
# ========================================
|
| 30 |
+
# 故障转移配置
|
| 31 |
+
# ========================================
|
| 32 |
+
# 当适配器返回网络错误时,自动尝试其他支持相同模型的 Worker
|
| 33 |
+
failover:
|
| 34 |
+
enabled: true # 启用故障转移
|
| 35 |
+
maxRetries: 2 # 最多重试次数 (0=无限制)
|
| 36 |
+
|
| 37 |
+
# ========================================
|
| 38 |
+
# 浏览器实例列表
|
| 39 |
+
# ========================================
|
| 40 |
+
# 每个 Instance 代表一个独立的浏览器进程 (Context + UserData + Proxy)
|
| 41 |
+
# 登录模式:
|
| 42 |
+
# npm start -- -login 启动第一个 Worker 进行登录
|
| 43 |
+
# npm start -- -login=workerName 启动指定名称的 Worker 进行登录
|
| 44 |
+
# 注意: Worker 名称在全局必须唯一
|
| 45 |
+
# ========================================
|
| 46 |
+
instances:
|
| 47 |
+
# ------------------------------------------------
|
| 48 |
+
# [实例 1] 默认浏览器实例
|
| 49 |
+
# ------------------------------------------------
|
| 50 |
+
- name: "browser_default" # 实例 ID (用于日志显示和Cookie获取)
|
| 51 |
+
# userDataMark 不设置时,数据存放在 data/camoufoxUserData
|
| 52 |
+
# 同一实例下的所有 Worker 共享浏览器数据和登录状态
|
| 53 |
+
# 使用 Google OAuth 等统一登录时,只需登录一次即可用于所有 Worker
|
| 54 |
+
|
| 55 |
+
# 该浏览器实例具备的能力 (适配器列表)
|
| 56 |
+
# 相当于在这个浏览器里打开了不同的标签页
|
| 57 |
+
workers:
|
| 58 |
+
- name: "default" # 唯一标识 (用于登录模式和日志显示)
|
| 59 |
+
# 适配器类型列表:
|
| 60 |
+
# lmarena (LMArena 图片生成)
|
| 61 |
+
# lmarena_text (LMArena 文本生成)
|
| 62 |
+
# gemini_biz (Gemini Business 图片、视频生成)
|
| 63 |
+
# gemini_biz_text (Gemini Business 文本生成)
|
| 64 |
+
# gemini (Google Gemini 图片、视频生成)
|
| 65 |
+
# gemini_text (Google Gemini 文本生成)
|
| 66 |
+
# zai_is (zAI 图片生成)
|
| 67 |
+
# nanobananafree_ai (NanoBananaFree 图片生成)
|
| 68 |
+
# zenmux_ai_text (ZenMux 文本生成)
|
| 69 |
+
# chatgpt (ChatGPT 图片生成)
|
| 70 |
+
type: lmarena # 适配器类型
|
| 71 |
+
|
| 72 |
+
# ------------------------------------------------
|
| 73 |
+
# 以下为多实例配置示例 (默认注释)
|
| 74 |
+
# ------------------------------------------------
|
| 75 |
+
|
| 76 |
+
# [实例 2] 独立数据目录 + 专属代理
|
| 77 |
+
# - name: "browser_us_01"
|
| 78 |
+
# userDataMark: "us_01" # 数据目录: data/camoufoxUserData_us_01
|
| 79 |
+
#
|
| 80 |
+
# # 实例级代理 (该实例下所有 Worker 共享此代理)
|
| 81 |
+
# proxy:
|
| 82 |
+
# enable: true
|
| 83 |
+
# type: socks5
|
| 84 |
+
# host: 192.168.1.10
|
| 85 |
+
# port: 1080
|
| 86 |
+
# user: myuser # 可选认证
|
| 87 |
+
# passwd: mypassword
|
| 88 |
+
#
|
| 89 |
+
# workers:
|
| 90 |
+
# - name: "us_lmarena"
|
| 91 |
+
# type: lmarena
|
| 92 |
+
#
|
| 93 |
+
# - name: "us_zai"
|
| 94 |
+
# type: zai_is
|
| 95 |
+
#
|
| 96 |
+
# # 聚合类型 Worker (单标签多后端)
|
| 97 |
+
# - name: "us_merged"
|
| 98 |
+
# type: merge
|
| 99 |
+
# mergeTypes: [gemini_biz, nanobananafree_ai]
|
| 100 |
+
# mergeMonitor: gemini_biz # 空闲时挂机监控的后端 (可选,留空则不启用)
|
| 101 |
+
|
| 102 |
+
# [实例 3] 强制直连 (不使用代理)
|
| 103 |
+
# - name: "browser_direct"
|
| 104 |
+
# userDataMark: "direct"
|
| 105 |
+
# proxy:
|
| 106 |
+
# enable: false # 即使有全局代理也不使用
|
| 107 |
+
#
|
| 108 |
+
# workers:
|
| 109 |
+
# - name: "direct_gemini"
|
| 110 |
+
# type: gemini_biz
|
| 111 |
+
|
| 112 |
+
# ========================================
|
| 113 |
+
# 适配器专属配置 (按需填写)
|
| 114 |
+
# ========================================
|
| 115 |
+
adapter:
|
| 116 |
+
# Gemini Business 设置
|
| 117 |
+
gemini_biz:
|
| 118 |
+
# 入口URL
|
| 119 |
+
# 示例: "https://business.gemini.google/home/cid/8888a888-b6e0-88be-86e1-888cf3ee8cf4"
|
| 120 |
+
entryUrl: ""
|
| 121 |
+
# Lmarena 配置
|
| 122 |
+
lmarena:
|
| 123 |
+
# 开启后直接返回图片 URL (但其他不支持该选项的适配器仍然会返回 Base64)
|
| 124 |
+
returnUrl: false
|
| 125 |
+
# 该适配器的模型黑白名单 (每个适配器都可以使用该功能,配置上级为适配器ID,推荐使用 WebUI 修改)
|
| 126 |
+
# modelFilter:
|
| 127 |
+
# mode: whitelist # 白名单whitelist 黑名单blacklist
|
| 128 |
+
# list: # 仅启用和仅禁用的模型列表
|
| 129 |
+
# - gemini-3-pro-image-preview
|
| 130 |
+
# - gemini-3-pro-image-preview-2k
|
| 131 |
+
# - gemini-2.5-flash-image-preview
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
queue:
|
| 135 |
+
# 队列缓冲区大小(非流式请求的额外排队数)
|
| 136 |
+
# 实际队列上限 = Workers数量 + queueBuffer
|
| 137 |
+
# 设为 0 则不限制非流式请求数量
|
| 138 |
+
queueBuffer: 2
|
| 139 |
+
# 图片数量上限
|
| 140 |
+
# 网页最多支持10个附件,如果设置大于10则直接丢弃超出10的图片
|
| 141 |
+
imageLimit: 5
|
| 142 |
+
|
| 143 |
+
browser:
|
| 144 |
+
# 浏览器可执行文件路径 (留空则使用默认的)
|
| 145 |
+
# 非必要不建议修改,否则你要处理很多额外依赖
|
| 146 |
+
# Windows系统示例 "C:\\camoufox\\camoufox.exe"
|
| 147 |
+
# Linux系统示例 "/opt/camoufox/camoufox"
|
| 148 |
+
path: ""
|
| 149 |
+
|
| 150 |
+
# 是否启用无头模式
|
| 151 |
+
headless: false
|
| 152 |
+
|
| 153 |
+
# 站点隔离 (fission.autostart)
|
| 154 |
+
# 开启保持 Firefox 默认开启状态
|
| 155 |
+
# 关闭此项可显著降低内存占用,防止低配服务器崩溃
|
| 156 |
+
# ⚠️ 风险提示: 正常 Firefox 用户默认开启 Fission,虽然关闭它不会泄露常规指纹,
|
| 157 |
+
# 但极高阶的反爬系统可能会通过检测“单进程模型”或“跨进程通信延迟”来识别自动化特征!
|
| 158 |
+
fission: true
|
| 159 |
+
|
| 160 |
+
# [全局代理] 如果 Instance 没有独立配置代理,将使用此配置
|
| 161 |
+
proxy:
|
| 162 |
+
# 是否启用代理
|
| 163 |
+
enable: false
|
| 164 |
+
# 代理类型: http 或 socks5
|
| 165 |
+
type: http
|
| 166 |
+
# 代理主机
|
| 167 |
+
host: 127.0.0.1
|
| 168 |
+
# 代理端口
|
| 169 |
+
port: 7890
|
| 170 |
+
# 代理认证 (可选)
|
| 171 |
+
# user: username
|
| 172 |
+
# passwd: password
|
config.hf.yaml
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Space 优化配置
|
| 2 |
+
# 针对 CPU 基础版 (2 vCPU, 16 GB) 优化
|
| 3 |
+
|
| 4 |
+
# 日志等级: debug | info | warn | error
|
| 5 |
+
logLevel: info
|
| 6 |
+
|
| 7 |
+
server:
|
| 8 |
+
# 监听端口 (会被 start_hf.sh 覆盖为 7860)
|
| 9 |
+
port: 7860
|
| 10 |
+
# 鉴权 API Token (可使用 npm run genkey 生成)
|
| 11 |
+
# 该配置会对 API 接口和 WebUI 生效
|
| 12 |
+
auth: sk-change-me-to-your-secure-key
|
| 13 |
+
# 流式请求心跳设置
|
| 14 |
+
keepalive:
|
| 15 |
+
mode: "comment"
|
| 16 |
+
|
| 17 |
+
backend:
|
| 18 |
+
pool:
|
| 19 |
+
# 全局调度策略
|
| 20 |
+
strategy: least_busy
|
| 21 |
+
|
| 22 |
+
# 故障转移配置
|
| 23 |
+
failover:
|
| 24 |
+
enabled: true
|
| 25 |
+
maxRetries: 2
|
| 26 |
+
|
| 27 |
+
# 浏览器实例列表
|
| 28 |
+
instances:
|
| 29 |
+
# 默认浏览器实例
|
| 30 |
+
- name: "browser_default"
|
| 31 |
+
workers:
|
| 32 |
+
- name: "default"
|
| 33 |
+
type: lmarena
|
| 34 |
+
|
| 35 |
+
adapter:
|
| 36 |
+
lmarena:
|
| 37 |
+
returnUrl: false
|
| 38 |
+
|
| 39 |
+
queue:
|
| 40 |
+
# 队列缓冲区大小
|
| 41 |
+
queueBuffer: 2
|
| 42 |
+
# 图片数量上限
|
| 43 |
+
imageLimit: 5
|
| 44 |
+
|
| 45 |
+
browser:
|
| 46 |
+
# 浏览器可执行文件路径
|
| 47 |
+
path: ""
|
| 48 |
+
|
| 49 |
+
# 无头模式 (Hugging Face Space 推荐)
|
| 50 |
+
headless: false
|
| 51 |
+
|
| 52 |
+
# 关闭 Fission 降低内存占用
|
| 53 |
+
# ⚠️ 可能会增加被检测风险,但 HF Space 资源有限
|
| 54 |
+
fission: false
|
docker-compose.yaml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
webai-2api:
|
| 3 |
+
image: foxhui/webai-2api:latest
|
| 4 |
+
container_name: webai-2api
|
| 5 |
+
restart: unless-stopped
|
| 6 |
+
ports:
|
| 7 |
+
- "3000:3000" # API + WebUI
|
| 8 |
+
volumes:
|
| 9 |
+
- ./data:/app/data # 数据和配置持久化,config.yaml 会自动生成到此目录
|
| 10 |
+
shm_size: '2gb'
|
| 11 |
+
init: true
|
package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "webai-2api",
|
| 3 |
+
"version": "3.0.0",
|
| 4 |
+
"description": "基于 Playwright + Camoufox 的自动化图像生成工具",
|
| 5 |
+
"license": "MIT",
|
| 6 |
+
"author": "foxhui",
|
| 7 |
+
"type": "module",
|
| 8 |
+
"scripts": {
|
| 9 |
+
"start": "node supervisor.js",
|
| 10 |
+
"genkey": "node scripts/genkey.js",
|
| 11 |
+
"init": "node scripts/init.js",
|
| 12 |
+
"postinstall": "node scripts/postinstall.js",
|
| 13 |
+
"save-data": "node scripts/save-data.js",
|
| 14 |
+
"restore-data": "node scripts/restore-data.js",
|
| 15 |
+
"clear-data": "node scripts/clear-data.js",
|
| 16 |
+
"save-data-webdav": "node scripts/save-data-webdav.js",
|
| 17 |
+
"restore-data-webdav": "node scripts/restore-data-webdav.js"
|
| 18 |
+
},
|
| 19 |
+
"imports": {
|
| 20 |
+
"#config": "./src/config/index.js",
|
| 21 |
+
"#utils/*": "./src/utils/*.js",
|
| 22 |
+
"#backend/*": "./src/backend/*.js",
|
| 23 |
+
"#server/*": "./src/server/*.js"
|
| 24 |
+
},
|
| 25 |
+
"dependencies": {
|
| 26 |
+
"@inquirer/prompts": "^8.0.1",
|
| 27 |
+
"better-sqlite3": "^12.5.0",
|
| 28 |
+
"camoufox-js": "^0.8.3",
|
| 29 |
+
"compressing": "^2.0.0",
|
| 30 |
+
"fingerprint-generator": "^2.1.78",
|
| 31 |
+
"ghost-cursor-playwright-port": "^1.4.3",
|
| 32 |
+
"got-scraping": "^4.1.2",
|
| 33 |
+
"playwright-core": "^1.57.0",
|
| 34 |
+
"proxy-chain": "^2.6.0",
|
| 35 |
+
"sharp": "^0.34.5",
|
| 36 |
+
"webdav": "^5.7.1",
|
| 37 |
+
"yaml": "^2.8.2"
|
| 38 |
+
}
|
| 39 |
+
}
|
patches/camoufox-js@0.8.3.locale.patched.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as fs from "node:fs";
|
| 2 |
+
import * as path from "node:path";
|
| 3 |
+
import tags from "language-tags";
|
| 4 |
+
import maxmind from "maxmind";
|
| 5 |
+
import xml2js from "xml2js";
|
| 6 |
+
import { InvalidLocale, MissingRelease, NotInstalledGeoIPExtra, UnknownIPLocation, UnknownLanguage, UnknownTerritory, } from "./exceptions.js";
|
| 7 |
+
import { validateIP } from "./ip.js";
|
| 8 |
+
import { GitHubDownloader, INSTALL_DIR, webdl } from "./pkgman.js";
|
| 9 |
+
import { getAsBooleanFromENV } from "./utils.js";
|
| 10 |
+
import { LeakWarning } from "./warnings.js";
|
| 11 |
+
export const ALLOW_GEOIP = true;
|
| 12 |
+
class Locale {
|
| 13 |
+
language;
|
| 14 |
+
region;
|
| 15 |
+
script;
|
| 16 |
+
constructor(language, region, script) {
|
| 17 |
+
this.language = language;
|
| 18 |
+
this.region = region;
|
| 19 |
+
this.script = script;
|
| 20 |
+
}
|
| 21 |
+
asString() {
|
| 22 |
+
if (this.region) {
|
| 23 |
+
return `${this.language}-${this.region}`;
|
| 24 |
+
}
|
| 25 |
+
return this.language;
|
| 26 |
+
}
|
| 27 |
+
asConfig() {
|
| 28 |
+
if (!this.region) {
|
| 29 |
+
throw new Error("Region is required for config");
|
| 30 |
+
}
|
| 31 |
+
const data = {
|
| 32 |
+
"locale:region": this.region,
|
| 33 |
+
"locale:language": this.language,
|
| 34 |
+
};
|
| 35 |
+
if (this.script) {
|
| 36 |
+
data["locale:script"] = this.script;
|
| 37 |
+
}
|
| 38 |
+
return data;
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
class Geolocation {
|
| 42 |
+
locale;
|
| 43 |
+
longitude;
|
| 44 |
+
latitude;
|
| 45 |
+
timezone;
|
| 46 |
+
accuracy;
|
| 47 |
+
constructor(locale, longitude, latitude, timezone, accuracy) {
|
| 48 |
+
this.locale = locale;
|
| 49 |
+
this.longitude = longitude;
|
| 50 |
+
this.latitude = latitude;
|
| 51 |
+
this.timezone = timezone;
|
| 52 |
+
this.accuracy = accuracy;
|
| 53 |
+
}
|
| 54 |
+
asConfig() {
|
| 55 |
+
const data = {
|
| 56 |
+
"geolocation:longitude": this.longitude,
|
| 57 |
+
"geolocation:latitude": this.latitude,
|
| 58 |
+
timezone: this.timezone,
|
| 59 |
+
...this.locale.asConfig(),
|
| 60 |
+
};
|
| 61 |
+
if (this.accuracy !== undefined) {
|
| 62 |
+
data["geolocation:accuracy"] = this.accuracy;
|
| 63 |
+
}
|
| 64 |
+
return data;
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
function verifyLocale(loc) {
|
| 68 |
+
if (tags.check(loc)) {
|
| 69 |
+
return;
|
| 70 |
+
}
|
| 71 |
+
throw InvalidLocale.invalidInput(loc);
|
| 72 |
+
}
|
| 73 |
+
export function normalizeLocale(locale) {
|
| 74 |
+
verifyLocale(locale);
|
| 75 |
+
const parser = tags(locale);
|
| 76 |
+
if (!parser.region) {
|
| 77 |
+
throw InvalidLocale.invalidInput(locale);
|
| 78 |
+
}
|
| 79 |
+
return new Locale(parser.language()?.format() ?? "en", parser.region()?.format(), parser.language()?.script()?.format());
|
| 80 |
+
}
|
| 81 |
+
export function handleLocale(locale, ignoreRegion = false) {
|
| 82 |
+
if (locale.length > 3) {
|
| 83 |
+
return normalizeLocale(locale);
|
| 84 |
+
}
|
| 85 |
+
try {
|
| 86 |
+
return SELECTOR.fromRegion(locale);
|
| 87 |
+
}
|
| 88 |
+
catch (e) {
|
| 89 |
+
if (e instanceof UnknownTerritory) {
|
| 90 |
+
}
|
| 91 |
+
else {
|
| 92 |
+
throw e;
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
if (ignoreRegion) {
|
| 96 |
+
verifyLocale(locale);
|
| 97 |
+
return new Locale(locale);
|
| 98 |
+
}
|
| 99 |
+
try {
|
| 100 |
+
const language = SELECTOR.fromLanguage(locale);
|
| 101 |
+
LeakWarning.warn("no_region");
|
| 102 |
+
return language;
|
| 103 |
+
}
|
| 104 |
+
catch (e) {
|
| 105 |
+
if (e instanceof UnknownLanguage) {
|
| 106 |
+
}
|
| 107 |
+
else {
|
| 108 |
+
throw e;
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
throw InvalidLocale.invalidInput(locale);
|
| 112 |
+
}
|
| 113 |
+
export function handleLocales(locales, config) {
|
| 114 |
+
if (typeof locales === "string") {
|
| 115 |
+
locales = locales.split(",").map((loc) => loc.trim());
|
| 116 |
+
}
|
| 117 |
+
const intlLocale = handleLocale(locales[0]).asConfig();
|
| 118 |
+
for (const key in intlLocale) {
|
| 119 |
+
config[key] = intlLocale[key];
|
| 120 |
+
}
|
| 121 |
+
if (locales.length < 2) {
|
| 122 |
+
return;
|
| 123 |
+
}
|
| 124 |
+
config["locale:all"] = joinUnique(locales.map((locale) => handleLocale(locale, true).asString()));
|
| 125 |
+
}
|
| 126 |
+
function joinUnique(seq) {
|
| 127 |
+
const seen = new Set();
|
| 128 |
+
return seq.filter((x) => !seen.has(x) && seen.add(x)).join(", ");
|
| 129 |
+
}
|
| 130 |
+
// [PATCH] Portable Mode: 优先检查项目根目录下的 camoufox 文件夹
|
| 131 |
+
const localMMDB = path.join(process.cwd(), "camoufox", "GeoLite2-City.mmdb");
|
| 132 |
+
const MMDB_FILE = fs.existsSync(localMMDB)
|
| 133 |
+
? localMMDB
|
| 134 |
+
: path.join(INSTALL_DIR.toString(), "GeoLite2-City.mmdb");
|
| 135 |
+
//const MMDB_FILE = path.join(INSTALL_DIR.toString(), "GeoLite2-City.mmdb");
|
| 136 |
+
|
| 137 |
+
const MMDB_REPO = "P3TERX/GeoLite.mmdb";
|
| 138 |
+
class MaxMindDownloader extends GitHubDownloader {
|
| 139 |
+
checkAsset(asset) {
|
| 140 |
+
if (asset.name.endsWith("-City.mmdb")) {
|
| 141 |
+
return asset.browser_download_url;
|
| 142 |
+
}
|
| 143 |
+
return null;
|
| 144 |
+
}
|
| 145 |
+
missingAssetError() {
|
| 146 |
+
throw new MissingRelease("Failed to find GeoIP database release asset");
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
export function geoipAllowed() {
|
| 150 |
+
if (!ALLOW_GEOIP) {
|
| 151 |
+
throw new NotInstalledGeoIPExtra("Please install the geoip extra to use this feature: pip install camoufox[geoip]");
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
export async function downloadMMDB() {
|
| 155 |
+
geoipAllowed();
|
| 156 |
+
if (getAsBooleanFromENV("PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD", false)) {
|
| 157 |
+
console.log("Skipping GeoIP database download due to PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD set!");
|
| 158 |
+
return;
|
| 159 |
+
}
|
| 160 |
+
const assetUrl = await new MaxMindDownloader(MMDB_REPO).getAsset();
|
| 161 |
+
const fileStream = fs.createWriteStream(MMDB_FILE);
|
| 162 |
+
await webdl(assetUrl, "Downloading GeoIP database", true, fileStream);
|
| 163 |
+
}
|
| 164 |
+
export function removeMMDB() {
|
| 165 |
+
if (!fs.existsSync(MMDB_FILE)) {
|
| 166 |
+
console.log("GeoIP database not found.");
|
| 167 |
+
return;
|
| 168 |
+
}
|
| 169 |
+
fs.unlinkSync(MMDB_FILE);
|
| 170 |
+
console.log("GeoIP database removed.");
|
| 171 |
+
}
|
| 172 |
+
export async function getGeolocation(ip) {
|
| 173 |
+
if (!fs.existsSync(MMDB_FILE)) {
|
| 174 |
+
await downloadMMDB();
|
| 175 |
+
}
|
| 176 |
+
validateIP(ip);
|
| 177 |
+
const reader = await maxmind.open(MMDB_FILE);
|
| 178 |
+
const resp = reader.get(ip);
|
| 179 |
+
const isoCode = resp.country?.iso_code.toUpperCase();
|
| 180 |
+
const location = resp.location;
|
| 181 |
+
if (!location?.longitude ||
|
| 182 |
+
!location?.latitude ||
|
| 183 |
+
!location?.time_zone ||
|
| 184 |
+
!isoCode) {
|
| 185 |
+
throw new UnknownIPLocation(`Unknown IP location: ${ip}`);
|
| 186 |
+
}
|
| 187 |
+
// [PATCHED] 强制锁定为美式英语,但保留 IP 的经纬度和时区
|
| 188 |
+
const locale = new Locale("en", "US");
|
| 189 |
+
return new Geolocation(locale, location.longitude, location.latitude, location.time_zone);
|
| 190 |
+
//const locale = SELECTOR.fromRegion(isoCode);
|
| 191 |
+
//return new Geolocation(locale, location.longitude, location.latitude, location.time_zone);
|
| 192 |
+
}
|
| 193 |
+
async function getUnicodeInfo() {
|
| 194 |
+
const data = await fs.promises.readFile(path.join(import.meta.dirname, "data-files", "territoryInfo.xml"));
|
| 195 |
+
const parser = new xml2js.Parser();
|
| 196 |
+
return parser.parseStringPromise(data);
|
| 197 |
+
}
|
| 198 |
+
function asFloat(element, attr) {
|
| 199 |
+
return parseFloat(element[attr] || "0");
|
| 200 |
+
}
|
| 201 |
+
class StatisticalLocaleSelector {
|
| 202 |
+
root;
|
| 203 |
+
constructor() {
|
| 204 |
+
this.loadUnicodeInfo();
|
| 205 |
+
}
|
| 206 |
+
async loadUnicodeInfo() {
|
| 207 |
+
this.root = await getUnicodeInfo();
|
| 208 |
+
}
|
| 209 |
+
loadTerritoryData(isoCode) {
|
| 210 |
+
const territory = this.root.territoryInfo.territory.find((t) => t.$.type === isoCode);
|
| 211 |
+
if (!territory) {
|
| 212 |
+
throw new UnknownTerritory(`Unknown territory: ${isoCode}`);
|
| 213 |
+
}
|
| 214 |
+
const langPopulations = territory.languagePopulation;
|
| 215 |
+
if (!langPopulations) {
|
| 216 |
+
throw new Error(`No language data found for region: ${isoCode}`);
|
| 217 |
+
}
|
| 218 |
+
const languages = langPopulations.map((lang) => lang.$.type);
|
| 219 |
+
const percentages = langPopulations.map((lang) => asFloat(lang.$, "populationPercent"));
|
| 220 |
+
return this.normalizeProbabilities(languages, percentages);
|
| 221 |
+
}
|
| 222 |
+
loadLanguageData(language) {
|
| 223 |
+
const territories = this.root.territory.filter((t) => t.languagePopulation.some((lp) => lp.$.type === language));
|
| 224 |
+
if (!territories.length) {
|
| 225 |
+
throw new UnknownLanguage(`No region data found for language: ${language}`);
|
| 226 |
+
}
|
| 227 |
+
const regions = [];
|
| 228 |
+
const percentages = [];
|
| 229 |
+
for (const terr of territories) {
|
| 230 |
+
const region = terr.$.type;
|
| 231 |
+
const langPop = terr.languagePopulation.find((lp) => lp.$.type === language);
|
| 232 |
+
if (region && langPop) {
|
| 233 |
+
regions.push(region);
|
| 234 |
+
percentages.push(((asFloat(langPop.$, "populationPercent") *
|
| 235 |
+
asFloat(terr.$, "literacyPercent")) /
|
| 236 |
+
10000) *
|
| 237 |
+
asFloat(terr.$, "population"));
|
| 238 |
+
}
|
| 239 |
+
}
|
| 240 |
+
if (!regions.length) {
|
| 241 |
+
throw new Error(`No valid region data found for language: ${language}`);
|
| 242 |
+
}
|
| 243 |
+
return this.normalizeProbabilities(regions, percentages);
|
| 244 |
+
}
|
| 245 |
+
normalizeProbabilities(languages, freq) {
|
| 246 |
+
const total = freq.reduce((a, b) => a + b, 0);
|
| 247 |
+
return [languages, freq.map((f) => f / total)];
|
| 248 |
+
}
|
| 249 |
+
weightedRandomChoice(items, weights) {
|
| 250 |
+
if (items.length === 0) {
|
| 251 |
+
throw new Error("items must not be empty");
|
| 252 |
+
}
|
| 253 |
+
if (items.length !== weights.length) {
|
| 254 |
+
throw new Error("items and weights must have the same length");
|
| 255 |
+
}
|
| 256 |
+
let total = 0;
|
| 257 |
+
for (const w of weights) {
|
| 258 |
+
if (w < 0) {
|
| 259 |
+
throw new Error("weights must be non-negative");
|
| 260 |
+
}
|
| 261 |
+
total += w;
|
| 262 |
+
}
|
| 263 |
+
// Fallback to uniform choice if all weights are zero
|
| 264 |
+
if (total === 0) {
|
| 265 |
+
return items[Math.floor(Math.random() * items.length)];
|
| 266 |
+
}
|
| 267 |
+
const r = Math.random() * total;
|
| 268 |
+
let acc = 0;
|
| 269 |
+
for (let i = 0; i < items.length; i++) {
|
| 270 |
+
acc += weights[i];
|
| 271 |
+
if (r < acc) {
|
| 272 |
+
return items[i];
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
// Numerical edge case
|
| 276 |
+
return items[items.length - 1];
|
| 277 |
+
}
|
| 278 |
+
fromRegion(region) {
|
| 279 |
+
const [languages, probabilities] = this.loadTerritoryData(region);
|
| 280 |
+
const language = this.weightedRandomChoice(languages, probabilities).replace("_", "-");
|
| 281 |
+
return normalizeLocale(`${language}-${region}`);
|
| 282 |
+
}
|
| 283 |
+
fromLanguage(language) {
|
| 284 |
+
const [regions, probabilities] = this.loadLanguageData(language);
|
| 285 |
+
const region = this.weightedRandomChoice(regions, probabilities);
|
| 286 |
+
return normalizeLocale(`${language}-${region}`);
|
| 287 |
+
}
|
| 288 |
+
}
|
| 289 |
+
const SELECTOR = new StatisticalLocaleSelector();
|
patches/camoufox-js@0.8.3.pkgman.patched.js
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { execSync } from "node:child_process";
|
| 2 |
+
import * as fs from "node:fs";
|
| 3 |
+
import * as os from "node:os";
|
| 4 |
+
import * as path from "node:path";
|
| 5 |
+
import { setTimeout } from "node:timers/promises";
|
| 6 |
+
import AdmZip from "adm-zip";
|
| 7 |
+
import ProgressBar from "progress";
|
| 8 |
+
import { CONSTRAINTS } from "./__version__.js";
|
| 9 |
+
import { CamoufoxNotInstalled, FileNotFoundError, MissingRelease, UnsupportedArchitecture, UnsupportedOS, UnsupportedVersion, } from "./exceptions.js";
|
| 10 |
+
const ARCH_MAP = {
|
| 11 |
+
x64: "x86_64",
|
| 12 |
+
ia32: "i686",
|
| 13 |
+
arm64: "arm64",
|
| 14 |
+
arm: "arm64",
|
| 15 |
+
};
|
| 16 |
+
const OS_MAP = {
|
| 17 |
+
darwin: "mac",
|
| 18 |
+
linux: "lin",
|
| 19 |
+
win32: "win",
|
| 20 |
+
};
|
| 21 |
+
if (!(process.platform in OS_MAP)) {
|
| 22 |
+
throw new UnsupportedOS(`OS ${process.platform} is not supported`);
|
| 23 |
+
}
|
| 24 |
+
export const OS_NAME = OS_MAP[process.platform];
|
| 25 |
+
// [PATCH] Portable Mode: 优先使用项目目录下的 camoufox 文件夹
|
| 26 |
+
const localInstallDir = path.join(process.cwd(), "camoufox");
|
| 27 |
+
export const INSTALL_DIR = fs.existsSync(localInstallDir) ? localInstallDir : userCacheDir("camoufox");
|
| 28 |
+
//export const INSTALL_DIR = userCacheDir("camoufox");
|
| 29 |
+
|
| 30 |
+
export const LOCAL_DATA = path.join(import.meta.dirname, "data-files");
|
| 31 |
+
export const OS_ARCH_MATRIX = {
|
| 32 |
+
win: ["x86_64", "i686"],
|
| 33 |
+
mac: ["x86_64", "arm64"],
|
| 34 |
+
lin: ["x86_64", "arm64", "i686"],
|
| 35 |
+
};
|
| 36 |
+
const LAUNCH_FILE = {
|
| 37 |
+
win: "camoufox.exe",
|
| 38 |
+
mac: "../MacOS/camoufox",
|
| 39 |
+
lin: "camoufox-bin",
|
| 40 |
+
};
|
| 41 |
+
class Version {
|
| 42 |
+
release;
|
| 43 |
+
version;
|
| 44 |
+
sorted_rel;
|
| 45 |
+
constructor(release, version) {
|
| 46 |
+
this.release = release;
|
| 47 |
+
this.version = version;
|
| 48 |
+
this.sorted_rel = this.buildSortedRel();
|
| 49 |
+
}
|
| 50 |
+
buildSortedRel() {
|
| 51 |
+
const parts = this.release
|
| 52 |
+
.split(".")
|
| 53 |
+
.map((x) => Number.isNaN(Number(x)) ? x.charCodeAt(0) - 1024 : Number(x));
|
| 54 |
+
while (parts.length < 5) {
|
| 55 |
+
parts.push(0);
|
| 56 |
+
}
|
| 57 |
+
return parts;
|
| 58 |
+
}
|
| 59 |
+
get fullString() {
|
| 60 |
+
return `${this.version}-${this.release}`;
|
| 61 |
+
}
|
| 62 |
+
equals(other) {
|
| 63 |
+
return this.sorted_rel.join(".") === other.sorted_rel.join(".");
|
| 64 |
+
}
|
| 65 |
+
lessThan(other) {
|
| 66 |
+
for (let i = 0; i < this.sorted_rel.length; i++) {
|
| 67 |
+
if (this.sorted_rel[i] < other.sorted_rel[i])
|
| 68 |
+
return true;
|
| 69 |
+
if (this.sorted_rel[i] > other.sorted_rel[i])
|
| 70 |
+
return false;
|
| 71 |
+
}
|
| 72 |
+
return false;
|
| 73 |
+
}
|
| 74 |
+
isSupported() {
|
| 75 |
+
return VERSION_MIN.lessThan(this) && this.lessThan(VERSION_MAX);
|
| 76 |
+
}
|
| 77 |
+
static fromPath(filePath = INSTALL_DIR) {
|
| 78 |
+
const versionPath = path.join(filePath.toString(), "version.json");
|
| 79 |
+
if (!fs.existsSync(versionPath)) {
|
| 80 |
+
throw new FileNotFoundError(`Version information not found at ${versionPath}. Please run \`camoufox fetch\` to install.`);
|
| 81 |
+
}
|
| 82 |
+
const versionData = JSON.parse(fs.readFileSync(versionPath, "utf-8"));
|
| 83 |
+
return new Version(versionData.release, versionData.version);
|
| 84 |
+
}
|
| 85 |
+
static isSupportedPath(path) {
|
| 86 |
+
return Version.fromPath(path).isSupported();
|
| 87 |
+
}
|
| 88 |
+
static buildMinMax() {
|
| 89 |
+
return [
|
| 90 |
+
new Version(CONSTRAINTS.MIN_VERSION),
|
| 91 |
+
new Version(CONSTRAINTS.MAX_VERSION),
|
| 92 |
+
];
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
const [VERSION_MIN, VERSION_MAX] = Version.buildMinMax();
|
| 96 |
+
export class GitHubDownloader {
|
| 97 |
+
githubRepo;
|
| 98 |
+
apiUrl;
|
| 99 |
+
constructor(githubRepo) {
|
| 100 |
+
this.githubRepo = githubRepo;
|
| 101 |
+
this.apiUrl = `https://api.github.com/repos/${githubRepo}/releases`;
|
| 102 |
+
}
|
| 103 |
+
checkAsset(asset) {
|
| 104 |
+
return asset.browser_download_url;
|
| 105 |
+
}
|
| 106 |
+
missingAssetError() {
|
| 107 |
+
throw new MissingRelease(`Could not find a release asset in ${this.githubRepo}.`);
|
| 108 |
+
}
|
| 109 |
+
async getAsset({ retries } = { retries: 5 }) {
|
| 110 |
+
let attempts = 0;
|
| 111 |
+
let response;
|
| 112 |
+
while (attempts < retries) {
|
| 113 |
+
try {
|
| 114 |
+
response = await fetch(this.apiUrl);
|
| 115 |
+
if (response.ok)
|
| 116 |
+
break;
|
| 117 |
+
}
|
| 118 |
+
catch (e) {
|
| 119 |
+
console.error(e, `retrying (${attempts + 1}/${retries})...`);
|
| 120 |
+
await setTimeout(5e3);
|
| 121 |
+
}
|
| 122 |
+
attempts++;
|
| 123 |
+
}
|
| 124 |
+
if (!response || !response.ok) {
|
| 125 |
+
throw new Error(`Failed to fetch releases from ${this.apiUrl} after ${retries} attempts`);
|
| 126 |
+
}
|
| 127 |
+
const releases = await response.json();
|
| 128 |
+
for (const release of releases) {
|
| 129 |
+
for (const asset of release.assets) {
|
| 130 |
+
const data = this.checkAsset(asset);
|
| 131 |
+
if (data) {
|
| 132 |
+
return data;
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
this.missingAssetError();
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
export class CamoufoxFetcher extends GitHubDownloader {
|
| 140 |
+
arch;
|
| 141 |
+
_version_obj;
|
| 142 |
+
pattern;
|
| 143 |
+
_url;
|
| 144 |
+
constructor() {
|
| 145 |
+
super("daijro/camoufox");
|
| 146 |
+
this.arch = CamoufoxFetcher.getPlatformArch();
|
| 147 |
+
this.pattern = new RegExp(`camoufox-(.+)-(.+)-${OS_NAME}\\.${this.arch}\\.zip`);
|
| 148 |
+
}
|
| 149 |
+
async init() {
|
| 150 |
+
await this.fetchLatest();
|
| 151 |
+
}
|
| 152 |
+
checkAsset(asset) {
|
| 153 |
+
const match = asset.name.match(this.pattern);
|
| 154 |
+
if (!match)
|
| 155 |
+
return null;
|
| 156 |
+
const version = new Version(match[2], match[1]);
|
| 157 |
+
if (!version.isSupported())
|
| 158 |
+
return null;
|
| 159 |
+
return [version, asset.browser_download_url];
|
| 160 |
+
}
|
| 161 |
+
missingAssetError() {
|
| 162 |
+
throw new MissingRelease(`No matching release found for ${OS_NAME} ${this.arch} in the supported range: (${CONSTRAINTS.asRange()}). Please update the library.`);
|
| 163 |
+
}
|
| 164 |
+
static getPlatformArch() {
|
| 165 |
+
const platArch = os.arch().toLowerCase();
|
| 166 |
+
if (!(platArch in ARCH_MAP)) {
|
| 167 |
+
throw new UnsupportedArchitecture(`Architecture ${platArch} is not supported`);
|
| 168 |
+
}
|
| 169 |
+
const arch = ARCH_MAP[platArch];
|
| 170 |
+
if (!OS_ARCH_MATRIX[OS_NAME].includes(arch)) {
|
| 171 |
+
throw new UnsupportedArchitecture(`Architecture ${arch} is not supported for ${OS_NAME}`);
|
| 172 |
+
}
|
| 173 |
+
return arch;
|
| 174 |
+
}
|
| 175 |
+
async fetchLatest() {
|
| 176 |
+
if (this._version_obj)
|
| 177 |
+
return;
|
| 178 |
+
const releaseData = await this.getAsset();
|
| 179 |
+
this._version_obj = releaseData[0];
|
| 180 |
+
this._url = releaseData[1];
|
| 181 |
+
}
|
| 182 |
+
static async downloadFile(url) {
|
| 183 |
+
const response = await fetch(url);
|
| 184 |
+
return Buffer.from(await response.arrayBuffer());
|
| 185 |
+
}
|
| 186 |
+
async extractZip(zipFile) {
|
| 187 |
+
const zip = new AdmZip(zipFile);
|
| 188 |
+
zip.extractAllTo(INSTALL_DIR.toString(), true);
|
| 189 |
+
}
|
| 190 |
+
static cleanup() {
|
| 191 |
+
if (fs.existsSync(INSTALL_DIR)) {
|
| 192 |
+
fs.rmSync(INSTALL_DIR, { recursive: true });
|
| 193 |
+
return true;
|
| 194 |
+
}
|
| 195 |
+
return false;
|
| 196 |
+
}
|
| 197 |
+
setVersion() {
|
| 198 |
+
fs.writeFileSync(path.join(INSTALL_DIR.toString(), "version.json"), JSON.stringify({ version: this.version, release: this.release }));
|
| 199 |
+
}
|
| 200 |
+
async install() {
|
| 201 |
+
await this.init();
|
| 202 |
+
await CamoufoxFetcher.cleanup();
|
| 203 |
+
try {
|
| 204 |
+
fs.mkdirSync(INSTALL_DIR, { recursive: true });
|
| 205 |
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "camoufox-"));
|
| 206 |
+
const tempFilePath = path.join(tempDir, "camoufox.zip");
|
| 207 |
+
const tempFileStream = fs.createWriteStream(tempFilePath);
|
| 208 |
+
await webdl(this.url, "Downloading Camoufox...", true, tempFileStream);
|
| 209 |
+
await new Promise((r) => tempFileStream.close(r));
|
| 210 |
+
await this.extractZip(tempFilePath);
|
| 211 |
+
this.setVersion();
|
| 212 |
+
if (OS_NAME !== "win") {
|
| 213 |
+
execSync(`chmod -R 755 ${INSTALL_DIR}`);
|
| 214 |
+
}
|
| 215 |
+
console.log("Camoufox successfully installed.");
|
| 216 |
+
}
|
| 217 |
+
catch (e) {
|
| 218 |
+
console.error(`Error installing Camoufox: ${e}`);
|
| 219 |
+
await CamoufoxFetcher.cleanup();
|
| 220 |
+
throw e;
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
get url() {
|
| 224 |
+
if (!this._url) {
|
| 225 |
+
throw new Error("Url is not available. Make sure to run fetchLatest first.");
|
| 226 |
+
}
|
| 227 |
+
return this._url;
|
| 228 |
+
}
|
| 229 |
+
get version() {
|
| 230 |
+
if (!this._version_obj || !this._version_obj.version) {
|
| 231 |
+
throw new Error("Version is not available. Make sure to run fetchLatest first.");
|
| 232 |
+
}
|
| 233 |
+
return this._version_obj.version;
|
| 234 |
+
}
|
| 235 |
+
get release() {
|
| 236 |
+
if (!this._version_obj) {
|
| 237 |
+
throw new Error("Release information is not available. Make sure to run the installation first.");
|
| 238 |
+
}
|
| 239 |
+
return this._version_obj.release;
|
| 240 |
+
}
|
| 241 |
+
get verstr() {
|
| 242 |
+
if (!this._version_obj) {
|
| 243 |
+
throw new Error("Version is not available. Make sure to run the installation first.");
|
| 244 |
+
}
|
| 245 |
+
return this._version_obj.fullString;
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
function userCacheDir(appName) {
|
| 249 |
+
if (OS_NAME === "win") {
|
| 250 |
+
return path.join(os.homedir(), "AppData", "Local", appName, appName, "Cache");
|
| 251 |
+
}
|
| 252 |
+
else if (OS_NAME === "mac") {
|
| 253 |
+
return path.join(os.homedir(), "Library", "Caches", appName);
|
| 254 |
+
}
|
| 255 |
+
else {
|
| 256 |
+
return path.join(os.homedir(), ".cache", appName);
|
| 257 |
+
}
|
| 258 |
+
}
|
| 259 |
+
export function installedVerStr() {
|
| 260 |
+
return Version.fromPath().fullString;
|
| 261 |
+
}
|
| 262 |
+
export function camoufoxPath(downloadIfMissing = true) {
|
| 263 |
+
// Ensure the directory exists and is not empty
|
| 264 |
+
if (!fs.existsSync(INSTALL_DIR) || fs.readdirSync(INSTALL_DIR).length === 0) {
|
| 265 |
+
if (!downloadIfMissing) {
|
| 266 |
+
throw new Error(`Camoufox executable not found at ${INSTALL_DIR}`);
|
| 267 |
+
}
|
| 268 |
+
}
|
| 269 |
+
else if (fs.existsSync(INSTALL_DIR) &&
|
| 270 |
+
Version.isSupportedPath(INSTALL_DIR)) {
|
| 271 |
+
return INSTALL_DIR;
|
| 272 |
+
}
|
| 273 |
+
else {
|
| 274 |
+
if (!downloadIfMissing) {
|
| 275 |
+
throw new UnsupportedVersion("Camoufox executable is outdated.");
|
| 276 |
+
}
|
| 277 |
+
}
|
| 278 |
+
// Install and recheck
|
| 279 |
+
const fetcher = new CamoufoxFetcher();
|
| 280 |
+
fetcher.install().then(() => camoufoxPath());
|
| 281 |
+
return INSTALL_DIR;
|
| 282 |
+
}
|
| 283 |
+
export function getPath(file) {
|
| 284 |
+
if (OS_NAME === "mac") {
|
| 285 |
+
return path.resolve(camoufoxPath().toString(), "Camoufox.app", "Contents", "Resources", file);
|
| 286 |
+
}
|
| 287 |
+
return path.join(camoufoxPath().toString(), file);
|
| 288 |
+
}
|
| 289 |
+
export function launchPath() {
|
| 290 |
+
const launchPath = getPath(LAUNCH_FILE[OS_NAME]);
|
| 291 |
+
if (!fs.existsSync(launchPath)) {
|
| 292 |
+
throw new CamoufoxNotInstalled(`Camoufox is not installed at ${camoufoxPath()}. Please run \`camoufox fetch\` to install.`);
|
| 293 |
+
}
|
| 294 |
+
return launchPath;
|
| 295 |
+
}
|
| 296 |
+
export async function webdl(url, desc = "", bar = true, buffer = null, { retries } = { retries: 5 }) {
|
| 297 |
+
let attempts = 0;
|
| 298 |
+
let response;
|
| 299 |
+
while (attempts < retries) {
|
| 300 |
+
try {
|
| 301 |
+
response = await fetch(url);
|
| 302 |
+
if (response.ok)
|
| 303 |
+
break;
|
| 304 |
+
}
|
| 305 |
+
catch (e) {
|
| 306 |
+
console.error(e, `retrying (${attempts + 1}/${retries})...`);
|
| 307 |
+
await setTimeout(5e3);
|
| 308 |
+
}
|
| 309 |
+
attempts++;
|
| 310 |
+
}
|
| 311 |
+
if (!response || !response.ok) {
|
| 312 |
+
throw new Error(`Failed to download from ${url} after ${retries} attempts`);
|
| 313 |
+
}
|
| 314 |
+
const totalSize = parseInt(response.headers.get("content-length") || "0", 10);
|
| 315 |
+
const progressBar = bar
|
| 316 |
+
? new ProgressBar(`${desc} [:bar] :percent :etas`, {
|
| 317 |
+
total: totalSize,
|
| 318 |
+
width: 40,
|
| 319 |
+
})
|
| 320 |
+
: null;
|
| 321 |
+
const chunks = [];
|
| 322 |
+
for await (const chunk of response.body) {
|
| 323 |
+
if (buffer) {
|
| 324 |
+
buffer.write(chunk);
|
| 325 |
+
}
|
| 326 |
+
else {
|
| 327 |
+
chunks.push(chunk);
|
| 328 |
+
}
|
| 329 |
+
if (progressBar) {
|
| 330 |
+
progressBar.tick(chunk.length, "X");
|
| 331 |
+
}
|
| 332 |
+
}
|
| 333 |
+
const fileBuffer = Buffer.concat(chunks);
|
| 334 |
+
return fileBuffer;
|
| 335 |
+
}
|
| 336 |
+
export async function unzip(zipFile, extractPath, desc, bar = true) {
|
| 337 |
+
const zip = new AdmZip(zipFile);
|
| 338 |
+
const zipEntries = zip.getEntries();
|
| 339 |
+
if (bar) {
|
| 340 |
+
console.log(desc || "Extracting files...");
|
| 341 |
+
}
|
| 342 |
+
for (const entry of zipEntries) {
|
| 343 |
+
if (bar) {
|
| 344 |
+
console.log(`Extracting ${entry.entryName}`);
|
| 345 |
+
}
|
| 346 |
+
zip.extractEntryTo(entry, extractPath, false, true);
|
| 347 |
+
}
|
| 348 |
+
if (bar) {
|
| 349 |
+
console.log("Extraction complete.");
|
| 350 |
+
}
|
| 351 |
+
}
|
pnpm-lock.yaml
ADDED
|
@@ -0,0 +1,2132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
lockfileVersion: '9.0'
|
| 2 |
+
|
| 3 |
+
settings:
|
| 4 |
+
autoInstallPeers: true
|
| 5 |
+
excludeLinksFromLockfile: false
|
| 6 |
+
|
| 7 |
+
importers:
|
| 8 |
+
|
| 9 |
+
.:
|
| 10 |
+
dependencies:
|
| 11 |
+
'@inquirer/prompts':
|
| 12 |
+
specifier: ^8.0.1
|
| 13 |
+
version: 8.0.1(@types/node@24.10.1)
|
| 14 |
+
better-sqlite3:
|
| 15 |
+
specifier: ^12.5.0
|
| 16 |
+
version: 12.5.0
|
| 17 |
+
camoufox-js:
|
| 18 |
+
specifier: ^0.8.3
|
| 19 |
+
version: 0.8.3(playwright-core@1.57.0)
|
| 20 |
+
compressing:
|
| 21 |
+
specifier: ^2.0.0
|
| 22 |
+
version: 2.0.0
|
| 23 |
+
fingerprint-generator:
|
| 24 |
+
specifier: ^2.1.78
|
| 25 |
+
version: 2.1.78
|
| 26 |
+
ghost-cursor-playwright-port:
|
| 27 |
+
specifier: ^1.4.3
|
| 28 |
+
version: 1.4.3(@playwright/test@1.57.0)(playwright-core@1.57.0)(playwright@1.57.0)
|
| 29 |
+
got-scraping:
|
| 30 |
+
specifier: ^4.1.2
|
| 31 |
+
version: 4.1.2
|
| 32 |
+
playwright-core:
|
| 33 |
+
specifier: ^1.57.0
|
| 34 |
+
version: 1.57.0
|
| 35 |
+
proxy-chain:
|
| 36 |
+
specifier: ^2.6.0
|
| 37 |
+
version: 2.6.0
|
| 38 |
+
sharp:
|
| 39 |
+
specifier: ^0.34.5
|
| 40 |
+
version: 0.34.5
|
| 41 |
+
yaml:
|
| 42 |
+
specifier: ^2.8.2
|
| 43 |
+
version: 2.8.2
|
| 44 |
+
|
| 45 |
+
packages:
|
| 46 |
+
|
| 47 |
+
'@eggjs/yauzl@2.11.0':
|
| 48 |
+
resolution: {integrity: sha512-Jq+k2fCZJ3i3HShb0nxLUiAgq5pwo8JTT1TrH22JoehZQ0Nm2dvByGIja1NYfNyuE4Tx5/Dns5nVsBN/mlC8yg==}
|
| 49 |
+
|
| 50 |
+
'@emnapi/runtime@1.7.1':
|
| 51 |
+
resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==}
|
| 52 |
+
|
| 53 |
+
'@img/colour@1.0.0':
|
| 54 |
+
resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
|
| 55 |
+
engines: {node: '>=18'}
|
| 56 |
+
|
| 57 |
+
'@img/sharp-darwin-arm64@0.34.5':
|
| 58 |
+
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
|
| 59 |
+
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
| 60 |
+
cpu: [arm64]
|
| 61 |
+
os: [darwin]
|
| 62 |
+
|
| 63 |
+
'@img/sharp-darwin-x64@0.34.5':
|
| 64 |
+
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
|
| 65 |
+
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
| 66 |
+
cpu: [x64]
|
| 67 |
+
os: [darwin]
|
| 68 |
+
|
| 69 |
+
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
| 70 |
+
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
|
| 71 |
+
cpu: [arm64]
|
| 72 |
+
os: [darwin]
|
| 73 |
+
|
| 74 |
+
'@img/sharp-libvips-darwin-x64@1.2.4':
|
| 75 |
+
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
|
| 76 |
+
cpu: [x64]
|
| 77 |
+
os: [darwin]
|
| 78 |
+
|
| 79 |
+
'@img/sharp-libvips-linux-arm64@1.2.4':
|
| 80 |
+
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
| 81 |
+
cpu: [arm64]
|
| 82 |
+
os: [linux]
|
| 83 |
+
libc: [glibc]
|
| 84 |
+
|
| 85 |
+
'@img/sharp-libvips-linux-arm@1.2.4':
|
| 86 |
+
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
| 87 |
+
cpu: [arm]
|
| 88 |
+
os: [linux]
|
| 89 |
+
libc: [glibc]
|
| 90 |
+
|
| 91 |
+
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
| 92 |
+
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
| 93 |
+
cpu: [ppc64]
|
| 94 |
+
os: [linux]
|
| 95 |
+
libc: [glibc]
|
| 96 |
+
|
| 97 |
+
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
| 98 |
+
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
| 99 |
+
cpu: [riscv64]
|
| 100 |
+
os: [linux]
|
| 101 |
+
libc: [glibc]
|
| 102 |
+
|
| 103 |
+
'@img/sharp-libvips-linux-s390x@1.2.4':
|
| 104 |
+
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
| 105 |
+
cpu: [s390x]
|
| 106 |
+
os: [linux]
|
| 107 |
+
libc: [glibc]
|
| 108 |
+
|
| 109 |
+
'@img/sharp-libvips-linux-x64@1.2.4':
|
| 110 |
+
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
| 111 |
+
cpu: [x64]
|
| 112 |
+
os: [linux]
|
| 113 |
+
libc: [glibc]
|
| 114 |
+
|
| 115 |
+
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
| 116 |
+
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
| 117 |
+
cpu: [arm64]
|
| 118 |
+
os: [linux]
|
| 119 |
+
libc: [musl]
|
| 120 |
+
|
| 121 |
+
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
| 122 |
+
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
| 123 |
+
cpu: [x64]
|
| 124 |
+
os: [linux]
|
| 125 |
+
libc: [musl]
|
| 126 |
+
|
| 127 |
+
'@img/sharp-linux-arm64@0.34.5':
|
| 128 |
+
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
| 129 |
+
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
| 130 |
+
cpu: [arm64]
|
| 131 |
+
os: [linux]
|
| 132 |
+
libc: [glibc]
|
| 133 |
+
|
| 134 |
+
'@img/sharp-linux-arm@0.34.5':
|
| 135 |
+
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
| 136 |
+
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
| 137 |
+
cpu: [arm]
|
| 138 |
+
os: [linux]
|
| 139 |
+
libc: [glibc]
|
| 140 |
+
|
| 141 |
+
'@img/sharp-linux-ppc64@0.34.5':
|
| 142 |
+
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
| 143 |
+
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
| 144 |
+
cpu: [ppc64]
|
| 145 |
+
os: [linux]
|
| 146 |
+
libc: [glibc]
|
| 147 |
+
|
| 148 |
+
'@img/sharp-linux-riscv64@0.34.5':
|
| 149 |
+
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
| 150 |
+
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
| 151 |
+
cpu: [riscv64]
|
| 152 |
+
os: [linux]
|
| 153 |
+
libc: [glibc]
|
| 154 |
+
|
| 155 |
+
'@img/sharp-linux-s390x@0.34.5':
|
| 156 |
+
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
| 157 |
+
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
| 158 |
+
cpu: [s390x]
|
| 159 |
+
os: [linux]
|
| 160 |
+
libc: [glibc]
|
| 161 |
+
|
| 162 |
+
'@img/sharp-linux-x64@0.34.5':
|
| 163 |
+
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
| 164 |
+
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
| 165 |
+
cpu: [x64]
|
| 166 |
+
os: [linux]
|
| 167 |
+
libc: [glibc]
|
| 168 |
+
|
| 169 |
+
'@img/sharp-linuxmusl-arm64@0.34.5':
|
| 170 |
+
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
| 171 |
+
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
| 172 |
+
cpu: [arm64]
|
| 173 |
+
os: [linux]
|
| 174 |
+
libc: [musl]
|
| 175 |
+
|
| 176 |
+
'@img/sharp-linuxmusl-x64@0.34.5':
|
| 177 |
+
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
| 178 |
+
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
| 179 |
+
cpu: [x64]
|
| 180 |
+
os: [linux]
|
| 181 |
+
libc: [musl]
|
| 182 |
+
|
| 183 |
+
'@img/sharp-wasm32@0.34.5':
|
| 184 |
+
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
| 185 |
+
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
| 186 |
+
cpu: [wasm32]
|
| 187 |
+
|
| 188 |
+
'@img/sharp-win32-arm64@0.34.5':
|
| 189 |
+
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
|
| 190 |
+
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
| 191 |
+
cpu: [arm64]
|
| 192 |
+
os: [win32]
|
| 193 |
+
|
| 194 |
+
'@img/sharp-win32-ia32@0.34.5':
|
| 195 |
+
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
|
| 196 |
+
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
| 197 |
+
cpu: [ia32]
|
| 198 |
+
os: [win32]
|
| 199 |
+
|
| 200 |
+
'@img/sharp-win32-x64@0.34.5':
|
| 201 |
+
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
|
| 202 |
+
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
| 203 |
+
cpu: [x64]
|
| 204 |
+
os: [win32]
|
| 205 |
+
|
| 206 |
+
'@inquirer/ansi@2.0.1':
|
| 207 |
+
resolution: {integrity: sha512-QAZUk6BBncv/XmSEZTscd8qazzjV3E0leUMrEPjxCd51QBgCKmprUGLex5DTsNtURm7LMzv+CLcd6S86xvBfYg==}
|
| 208 |
+
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
| 209 |
+
|
| 210 |
+
'@inquirer/checkbox@5.0.1':
|
| 211 |
+
resolution: {integrity: sha512-5VPFBK8jKdsjMK3DTFOlbR0+Kkd4q0AWB7VhWQn6ppv44dr3b7PU8wSJQTC5oA0f/aGW7v/ZozQJAY9zx6PKig==}
|
| 212 |
+
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
| 213 |
+
peerDependencies:
|
| 214 |
+
'@types/node': '>=18'
|
| 215 |
+
peerDependenciesMeta:
|
| 216 |
+
'@types/node':
|
| 217 |
+
optional: true
|
| 218 |
+
|
| 219 |
+
'@inquirer/confirm@6.0.1':
|
| 220 |
+
resolution: {integrity: sha512-wD+pM7IxLn1TdcQN12Q6wcFe5VpyCuh/I2sSmqO5KjWH2R4v+GkUToHb+PsDGobOe1MtAlXMwGNkZUPc2+L6NA==}
|
| 221 |
+
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
| 222 |
+
peerDependencies:
|
| 223 |
+
'@types/node': '>=18'
|
| 224 |
+
peerDependenciesMeta:
|
| 225 |
+
'@types/node':
|
| 226 |
+
optional: true
|
| 227 |
+
|
| 228 |
+
'@inquirer/core@11.0.1':
|
| 229 |
+
resolution: {integrity: sha512-Tpf49h50e4KYffVUCXzkx4gWMafUi3aDQDwfVAAGBNnVcXiwJIj4m2bKlZ7Kgyf6wjt1eyXH1wDGXcAokm4Ssw==}
|
| 230 |
+
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
| 231 |
+
peerDependencies:
|
| 232 |
+
'@types/node': '>=18'
|
| 233 |
+
peerDependenciesMeta:
|
| 234 |
+
'@types/node':
|
| 235 |
+
optional: true
|
| 236 |
+
|
| 237 |
+
'@inquirer/editor@5.0.1':
|
| 238 |
+
resolution: {integrity: sha512-zDKobHI7Ry++4noiV9Z5VfYgSVpPZoMApviIuGwLOMciQaP+dGzCO+1fcwI441riklRiZg4yURWyEoX0Zy2zZw==}
|
| 239 |
+
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
| 240 |
+
peerDependencies:
|
| 241 |
+
'@types/node': '>=18'
|
| 242 |
+
peerDependenciesMeta:
|
| 243 |
+
'@types/node':
|
| 244 |
+
optional: true
|
| 245 |
+
|
| 246 |
+
'@inquirer/expand@5.0.1':
|
| 247 |
+
resolution: {integrity: sha512-TBrTpAB6uZNnGQHtSEkbvJZIQ3dXZOrwqQSO9uUbwct3G2LitwBCE5YZj98MbQ5nzihzs5pRjY1K9RRLH4WgoA==}
|
| 248 |
+
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
| 249 |
+
peerDependencies:
|
| 250 |
+
'@types/node': '>=18'
|
| 251 |
+
peerDependenciesMeta:
|
| 252 |
+
'@types/node':
|
| 253 |
+
optional: true
|
| 254 |
+
|
| 255 |
+
'@inquirer/external-editor@2.0.1':
|
| 256 |
+
resolution: {integrity: sha512-BPYWJXCAK9w6R+pb2s3WyxUz9ts9SP/LDOUwA9fu7LeuyYgojz83i0DSRwezu736BgMwz14G63Xwj70hSzHohQ==}
|
| 257 |
+
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
| 258 |
+
peerDependencies:
|
| 259 |
+
'@types/node': '>=18'
|
| 260 |
+
peerDependenciesMeta:
|
| 261 |
+
'@types/node':
|
| 262 |
+
optional: true
|
| 263 |
+
|
| 264 |
+
'@inquirer/figures@2.0.1':
|
| 265 |
+
resolution: {integrity: sha512-KtMxyjLCuDFqAWHmCY9qMtsZ09HnjMsm8H3OvpSIpfhHdfw3/AiGWHNrfRwbyvHPtOJpumm8wGn5fkhtvkWRsg==}
|
| 266 |
+
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
| 267 |
+
|
| 268 |
+
'@inquirer/input@5.0.1':
|
| 269 |
+
resolution: {integrity: sha512-cEhEUohCpE2BCuLKtFFZGp4Ief05SEcqeAOq9NxzN5ThOQP8Rl5N/Nt9VEDORK1bRb2Sk/zoOyQYfysPQwyQtA==}
|
| 270 |
+
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
| 271 |
+
peerDependencies:
|
| 272 |
+
'@types/node': '>=18'
|
| 273 |
+
peerDependenciesMeta:
|
| 274 |
+
'@types/node':
|
| 275 |
+
optional: true
|
| 276 |
+
|
| 277 |
+
'@inquirer/number@4.0.1':
|
| 278 |
+
resolution: {integrity: sha512-4//zgBGHe8Q/FfCoUXZUrUHyK/q5dyqiwsePz3oSSPSmw1Ijo35ZkjaftnxroygcUlLYfXqm+0q08lnB5hd49A==}
|
| 279 |
+
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
| 280 |
+
peerDependencies:
|
| 281 |
+
'@types/node': '>=18'
|
| 282 |
+
peerDependenciesMeta:
|
| 283 |
+
'@types/node':
|
| 284 |
+
optional: true
|
| 285 |
+
|
| 286 |
+
'@inquirer/password@5.0.1':
|
| 287 |
+
resolution: {integrity: sha512-UJudHpd7Ia30Q+x+ctYqI9Nh6SyEkaBscpa7J6Ts38oc1CNSws0I1hJEdxbQBlxQd65z5GEJPM4EtNf6tzfWaQ==}
|
| 288 |
+
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
| 289 |
+
peerDependencies:
|
| 290 |
+
'@types/node': '>=18'
|
| 291 |
+
peerDependenciesMeta:
|
| 292 |
+
'@types/node':
|
| 293 |
+
optional: true
|
| 294 |
+
|
| 295 |
+
'@inquirer/prompts@8.0.1':
|
| 296 |
+
resolution: {integrity: sha512-MURRu/cyvLm9vchDDaVZ9u4p+ADnY0Mz3LQr0KTgihrrvuKZlqcWwlBC4lkOMvd0KKX4Wz7Ww9+uA7qEpQaqjg==}
|
| 297 |
+
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
| 298 |
+
peerDependencies:
|
| 299 |
+
'@types/node': '>=18'
|
| 300 |
+
peerDependenciesMeta:
|
| 301 |
+
'@types/node':
|
| 302 |
+
optional: true
|
| 303 |
+
|
| 304 |
+
'@inquirer/rawlist@5.0.1':
|
| 305 |
+
resolution: {integrity: sha512-vVfVHKUgH6rZmMlyd0jOuGZo0Fw1jfcOqZF96lMwlgavx7g0x7MICe316bV01EEoI+c68vMdbkTTawuw3O+Fgw==}
|
| 306 |
+
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
| 307 |
+
peerDependencies:
|
| 308 |
+
'@types/node': '>=18'
|
| 309 |
+
peerDependenciesMeta:
|
| 310 |
+
'@types/node':
|
| 311 |
+
optional: true
|
| 312 |
+
|
| 313 |
+
'@inquirer/search@4.0.1':
|
| 314 |
+
resolution: {integrity: sha512-XwiaK5xBvr31STX6Ji8iS3HCRysBXfL/jUbTzufdWTS6LTGtvDQA50oVETt1BJgjKyQBp9vt0VU6AmU/AnOaGA==}
|
| 315 |
+
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
| 316 |
+
peerDependencies:
|
| 317 |
+
'@types/node': '>=18'
|
| 318 |
+
peerDependenciesMeta:
|
| 319 |
+
'@types/node':
|
| 320 |
+
optional: true
|
| 321 |
+
|
| 322 |
+
'@inquirer/select@5.0.1':
|
| 323 |
+
resolution: {integrity: sha512-gPByrgYoezGyKMq5KjV7Tuy1JU2ArIy6/sI8sprw0OpXope3VGQwP5FK1KD4eFFqEhKu470Dwe6/AyDPmGRA0Q==}
|
| 324 |
+
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
| 325 |
+
peerDependencies:
|
| 326 |
+
'@types/node': '>=18'
|
| 327 |
+
peerDependenciesMeta:
|
| 328 |
+
'@types/node':
|
| 329 |
+
optional: true
|
| 330 |
+
|
| 331 |
+
'@inquirer/type@4.0.1':
|
| 332 |
+
resolution: {integrity: sha512-odO8YwoQAw/eVu/PSPsDDVPmqO77r/Mq7zcoF5VduVqIu2wSRWUgmYb5K9WH1no0SjLnOe8MDKtDL++z6mfo2g==}
|
| 333 |
+
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
| 334 |
+
peerDependencies:
|
| 335 |
+
'@types/node': '>=18'
|
| 336 |
+
peerDependenciesMeta:
|
| 337 |
+
'@types/node':
|
| 338 |
+
optional: true
|
| 339 |
+
|
| 340 |
+
'@isaacs/balanced-match@4.0.1':
|
| 341 |
+
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
|
| 342 |
+
engines: {node: 20 || >=22}
|
| 343 |
+
|
| 344 |
+
'@isaacs/brace-expansion@5.0.0':
|
| 345 |
+
resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==}
|
| 346 |
+
engines: {node: 20 || >=22}
|
| 347 |
+
|
| 348 |
+
'@keyv/serialize@1.1.1':
|
| 349 |
+
resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
|
| 350 |
+
|
| 351 |
+
'@playwright/test@1.57.0':
|
| 352 |
+
resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==}
|
| 353 |
+
engines: {node: '>=18'}
|
| 354 |
+
hasBin: true
|
| 355 |
+
|
| 356 |
+
'@sec-ant/readable-stream@0.4.1':
|
| 357 |
+
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
|
| 358 |
+
|
| 359 |
+
'@sindresorhus/is@4.6.0':
|
| 360 |
+
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
| 361 |
+
engines: {node: '>=10'}
|
| 362 |
+
|
| 363 |
+
'@sindresorhus/is@5.6.0':
|
| 364 |
+
resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==}
|
| 365 |
+
engines: {node: '>=14.16'}
|
| 366 |
+
|
| 367 |
+
'@sindresorhus/is@7.1.1':
|
| 368 |
+
resolution: {integrity: sha512-rO92VvpgMc3kfiTjGT52LEtJ8Yc5kCWhZjLQ3LwlA4pSgPpQO7bVpYXParOD8Jwf+cVQECJo3yP/4I8aZtUQTQ==}
|
| 369 |
+
engines: {node: '>=18'}
|
| 370 |
+
|
| 371 |
+
'@types/bezier-js@4.1.3':
|
| 372 |
+
resolution: {integrity: sha512-FNVVCu5mx/rJCWBxLTcL7oOajmGtWtBTDjq6DSUWUI12GeePivrZZXz+UgE0D6VYsLEjvExRO03z4hVtu3pTEQ==}
|
| 373 |
+
|
| 374 |
+
'@types/http-cache-semantics@4.0.4':
|
| 375 |
+
resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==}
|
| 376 |
+
|
| 377 |
+
'@types/node@24.10.1':
|
| 378 |
+
resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==}
|
| 379 |
+
|
| 380 |
+
adm-zip@0.5.16:
|
| 381 |
+
resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==}
|
| 382 |
+
engines: {node: '>=12.0'}
|
| 383 |
+
|
| 384 |
+
agent-base@7.1.4:
|
| 385 |
+
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
| 386 |
+
engines: {node: '>= 14'}
|
| 387 |
+
|
| 388 |
+
ansi-regex@6.2.2:
|
| 389 |
+
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
|
| 390 |
+
engines: {node: '>=12'}
|
| 391 |
+
|
| 392 |
+
ansi-styles@6.2.3:
|
| 393 |
+
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
|
| 394 |
+
engines: {node: '>=12'}
|
| 395 |
+
|
| 396 |
+
available-typed-arrays@1.0.7:
|
| 397 |
+
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
| 398 |
+
engines: {node: '>= 0.4'}
|
| 399 |
+
|
| 400 |
+
base64-js@1.5.1:
|
| 401 |
+
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
| 402 |
+
|
| 403 |
+
baseline-browser-mapping@2.8.30:
|
| 404 |
+
resolution: {integrity: sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==}
|
| 405 |
+
hasBin: true
|
| 406 |
+
|
| 407 |
+
better-sqlite3@12.5.0:
|
| 408 |
+
resolution: {integrity: sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==}
|
| 409 |
+
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x}
|
| 410 |
+
|
| 411 |
+
bezier-js@6.1.4:
|
| 412 |
+
resolution: {integrity: sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==}
|
| 413 |
+
|
| 414 |
+
bindings@1.5.0:
|
| 415 |
+
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
|
| 416 |
+
|
| 417 |
+
bl@1.2.3:
|
| 418 |
+
resolution: {integrity: sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==}
|
| 419 |
+
|
| 420 |
+
bl@4.1.0:
|
| 421 |
+
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
| 422 |
+
|
| 423 |
+
browserslist@4.28.0:
|
| 424 |
+
resolution: {integrity: sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==}
|
| 425 |
+
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
| 426 |
+
hasBin: true
|
| 427 |
+
|
| 428 |
+
buffer-alloc-unsafe@1.1.0:
|
| 429 |
+
resolution: {integrity: sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==}
|
| 430 |
+
|
| 431 |
+
buffer-alloc@1.2.0:
|
| 432 |
+
resolution: {integrity: sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==}
|
| 433 |
+
|
| 434 |
+
buffer-crc32@0.2.13:
|
| 435 |
+
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
|
| 436 |
+
|
| 437 |
+
buffer-fill@1.0.0:
|
| 438 |
+
resolution: {integrity: sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==}
|
| 439 |
+
|
| 440 |
+
buffer@5.7.1:
|
| 441 |
+
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
| 442 |
+
|
| 443 |
+
byte-counter@0.1.0:
|
| 444 |
+
resolution: {integrity: sha512-jheRLVMeUKrDBjVw2O5+k4EvR4t9wtxHL+bo/LxfkxsVeuGMy3a5SEGgXdAFA4FSzTrU8rQXQIrsZ3oBq5a0pQ==}
|
| 445 |
+
engines: {node: '>=20'}
|
| 446 |
+
|
| 447 |
+
cacheable-lookup@7.0.0:
|
| 448 |
+
resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==}
|
| 449 |
+
engines: {node: '>=14.16'}
|
| 450 |
+
|
| 451 |
+
cacheable-request@13.0.15:
|
| 452 |
+
resolution: {integrity: sha512-NjiSrjv37X73FmGGU5ec/M83vWQ6q1Ae3BFe+ABfdeeMy4LOMKYTpfEjrBnLedu43clKZtsYbKrHTIQE7vKq+A==}
|
| 453 |
+
engines: {node: '>=18'}
|
| 454 |
+
|
| 455 |
+
call-bind-apply-helpers@1.0.2:
|
| 456 |
+
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
| 457 |
+
engines: {node: '>= 0.4'}
|
| 458 |
+
|
| 459 |
+
call-bind@1.0.8:
|
| 460 |
+
resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==}
|
| 461 |
+
engines: {node: '>= 0.4'}
|
| 462 |
+
|
| 463 |
+
call-bound@1.0.4:
|
| 464 |
+
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
|
| 465 |
+
engines: {node: '>= 0.4'}
|
| 466 |
+
|
| 467 |
+
callsites@3.1.0:
|
| 468 |
+
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
| 469 |
+
engines: {node: '>=6'}
|
| 470 |
+
|
| 471 |
+
callsites@4.2.0:
|
| 472 |
+
resolution: {integrity: sha512-kfzR4zzQtAE9PC7CzZsjl3aBNbXWuXiSeOCdLcPpBfGW8YuCqQHcRPFDbr/BPVmd3EEPVpuFzLyuT/cUhPr4OQ==}
|
| 473 |
+
engines: {node: '>=12.20'}
|
| 474 |
+
|
| 475 |
+
camoufox-js@0.8.3:
|
| 476 |
+
resolution: {integrity: sha512-oHFx/xWRfdDujZUsFszlWioK4hY5KNBMcIkw86S9eIQD0pLBWvvVd8FfzfuqIG0VAKdZqxGmF1m1ARsace2L1Q==}
|
| 477 |
+
engines: {node: '>= 20'}
|
| 478 |
+
hasBin: true
|
| 479 |
+
peerDependencies:
|
| 480 |
+
playwright-core: '*'
|
| 481 |
+
|
| 482 |
+
caniuse-lite@1.0.30001756:
|
| 483 |
+
resolution: {integrity: sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==}
|
| 484 |
+
|
| 485 |
+
chardet@2.1.1:
|
| 486 |
+
resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==}
|
| 487 |
+
|
| 488 |
+
chownr@1.1.4:
|
| 489 |
+
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
|
| 490 |
+
|
| 491 |
+
cli-width@4.1.0:
|
| 492 |
+
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
|
| 493 |
+
engines: {node: '>= 12'}
|
| 494 |
+
|
| 495 |
+
commander@14.0.2:
|
| 496 |
+
resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==}
|
| 497 |
+
engines: {node: '>=20'}
|
| 498 |
+
|
| 499 |
+
compressing@2.0.0:
|
| 500 |
+
resolution: {integrity: sha512-hRG5wpuy/lkO/oO8AEhSmLw2FVJOs2DnFPtmm0XUVWoDP6k3HAw5RVgyzbbATl0ytjJDCY03DvRiyjHkSHc1Dg==}
|
| 501 |
+
engines: {node: '>= 18.0.0'}
|
| 502 |
+
|
| 503 |
+
core-util-is@1.0.3:
|
| 504 |
+
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
| 505 |
+
|
| 506 |
+
debug@4.4.3:
|
| 507 |
+
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
| 508 |
+
engines: {node: '>=6.0'}
|
| 509 |
+
peerDependencies:
|
| 510 |
+
supports-color: '*'
|
| 511 |
+
peerDependenciesMeta:
|
| 512 |
+
supports-color:
|
| 513 |
+
optional: true
|
| 514 |
+
|
| 515 |
+
decompress-response@10.0.0:
|
| 516 |
+
resolution: {integrity: sha512-oj7KWToJuuxlPr7VV0vabvxEIiqNMo+q0NueIiL3XhtwC6FVOX7Hr1c0C4eD0bmf7Zr+S/dSf2xvkH3Ad6sU3Q==}
|
| 517 |
+
engines: {node: '>=20'}
|
| 518 |
+
|
| 519 |
+
decompress-response@6.0.0:
|
| 520 |
+
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
|
| 521 |
+
engines: {node: '>=10'}
|
| 522 |
+
|
| 523 |
+
deep-extend@0.6.0:
|
| 524 |
+
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
|
| 525 |
+
engines: {node: '>=4.0.0'}
|
| 526 |
+
|
| 527 |
+
define-data-property@1.1.4:
|
| 528 |
+
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
|
| 529 |
+
engines: {node: '>= 0.4'}
|
| 530 |
+
|
| 531 |
+
detect-europe-js@0.1.2:
|
| 532 |
+
resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==}
|
| 533 |
+
|
| 534 |
+
detect-libc@2.1.2:
|
| 535 |
+
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
| 536 |
+
engines: {node: '>=8'}
|
| 537 |
+
|
| 538 |
+
dot-prop@6.0.1:
|
| 539 |
+
resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==}
|
| 540 |
+
engines: {node: '>=10'}
|
| 541 |
+
|
| 542 |
+
dot-prop@7.2.0:
|
| 543 |
+
resolution: {integrity: sha512-Ol/IPXUARn9CSbkrdV4VJo7uCy1I3VuSiWCaFSg+8BdUOzF9n3jefIpcgAydvUZbTdEBZs2vEiTiS9m61ssiDA==}
|
| 544 |
+
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
| 545 |
+
|
| 546 |
+
dunder-proto@1.0.1:
|
| 547 |
+
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
| 548 |
+
engines: {node: '>= 0.4'}
|
| 549 |
+
|
| 550 |
+
electron-to-chromium@1.5.259:
|
| 551 |
+
resolution: {integrity: sha512-I+oLXgpEJzD6Cwuwt1gYjxsDmu/S/Kd41mmLA3O+/uH2pFRO/DvOjUyGozL8j3KeLV6WyZ7ssPwELMsXCcsJAQ==}
|
| 552 |
+
|
| 553 |
+
emoji-regex@10.6.0:
|
| 554 |
+
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
| 555 |
+
|
| 556 |
+
end-of-stream@1.4.5:
|
| 557 |
+
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
|
| 558 |
+
|
| 559 |
+
es-define-property@1.0.1:
|
| 560 |
+
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
| 561 |
+
engines: {node: '>= 0.4'}
|
| 562 |
+
|
| 563 |
+
es-errors@1.3.0:
|
| 564 |
+
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
| 565 |
+
engines: {node: '>= 0.4'}
|
| 566 |
+
|
| 567 |
+
es-object-atoms@1.1.1:
|
| 568 |
+
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
| 569 |
+
engines: {node: '>= 0.4'}
|
| 570 |
+
|
| 571 |
+
escalade@3.2.0:
|
| 572 |
+
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
| 573 |
+
engines: {node: '>=6'}
|
| 574 |
+
|
| 575 |
+
expand-template@2.0.3:
|
| 576 |
+
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
|
| 577 |
+
engines: {node: '>=6'}
|
| 578 |
+
|
| 579 |
+
fd-slicer2@1.2.0:
|
| 580 |
+
resolution: {integrity: sha512-3lBUNUckhMZduCc4g+Pw4Ve16LD9vpX9b8qUkkKq2mgDRLYWzblszZH2luADnJqjJe+cypngjCuKRm/IW12rRw==}
|
| 581 |
+
|
| 582 |
+
file-uri-to-path@1.0.0:
|
| 583 |
+
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
|
| 584 |
+
|
| 585 |
+
fingerprint-generator@2.1.78:
|
| 586 |
+
resolution: {integrity: sha512-IsmMGYXSmh4F70ltWXMHjd454GKBYHdLm/QjCn0gWhMqkotLVhgbMHxtRq+l1IYIapteC+HZEVDAYSzj2Gs+4Q==}
|
| 587 |
+
engines: {node: '>=16.0.0'}
|
| 588 |
+
|
| 589 |
+
flushwritable@1.0.0:
|
| 590 |
+
resolution: {integrity: sha512-3VELfuWCLVzt5d2Gblk8qcqFro6nuwvxwMzHaENVDHI7rxcBRtMCwTk/E9FXcgh+82DSpavPNDueA9+RxXJoFg==}
|
| 591 |
+
|
| 592 |
+
for-each@0.3.5:
|
| 593 |
+
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
| 594 |
+
engines: {node: '>= 0.4'}
|
| 595 |
+
|
| 596 |
+
form-data-encoder@4.1.0:
|
| 597 |
+
resolution: {integrity: sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==}
|
| 598 |
+
engines: {node: '>= 18'}
|
| 599 |
+
|
| 600 |
+
fs-constants@1.0.0:
|
| 601 |
+
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
|
| 602 |
+
|
| 603 |
+
fsevents@2.3.2:
|
| 604 |
+
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
| 605 |
+
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
| 606 |
+
os: [darwin]
|
| 607 |
+
|
| 608 |
+
function-bind@1.1.2:
|
| 609 |
+
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
| 610 |
+
|
| 611 |
+
generative-bayesian-network@2.1.77:
|
| 612 |
+
resolution: {integrity: sha512-viU4CRPsmgiklR94LhvdMndaY73BkCH1pGjmOjWbLR/ZwcUd06gKF3TCcsS3npRl74o33YSInSixxm16wIukcA==}
|
| 613 |
+
|
| 614 |
+
generative-bayesian-network@2.1.78:
|
| 615 |
+
resolution: {integrity: sha512-c9EFbWfqkWEy4Rux23PI+QEn768VQq/au83OAst4XLs+qOR3YjmN3Jx0faMuOy1VnSS10hh49+1mOAzDiUPKbQ==}
|
| 616 |
+
|
| 617 |
+
get-east-asian-width@1.4.0:
|
| 618 |
+
resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==}
|
| 619 |
+
engines: {node: '>=18'}
|
| 620 |
+
|
| 621 |
+
get-intrinsic@1.3.0:
|
| 622 |
+
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
| 623 |
+
engines: {node: '>= 0.4'}
|
| 624 |
+
|
| 625 |
+
get-proto@1.0.1:
|
| 626 |
+
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
| 627 |
+
engines: {node: '>= 0.4'}
|
| 628 |
+
|
| 629 |
+
get-ready@1.0.0:
|
| 630 |
+
resolution: {integrity: sha512-mFXCZPJIlcYcth+N8267+mghfYN9h3EhsDa6JSnbA3Wrhh/XFpuowviFcsDeYZtKspQyWyJqfs4O6P8CHeTwzw==}
|
| 631 |
+
|
| 632 |
+
get-stream@9.0.1:
|
| 633 |
+
resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
|
| 634 |
+
engines: {node: '>=18'}
|
| 635 |
+
|
| 636 |
+
ghost-cursor-playwright-port@1.4.3:
|
| 637 |
+
resolution: {integrity: sha512-+h1skiBy3S6ItlrW91JgmexcS9qH8Vhk9XBkC20RW+jTjfiZvg0RWt73iy4AzJhUWZCy7pZWZqSFNBK4bdIahw==}
|
| 638 |
+
peerDependencies:
|
| 639 |
+
'@playwright/test': ^1.54.0
|
| 640 |
+
playwright: ^1.54.0
|
| 641 |
+
playwright-core: ^1.54.0
|
| 642 |
+
|
| 643 |
+
github-from-package@0.0.0:
|
| 644 |
+
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
|
| 645 |
+
|
| 646 |
+
glob@13.0.0:
|
| 647 |
+
resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==}
|
| 648 |
+
engines: {node: 20 || >=22}
|
| 649 |
+
|
| 650 |
+
gopd@1.2.0:
|
| 651 |
+
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
| 652 |
+
engines: {node: '>= 0.4'}
|
| 653 |
+
|
| 654 |
+
got-scraping@4.1.2:
|
| 655 |
+
resolution: {integrity: sha512-LtVwPM5YLnNY7HVT/AK/yDBUg/4yOZSlAjjug2ovrHQseS43QCmO1XosKKXcXrfc6OMX8OnDbAWIauFMcaJ5TQ==}
|
| 656 |
+
engines: {node: '>=16'}
|
| 657 |
+
|
| 658 |
+
got@14.6.4:
|
| 659 |
+
resolution: {integrity: sha512-DjsLab39NUMf5iYlK9asVCkHMhaA2hEhrlmf+qXRhjEivuuBHWYbjmty9DA3OORUwZgENTB+6vSmY2ZW8gFHVw==}
|
| 660 |
+
engines: {node: '>=20'}
|
| 661 |
+
|
| 662 |
+
has-property-descriptors@1.0.2:
|
| 663 |
+
resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
|
| 664 |
+
|
| 665 |
+
has-symbols@1.1.0:
|
| 666 |
+
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
| 667 |
+
engines: {node: '>= 0.4'}
|
| 668 |
+
|
| 669 |
+
has-tostringtag@1.0.2:
|
| 670 |
+
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
|
| 671 |
+
engines: {node: '>= 0.4'}
|
| 672 |
+
|
| 673 |
+
hasown@2.0.2:
|
| 674 |
+
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
| 675 |
+
engines: {node: '>= 0.4'}
|
| 676 |
+
|
| 677 |
+
header-generator@2.1.77:
|
| 678 |
+
resolution: {integrity: sha512-ggSG/mfkFMu8CO7xP591G8kp1IJCBvgXu7M8oxTjC9u914JsIzE6zIfoFsXzA+pf0utWJhUsdqU0oV/DtQ4DFQ==}
|
| 679 |
+
engines: {node: '>=16.0.0'}
|
| 680 |
+
|
| 681 |
+
header-generator@2.1.78:
|
| 682 |
+
resolution: {integrity: sha512-SB3tPpdYPh1wOAj/st8GGezIxUNx4COvKfWCZX87ehcnoQRJVZMNzhY93CxG4U8mP0UxrtUm1bmVT4mQ0oXdjA==}
|
| 683 |
+
engines: {node: '>=16.0.0'}
|
| 684 |
+
|
| 685 |
+
http-cache-semantics@4.2.0:
|
| 686 |
+
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
| 687 |
+
|
| 688 |
+
http2-wrapper@2.2.1:
|
| 689 |
+
resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==}
|
| 690 |
+
engines: {node: '>=10.19.0'}
|
| 691 |
+
|
| 692 |
+
iconv-lite@0.5.2:
|
| 693 |
+
resolution: {integrity: sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==}
|
| 694 |
+
engines: {node: '>=0.10.0'}
|
| 695 |
+
|
| 696 |
+
iconv-lite@0.7.0:
|
| 697 |
+
resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==}
|
| 698 |
+
engines: {node: '>=0.10.0'}
|
| 699 |
+
|
| 700 |
+
ieee754@1.2.1:
|
| 701 |
+
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
| 702 |
+
|
| 703 |
+
impit-darwin-arm64@0.7.1:
|
| 704 |
+
resolution: {integrity: sha512-3TqFaC+hYqC/2GnSV4qKxNak4fbMJwURkybJ2TLH1NZNWypkIFL1/Qg7NLmtcOn17D7Kfwmvt2Wcmscsmhf1uA==}
|
| 705 |
+
engines: {node: '>= 10'}
|
| 706 |
+
cpu: [arm64]
|
| 707 |
+
os: [darwin]
|
| 708 |
+
|
| 709 |
+
impit-darwin-x64@0.7.1:
|
| 710 |
+
resolution: {integrity: sha512-TYQKEV3xIN3t745yPhmEq0zPILBtSho8RcPxzL3CRwDThzgNvCfES/Zj3wvZbwiooZqXy61wscDKDYIkUKnM+A==}
|
| 711 |
+
engines: {node: '>= 10'}
|
| 712 |
+
cpu: [x64]
|
| 713 |
+
os: [darwin]
|
| 714 |
+
|
| 715 |
+
impit-linux-arm64-gnu@0.7.1:
|
| 716 |
+
resolution: {integrity: sha512-h2w42FzF7hBxNL7rRTUe/KapHeXkcUPF4vqtWLFdTa3WgEXl8RriY48JFcx2g1jqO5eLo+sepeD3asYG+YLkaw==}
|
| 717 |
+
engines: {node: '>= 10'}
|
| 718 |
+
cpu: [arm64]
|
| 719 |
+
os: [linux]
|
| 720 |
+
libc: [glibc]
|
| 721 |
+
|
| 722 |
+
impit-linux-arm64-musl@0.7.1:
|
| 723 |
+
resolution: {integrity: sha512-CkCdGnEK0AQjYBdSsNWkLw5yqT7RDpJoImekBidtRDTiSY/O5+5saBFi4K8YbElJkHeh/6F3heriLzRahiXQOA==}
|
| 724 |
+
engines: {node: '>= 10'}
|
| 725 |
+
cpu: [arm64]
|
| 726 |
+
os: [linux]
|
| 727 |
+
libc: [musl]
|
| 728 |
+
|
| 729 |
+
impit-linux-x64-gnu@0.7.1:
|
| 730 |
+
resolution: {integrity: sha512-vmTifxxCp7FPh82t1EqbVnWFpXIS6k9Ls5QKuf7FTzejj6YFNfsvPxxGsNpsTZB3C9V7zb5kIr0kTVIcEJbU/A==}
|
| 731 |
+
engines: {node: '>= 10'}
|
| 732 |
+
cpu: [x64]
|
| 733 |
+
os: [linux]
|
| 734 |
+
libc: [glibc]
|
| 735 |
+
|
| 736 |
+
impit-linux-x64-musl@0.7.1:
|
| 737 |
+
resolution: {integrity: sha512-1wgAfOgykI3SBNbVaW+KM88Fxpew5viLZBG5YQhDfALAADqty4Sk4fPECUEfOYtPzZRi4ikE9Rs0XHKWhoGgYA==}
|
| 738 |
+
engines: {node: '>= 10'}
|
| 739 |
+
cpu: [x64]
|
| 740 |
+
os: [linux]
|
| 741 |
+
libc: [musl]
|
| 742 |
+
|
| 743 |
+
impit-win32-arm64-msvc@0.7.1:
|
| 744 |
+
resolution: {integrity: sha512-6GadQitHSJfESMKnAsny8Wuv3QWCYWrIFRHOEpol59PVGTPTQI9+eu2QREqUVufgf7YEzAB2VKXyPQp4tFbCnQ==}
|
| 745 |
+
engines: {node: '>= 10'}
|
| 746 |
+
cpu: [arm64]
|
| 747 |
+
os: [win32]
|
| 748 |
+
|
| 749 |
+
impit-win32-x64-msvc@0.7.1:
|
| 750 |
+
resolution: {integrity: sha512-vl5w/7lSMVICTiJY3jPJfmj1DEfr2xPFHGJqYZj0xUVOrzocpCIN0u+JVzt2Azizjn4YC2g1GIi+uPZeil0p3Q==}
|
| 751 |
+
engines: {node: '>= 10'}
|
| 752 |
+
cpu: [x64]
|
| 753 |
+
os: [win32]
|
| 754 |
+
|
| 755 |
+
impit@0.7.1:
|
| 756 |
+
resolution: {integrity: sha512-fdnrZZFToKJGQCP9kIt+BQsk8WTTgb4MZE8FM01047U/GCcSu6b/MljAyyPFNb7N3sY2Kcl9unP2DT+/asFPNw==}
|
| 757 |
+
engines: {node: '>= 20'}
|
| 758 |
+
|
| 759 |
+
inherits@2.0.4:
|
| 760 |
+
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
| 761 |
+
|
| 762 |
+
ini@1.3.8:
|
| 763 |
+
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
|
| 764 |
+
|
| 765 |
+
ip-address@10.1.0:
|
| 766 |
+
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
|
| 767 |
+
engines: {node: '>= 12'}
|
| 768 |
+
|
| 769 |
+
is-callable@1.2.7:
|
| 770 |
+
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
|
| 771 |
+
engines: {node: '>= 0.4'}
|
| 772 |
+
|
| 773 |
+
is-obj@2.0.0:
|
| 774 |
+
resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==}
|
| 775 |
+
engines: {node: '>=8'}
|
| 776 |
+
|
| 777 |
+
is-standalone-pwa@0.1.1:
|
| 778 |
+
resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==}
|
| 779 |
+
|
| 780 |
+
is-stream@4.0.1:
|
| 781 |
+
resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
|
| 782 |
+
engines: {node: '>=18'}
|
| 783 |
+
|
| 784 |
+
is-typed-array@1.1.15:
|
| 785 |
+
resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
|
| 786 |
+
engines: {node: '>= 0.4'}
|
| 787 |
+
|
| 788 |
+
isarray@1.0.0:
|
| 789 |
+
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
| 790 |
+
|
| 791 |
+
isarray@2.0.5:
|
| 792 |
+
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
|
| 793 |
+
|
| 794 |
+
keyv@5.5.4:
|
| 795 |
+
resolution: {integrity: sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==}
|
| 796 |
+
|
| 797 |
+
language-subtag-registry@0.3.23:
|
| 798 |
+
resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
|
| 799 |
+
|
| 800 |
+
language-tags@2.1.0:
|
| 801 |
+
resolution: {integrity: sha512-D4CgpyCt+61f6z2jHjJS1OmZPviAWM57iJ9OKdFFWSNgS7Udj9QVWqyGs/cveVNF57XpZmhSvMdVIV5mjLA7Vg==}
|
| 802 |
+
engines: {node: '>=22'}
|
| 803 |
+
|
| 804 |
+
lodash.isequal@4.5.0:
|
| 805 |
+
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
| 806 |
+
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
|
| 807 |
+
|
| 808 |
+
lowercase-keys@3.0.0:
|
| 809 |
+
resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==}
|
| 810 |
+
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
| 811 |
+
|
| 812 |
+
lru-cache@11.2.2:
|
| 813 |
+
resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==}
|
| 814 |
+
engines: {node: 20 || >=22}
|
| 815 |
+
|
| 816 |
+
math-intrinsics@1.1.0:
|
| 817 |
+
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
| 818 |
+
engines: {node: '>= 0.4'}
|
| 819 |
+
|
| 820 |
+
maxmind@5.0.1:
|
| 821 |
+
resolution: {integrity: sha512-hYxQxvHkBUlyF34f7IlQOb60rytezCi2oZ8H/BtZpcoodXTlcK1eLgf7kY2TofHqBC3o+Hqtvde9kS72gFQSDw==}
|
| 822 |
+
engines: {node: '>=12', npm: '>=6'}
|
| 823 |
+
|
| 824 |
+
mimic-response@3.1.0:
|
| 825 |
+
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
|
| 826 |
+
engines: {node: '>=10'}
|
| 827 |
+
|
| 828 |
+
mimic-response@4.0.0:
|
| 829 |
+
resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==}
|
| 830 |
+
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
| 831 |
+
|
| 832 |
+
minimatch@10.1.1:
|
| 833 |
+
resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==}
|
| 834 |
+
engines: {node: 20 || >=22}
|
| 835 |
+
|
| 836 |
+
minimist@1.2.8:
|
| 837 |
+
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
| 838 |
+
|
| 839 |
+
minipass@7.1.2:
|
| 840 |
+
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
|
| 841 |
+
engines: {node: '>=16 || 14 >=14.17'}
|
| 842 |
+
|
| 843 |
+
mkdirp-classic@0.5.3:
|
| 844 |
+
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
|
| 845 |
+
|
| 846 |
+
mmdb-lib@3.0.1:
|
| 847 |
+
resolution: {integrity: sha512-dyAyMR+cRykZd1mw5altC9f4vKpCsuywPwo8l/L5fKqDay2zmqT0mF/BvUoXnQiqGn+nceO914rkPKJoyFnGxA==}
|
| 848 |
+
engines: {node: '>=10', npm: '>=6'}
|
| 849 |
+
|
| 850 |
+
ms@2.1.3:
|
| 851 |
+
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
| 852 |
+
|
| 853 |
+
mute-stream@3.0.0:
|
| 854 |
+
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
|
| 855 |
+
engines: {node: ^20.17.0 || >=22.9.0}
|
| 856 |
+
|
| 857 |
+
napi-build-utils@2.0.0:
|
| 858 |
+
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
|
| 859 |
+
|
| 860 |
+
node-abi@3.85.0:
|
| 861 |
+
resolution: {integrity: sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==}
|
| 862 |
+
engines: {node: '>=10'}
|
| 863 |
+
|
| 864 |
+
node-releases@2.0.27:
|
| 865 |
+
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
| 866 |
+
|
| 867 |
+
normalize-url@8.1.0:
|
| 868 |
+
resolution: {integrity: sha512-X06Mfd/5aKsRHc0O0J5CUedwnPmnDtLF2+nq+KN9KSDlJHkPuh0JUviWjEWMe0SW/9TDdSLVPuk7L5gGTIA1/w==}
|
| 869 |
+
engines: {node: '>=14.16'}
|
| 870 |
+
|
| 871 |
+
once@1.4.0:
|
| 872 |
+
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
| 873 |
+
|
| 874 |
+
ow@0.28.2:
|
| 875 |
+
resolution: {integrity: sha512-dD4UpyBh/9m4X2NVjA+73/ZPBRF+uF4zIMFvvQsabMiEK8x41L3rQ8EENOi35kyyoaJwNxEeJcP6Fj1H4U409Q==}
|
| 876 |
+
engines: {node: '>=12'}
|
| 877 |
+
|
| 878 |
+
ow@1.1.1:
|
| 879 |
+
resolution: {integrity: sha512-sJBRCbS5vh1Jp9EOgwp1Ws3c16lJrUkJYlvWTYC03oyiYVwS/ns7lKRWow4w4XjDyTrA2pplQv4B2naWSR6yDA==}
|
| 880 |
+
engines: {node: '>=14.16'}
|
| 881 |
+
|
| 882 |
+
p-cancelable@4.0.1:
|
| 883 |
+
resolution: {integrity: sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg==}
|
| 884 |
+
engines: {node: '>=14.16'}
|
| 885 |
+
|
| 886 |
+
path-scurry@2.0.1:
|
| 887 |
+
resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==}
|
| 888 |
+
engines: {node: 20 || >=22}
|
| 889 |
+
|
| 890 |
+
pend@1.2.0:
|
| 891 |
+
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
|
| 892 |
+
|
| 893 |
+
picocolors@1.1.1:
|
| 894 |
+
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
| 895 |
+
|
| 896 |
+
playwright-core@1.57.0:
|
| 897 |
+
resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==}
|
| 898 |
+
engines: {node: '>=18'}
|
| 899 |
+
hasBin: true
|
| 900 |
+
|
| 901 |
+
playwright@1.57.0:
|
| 902 |
+
resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==}
|
| 903 |
+
engines: {node: '>=18'}
|
| 904 |
+
hasBin: true
|
| 905 |
+
|
| 906 |
+
possible-typed-array-names@1.1.0:
|
| 907 |
+
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
| 908 |
+
engines: {node: '>= 0.4'}
|
| 909 |
+
|
| 910 |
+
prebuild-install@7.1.3:
|
| 911 |
+
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
| 912 |
+
engines: {node: '>=10'}
|
| 913 |
+
hasBin: true
|
| 914 |
+
|
| 915 |
+
process-nextick-args@2.0.1:
|
| 916 |
+
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
| 917 |
+
|
| 918 |
+
progress@2.0.3:
|
| 919 |
+
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
| 920 |
+
engines: {node: '>=0.4.0'}
|
| 921 |
+
|
| 922 |
+
proxy-chain@2.6.0:
|
| 923 |
+
resolution: {integrity: sha512-+NpVKSk68j8sQJG2tBbFuJxMzKTlqeCXXFbqvlyiFhnmxdcYJSv4XZzUSIfwIUwR3D0T8fEJqrA4C7yykU40Pw==}
|
| 924 |
+
engines: {node: '>=14'}
|
| 925 |
+
|
| 926 |
+
pump@3.0.3:
|
| 927 |
+
resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
|
| 928 |
+
|
| 929 |
+
quick-lru@5.1.1:
|
| 930 |
+
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
|
| 931 |
+
engines: {node: '>=10'}
|
| 932 |
+
|
| 933 |
+
quick-lru@7.3.0:
|
| 934 |
+
resolution: {integrity: sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==}
|
| 935 |
+
engines: {node: '>=18'}
|
| 936 |
+
|
| 937 |
+
rc@1.2.8:
|
| 938 |
+
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
| 939 |
+
hasBin: true
|
| 940 |
+
|
| 941 |
+
readable-stream@2.3.8:
|
| 942 |
+
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
| 943 |
+
|
| 944 |
+
readable-stream@3.6.2:
|
| 945 |
+
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
| 946 |
+
engines: {node: '>= 6'}
|
| 947 |
+
|
| 948 |
+
resolve-alpn@1.2.1:
|
| 949 |
+
resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
|
| 950 |
+
|
| 951 |
+
responselike@4.0.2:
|
| 952 |
+
resolution: {integrity: sha512-cGk8IbWEAnaCpdAt1BHzJ3Ahz5ewDJa0KseTsE3qIRMJ3C698W8psM7byCeWVpd/Ha7FUYzuRVzXoKoM6nRUbA==}
|
| 953 |
+
engines: {node: '>=20'}
|
| 954 |
+
|
| 955 |
+
safe-buffer@5.1.2:
|
| 956 |
+
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
| 957 |
+
|
| 958 |
+
safe-buffer@5.2.1:
|
| 959 |
+
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
| 960 |
+
|
| 961 |
+
safer-buffer@2.1.2:
|
| 962 |
+
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
| 963 |
+
|
| 964 |
+
sax@1.4.3:
|
| 965 |
+
resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==}
|
| 966 |
+
|
| 967 |
+
semver@7.7.3:
|
| 968 |
+
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
|
| 969 |
+
engines: {node: '>=10'}
|
| 970 |
+
hasBin: true
|
| 971 |
+
|
| 972 |
+
set-function-length@1.2.2:
|
| 973 |
+
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
| 974 |
+
engines: {node: '>= 0.4'}
|
| 975 |
+
|
| 976 |
+
sharp@0.34.5:
|
| 977 |
+
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
| 978 |
+
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
| 979 |
+
|
| 980 |
+
signal-exit@4.1.0:
|
| 981 |
+
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
| 982 |
+
engines: {node: '>=14'}
|
| 983 |
+
|
| 984 |
+
simple-concat@1.0.1:
|
| 985 |
+
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
|
| 986 |
+
|
| 987 |
+
simple-get@4.0.1:
|
| 988 |
+
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
|
| 989 |
+
|
| 990 |
+
smart-buffer@4.2.0:
|
| 991 |
+
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
|
| 992 |
+
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
|
| 993 |
+
|
| 994 |
+
socks-proxy-agent@8.0.5:
|
| 995 |
+
resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==}
|
| 996 |
+
engines: {node: '>= 14'}
|
| 997 |
+
|
| 998 |
+
socks@2.8.7:
|
| 999 |
+
resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==}
|
| 1000 |
+
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
|
| 1001 |
+
|
| 1002 |
+
streamifier@0.1.1:
|
| 1003 |
+
resolution: {integrity: sha512-zDgl+muIlWzXNsXeyUfOk9dChMjlpkq0DRsxujtYPgyJ676yQ8jEm6zzaaWHFDg5BNcLuif0eD2MTyJdZqXpdg==}
|
| 1004 |
+
engines: {node: '>=0.10'}
|
| 1005 |
+
|
| 1006 |
+
string-width@7.2.0:
|
| 1007 |
+
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
|
| 1008 |
+
engines: {node: '>=18'}
|
| 1009 |
+
|
| 1010 |
+
string_decoder@1.1.1:
|
| 1011 |
+
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
| 1012 |
+
|
| 1013 |
+
string_decoder@1.3.0:
|
| 1014 |
+
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
| 1015 |
+
|
| 1016 |
+
strip-ansi@7.1.2:
|
| 1017 |
+
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
|
| 1018 |
+
engines: {node: '>=12'}
|
| 1019 |
+
|
| 1020 |
+
strip-json-comments@2.0.1:
|
| 1021 |
+
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
|
| 1022 |
+
engines: {node: '>=0.10.0'}
|
| 1023 |
+
|
| 1024 |
+
tar-fs@2.1.4:
|
| 1025 |
+
resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==}
|
| 1026 |
+
|
| 1027 |
+
tar-stream@1.6.2:
|
| 1028 |
+
resolution: {integrity: sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==}
|
| 1029 |
+
engines: {node: '>= 0.8.0'}
|
| 1030 |
+
|
| 1031 |
+
tar-stream@2.2.0:
|
| 1032 |
+
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
| 1033 |
+
engines: {node: '>=6'}
|
| 1034 |
+
|
| 1035 |
+
tiny-lru@11.4.5:
|
| 1036 |
+
resolution: {integrity: sha512-hkcz3FjNJfKXjV4mjQ1OrXSLAehg8Hw+cEZclOVT+5c/cWQWImQ9wolzTjth+dmmDe++p3bme3fTxz6Q4Etsqw==}
|
| 1037 |
+
engines: {node: '>=12'}
|
| 1038 |
+
|
| 1039 |
+
to-buffer@1.2.2:
|
| 1040 |
+
resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==}
|
| 1041 |
+
engines: {node: '>= 0.4'}
|
| 1042 |
+
|
| 1043 |
+
tslib@2.8.1:
|
| 1044 |
+
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
| 1045 |
+
|
| 1046 |
+
tunnel-agent@0.6.0:
|
| 1047 |
+
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
| 1048 |
+
|
| 1049 |
+
type-fest@2.19.0:
|
| 1050 |
+
resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==}
|
| 1051 |
+
engines: {node: '>=12.20'}
|
| 1052 |
+
|
| 1053 |
+
type-fest@4.41.0:
|
| 1054 |
+
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
|
| 1055 |
+
engines: {node: '>=16'}
|
| 1056 |
+
|
| 1057 |
+
typed-array-buffer@1.0.3:
|
| 1058 |
+
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
|
| 1059 |
+
engines: {node: '>= 0.4'}
|
| 1060 |
+
|
| 1061 |
+
ua-is-frozen@0.1.2:
|
| 1062 |
+
resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==}
|
| 1063 |
+
|
| 1064 |
+
ua-parser-js@2.0.6:
|
| 1065 |
+
resolution: {integrity: sha512-EmaxXfltJaDW75SokrY4/lXMrVyXomE/0FpIIqP2Ctic93gK7rlme55Cwkz8l3YZ6gqf94fCU7AnIkidd/KXPg==}
|
| 1066 |
+
hasBin: true
|
| 1067 |
+
|
| 1068 |
+
undici-types@7.16.0:
|
| 1069 |
+
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
| 1070 |
+
|
| 1071 |
+
update-browserslist-db@1.1.4:
|
| 1072 |
+
resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==}
|
| 1073 |
+
hasBin: true
|
| 1074 |
+
peerDependencies:
|
| 1075 |
+
browserslist: '>= 4.21.0'
|
| 1076 |
+
|
| 1077 |
+
util-deprecate@1.0.2:
|
| 1078 |
+
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
| 1079 |
+
|
| 1080 |
+
vali-date@1.0.0:
|
| 1081 |
+
resolution: {integrity: sha512-sgECfZthyaCKW10N0fm27cg8HYTFK5qMWgypqkXMQ4Wbl/zZKx7xZICgcoxIIE+WFAP/MBL2EFwC/YvLxw3Zeg==}
|
| 1082 |
+
engines: {node: '>=0.10.0'}
|
| 1083 |
+
|
| 1084 |
+
which-typed-array@1.1.19:
|
| 1085 |
+
resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==}
|
| 1086 |
+
engines: {node: '>= 0.4'}
|
| 1087 |
+
|
| 1088 |
+
wrap-ansi@9.0.2:
|
| 1089 |
+
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
|
| 1090 |
+
engines: {node: '>=18'}
|
| 1091 |
+
|
| 1092 |
+
wrappy@1.0.2:
|
| 1093 |
+
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
| 1094 |
+
|
| 1095 |
+
xml2js@0.6.2:
|
| 1096 |
+
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
|
| 1097 |
+
engines: {node: '>=4.0.0'}
|
| 1098 |
+
|
| 1099 |
+
xmlbuilder@11.0.1:
|
| 1100 |
+
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
|
| 1101 |
+
engines: {node: '>=4.0'}
|
| 1102 |
+
|
| 1103 |
+
xtend@4.0.2:
|
| 1104 |
+
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
| 1105 |
+
engines: {node: '>=0.4'}
|
| 1106 |
+
|
| 1107 |
+
yaml@2.8.2:
|
| 1108 |
+
resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
|
| 1109 |
+
engines: {node: '>= 14.6'}
|
| 1110 |
+
hasBin: true
|
| 1111 |
+
|
| 1112 |
+
yazl@2.5.1:
|
| 1113 |
+
resolution: {integrity: sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==}
|
| 1114 |
+
|
| 1115 |
+
snapshots:
|
| 1116 |
+
|
| 1117 |
+
'@eggjs/yauzl@2.11.0':
|
| 1118 |
+
dependencies:
|
| 1119 |
+
buffer-crc32: 0.2.13
|
| 1120 |
+
fd-slicer2: 1.2.0
|
| 1121 |
+
|
| 1122 |
+
'@emnapi/runtime@1.7.1':
|
| 1123 |
+
dependencies:
|
| 1124 |
+
tslib: 2.8.1
|
| 1125 |
+
optional: true
|
| 1126 |
+
|
| 1127 |
+
'@img/colour@1.0.0': {}
|
| 1128 |
+
|
| 1129 |
+
'@img/sharp-darwin-arm64@0.34.5':
|
| 1130 |
+
optionalDependencies:
|
| 1131 |
+
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
| 1132 |
+
optional: true
|
| 1133 |
+
|
| 1134 |
+
'@img/sharp-darwin-x64@0.34.5':
|
| 1135 |
+
optionalDependencies:
|
| 1136 |
+
'@img/sharp-libvips-darwin-x64': 1.2.4
|
| 1137 |
+
optional: true
|
| 1138 |
+
|
| 1139 |
+
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
| 1140 |
+
optional: true
|
| 1141 |
+
|
| 1142 |
+
'@img/sharp-libvips-darwin-x64@1.2.4':
|
| 1143 |
+
optional: true
|
| 1144 |
+
|
| 1145 |
+
'@img/sharp-libvips-linux-arm64@1.2.4':
|
| 1146 |
+
optional: true
|
| 1147 |
+
|
| 1148 |
+
'@img/sharp-libvips-linux-arm@1.2.4':
|
| 1149 |
+
optional: true
|
| 1150 |
+
|
| 1151 |
+
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
| 1152 |
+
optional: true
|
| 1153 |
+
|
| 1154 |
+
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
| 1155 |
+
optional: true
|
| 1156 |
+
|
| 1157 |
+
'@img/sharp-libvips-linux-s390x@1.2.4':
|
| 1158 |
+
optional: true
|
| 1159 |
+
|
| 1160 |
+
'@img/sharp-libvips-linux-x64@1.2.4':
|
| 1161 |
+
optional: true
|
| 1162 |
+
|
| 1163 |
+
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
| 1164 |
+
optional: true
|
| 1165 |
+
|
| 1166 |
+
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
| 1167 |
+
optional: true
|
| 1168 |
+
|
| 1169 |
+
'@img/sharp-linux-arm64@0.34.5':
|
| 1170 |
+
optionalDependencies:
|
| 1171 |
+
'@img/sharp-libvips-linux-arm64': 1.2.4
|
| 1172 |
+
optional: true
|
| 1173 |
+
|
| 1174 |
+
'@img/sharp-linux-arm@0.34.5':
|
| 1175 |
+
optionalDependencies:
|
| 1176 |
+
'@img/sharp-libvips-linux-arm': 1.2.4
|
| 1177 |
+
optional: true
|
| 1178 |
+
|
| 1179 |
+
'@img/sharp-linux-ppc64@0.34.5':
|
| 1180 |
+
optionalDependencies:
|
| 1181 |
+
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
| 1182 |
+
optional: true
|
| 1183 |
+
|
| 1184 |
+
'@img/sharp-linux-riscv64@0.34.5':
|
| 1185 |
+
optionalDependencies:
|
| 1186 |
+
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
| 1187 |
+
optional: true
|
| 1188 |
+
|
| 1189 |
+
'@img/sharp-linux-s390x@0.34.5':
|
| 1190 |
+
optionalDependencies:
|
| 1191 |
+
'@img/sharp-libvips-linux-s390x': 1.2.4
|
| 1192 |
+
optional: true
|
| 1193 |
+
|
| 1194 |
+
'@img/sharp-linux-x64@0.34.5':
|
| 1195 |
+
optionalDependencies:
|
| 1196 |
+
'@img/sharp-libvips-linux-x64': 1.2.4
|
| 1197 |
+
optional: true
|
| 1198 |
+
|
| 1199 |
+
'@img/sharp-linuxmusl-arm64@0.34.5':
|
| 1200 |
+
optionalDependencies:
|
| 1201 |
+
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
| 1202 |
+
optional: true
|
| 1203 |
+
|
| 1204 |
+
'@img/sharp-linuxmusl-x64@0.34.5':
|
| 1205 |
+
optionalDependencies:
|
| 1206 |
+
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
| 1207 |
+
optional: true
|
| 1208 |
+
|
| 1209 |
+
'@img/sharp-wasm32@0.34.5':
|
| 1210 |
+
dependencies:
|
| 1211 |
+
'@emnapi/runtime': 1.7.1
|
| 1212 |
+
optional: true
|
| 1213 |
+
|
| 1214 |
+
'@img/sharp-win32-arm64@0.34.5':
|
| 1215 |
+
optional: true
|
| 1216 |
+
|
| 1217 |
+
'@img/sharp-win32-ia32@0.34.5':
|
| 1218 |
+
optional: true
|
| 1219 |
+
|
| 1220 |
+
'@img/sharp-win32-x64@0.34.5':
|
| 1221 |
+
optional: true
|
| 1222 |
+
|
| 1223 |
+
'@inquirer/ansi@2.0.1': {}
|
| 1224 |
+
|
| 1225 |
+
'@inquirer/checkbox@5.0.1(@types/node@24.10.1)':
|
| 1226 |
+
dependencies:
|
| 1227 |
+
'@inquirer/ansi': 2.0.1
|
| 1228 |
+
'@inquirer/core': 11.0.1(@types/node@24.10.1)
|
| 1229 |
+
'@inquirer/figures': 2.0.1
|
| 1230 |
+
'@inquirer/type': 4.0.1(@types/node@24.10.1)
|
| 1231 |
+
optionalDependencies:
|
| 1232 |
+
'@types/node': 24.10.1
|
| 1233 |
+
|
| 1234 |
+
'@inquirer/confirm@6.0.1(@types/node@24.10.1)':
|
| 1235 |
+
dependencies:
|
| 1236 |
+
'@inquirer/core': 11.0.1(@types/node@24.10.1)
|
| 1237 |
+
'@inquirer/type': 4.0.1(@types/node@24.10.1)
|
| 1238 |
+
optionalDependencies:
|
| 1239 |
+
'@types/node': 24.10.1
|
| 1240 |
+
|
| 1241 |
+
'@inquirer/core@11.0.1(@types/node@24.10.1)':
|
| 1242 |
+
dependencies:
|
| 1243 |
+
'@inquirer/ansi': 2.0.1
|
| 1244 |
+
'@inquirer/figures': 2.0.1
|
| 1245 |
+
'@inquirer/type': 4.0.1(@types/node@24.10.1)
|
| 1246 |
+
cli-width: 4.1.0
|
| 1247 |
+
mute-stream: 3.0.0
|
| 1248 |
+
signal-exit: 4.1.0
|
| 1249 |
+
wrap-ansi: 9.0.2
|
| 1250 |
+
optionalDependencies:
|
| 1251 |
+
'@types/node': 24.10.1
|
| 1252 |
+
|
| 1253 |
+
'@inquirer/editor@5.0.1(@types/node@24.10.1)':
|
| 1254 |
+
dependencies:
|
| 1255 |
+
'@inquirer/core': 11.0.1(@types/node@24.10.1)
|
| 1256 |
+
'@inquirer/external-editor': 2.0.1(@types/node@24.10.1)
|
| 1257 |
+
'@inquirer/type': 4.0.1(@types/node@24.10.1)
|
| 1258 |
+
optionalDependencies:
|
| 1259 |
+
'@types/node': 24.10.1
|
| 1260 |
+
|
| 1261 |
+
'@inquirer/expand@5.0.1(@types/node@24.10.1)':
|
| 1262 |
+
dependencies:
|
| 1263 |
+
'@inquirer/core': 11.0.1(@types/node@24.10.1)
|
| 1264 |
+
'@inquirer/type': 4.0.1(@types/node@24.10.1)
|
| 1265 |
+
optionalDependencies:
|
| 1266 |
+
'@types/node': 24.10.1
|
| 1267 |
+
|
| 1268 |
+
'@inquirer/external-editor@2.0.1(@types/node@24.10.1)':
|
| 1269 |
+
dependencies:
|
| 1270 |
+
chardet: 2.1.1
|
| 1271 |
+
iconv-lite: 0.7.0
|
| 1272 |
+
optionalDependencies:
|
| 1273 |
+
'@types/node': 24.10.1
|
| 1274 |
+
|
| 1275 |
+
'@inquirer/figures@2.0.1': {}
|
| 1276 |
+
|
| 1277 |
+
'@inquirer/input@5.0.1(@types/node@24.10.1)':
|
| 1278 |
+
dependencies:
|
| 1279 |
+
'@inquirer/core': 11.0.1(@types/node@24.10.1)
|
| 1280 |
+
'@inquirer/type': 4.0.1(@types/node@24.10.1)
|
| 1281 |
+
optionalDependencies:
|
| 1282 |
+
'@types/node': 24.10.1
|
| 1283 |
+
|
| 1284 |
+
'@inquirer/number@4.0.1(@types/node@24.10.1)':
|
| 1285 |
+
dependencies:
|
| 1286 |
+
'@inquirer/core': 11.0.1(@types/node@24.10.1)
|
| 1287 |
+
'@inquirer/type': 4.0.1(@types/node@24.10.1)
|
| 1288 |
+
optionalDependencies:
|
| 1289 |
+
'@types/node': 24.10.1
|
| 1290 |
+
|
| 1291 |
+
'@inquirer/password@5.0.1(@types/node@24.10.1)':
|
| 1292 |
+
dependencies:
|
| 1293 |
+
'@inquirer/ansi': 2.0.1
|
| 1294 |
+
'@inquirer/core': 11.0.1(@types/node@24.10.1)
|
| 1295 |
+
'@inquirer/type': 4.0.1(@types/node@24.10.1)
|
| 1296 |
+
optionalDependencies:
|
| 1297 |
+
'@types/node': 24.10.1
|
| 1298 |
+
|
| 1299 |
+
'@inquirer/prompts@8.0.1(@types/node@24.10.1)':
|
| 1300 |
+
dependencies:
|
| 1301 |
+
'@inquirer/checkbox': 5.0.1(@types/node@24.10.1)
|
| 1302 |
+
'@inquirer/confirm': 6.0.1(@types/node@24.10.1)
|
| 1303 |
+
'@inquirer/editor': 5.0.1(@types/node@24.10.1)
|
| 1304 |
+
'@inquirer/expand': 5.0.1(@types/node@24.10.1)
|
| 1305 |
+
'@inquirer/input': 5.0.1(@types/node@24.10.1)
|
| 1306 |
+
'@inquirer/number': 4.0.1(@types/node@24.10.1)
|
| 1307 |
+
'@inquirer/password': 5.0.1(@types/node@24.10.1)
|
| 1308 |
+
'@inquirer/rawlist': 5.0.1(@types/node@24.10.1)
|
| 1309 |
+
'@inquirer/search': 4.0.1(@types/node@24.10.1)
|
| 1310 |
+
'@inquirer/select': 5.0.1(@types/node@24.10.1)
|
| 1311 |
+
optionalDependencies:
|
| 1312 |
+
'@types/node': 24.10.1
|
| 1313 |
+
|
| 1314 |
+
'@inquirer/rawlist@5.0.1(@types/node@24.10.1)':
|
| 1315 |
+
dependencies:
|
| 1316 |
+
'@inquirer/core': 11.0.1(@types/node@24.10.1)
|
| 1317 |
+
'@inquirer/type': 4.0.1(@types/node@24.10.1)
|
| 1318 |
+
optionalDependencies:
|
| 1319 |
+
'@types/node': 24.10.1
|
| 1320 |
+
|
| 1321 |
+
'@inquirer/search@4.0.1(@types/node@24.10.1)':
|
| 1322 |
+
dependencies:
|
| 1323 |
+
'@inquirer/core': 11.0.1(@types/node@24.10.1)
|
| 1324 |
+
'@inquirer/figures': 2.0.1
|
| 1325 |
+
'@inquirer/type': 4.0.1(@types/node@24.10.1)
|
| 1326 |
+
optionalDependencies:
|
| 1327 |
+
'@types/node': 24.10.1
|
| 1328 |
+
|
| 1329 |
+
'@inquirer/select@5.0.1(@types/node@24.10.1)':
|
| 1330 |
+
dependencies:
|
| 1331 |
+
'@inquirer/ansi': 2.0.1
|
| 1332 |
+
'@inquirer/core': 11.0.1(@types/node@24.10.1)
|
| 1333 |
+
'@inquirer/figures': 2.0.1
|
| 1334 |
+
'@inquirer/type': 4.0.1(@types/node@24.10.1)
|
| 1335 |
+
optionalDependencies:
|
| 1336 |
+
'@types/node': 24.10.1
|
| 1337 |
+
|
| 1338 |
+
'@inquirer/type@4.0.1(@types/node@24.10.1)':
|
| 1339 |
+
optionalDependencies:
|
| 1340 |
+
'@types/node': 24.10.1
|
| 1341 |
+
|
| 1342 |
+
'@isaacs/balanced-match@4.0.1': {}
|
| 1343 |
+
|
| 1344 |
+
'@isaacs/brace-expansion@5.0.0':
|
| 1345 |
+
dependencies:
|
| 1346 |
+
'@isaacs/balanced-match': 4.0.1
|
| 1347 |
+
|
| 1348 |
+
'@keyv/serialize@1.1.1': {}
|
| 1349 |
+
|
| 1350 |
+
'@playwright/test@1.57.0':
|
| 1351 |
+
dependencies:
|
| 1352 |
+
playwright: 1.57.0
|
| 1353 |
+
|
| 1354 |
+
'@sec-ant/readable-stream@0.4.1': {}
|
| 1355 |
+
|
| 1356 |
+
'@sindresorhus/is@4.6.0': {}
|
| 1357 |
+
|
| 1358 |
+
'@sindresorhus/is@5.6.0': {}
|
| 1359 |
+
|
| 1360 |
+
'@sindresorhus/is@7.1.1': {}
|
| 1361 |
+
|
| 1362 |
+
'@types/bezier-js@4.1.3': {}
|
| 1363 |
+
|
| 1364 |
+
'@types/http-cache-semantics@4.0.4': {}
|
| 1365 |
+
|
| 1366 |
+
'@types/node@24.10.1':
|
| 1367 |
+
dependencies:
|
| 1368 |
+
undici-types: 7.16.0
|
| 1369 |
+
optional: true
|
| 1370 |
+
|
| 1371 |
+
adm-zip@0.5.16: {}
|
| 1372 |
+
|
| 1373 |
+
agent-base@7.1.4: {}
|
| 1374 |
+
|
| 1375 |
+
ansi-regex@6.2.2: {}
|
| 1376 |
+
|
| 1377 |
+
ansi-styles@6.2.3: {}
|
| 1378 |
+
|
| 1379 |
+
available-typed-arrays@1.0.7:
|
| 1380 |
+
dependencies:
|
| 1381 |
+
possible-typed-array-names: 1.1.0
|
| 1382 |
+
|
| 1383 |
+
base64-js@1.5.1: {}
|
| 1384 |
+
|
| 1385 |
+
baseline-browser-mapping@2.8.30: {}
|
| 1386 |
+
|
| 1387 |
+
better-sqlite3@12.5.0:
|
| 1388 |
+
dependencies:
|
| 1389 |
+
bindings: 1.5.0
|
| 1390 |
+
prebuild-install: 7.1.3
|
| 1391 |
+
|
| 1392 |
+
bezier-js@6.1.4: {}
|
| 1393 |
+
|
| 1394 |
+
bindings@1.5.0:
|
| 1395 |
+
dependencies:
|
| 1396 |
+
file-uri-to-path: 1.0.0
|
| 1397 |
+
|
| 1398 |
+
bl@1.2.3:
|
| 1399 |
+
dependencies:
|
| 1400 |
+
readable-stream: 2.3.8
|
| 1401 |
+
safe-buffer: 5.2.1
|
| 1402 |
+
|
| 1403 |
+
bl@4.1.0:
|
| 1404 |
+
dependencies:
|
| 1405 |
+
buffer: 5.7.1
|
| 1406 |
+
inherits: 2.0.4
|
| 1407 |
+
readable-stream: 3.6.2
|
| 1408 |
+
|
| 1409 |
+
browserslist@4.28.0:
|
| 1410 |
+
dependencies:
|
| 1411 |
+
baseline-browser-mapping: 2.8.30
|
| 1412 |
+
caniuse-lite: 1.0.30001756
|
| 1413 |
+
electron-to-chromium: 1.5.259
|
| 1414 |
+
node-releases: 2.0.27
|
| 1415 |
+
update-browserslist-db: 1.1.4(browserslist@4.28.0)
|
| 1416 |
+
|
| 1417 |
+
buffer-alloc-unsafe@1.1.0: {}
|
| 1418 |
+
|
| 1419 |
+
buffer-alloc@1.2.0:
|
| 1420 |
+
dependencies:
|
| 1421 |
+
buffer-alloc-unsafe: 1.1.0
|
| 1422 |
+
buffer-fill: 1.0.0
|
| 1423 |
+
|
| 1424 |
+
buffer-crc32@0.2.13: {}
|
| 1425 |
+
|
| 1426 |
+
buffer-fill@1.0.0: {}
|
| 1427 |
+
|
| 1428 |
+
buffer@5.7.1:
|
| 1429 |
+
dependencies:
|
| 1430 |
+
base64-js: 1.5.1
|
| 1431 |
+
ieee754: 1.2.1
|
| 1432 |
+
|
| 1433 |
+
byte-counter@0.1.0: {}
|
| 1434 |
+
|
| 1435 |
+
cacheable-lookup@7.0.0: {}
|
| 1436 |
+
|
| 1437 |
+
cacheable-request@13.0.15:
|
| 1438 |
+
dependencies:
|
| 1439 |
+
'@types/http-cache-semantics': 4.0.4
|
| 1440 |
+
get-stream: 9.0.1
|
| 1441 |
+
http-cache-semantics: 4.2.0
|
| 1442 |
+
keyv: 5.5.4
|
| 1443 |
+
mimic-response: 4.0.0
|
| 1444 |
+
normalize-url: 8.1.0
|
| 1445 |
+
responselike: 4.0.2
|
| 1446 |
+
|
| 1447 |
+
call-bind-apply-helpers@1.0.2:
|
| 1448 |
+
dependencies:
|
| 1449 |
+
es-errors: 1.3.0
|
| 1450 |
+
function-bind: 1.1.2
|
| 1451 |
+
|
| 1452 |
+
call-bind@1.0.8:
|
| 1453 |
+
dependencies:
|
| 1454 |
+
call-bind-apply-helpers: 1.0.2
|
| 1455 |
+
es-define-property: 1.0.1
|
| 1456 |
+
get-intrinsic: 1.3.0
|
| 1457 |
+
set-function-length: 1.2.2
|
| 1458 |
+
|
| 1459 |
+
call-bound@1.0.4:
|
| 1460 |
+
dependencies:
|
| 1461 |
+
call-bind-apply-helpers: 1.0.2
|
| 1462 |
+
get-intrinsic: 1.3.0
|
| 1463 |
+
|
| 1464 |
+
callsites@3.1.0: {}
|
| 1465 |
+
|
| 1466 |
+
callsites@4.2.0: {}
|
| 1467 |
+
|
| 1468 |
+
camoufox-js@0.8.3(playwright-core@1.57.0):
|
| 1469 |
+
dependencies:
|
| 1470 |
+
adm-zip: 0.5.16
|
| 1471 |
+
better-sqlite3: 12.5.0
|
| 1472 |
+
commander: 14.0.2
|
| 1473 |
+
fingerprint-generator: 2.1.78
|
| 1474 |
+
glob: 13.0.0
|
| 1475 |
+
impit: 0.7.1
|
| 1476 |
+
language-tags: 2.1.0
|
| 1477 |
+
maxmind: 5.0.1
|
| 1478 |
+
playwright-core: 1.57.0
|
| 1479 |
+
progress: 2.0.3
|
| 1480 |
+
ua-parser-js: 2.0.6
|
| 1481 |
+
xml2js: 0.6.2
|
| 1482 |
+
|
| 1483 |
+
caniuse-lite@1.0.30001756: {}
|
| 1484 |
+
|
| 1485 |
+
chardet@2.1.1: {}
|
| 1486 |
+
|
| 1487 |
+
chownr@1.1.4: {}
|
| 1488 |
+
|
| 1489 |
+
cli-width@4.1.0: {}
|
| 1490 |
+
|
| 1491 |
+
commander@14.0.2: {}
|
| 1492 |
+
|
| 1493 |
+
compressing@2.0.0:
|
| 1494 |
+
dependencies:
|
| 1495 |
+
'@eggjs/yauzl': 2.11.0
|
| 1496 |
+
flushwritable: 1.0.0
|
| 1497 |
+
get-ready: 1.0.0
|
| 1498 |
+
iconv-lite: 0.5.2
|
| 1499 |
+
streamifier: 0.1.1
|
| 1500 |
+
tar-stream: 1.6.2
|
| 1501 |
+
yazl: 2.5.1
|
| 1502 |
+
|
| 1503 |
+
core-util-is@1.0.3: {}
|
| 1504 |
+
|
| 1505 |
+
debug@4.4.3:
|
| 1506 |
+
dependencies:
|
| 1507 |
+
ms: 2.1.3
|
| 1508 |
+
|
| 1509 |
+
decompress-response@10.0.0:
|
| 1510 |
+
dependencies:
|
| 1511 |
+
mimic-response: 4.0.0
|
| 1512 |
+
|
| 1513 |
+
decompress-response@6.0.0:
|
| 1514 |
+
dependencies:
|
| 1515 |
+
mimic-response: 3.1.0
|
| 1516 |
+
|
| 1517 |
+
deep-extend@0.6.0: {}
|
| 1518 |
+
|
| 1519 |
+
define-data-property@1.1.4:
|
| 1520 |
+
dependencies:
|
| 1521 |
+
es-define-property: 1.0.1
|
| 1522 |
+
es-errors: 1.3.0
|
| 1523 |
+
gopd: 1.2.0
|
| 1524 |
+
|
| 1525 |
+
detect-europe-js@0.1.2: {}
|
| 1526 |
+
|
| 1527 |
+
detect-libc@2.1.2: {}
|
| 1528 |
+
|
| 1529 |
+
dot-prop@6.0.1:
|
| 1530 |
+
dependencies:
|
| 1531 |
+
is-obj: 2.0.0
|
| 1532 |
+
|
| 1533 |
+
dot-prop@7.2.0:
|
| 1534 |
+
dependencies:
|
| 1535 |
+
type-fest: 2.19.0
|
| 1536 |
+
|
| 1537 |
+
dunder-proto@1.0.1:
|
| 1538 |
+
dependencies:
|
| 1539 |
+
call-bind-apply-helpers: 1.0.2
|
| 1540 |
+
es-errors: 1.3.0
|
| 1541 |
+
gopd: 1.2.0
|
| 1542 |
+
|
| 1543 |
+
electron-to-chromium@1.5.259: {}
|
| 1544 |
+
|
| 1545 |
+
emoji-regex@10.6.0: {}
|
| 1546 |
+
|
| 1547 |
+
end-of-stream@1.4.5:
|
| 1548 |
+
dependencies:
|
| 1549 |
+
once: 1.4.0
|
| 1550 |
+
|
| 1551 |
+
es-define-property@1.0.1: {}
|
| 1552 |
+
|
| 1553 |
+
es-errors@1.3.0: {}
|
| 1554 |
+
|
| 1555 |
+
es-object-atoms@1.1.1:
|
| 1556 |
+
dependencies:
|
| 1557 |
+
es-errors: 1.3.0
|
| 1558 |
+
|
| 1559 |
+
escalade@3.2.0: {}
|
| 1560 |
+
|
| 1561 |
+
expand-template@2.0.3: {}
|
| 1562 |
+
|
| 1563 |
+
fd-slicer2@1.2.0:
|
| 1564 |
+
dependencies:
|
| 1565 |
+
pend: 1.2.0
|
| 1566 |
+
|
| 1567 |
+
file-uri-to-path@1.0.0: {}
|
| 1568 |
+
|
| 1569 |
+
fingerprint-generator@2.1.78:
|
| 1570 |
+
dependencies:
|
| 1571 |
+
generative-bayesian-network: 2.1.78
|
| 1572 |
+
header-generator: 2.1.78
|
| 1573 |
+
tslib: 2.8.1
|
| 1574 |
+
|
| 1575 |
+
flushwritable@1.0.0: {}
|
| 1576 |
+
|
| 1577 |
+
for-each@0.3.5:
|
| 1578 |
+
dependencies:
|
| 1579 |
+
is-callable: 1.2.7
|
| 1580 |
+
|
| 1581 |
+
form-data-encoder@4.1.0: {}
|
| 1582 |
+
|
| 1583 |
+
fs-constants@1.0.0: {}
|
| 1584 |
+
|
| 1585 |
+
fsevents@2.3.2:
|
| 1586 |
+
optional: true
|
| 1587 |
+
|
| 1588 |
+
function-bind@1.1.2: {}
|
| 1589 |
+
|
| 1590 |
+
generative-bayesian-network@2.1.77:
|
| 1591 |
+
dependencies:
|
| 1592 |
+
adm-zip: 0.5.16
|
| 1593 |
+
tslib: 2.8.1
|
| 1594 |
+
|
| 1595 |
+
generative-bayesian-network@2.1.78:
|
| 1596 |
+
dependencies:
|
| 1597 |
+
adm-zip: 0.5.16
|
| 1598 |
+
tslib: 2.8.1
|
| 1599 |
+
|
| 1600 |
+
get-east-asian-width@1.4.0: {}
|
| 1601 |
+
|
| 1602 |
+
get-intrinsic@1.3.0:
|
| 1603 |
+
dependencies:
|
| 1604 |
+
call-bind-apply-helpers: 1.0.2
|
| 1605 |
+
es-define-property: 1.0.1
|
| 1606 |
+
es-errors: 1.3.0
|
| 1607 |
+
es-object-atoms: 1.1.1
|
| 1608 |
+
function-bind: 1.1.2
|
| 1609 |
+
get-proto: 1.0.1
|
| 1610 |
+
gopd: 1.2.0
|
| 1611 |
+
has-symbols: 1.1.0
|
| 1612 |
+
hasown: 2.0.2
|
| 1613 |
+
math-intrinsics: 1.1.0
|
| 1614 |
+
|
| 1615 |
+
get-proto@1.0.1:
|
| 1616 |
+
dependencies:
|
| 1617 |
+
dunder-proto: 1.0.1
|
| 1618 |
+
es-object-atoms: 1.1.1
|
| 1619 |
+
|
| 1620 |
+
get-ready@1.0.0: {}
|
| 1621 |
+
|
| 1622 |
+
get-stream@9.0.1:
|
| 1623 |
+
dependencies:
|
| 1624 |
+
'@sec-ant/readable-stream': 0.4.1
|
| 1625 |
+
is-stream: 4.0.1
|
| 1626 |
+
|
| 1627 |
+
ghost-cursor-playwright-port@1.4.3(@playwright/test@1.57.0)(playwright-core@1.57.0)(playwright@1.57.0):
|
| 1628 |
+
dependencies:
|
| 1629 |
+
'@playwright/test': 1.57.0
|
| 1630 |
+
'@types/bezier-js': 4.1.3
|
| 1631 |
+
bezier-js: 6.1.4
|
| 1632 |
+
debug: 4.4.3
|
| 1633 |
+
playwright: 1.57.0
|
| 1634 |
+
playwright-core: 1.57.0
|
| 1635 |
+
transitivePeerDependencies:
|
| 1636 |
+
- supports-color
|
| 1637 |
+
|
| 1638 |
+
github-from-package@0.0.0: {}
|
| 1639 |
+
|
| 1640 |
+
glob@13.0.0:
|
| 1641 |
+
dependencies:
|
| 1642 |
+
minimatch: 10.1.1
|
| 1643 |
+
minipass: 7.1.2
|
| 1644 |
+
path-scurry: 2.0.1
|
| 1645 |
+
|
| 1646 |
+
gopd@1.2.0: {}
|
| 1647 |
+
|
| 1648 |
+
got-scraping@4.1.2:
|
| 1649 |
+
dependencies:
|
| 1650 |
+
got: 14.6.4
|
| 1651 |
+
header-generator: 2.1.77
|
| 1652 |
+
http2-wrapper: 2.2.1
|
| 1653 |
+
mimic-response: 4.0.0
|
| 1654 |
+
ow: 1.1.1
|
| 1655 |
+
quick-lru: 7.3.0
|
| 1656 |
+
tslib: 2.8.1
|
| 1657 |
+
|
| 1658 |
+
got@14.6.4:
|
| 1659 |
+
dependencies:
|
| 1660 |
+
'@sindresorhus/is': 7.1.1
|
| 1661 |
+
byte-counter: 0.1.0
|
| 1662 |
+
cacheable-lookup: 7.0.0
|
| 1663 |
+
cacheable-request: 13.0.15
|
| 1664 |
+
decompress-response: 10.0.0
|
| 1665 |
+
form-data-encoder: 4.1.0
|
| 1666 |
+
http2-wrapper: 2.2.1
|
| 1667 |
+
keyv: 5.5.4
|
| 1668 |
+
lowercase-keys: 3.0.0
|
| 1669 |
+
p-cancelable: 4.0.1
|
| 1670 |
+
responselike: 4.0.2
|
| 1671 |
+
type-fest: 4.41.0
|
| 1672 |
+
|
| 1673 |
+
has-property-descriptors@1.0.2:
|
| 1674 |
+
dependencies:
|
| 1675 |
+
es-define-property: 1.0.1
|
| 1676 |
+
|
| 1677 |
+
has-symbols@1.1.0: {}
|
| 1678 |
+
|
| 1679 |
+
has-tostringtag@1.0.2:
|
| 1680 |
+
dependencies:
|
| 1681 |
+
has-symbols: 1.1.0
|
| 1682 |
+
|
| 1683 |
+
hasown@2.0.2:
|
| 1684 |
+
dependencies:
|
| 1685 |
+
function-bind: 1.1.2
|
| 1686 |
+
|
| 1687 |
+
header-generator@2.1.77:
|
| 1688 |
+
dependencies:
|
| 1689 |
+
browserslist: 4.28.0
|
| 1690 |
+
generative-bayesian-network: 2.1.77
|
| 1691 |
+
ow: 0.28.2
|
| 1692 |
+
tslib: 2.8.1
|
| 1693 |
+
|
| 1694 |
+
header-generator@2.1.78:
|
| 1695 |
+
dependencies:
|
| 1696 |
+
browserslist: 4.28.0
|
| 1697 |
+
generative-bayesian-network: 2.1.78
|
| 1698 |
+
ow: 0.28.2
|
| 1699 |
+
tslib: 2.8.1
|
| 1700 |
+
|
| 1701 |
+
http-cache-semantics@4.2.0: {}
|
| 1702 |
+
|
| 1703 |
+
http2-wrapper@2.2.1:
|
| 1704 |
+
dependencies:
|
| 1705 |
+
quick-lru: 5.1.1
|
| 1706 |
+
resolve-alpn: 1.2.1
|
| 1707 |
+
|
| 1708 |
+
iconv-lite@0.5.2:
|
| 1709 |
+
dependencies:
|
| 1710 |
+
safer-buffer: 2.1.2
|
| 1711 |
+
|
| 1712 |
+
iconv-lite@0.7.0:
|
| 1713 |
+
dependencies:
|
| 1714 |
+
safer-buffer: 2.1.2
|
| 1715 |
+
|
| 1716 |
+
ieee754@1.2.1: {}
|
| 1717 |
+
|
| 1718 |
+
impit-darwin-arm64@0.7.1:
|
| 1719 |
+
optional: true
|
| 1720 |
+
|
| 1721 |
+
impit-darwin-x64@0.7.1:
|
| 1722 |
+
optional: true
|
| 1723 |
+
|
| 1724 |
+
impit-linux-arm64-gnu@0.7.1:
|
| 1725 |
+
optional: true
|
| 1726 |
+
|
| 1727 |
+
impit-linux-arm64-musl@0.7.1:
|
| 1728 |
+
optional: true
|
| 1729 |
+
|
| 1730 |
+
impit-linux-x64-gnu@0.7.1:
|
| 1731 |
+
optional: true
|
| 1732 |
+
|
| 1733 |
+
impit-linux-x64-musl@0.7.1:
|
| 1734 |
+
optional: true
|
| 1735 |
+
|
| 1736 |
+
impit-win32-arm64-msvc@0.7.1:
|
| 1737 |
+
optional: true
|
| 1738 |
+
|
| 1739 |
+
impit-win32-x64-msvc@0.7.1:
|
| 1740 |
+
optional: true
|
| 1741 |
+
|
| 1742 |
+
impit@0.7.1:
|
| 1743 |
+
optionalDependencies:
|
| 1744 |
+
impit-darwin-arm64: 0.7.1
|
| 1745 |
+
impit-darwin-x64: 0.7.1
|
| 1746 |
+
impit-linux-arm64-gnu: 0.7.1
|
| 1747 |
+
impit-linux-arm64-musl: 0.7.1
|
| 1748 |
+
impit-linux-x64-gnu: 0.7.1
|
| 1749 |
+
impit-linux-x64-musl: 0.7.1
|
| 1750 |
+
impit-win32-arm64-msvc: 0.7.1
|
| 1751 |
+
impit-win32-x64-msvc: 0.7.1
|
| 1752 |
+
|
| 1753 |
+
inherits@2.0.4: {}
|
| 1754 |
+
|
| 1755 |
+
ini@1.3.8: {}
|
| 1756 |
+
|
| 1757 |
+
ip-address@10.1.0: {}
|
| 1758 |
+
|
| 1759 |
+
is-callable@1.2.7: {}
|
| 1760 |
+
|
| 1761 |
+
is-obj@2.0.0: {}
|
| 1762 |
+
|
| 1763 |
+
is-standalone-pwa@0.1.1: {}
|
| 1764 |
+
|
| 1765 |
+
is-stream@4.0.1: {}
|
| 1766 |
+
|
| 1767 |
+
is-typed-array@1.1.15:
|
| 1768 |
+
dependencies:
|
| 1769 |
+
which-typed-array: 1.1.19
|
| 1770 |
+
|
| 1771 |
+
isarray@1.0.0: {}
|
| 1772 |
+
|
| 1773 |
+
isarray@2.0.5: {}
|
| 1774 |
+
|
| 1775 |
+
keyv@5.5.4:
|
| 1776 |
+
dependencies:
|
| 1777 |
+
'@keyv/serialize': 1.1.1
|
| 1778 |
+
|
| 1779 |
+
language-subtag-registry@0.3.23: {}
|
| 1780 |
+
|
| 1781 |
+
language-tags@2.1.0:
|
| 1782 |
+
dependencies:
|
| 1783 |
+
language-subtag-registry: 0.3.23
|
| 1784 |
+
|
| 1785 |
+
lodash.isequal@4.5.0: {}
|
| 1786 |
+
|
| 1787 |
+
lowercase-keys@3.0.0: {}
|
| 1788 |
+
|
| 1789 |
+
lru-cache@11.2.2: {}
|
| 1790 |
+
|
| 1791 |
+
math-intrinsics@1.1.0: {}
|
| 1792 |
+
|
| 1793 |
+
maxmind@5.0.1:
|
| 1794 |
+
dependencies:
|
| 1795 |
+
mmdb-lib: 3.0.1
|
| 1796 |
+
tiny-lru: 11.4.5
|
| 1797 |
+
|
| 1798 |
+
mimic-response@3.1.0: {}
|
| 1799 |
+
|
| 1800 |
+
mimic-response@4.0.0: {}
|
| 1801 |
+
|
| 1802 |
+
minimatch@10.1.1:
|
| 1803 |
+
dependencies:
|
| 1804 |
+
'@isaacs/brace-expansion': 5.0.0
|
| 1805 |
+
|
| 1806 |
+
minimist@1.2.8: {}
|
| 1807 |
+
|
| 1808 |
+
minipass@7.1.2: {}
|
| 1809 |
+
|
| 1810 |
+
mkdirp-classic@0.5.3: {}
|
| 1811 |
+
|
| 1812 |
+
mmdb-lib@3.0.1: {}
|
| 1813 |
+
|
| 1814 |
+
ms@2.1.3: {}
|
| 1815 |
+
|
| 1816 |
+
mute-stream@3.0.0: {}
|
| 1817 |
+
|
| 1818 |
+
napi-build-utils@2.0.0: {}
|
| 1819 |
+
|
| 1820 |
+
node-abi@3.85.0:
|
| 1821 |
+
dependencies:
|
| 1822 |
+
semver: 7.7.3
|
| 1823 |
+
|
| 1824 |
+
node-releases@2.0.27: {}
|
| 1825 |
+
|
| 1826 |
+
normalize-url@8.1.0: {}
|
| 1827 |
+
|
| 1828 |
+
once@1.4.0:
|
| 1829 |
+
dependencies:
|
| 1830 |
+
wrappy: 1.0.2
|
| 1831 |
+
|
| 1832 |
+
ow@0.28.2:
|
| 1833 |
+
dependencies:
|
| 1834 |
+
'@sindresorhus/is': 4.6.0
|
| 1835 |
+
callsites: 3.1.0
|
| 1836 |
+
dot-prop: 6.0.1
|
| 1837 |
+
lodash.isequal: 4.5.0
|
| 1838 |
+
vali-date: 1.0.0
|
| 1839 |
+
|
| 1840 |
+
ow@1.1.1:
|
| 1841 |
+
dependencies:
|
| 1842 |
+
'@sindresorhus/is': 5.6.0
|
| 1843 |
+
callsites: 4.2.0
|
| 1844 |
+
dot-prop: 7.2.0
|
| 1845 |
+
lodash.isequal: 4.5.0
|
| 1846 |
+
vali-date: 1.0.0
|
| 1847 |
+
|
| 1848 |
+
p-cancelable@4.0.1: {}
|
| 1849 |
+
|
| 1850 |
+
path-scurry@2.0.1:
|
| 1851 |
+
dependencies:
|
| 1852 |
+
lru-cache: 11.2.2
|
| 1853 |
+
minipass: 7.1.2
|
| 1854 |
+
|
| 1855 |
+
pend@1.2.0: {}
|
| 1856 |
+
|
| 1857 |
+
picocolors@1.1.1: {}
|
| 1858 |
+
|
| 1859 |
+
playwright-core@1.57.0: {}
|
| 1860 |
+
|
| 1861 |
+
playwright@1.57.0:
|
| 1862 |
+
dependencies:
|
| 1863 |
+
playwright-core: 1.57.0
|
| 1864 |
+
optionalDependencies:
|
| 1865 |
+
fsevents: 2.3.2
|
| 1866 |
+
|
| 1867 |
+
possible-typed-array-names@1.1.0: {}
|
| 1868 |
+
|
| 1869 |
+
prebuild-install@7.1.3:
|
| 1870 |
+
dependencies:
|
| 1871 |
+
detect-libc: 2.1.2
|
| 1872 |
+
expand-template: 2.0.3
|
| 1873 |
+
github-from-package: 0.0.0
|
| 1874 |
+
minimist: 1.2.8
|
| 1875 |
+
mkdirp-classic: 0.5.3
|
| 1876 |
+
napi-build-utils: 2.0.0
|
| 1877 |
+
node-abi: 3.85.0
|
| 1878 |
+
pump: 3.0.3
|
| 1879 |
+
rc: 1.2.8
|
| 1880 |
+
simple-get: 4.0.1
|
| 1881 |
+
tar-fs: 2.1.4
|
| 1882 |
+
tunnel-agent: 0.6.0
|
| 1883 |
+
|
| 1884 |
+
process-nextick-args@2.0.1: {}
|
| 1885 |
+
|
| 1886 |
+
progress@2.0.3: {}
|
| 1887 |
+
|
| 1888 |
+
proxy-chain@2.6.0:
|
| 1889 |
+
dependencies:
|
| 1890 |
+
socks: 2.8.7
|
| 1891 |
+
socks-proxy-agent: 8.0.5
|
| 1892 |
+
tslib: 2.8.1
|
| 1893 |
+
transitivePeerDependencies:
|
| 1894 |
+
- supports-color
|
| 1895 |
+
|
| 1896 |
+
pump@3.0.3:
|
| 1897 |
+
dependencies:
|
| 1898 |
+
end-of-stream: 1.4.5
|
| 1899 |
+
once: 1.4.0
|
| 1900 |
+
|
| 1901 |
+
quick-lru@5.1.1: {}
|
| 1902 |
+
|
| 1903 |
+
quick-lru@7.3.0: {}
|
| 1904 |
+
|
| 1905 |
+
rc@1.2.8:
|
| 1906 |
+
dependencies:
|
| 1907 |
+
deep-extend: 0.6.0
|
| 1908 |
+
ini: 1.3.8
|
| 1909 |
+
minimist: 1.2.8
|
| 1910 |
+
strip-json-comments: 2.0.1
|
| 1911 |
+
|
| 1912 |
+
readable-stream@2.3.8:
|
| 1913 |
+
dependencies:
|
| 1914 |
+
core-util-is: 1.0.3
|
| 1915 |
+
inherits: 2.0.4
|
| 1916 |
+
isarray: 1.0.0
|
| 1917 |
+
process-nextick-args: 2.0.1
|
| 1918 |
+
safe-buffer: 5.1.2
|
| 1919 |
+
string_decoder: 1.1.1
|
| 1920 |
+
util-deprecate: 1.0.2
|
| 1921 |
+
|
| 1922 |
+
readable-stream@3.6.2:
|
| 1923 |
+
dependencies:
|
| 1924 |
+
inherits: 2.0.4
|
| 1925 |
+
string_decoder: 1.3.0
|
| 1926 |
+
util-deprecate: 1.0.2
|
| 1927 |
+
|
| 1928 |
+
resolve-alpn@1.2.1: {}
|
| 1929 |
+
|
| 1930 |
+
responselike@4.0.2:
|
| 1931 |
+
dependencies:
|
| 1932 |
+
lowercase-keys: 3.0.0
|
| 1933 |
+
|
| 1934 |
+
safe-buffer@5.1.2: {}
|
| 1935 |
+
|
| 1936 |
+
safe-buffer@5.2.1: {}
|
| 1937 |
+
|
| 1938 |
+
safer-buffer@2.1.2: {}
|
| 1939 |
+
|
| 1940 |
+
sax@1.4.3: {}
|
| 1941 |
+
|
| 1942 |
+
semver@7.7.3: {}
|
| 1943 |
+
|
| 1944 |
+
set-function-length@1.2.2:
|
| 1945 |
+
dependencies:
|
| 1946 |
+
define-data-property: 1.1.4
|
| 1947 |
+
es-errors: 1.3.0
|
| 1948 |
+
function-bind: 1.1.2
|
| 1949 |
+
get-intrinsic: 1.3.0
|
| 1950 |
+
gopd: 1.2.0
|
| 1951 |
+
has-property-descriptors: 1.0.2
|
| 1952 |
+
|
| 1953 |
+
sharp@0.34.5:
|
| 1954 |
+
dependencies:
|
| 1955 |
+
'@img/colour': 1.0.0
|
| 1956 |
+
detect-libc: 2.1.2
|
| 1957 |
+
semver: 7.7.3
|
| 1958 |
+
optionalDependencies:
|
| 1959 |
+
'@img/sharp-darwin-arm64': 0.34.5
|
| 1960 |
+
'@img/sharp-darwin-x64': 0.34.5
|
| 1961 |
+
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
| 1962 |
+
'@img/sharp-libvips-darwin-x64': 1.2.4
|
| 1963 |
+
'@img/sharp-libvips-linux-arm': 1.2.4
|
| 1964 |
+
'@img/sharp-libvips-linux-arm64': 1.2.4
|
| 1965 |
+
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
| 1966 |
+
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
| 1967 |
+
'@img/sharp-libvips-linux-s390x': 1.2.4
|
| 1968 |
+
'@img/sharp-libvips-linux-x64': 1.2.4
|
| 1969 |
+
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
| 1970 |
+
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
| 1971 |
+
'@img/sharp-linux-arm': 0.34.5
|
| 1972 |
+
'@img/sharp-linux-arm64': 0.34.5
|
| 1973 |
+
'@img/sharp-linux-ppc64': 0.34.5
|
| 1974 |
+
'@img/sharp-linux-riscv64': 0.34.5
|
| 1975 |
+
'@img/sharp-linux-s390x': 0.34.5
|
| 1976 |
+
'@img/sharp-linux-x64': 0.34.5
|
| 1977 |
+
'@img/sharp-linuxmusl-arm64': 0.34.5
|
| 1978 |
+
'@img/sharp-linuxmusl-x64': 0.34.5
|
| 1979 |
+
'@img/sharp-wasm32': 0.34.5
|
| 1980 |
+
'@img/sharp-win32-arm64': 0.34.5
|
| 1981 |
+
'@img/sharp-win32-ia32': 0.34.5
|
| 1982 |
+
'@img/sharp-win32-x64': 0.34.5
|
| 1983 |
+
|
| 1984 |
+
signal-exit@4.1.0: {}
|
| 1985 |
+
|
| 1986 |
+
simple-concat@1.0.1: {}
|
| 1987 |
+
|
| 1988 |
+
simple-get@4.0.1:
|
| 1989 |
+
dependencies:
|
| 1990 |
+
decompress-response: 6.0.0
|
| 1991 |
+
once: 1.4.0
|
| 1992 |
+
simple-concat: 1.0.1
|
| 1993 |
+
|
| 1994 |
+
smart-buffer@4.2.0: {}
|
| 1995 |
+
|
| 1996 |
+
socks-proxy-agent@8.0.5:
|
| 1997 |
+
dependencies:
|
| 1998 |
+
agent-base: 7.1.4
|
| 1999 |
+
debug: 4.4.3
|
| 2000 |
+
socks: 2.8.7
|
| 2001 |
+
transitivePeerDependencies:
|
| 2002 |
+
- supports-color
|
| 2003 |
+
|
| 2004 |
+
socks@2.8.7:
|
| 2005 |
+
dependencies:
|
| 2006 |
+
ip-address: 10.1.0
|
| 2007 |
+
smart-buffer: 4.2.0
|
| 2008 |
+
|
| 2009 |
+
streamifier@0.1.1: {}
|
| 2010 |
+
|
| 2011 |
+
string-width@7.2.0:
|
| 2012 |
+
dependencies:
|
| 2013 |
+
emoji-regex: 10.6.0
|
| 2014 |
+
get-east-asian-width: 1.4.0
|
| 2015 |
+
strip-ansi: 7.1.2
|
| 2016 |
+
|
| 2017 |
+
string_decoder@1.1.1:
|
| 2018 |
+
dependencies:
|
| 2019 |
+
safe-buffer: 5.1.2
|
| 2020 |
+
|
| 2021 |
+
string_decoder@1.3.0:
|
| 2022 |
+
dependencies:
|
| 2023 |
+
safe-buffer: 5.2.1
|
| 2024 |
+
|
| 2025 |
+
strip-ansi@7.1.2:
|
| 2026 |
+
dependencies:
|
| 2027 |
+
ansi-regex: 6.2.2
|
| 2028 |
+
|
| 2029 |
+
strip-json-comments@2.0.1: {}
|
| 2030 |
+
|
| 2031 |
+
tar-fs@2.1.4:
|
| 2032 |
+
dependencies:
|
| 2033 |
+
chownr: 1.1.4
|
| 2034 |
+
mkdirp-classic: 0.5.3
|
| 2035 |
+
pump: 3.0.3
|
| 2036 |
+
tar-stream: 2.2.0
|
| 2037 |
+
|
| 2038 |
+
tar-stream@1.6.2:
|
| 2039 |
+
dependencies:
|
| 2040 |
+
bl: 1.2.3
|
| 2041 |
+
buffer-alloc: 1.2.0
|
| 2042 |
+
end-of-stream: 1.4.5
|
| 2043 |
+
fs-constants: 1.0.0
|
| 2044 |
+
readable-stream: 2.3.8
|
| 2045 |
+
to-buffer: 1.2.2
|
| 2046 |
+
xtend: 4.0.2
|
| 2047 |
+
|
| 2048 |
+
tar-stream@2.2.0:
|
| 2049 |
+
dependencies:
|
| 2050 |
+
bl: 4.1.0
|
| 2051 |
+
end-of-stream: 1.4.5
|
| 2052 |
+
fs-constants: 1.0.0
|
| 2053 |
+
inherits: 2.0.4
|
| 2054 |
+
readable-stream: 3.6.2
|
| 2055 |
+
|
| 2056 |
+
tiny-lru@11.4.5: {}
|
| 2057 |
+
|
| 2058 |
+
to-buffer@1.2.2:
|
| 2059 |
+
dependencies:
|
| 2060 |
+
isarray: 2.0.5
|
| 2061 |
+
safe-buffer: 5.2.1
|
| 2062 |
+
typed-array-buffer: 1.0.3
|
| 2063 |
+
|
| 2064 |
+
tslib@2.8.1: {}
|
| 2065 |
+
|
| 2066 |
+
tunnel-agent@0.6.0:
|
| 2067 |
+
dependencies:
|
| 2068 |
+
safe-buffer: 5.2.1
|
| 2069 |
+
|
| 2070 |
+
type-fest@2.19.0: {}
|
| 2071 |
+
|
| 2072 |
+
type-fest@4.41.0: {}
|
| 2073 |
+
|
| 2074 |
+
typed-array-buffer@1.0.3:
|
| 2075 |
+
dependencies:
|
| 2076 |
+
call-bound: 1.0.4
|
| 2077 |
+
es-errors: 1.3.0
|
| 2078 |
+
is-typed-array: 1.1.15
|
| 2079 |
+
|
| 2080 |
+
ua-is-frozen@0.1.2: {}
|
| 2081 |
+
|
| 2082 |
+
ua-parser-js@2.0.6:
|
| 2083 |
+
dependencies:
|
| 2084 |
+
detect-europe-js: 0.1.2
|
| 2085 |
+
is-standalone-pwa: 0.1.1
|
| 2086 |
+
ua-is-frozen: 0.1.2
|
| 2087 |
+
|
| 2088 |
+
undici-types@7.16.0:
|
| 2089 |
+
optional: true
|
| 2090 |
+
|
| 2091 |
+
update-browserslist-db@1.1.4(browserslist@4.28.0):
|
| 2092 |
+
dependencies:
|
| 2093 |
+
browserslist: 4.28.0
|
| 2094 |
+
escalade: 3.2.0
|
| 2095 |
+
picocolors: 1.1.1
|
| 2096 |
+
|
| 2097 |
+
util-deprecate@1.0.2: {}
|
| 2098 |
+
|
| 2099 |
+
vali-date@1.0.0: {}
|
| 2100 |
+
|
| 2101 |
+
which-typed-array@1.1.19:
|
| 2102 |
+
dependencies:
|
| 2103 |
+
available-typed-arrays: 1.0.7
|
| 2104 |
+
call-bind: 1.0.8
|
| 2105 |
+
call-bound: 1.0.4
|
| 2106 |
+
for-each: 0.3.5
|
| 2107 |
+
get-proto: 1.0.1
|
| 2108 |
+
gopd: 1.2.0
|
| 2109 |
+
has-tostringtag: 1.0.2
|
| 2110 |
+
|
| 2111 |
+
wrap-ansi@9.0.2:
|
| 2112 |
+
dependencies:
|
| 2113 |
+
ansi-styles: 6.2.3
|
| 2114 |
+
string-width: 7.2.0
|
| 2115 |
+
strip-ansi: 7.1.2
|
| 2116 |
+
|
| 2117 |
+
wrappy@1.0.2: {}
|
| 2118 |
+
|
| 2119 |
+
xml2js@0.6.2:
|
| 2120 |
+
dependencies:
|
| 2121 |
+
sax: 1.4.3
|
| 2122 |
+
xmlbuilder: 11.0.1
|
| 2123 |
+
|
| 2124 |
+
xmlbuilder@11.0.1: {}
|
| 2125 |
+
|
| 2126 |
+
xtend@4.0.2: {}
|
| 2127 |
+
|
| 2128 |
+
yaml@2.8.2: {}
|
| 2129 |
+
|
| 2130 |
+
yazl@2.5.1:
|
| 2131 |
+
dependencies:
|
| 2132 |
+
buffer-crc32: 0.2.13
|
pnpm-workspace.yaml
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ignoredBuiltDependencies:
|
| 2 |
+
- better-sqlite3
|
| 3 |
+
|
| 4 |
+
packages:
|
| 5 |
+
- '!webui/**'
|
public/index.html
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>WebAI2API - Hugging Face Space</title>
|
| 7 |
+
<style>
|
| 8 |
+
* {
|
| 9 |
+
margin: 0;
|
| 10 |
+
padding: 0;
|
| 11 |
+
box-sizing: border-box;
|
| 12 |
+
}
|
| 13 |
+
body {
|
| 14 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
| 15 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 16 |
+
min-height: 100vh;
|
| 17 |
+
display: flex;
|
| 18 |
+
align-items: center;
|
| 19 |
+
justify-content: center;
|
| 20 |
+
padding: 20px;
|
| 21 |
+
}
|
| 22 |
+
.container {
|
| 23 |
+
background: white;
|
| 24 |
+
border-radius: 20px;
|
| 25 |
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
| 26 |
+
max-width: 800px;
|
| 27 |
+
width: 100%;
|
| 28 |
+
padding: 40px;
|
| 29 |
+
}
|
| 30 |
+
.header {
|
| 31 |
+
text-align: center;
|
| 32 |
+
margin-bottom: 30px;
|
| 33 |
+
}
|
| 34 |
+
.logo {
|
| 35 |
+
font-size: 60px;
|
| 36 |
+
margin-bottom: 10px;
|
| 37 |
+
}
|
| 38 |
+
h1 {
|
| 39 |
+
color: #333;
|
| 40 |
+
font-size: 32px;
|
| 41 |
+
margin-bottom: 10px;
|
| 42 |
+
}
|
| 43 |
+
.status {
|
| 44 |
+
display: inline-block;
|
| 45 |
+
background: #10b981;
|
| 46 |
+
color: white;
|
| 47 |
+
padding: 8px 20px;
|
| 48 |
+
border-radius: 20px;
|
| 49 |
+
font-size: 14px;
|
| 50 |
+
font-weight: 600;
|
| 51 |
+
}
|
| 52 |
+
.info {
|
| 53 |
+
background: #f3f4f6;
|
| 54 |
+
border-radius: 10px;
|
| 55 |
+
padding: 20px;
|
| 56 |
+
margin: 20px 0;
|
| 57 |
+
}
|
| 58 |
+
.info h2 {
|
| 59 |
+
color: #667eea;
|
| 60 |
+
font-size: 20px;
|
| 61 |
+
margin-bottom: 15px;
|
| 62 |
+
}
|
| 63 |
+
.info p {
|
| 64 |
+
color: #666;
|
| 65 |
+
line-height: 1.6;
|
| 66 |
+
margin-bottom: 10px;
|
| 67 |
+
}
|
| 68 |
+
.info ul {
|
| 69 |
+
color: #666;
|
| 70 |
+
line-height: 1.8;
|
| 71 |
+
padding-left: 20px;
|
| 72 |
+
}
|
| 73 |
+
.buttons {
|
| 74 |
+
display: flex;
|
| 75 |
+
gap: 15px;
|
| 76 |
+
margin-top: 30px;
|
| 77 |
+
flex-wrap: wrap;
|
| 78 |
+
}
|
| 79 |
+
.btn {
|
| 80 |
+
flex: 1;
|
| 81 |
+
min-width: 200px;
|
| 82 |
+
padding: 15px 30px;
|
| 83 |
+
border: none;
|
| 84 |
+
border-radius: 10px;
|
| 85 |
+
font-size: 16px;
|
| 86 |
+
font-weight: 600;
|
| 87 |
+
cursor: pointer;
|
| 88 |
+
transition: all 0.3s;
|
| 89 |
+
text-decoration: none;
|
| 90 |
+
text-align: center;
|
| 91 |
+
}
|
| 92 |
+
.btn-primary {
|
| 93 |
+
background: #667eea;
|
| 94 |
+
color: white;
|
| 95 |
+
}
|
| 96 |
+
.btn-primary:hover {
|
| 97 |
+
background: #5568d3;
|
| 98 |
+
transform: translateY(-2px);
|
| 99 |
+
}
|
| 100 |
+
.btn-secondary {
|
| 101 |
+
background: #e5e7eb;
|
| 102 |
+
color: #374151;
|
| 103 |
+
}
|
| 104 |
+
.btn-secondary:hover {
|
| 105 |
+
background: #d1d5db;
|
| 106 |
+
transform: translateY(-2px);
|
| 107 |
+
}
|
| 108 |
+
.footer {
|
| 109 |
+
text-align: center;
|
| 110 |
+
margin-top: 30px;
|
| 111 |
+
color: #9ca3af;
|
| 112 |
+
font-size: 14px;
|
| 113 |
+
}
|
| 114 |
+
.api-endpoints {
|
| 115 |
+
background: #1f2937;
|
| 116 |
+
color: #e5e7eb;
|
| 117 |
+
border-radius: 10px;
|
| 118 |
+
padding: 20px;
|
| 119 |
+
margin: 20px 0;
|
| 120 |
+
font-family: 'Courier New', monospace;
|
| 121 |
+
font-size: 14px;
|
| 122 |
+
}
|
| 123 |
+
.api-endpoints code {
|
| 124 |
+
background: #374151;
|
| 125 |
+
padding: 2px 6px;
|
| 126 |
+
border-radius: 4px;
|
| 127 |
+
}
|
| 128 |
+
@media (max-width: 600px) {
|
| 129 |
+
.container {
|
| 130 |
+
padding: 20px;
|
| 131 |
+
}
|
| 132 |
+
h1 {
|
| 133 |
+
font-size: 24px;
|
| 134 |
+
}
|
| 135 |
+
.buttons {
|
| 136 |
+
flex-direction: column;
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
</style>
|
| 140 |
+
</head>
|
| 141 |
+
<body>
|
| 142 |
+
<div class="container">
|
| 143 |
+
<div class="header">
|
| 144 |
+
<div class="logo">🤖</div>
|
| 145 |
+
<h1>WebAI2API</h1>
|
| 146 |
+
<span class="status">✓ 服务运行中</span>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
<div class="info">
|
| 150 |
+
<h2>📝 项目简介</h2>
|
| 151 |
+
<p>WebAI2API 是一个基于 Camoufox (Playwright) 的网页版 AI 服务转通用 API 的工具。通过模拟人类操作与 LMArena、Gemini 等网站交互,提供兼容 OpenAI 格式的接口服务。</p>
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
<div class="info">
|
| 155 |
+
<h2>🌐 API 端点</h2>
|
| 156 |
+
<div class="api-endpoints">
|
| 157 |
+
<p>• <code>GET /v1/models</code> - 获取模型列表</p>
|
| 158 |
+
<p>• <code>POST /v1/chat/completions</code> - 文本对话</p>
|
| 159 |
+
<p>• <code>GET /v1/cookies</code> - 获取 Cookies</p>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
<div class="info">
|
| 164 |
+
<h2>✨ 主要特性</h2>
|
| 165 |
+
<ul>
|
| 166 |
+
<li>🤖 拟人交互:模拟人类打字与鼠标轨迹</li>
|
| 167 |
+
<li>🔄 接口兼容:提供标准 OpenAI 格式接口</li>
|
| 168 |
+
<li>🚀 并发隔离:支持多窗口并发执行</li>
|
| 169 |
+
<li>🛡️ 稳定防护:内��任务队列、负载均衡</li>
|
| 170 |
+
<li>🎨 网页管理:提供可视化管理界面</li>
|
| 171 |
+
</ul>
|
| 172 |
+
</div>
|
| 173 |
+
|
| 174 |
+
<div class="buttons">
|
| 175 |
+
<a href="/webui/" class="btn btn-primary">🎨 访问 WebUI</a>
|
| 176 |
+
<a href="/v1/models" class="btn btn-secondary">📋 查看模型列表</a>
|
| 177 |
+
</div>
|
| 178 |
+
|
| 179 |
+
<div class="info" style="margin-top: 30px;">
|
| 180 |
+
<h2>⚠️ 注意事项</h2>
|
| 181 |
+
<p>首次使用需要:</p>
|
| 182 |
+
<ul>
|
| 183 |
+
<li>访问 WebUI 完成账号登录初始化</li>
|
| 184 |
+
<li>配置 API Token(在 Space Settings 中设置 AUTH_TOKEN)</li>
|
| 185 |
+
<li>监控日志查看运行状态(Space Logs 页面)</li>
|
| 186 |
+
</ul>
|
| 187 |
+
</div>
|
| 188 |
+
|
| 189 |
+
<div class="footer">
|
| 190 |
+
<p>Powered by Hugging Face Spaces | 🙏 感谢 Hugging Face 提供托管服务</p>
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
<script>
|
| 195 |
+
// 简单的健康检查
|
| 196 |
+
fetch('/v1/models')
|
| 197 |
+
.then(response => response.json())
|
| 198 |
+
.then(data => {
|
| 199 |
+
console.log('API 运行正常,可用模型数量:', data.data?.length || 0);
|
| 200 |
+
})
|
| 201 |
+
.catch(error => {
|
| 202 |
+
console.log('API 检查失败:', error);
|
| 203 |
+
});
|
| 204 |
+
</script>
|
| 205 |
+
</body>
|
| 206 |
+
</html>
|
scripts/clear-data.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* 清除本地浏览器数据
|
| 5 |
+
*
|
| 6 |
+
* 使用方法:
|
| 7 |
+
* node scripts/clear-data.js
|
| 8 |
+
*
|
| 9 |
+
* 注意:
|
| 10 |
+
* 此操作不可逆,请谨慎使用
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
import fs from 'fs';
|
| 14 |
+
import path from 'path';
|
| 15 |
+
import { fileURLToPath } from 'url';
|
| 16 |
+
|
| 17 |
+
const __filename = fileURLToPath(import.meta.url);
|
| 18 |
+
const __dirname = path.dirname(__filename);
|
| 19 |
+
|
| 20 |
+
// 数据目录
|
| 21 |
+
const DATA_DIR = path.join(__dirname, '../data/camoufoxUserData');
|
| 22 |
+
|
| 23 |
+
console.log('==========================================');
|
| 24 |
+
console.log('WebAI2API - 数据清除脚本');
|
| 25 |
+
console.log('==========================================');
|
| 26 |
+
console.log('');
|
| 27 |
+
|
| 28 |
+
// 检查数据目录是否存在
|
| 29 |
+
if (!fs.existsSync(DATA_DIR)) {
|
| 30 |
+
console.log('✅ 数据目录不存在,无需清除');
|
| 31 |
+
process.exit(0);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
console.log('⚠️ 警告:此操作将删除所有浏览器数据!');
|
| 35 |
+
console.log('📁 数据目录:', DATA_DIR);
|
| 36 |
+
console.log('');
|
| 37 |
+
console.log('包含的内容:');
|
| 38 |
+
|
| 39 |
+
// 显示将要删除的文件
|
| 40 |
+
const files = getAllFiles(DATA_DIR);
|
| 41 |
+
if (files.length === 0) {
|
| 42 |
+
console.log(' (空目录)');
|
| 43 |
+
} else {
|
| 44 |
+
files.forEach(file => {
|
| 45 |
+
const relativePath = path.relative(DATA_DIR, file);
|
| 46 |
+
const stats = fs.statSync(file);
|
| 47 |
+
const size = (stats.size / 1024).toFixed(2);
|
| 48 |
+
console.log(` - ${relativePath} (${size} KB)`);
|
| 49 |
+
});
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
console.log('');
|
| 53 |
+
console.log('⏳ 5 秒后自动取消,按 Ctrl+C 立即取消...');
|
| 54 |
+
console.log('');
|
| 55 |
+
|
| 56 |
+
// 等待 5 秒
|
| 57 |
+
setTimeout(() => {
|
| 58 |
+
console.log('⏱️ 倒计时结束,开始删除...');
|
| 59 |
+
console.log('');
|
| 60 |
+
|
| 61 |
+
try {
|
| 62 |
+
// 删除数据目录
|
| 63 |
+
fs.rmSync(DATA_DIR, { recursive: true, force: true });
|
| 64 |
+
|
| 65 |
+
console.log('✅ 数据清除成功!');
|
| 66 |
+
console.log('');
|
| 67 |
+
console.log('下次启动时将使用新的浏览器实例。');
|
| 68 |
+
console.log('如需恢复数据,请运行:npm run restore-data');
|
| 69 |
+
|
| 70 |
+
} catch (error) {
|
| 71 |
+
console.error('');
|
| 72 |
+
console.error('❌ 清除失败:', error.message);
|
| 73 |
+
process.exit(1);
|
| 74 |
+
}
|
| 75 |
+
}, 5000);
|
| 76 |
+
|
| 77 |
+
/**
|
| 78 |
+
* 递归获取所有文件
|
| 79 |
+
*/
|
| 80 |
+
function getAllFiles(dir, fileList = []) {
|
| 81 |
+
const files = fs.readdirSync(dir);
|
| 82 |
+
files.forEach(file => {
|
| 83 |
+
const filePath = path.join(dir, file);
|
| 84 |
+
const stat = fs.statSync(filePath);
|
| 85 |
+
if (stat.isDirectory()) {
|
| 86 |
+
getAllFiles(filePath, fileList);
|
| 87 |
+
} else {
|
| 88 |
+
fileList.push(filePath);
|
| 89 |
+
}
|
| 90 |
+
});
|
| 91 |
+
return fileList;
|
| 92 |
+
}
|
scripts/genkey.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview 生成 API Key(CLI)
|
| 3 |
+
* @description 输出一个新的 `server.auth` Key,供写入 `config.yaml` 使用。
|
| 4 |
+
*
|
| 5 |
+
* 用法:`npm run genkey`
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import crypto from 'crypto';
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* 生成随机 API Key(用于 `config.yaml` 的 `server.auth`)
|
| 12 |
+
* 格式:sk-{48位十六进制字符}
|
| 13 |
+
* @returns {string} API Key
|
| 14 |
+
*/
|
| 15 |
+
function generateApiKey() {
|
| 16 |
+
return 'sk-' + crypto.randomBytes(24).toString('hex');
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
console.log('>>> [GenAPIKey] 生成新的 API Key:');
|
| 20 |
+
console.log(generateApiKey());
|
| 21 |
+
console.log('\n>>> 请将此 Key 复制到 config.yaml 文件的 server.auth 字段中。');
|
scripts/init.js
ADDED
|
@@ -0,0 +1,747 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview 运行环境初始化脚本(CLI)
|
| 3 |
+
* @description 用于下载/准备运行所需依赖(如 Camoufox、better-sqlite3 等)。
|
| 4 |
+
*
|
| 5 |
+
* 用法:
|
| 6 |
+
* npm run init # 自动初始化(无代理)
|
| 7 |
+
* npm run init -- -proxy # 自动初始化(交互式输入代理)
|
| 8 |
+
* npm run init -- -proxy=http://127.0.0.1:7890
|
| 9 |
+
* npm run init -- -proxy=socks5://user:pass@127.0.0.1:1080
|
| 10 |
+
* npm run init -- -custom # 自定义模式
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
import fs from 'fs';
|
| 14 |
+
import path from 'path';
|
| 15 |
+
import os from 'os';
|
| 16 |
+
import https from 'https';
|
| 17 |
+
import http from 'http';
|
| 18 |
+
import { fileURLToPath } from 'url';
|
| 19 |
+
import compressing from 'compressing';
|
| 20 |
+
import { logger } from '../src/utils/logger.js';
|
| 21 |
+
import { select, input } from '@inquirer/prompts';
|
| 22 |
+
import { anonymizeProxy, closeAnonymizedProxy } from 'proxy-chain';
|
| 23 |
+
|
| 24 |
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
| 25 |
+
const PROJECT_ROOT = path.join(__dirname, '..');
|
| 26 |
+
const TEMP_DIR = path.join(PROJECT_ROOT, 'data', 'temp');
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* 解析命令行代理参数
|
| 30 |
+
* @returns {Promise<string|null>} 代理 URL
|
| 31 |
+
*/
|
| 32 |
+
async function parseProxyArg() {
|
| 33 |
+
// 查找 -proxy 或 -proxy=xxx 参数
|
| 34 |
+
const proxyArg = process.argv.find(arg => arg.startsWith('-proxy'));
|
| 35 |
+
|
| 36 |
+
if (!proxyArg) {
|
| 37 |
+
return null;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// -proxy=http://... 格式
|
| 41 |
+
if (proxyArg.includes('=')) {
|
| 42 |
+
const proxyUrl = proxyArg.split('=')[1];
|
| 43 |
+
if (proxyUrl) {
|
| 44 |
+
logger.info('初始化', `使用代理: ${proxyUrl}`);
|
| 45 |
+
return proxyUrl;
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
// -proxy 不带参数,交互式输入
|
| 50 |
+
logger.info('初始化', '请输入代理配置...');
|
| 51 |
+
|
| 52 |
+
const proxyType = await select({
|
| 53 |
+
message: '代理类型',
|
| 54 |
+
choices: [
|
| 55 |
+
{ name: 'HTTP', value: 'http' },
|
| 56 |
+
{ name: 'SOCKS5', value: 'socks5' }
|
| 57 |
+
]
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
const host = await input({
|
| 61 |
+
message: '代理服务器地址',
|
| 62 |
+
default: '127.0.0.1',
|
| 63 |
+
validate: (val) => val.trim().length > 0 || '地址不能为空'
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
const port = await input({
|
| 67 |
+
message: '代理端口',
|
| 68 |
+
default: '7890',
|
| 69 |
+
validate: (val) => {
|
| 70 |
+
const num = parseInt(val, 10);
|
| 71 |
+
return (num > 0 && num <= 65535) || '端口必须是 1-65535 的数字';
|
| 72 |
+
}
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
const username = await input({
|
| 76 |
+
message: '用户名 (可选,回车跳过)',
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
const password = await input({
|
| 80 |
+
message: '密码 (可选,回车跳过)',
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
// 构建代理 URL
|
| 84 |
+
let proxyUrl = `${proxyType}://`;
|
| 85 |
+
if (username && password) {
|
| 86 |
+
proxyUrl += `${encodeURIComponent(username)}:${encodeURIComponent(password)}@`;
|
| 87 |
+
} else if (username) {
|
| 88 |
+
proxyUrl += `${encodeURIComponent(username)}@`;
|
| 89 |
+
}
|
| 90 |
+
proxyUrl += `${host}:${port}`;
|
| 91 |
+
|
| 92 |
+
logger.info('初始化', `使用代理: ${proxyUrl}`);
|
| 93 |
+
return proxyUrl;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// 确保临时目录存在
|
| 97 |
+
if (!fs.existsSync(TEMP_DIR)) {
|
| 98 |
+
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/**
|
| 102 |
+
* 获取 Node.js ABI 版本
|
| 103 |
+
*/
|
| 104 |
+
function getNodeABI() {
|
| 105 |
+
return process.versions.modules;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/**
|
| 109 |
+
* 获取平台信息
|
| 110 |
+
*/
|
| 111 |
+
function getPlatformInfo() {
|
| 112 |
+
const platform = os.platform();
|
| 113 |
+
const arch = os.arch();
|
| 114 |
+
const nodeVersion = process.version;
|
| 115 |
+
const abi = getNodeABI();
|
| 116 |
+
|
| 117 |
+
return { platform, arch, nodeVersion, abi };
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/**
|
| 121 |
+
* 验证平台支持
|
| 122 |
+
*/
|
| 123 |
+
function validatePlatform(platform, arch) {
|
| 124 |
+
const supported = {
|
| 125 |
+
'win32': ['x64'],
|
| 126 |
+
'darwin': ['x64', 'arm64'],
|
| 127 |
+
'linux': ['x64', 'arm64']
|
| 128 |
+
};
|
| 129 |
+
|
| 130 |
+
if (!supported[platform] || !supported[platform].includes(arch)) {
|
| 131 |
+
return false;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
return true;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
/**
|
| 138 |
+
* 验证 Node.js ABI 版本支持
|
| 139 |
+
*/
|
| 140 |
+
function validateABI(abi) {
|
| 141 |
+
const supportedABIs = [115, 121, 123, 125, 127, 128, 130, 131, 132, 133, 135, 136, 137, 139, 140, 141];
|
| 142 |
+
return supportedABIs.includes(parseInt(abi, 10));
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
/**
|
| 146 |
+
* 下载文件(带进度,流式,支持重试)
|
| 147 |
+
* @param {string} url - 下载地址
|
| 148 |
+
* @param {string} destPath - 目标文件路径
|
| 149 |
+
* @param {string|null} proxyUrl - 代理 URL(支持 http:// 和 socks5://)
|
| 150 |
+
* @param {number} maxRetries - 最大重试次数
|
| 151 |
+
*/
|
| 152 |
+
async function downloadFile(url, destPath, proxyUrl = null, maxRetries = 3) {
|
| 153 |
+
// 如果是 SOCKS5 代理,先转换为本地 HTTP 代理
|
| 154 |
+
let effectiveProxyUrl = proxyUrl;
|
| 155 |
+
let anonymizedProxy = null;
|
| 156 |
+
|
| 157 |
+
if (proxyUrl && proxyUrl.startsWith('socks5://')) {
|
| 158 |
+
try {
|
| 159 |
+
logger.info('初始化', `检测到 SOCKS5 代理,正在转换为 HTTP 代理...`);
|
| 160 |
+
anonymizedProxy = await anonymizeProxy(proxyUrl);
|
| 161 |
+
effectiveProxyUrl = anonymizedProxy;
|
| 162 |
+
logger.info('初始化', `SOCKS5 代理已转换: ${anonymizedProxy}`);
|
| 163 |
+
} catch (error) {
|
| 164 |
+
logger.error('初始化', `SOCKS5 代理转换失败: ${error.message}`);
|
| 165 |
+
throw error;
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
try {
|
| 170 |
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
| 171 |
+
try {
|
| 172 |
+
if (attempt > 1) {
|
| 173 |
+
logger.info('初始化', `第 ${attempt}/${maxRetries} 次尝试下载...`);
|
| 174 |
+
// 删除之前失败的文件
|
| 175 |
+
try {
|
| 176 |
+
if (fs.existsSync(destPath)) {
|
| 177 |
+
fs.unlinkSync(destPath);
|
| 178 |
+
}
|
| 179 |
+
} catch (e) { }
|
| 180 |
+
} else {
|
| 181 |
+
logger.info('初始化', `开始下载: ${url}`);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
await downloadFileOnce(url, destPath, effectiveProxyUrl);
|
| 185 |
+
return destPath;
|
| 186 |
+
} catch (error) {
|
| 187 |
+
logger.error('初始化', `下载失败 (尝试 ${attempt}/${maxRetries}): ${error.message}`);
|
| 188 |
+
|
| 189 |
+
if (attempt === maxRetries) {
|
| 190 |
+
throw error;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
// 等待后重试(递增延迟)
|
| 194 |
+
const delay = attempt * 2000;
|
| 195 |
+
logger.info('初始化', `${delay / 1000} 秒后重试...`);
|
| 196 |
+
await new Promise(resolve => setTimeout(resolve, delay));
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
} finally {
|
| 200 |
+
// 清理 SOCKS5 代理资源
|
| 201 |
+
if (anonymizedProxy) {
|
| 202 |
+
try {
|
| 203 |
+
await closeAnonymizedProxy(anonymizedProxy, true);
|
| 204 |
+
logger.debug('初始化', '已关闭临时代理桥接');
|
| 205 |
+
} catch (e) { }
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
/**
|
| 211 |
+
* 单次下载尝试(内部函数)
|
| 212 |
+
* 使用 Node.js 原生 http/https 模块,手动管理超时
|
| 213 |
+
* 只有在指定时间内没有任何数据传输才会触发超时
|
| 214 |
+
*/
|
| 215 |
+
async function downloadFileOnce(url, destPath, proxyUrl = null) {
|
| 216 |
+
const IDLE_TIMEOUT = 180000; // 3 分钟无数据传输才超时
|
| 217 |
+
|
| 218 |
+
return new Promise((resolve, reject) => {
|
| 219 |
+
const urlObj = new URL(url);
|
| 220 |
+
const isHttps = urlObj.protocol === 'https:';
|
| 221 |
+
|
| 222 |
+
let requestOptions = {
|
| 223 |
+
hostname: urlObj.hostname,
|
| 224 |
+
port: urlObj.port || (isHttps ? 443 : 80),
|
| 225 |
+
path: urlObj.pathname + urlObj.search,
|
| 226 |
+
method: 'GET',
|
| 227 |
+
headers: {
|
| 228 |
+
'User-Agent': 'Wget/1.21.4 (linux-gnu)',
|
| 229 |
+
'Accept': '*/*',
|
| 230 |
+
'Accept-Encoding': 'identity',
|
| 231 |
+
'Connection': 'keep-alive'
|
| 232 |
+
}
|
| 233 |
+
};
|
| 234 |
+
|
| 235 |
+
// 如果有 HTTP 代理(注意:这里只支持 HTTP 代理,SOCKS 需要额外处理)
|
| 236 |
+
let httpModule = isHttps ? https : http;
|
| 237 |
+
if (proxyUrl && proxyUrl.startsWith('http')) {
|
| 238 |
+
const proxyUrlObj = new URL(proxyUrl);
|
| 239 |
+
// 使用 CONNECT 隧道代理
|
| 240 |
+
requestOptions = {
|
| 241 |
+
hostname: proxyUrlObj.hostname,
|
| 242 |
+
port: proxyUrlObj.port || 80,
|
| 243 |
+
method: 'CONNECT',
|
| 244 |
+
path: `${urlObj.hostname}:${urlObj.port || (isHttps ? 443 : 80)}`,
|
| 245 |
+
headers: {
|
| 246 |
+
'Host': `${urlObj.hostname}:${urlObj.port || (isHttps ? 443 : 80)}`
|
| 247 |
+
}
|
| 248 |
+
};
|
| 249 |
+
if (proxyUrlObj.username) {
|
| 250 |
+
const auth = Buffer.from(`${proxyUrlObj.username}:${proxyUrlObj.password || ''}`).toString('base64');
|
| 251 |
+
requestOptions.headers['Proxy-Authorization'] = `Basic ${auth}`;
|
| 252 |
+
}
|
| 253 |
+
httpModule = http; // 代理连接始终是 HTTP
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
const fileStream = fs.createWriteStream(destPath);
|
| 257 |
+
let downloadedSize = 0;
|
| 258 |
+
let totalSize = 0;
|
| 259 |
+
let lastLogTime = Date.now();
|
| 260 |
+
let finished = false;
|
| 261 |
+
let idleTimer = null;
|
| 262 |
+
let req = null;
|
| 263 |
+
|
| 264 |
+
const resetIdleTimer = () => {
|
| 265 |
+
if (idleTimer) clearTimeout(idleTimer);
|
| 266 |
+
idleTimer = setTimeout(() => {
|
| 267 |
+
if (!finished) {
|
| 268 |
+
const error = new Error(`下载超时: ${IDLE_TIMEOUT / 1000} 秒内没有收到任何数据`);
|
| 269 |
+
cleanup();
|
| 270 |
+
reject(error);
|
| 271 |
+
}
|
| 272 |
+
}, IDLE_TIMEOUT);
|
| 273 |
+
};
|
| 274 |
+
|
| 275 |
+
const cleanup = () => {
|
| 276 |
+
finished = true;
|
| 277 |
+
if (idleTimer) clearTimeout(idleTimer);
|
| 278 |
+
if (req) {
|
| 279 |
+
try { req.destroy(); } catch (e) { }
|
| 280 |
+
}
|
| 281 |
+
fileStream.close();
|
| 282 |
+
};
|
| 283 |
+
|
| 284 |
+
const handleResponse = (res) => {
|
| 285 |
+
resetIdleTimer();
|
| 286 |
+
|
| 287 |
+
// 处理重定向
|
| 288 |
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
| 289 |
+
cleanup();
|
| 290 |
+
try { fs.unlinkSync(destPath); } catch (e) { }
|
| 291 |
+
logger.info('初始化', `重定向到: ${res.headers.location}`);
|
| 292 |
+
// 递归调用处理重定向
|
| 293 |
+
downloadFileOnce(res.headers.location, destPath, proxyUrl)
|
| 294 |
+
.then(resolve)
|
| 295 |
+
.catch(reject);
|
| 296 |
+
return;
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
if (res.statusCode !== 200) {
|
| 300 |
+
cleanup();
|
| 301 |
+
try { fs.unlinkSync(destPath); } catch (e) { }
|
| 302 |
+
reject(new Error(`HTTP 错误: ${res.statusCode}`));
|
| 303 |
+
return;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
totalSize = parseInt(res.headers['content-length'] || '0', 10);
|
| 307 |
+
if (totalSize > 0) {
|
| 308 |
+
logger.info('初始化', `文件大小: ${(totalSize / 1024 / 1024).toFixed(2)} MB`);
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
res.on('data', (chunk) => {
|
| 312 |
+
resetIdleTimer();
|
| 313 |
+
downloadedSize += chunk.length;
|
| 314 |
+
|
| 315 |
+
const now = Date.now();
|
| 316 |
+
if (totalSize > 0 && now - lastLogTime > 100) { // 100ms 更新一次,更流畅
|
| 317 |
+
const percent = ((downloadedSize / totalSize) * 100).toFixed(1);
|
| 318 |
+
const downloadedMB = (downloadedSize / 1024 / 1024).toFixed(2);
|
| 319 |
+
const totalMB = (totalSize / 1024 / 1024).toFixed(2);
|
| 320 |
+
// 使用 \r 回到行首,实现单行刷新
|
| 321 |
+
process.stdout.write(`\r下载进度: ${percent}% (${downloadedMB}MB / ${totalMB}MB) `);
|
| 322 |
+
lastLogTime = now;
|
| 323 |
+
}
|
| 324 |
+
});
|
| 325 |
+
|
| 326 |
+
res.on('error', (error) => {
|
| 327 |
+
if (finished) return;
|
| 328 |
+
cleanup();
|
| 329 |
+
try { fs.unlinkSync(destPath); } catch (e) { }
|
| 330 |
+
reject(error);
|
| 331 |
+
});
|
| 332 |
+
|
| 333 |
+
res.pipe(fileStream);
|
| 334 |
+
|
| 335 |
+
fileStream.on('error', (error) => {
|
| 336 |
+
if (finished) return;
|
| 337 |
+
cleanup();
|
| 338 |
+
reject(error);
|
| 339 |
+
});
|
| 340 |
+
|
| 341 |
+
fileStream.on('finish', () => {
|
| 342 |
+
if (finished) return;
|
| 343 |
+
finished = true;
|
| 344 |
+
if (idleTimer) clearTimeout(idleTimer);
|
| 345 |
+
|
| 346 |
+
const finalSize = (downloadedSize / 1024 / 1024).toFixed(2);
|
| 347 |
+
|
| 348 |
+
if (totalSize > 0 && downloadedSize !== totalSize) {
|
| 349 |
+
process.stdout.write('\n'); // 换行,避免与进度条混在一起
|
| 350 |
+
const errorMsg = `下载不完整: 预期 ${(totalSize / 1024 / 1024).toFixed(2)} MB, 实际 ${finalSize} MB`;
|
| 351 |
+
logger.error('初始化', errorMsg);
|
| 352 |
+
try { fs.unlinkSync(destPath); } catch (e) { }
|
| 353 |
+
reject(new Error(errorMsg));
|
| 354 |
+
return;
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
process.stdout.write('\n'); // 换行,结束进度条
|
| 358 |
+
logger.info('初始化', `下载完成: ${finalSize} MB`);
|
| 359 |
+
resolve(destPath);
|
| 360 |
+
});
|
| 361 |
+
};
|
| 362 |
+
|
| 363 |
+
resetIdleTimer();
|
| 364 |
+
|
| 365 |
+
// 如果使用代理,先建立隧道
|
| 366 |
+
if (proxyUrl && proxyUrl.startsWith('http')) {
|
| 367 |
+
req = http.request(requestOptions);
|
| 368 |
+
req.on('connect', (res, socket) => {
|
| 369 |
+
if (res.statusCode !== 200) {
|
| 370 |
+
cleanup();
|
| 371 |
+
reject(new Error(`代理连接失败: ${res.statusCode}`));
|
| 372 |
+
return;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
// 通过隧道发起真正的 HTTPS 请求
|
| 376 |
+
const tunnelOptions = {
|
| 377 |
+
hostname: urlObj.hostname,
|
| 378 |
+
port: urlObj.port || (isHttps ? 443 : 80),
|
| 379 |
+
path: urlObj.pathname + urlObj.search,
|
| 380 |
+
method: 'GET',
|
| 381 |
+
headers: {
|
| 382 |
+
'User-Agent': 'Wget/1.21.4 (linux-gnu)',
|
| 383 |
+
'Accept': '*/*',
|
| 384 |
+
'Accept-Encoding': 'identity',
|
| 385 |
+
'Connection': 'keep-alive',
|
| 386 |
+
'Host': urlObj.host
|
| 387 |
+
},
|
| 388 |
+
socket: socket,
|
| 389 |
+
agent: false
|
| 390 |
+
};
|
| 391 |
+
|
| 392 |
+
const tunnelReq = (isHttps ? https : http).request(tunnelOptions, handleResponse);
|
| 393 |
+
tunnelReq.on('error', (error) => {
|
| 394 |
+
if (finished) return;
|
| 395 |
+
cleanup();
|
| 396 |
+
try { fs.unlinkSync(destPath); } catch (e) { }
|
| 397 |
+
reject(error);
|
| 398 |
+
});
|
| 399 |
+
tunnelReq.end();
|
| 400 |
+
});
|
| 401 |
+
req.on('error', (error) => {
|
| 402 |
+
if (finished) return;
|
| 403 |
+
cleanup();
|
| 404 |
+
try { fs.unlinkSync(destPath); } catch (e) { }
|
| 405 |
+
reject(error);
|
| 406 |
+
});
|
| 407 |
+
req.end();
|
| 408 |
+
} else {
|
| 409 |
+
// 直连模式
|
| 410 |
+
req = httpModule.request(requestOptions, handleResponse);
|
| 411 |
+
req.on('error', (error) => {
|
| 412 |
+
if (finished) return;
|
| 413 |
+
cleanup();
|
| 414 |
+
try { fs.unlinkSync(destPath); } catch (e) { }
|
| 415 |
+
reject(error);
|
| 416 |
+
});
|
| 417 |
+
req.end();
|
| 418 |
+
}
|
| 419 |
+
});
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
/**
|
| 423 |
+
* 构建 better-sqlite3 下载 URL
|
| 424 |
+
*/
|
| 425 |
+
function getBetterSqlite3Url(platform, arch, abi) {
|
| 426 |
+
const version = '12.5.0';
|
| 427 |
+
const platformMap = {
|
| 428 |
+
'win32': 'win32',
|
| 429 |
+
'darwin': 'darwin',
|
| 430 |
+
'linux': 'linux'
|
| 431 |
+
};
|
| 432 |
+
|
| 433 |
+
const platformName = platformMap[platform];
|
| 434 |
+
const archName = arch; // x64 或 arm64
|
| 435 |
+
|
| 436 |
+
return `https://github.com/WiseLibs/better-sqlite3/releases/download/v${version}/better-sqlite3-v${version}-node-v${abi}-${platformName}-${archName}.tar.gz`;
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
/**
|
| 440 |
+
* 下载并安装 better-sqlite3
|
| 441 |
+
*/
|
| 442 |
+
async function installBetterSqlite3(platform, arch, abi, proxyUrl) {
|
| 443 |
+
logger.info('初始化', '开始安装 better-sqlite3...');
|
| 444 |
+
|
| 445 |
+
const url = getBetterSqlite3Url(platform, arch, abi);
|
| 446 |
+
const downloadPath = path.join(TEMP_DIR, 'better-sqlite3.tar.gz');
|
| 447 |
+
|
| 448 |
+
// 下载
|
| 449 |
+
await downloadFile(url, downloadPath, proxyUrl);
|
| 450 |
+
|
| 451 |
+
// 解压 .tar.gz 文件
|
| 452 |
+
logger.info('初始化', '正在解压 better-sqlite3...');
|
| 453 |
+
await compressing.tgz.uncompress(downloadPath, TEMP_DIR);
|
| 454 |
+
|
| 455 |
+
// 查找 better_sqlite3.node
|
| 456 |
+
const files = fs.readdirSync(TEMP_DIR, { recursive: true });
|
| 457 |
+
const nodeFile = files.find(f => f.endsWith('better_sqlite3.node'));
|
| 458 |
+
if (!nodeFile) {
|
| 459 |
+
throw new Error('未找到 better_sqlite3.node 文件');
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
// 复制到 node_modules
|
| 463 |
+
const buildDir = path.join(PROJECT_ROOT, 'node_modules', 'better-sqlite3', 'build', 'Release');
|
| 464 |
+
if (!fs.existsSync(buildDir)) {
|
| 465 |
+
fs.mkdirSync(buildDir, { recursive: true });
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
const sourcePath = path.join(TEMP_DIR, nodeFile);
|
| 469 |
+
const destPath = path.join(buildDir, 'better_sqlite3.node');
|
| 470 |
+
fs.copyFileSync(sourcePath, destPath);
|
| 471 |
+
|
| 472 |
+
logger.info('初始化', `better-sqlite3 安装成功: ${destPath}`);
|
| 473 |
+
|
| 474 |
+
// 清理
|
| 475 |
+
fs.unlinkSync(downloadPath);
|
| 476 |
+
// 清理解压后的所有文件
|
| 477 |
+
files.forEach(f => {
|
| 478 |
+
const filePath = path.join(TEMP_DIR, f);
|
| 479 |
+
try {
|
| 480 |
+
if (fs.existsSync(filePath)) {
|
| 481 |
+
const stat = fs.statSync(filePath);
|
| 482 |
+
if (stat.isDirectory()) {
|
| 483 |
+
fs.rmSync(filePath, { recursive: true, force: true });
|
| 484 |
+
} else {
|
| 485 |
+
fs.unlinkSync(filePath);
|
| 486 |
+
}
|
| 487 |
+
}
|
| 488 |
+
} catch (e) { }
|
| 489 |
+
});
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
/**
|
| 493 |
+
* 构建 Camoufox 下载 URL
|
| 494 |
+
*/
|
| 495 |
+
function getCamoufoxUrl(platform, arch) {
|
| 496 |
+
const version = '135.0.1-beta.24';
|
| 497 |
+
const platformMap = {
|
| 498 |
+
'win32': 'win',
|
| 499 |
+
'darwin': 'mac',
|
| 500 |
+
'linux': 'lin'
|
| 501 |
+
};
|
| 502 |
+
|
| 503 |
+
const archMap = {
|
| 504 |
+
'x64': 'x86_64',
|
| 505 |
+
'arm64': 'arm64'
|
| 506 |
+
};
|
| 507 |
+
|
| 508 |
+
const platformName = platformMap[platform];
|
| 509 |
+
const archName = archMap[arch];
|
| 510 |
+
|
| 511 |
+
return `https://github.com/daijro/camoufox/releases/download/v${version}/camoufox-${version}-${platformName}.${archName}.zip`;
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
/**
|
| 515 |
+
* 下载并安装 Camoufox
|
| 516 |
+
*/
|
| 517 |
+
async function installCamoufox(platform, arch, proxyUrl) {
|
| 518 |
+
logger.info('初始化', '开始安装 Camoufox 浏览器...');
|
| 519 |
+
|
| 520 |
+
const url = getCamoufoxUrl(platform, arch);
|
| 521 |
+
const downloadPath = path.join(TEMP_DIR, 'camoufox.zip');
|
| 522 |
+
|
| 523 |
+
// 下载
|
| 524 |
+
await downloadFile(url, downloadPath, proxyUrl);
|
| 525 |
+
|
| 526 |
+
// 解压 .zip 文件到 camoufox 目录
|
| 527 |
+
logger.info('初始化', '正在解压 Camoufox...');
|
| 528 |
+
const camoufoxDir = path.join(PROJECT_ROOT, 'camoufox');
|
| 529 |
+
if (!fs.existsSync(camoufoxDir)) {
|
| 530 |
+
fs.mkdirSync(camoufoxDir, { recursive: true });
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
await compressing.zip.uncompress(downloadPath, camoufoxDir);
|
| 534 |
+
|
| 535 |
+
// macOS 专用:复制 properties.json 到 MacOS 目录
|
| 536 |
+
if (platform === 'darwin') {
|
| 537 |
+
const resourcesPath = path.join(camoufoxDir, 'Camoufox.app', 'Contents', 'Resources', 'properties.json');
|
| 538 |
+
const macOSDir = path.join(camoufoxDir, 'Camoufox.app', 'Contents', 'MacOS');
|
| 539 |
+
const macOSPath = path.join(macOSDir, 'properties.json');
|
| 540 |
+
|
| 541 |
+
if (fs.existsSync(resourcesPath)) {
|
| 542 |
+
// 确保目标目录存在
|
| 543 |
+
if (!fs.existsSync(macOSDir)) {
|
| 544 |
+
fs.mkdirSync(macOSDir, { recursive: true });
|
| 545 |
+
}
|
| 546 |
+
fs.copyFileSync(resourcesPath, macOSPath);
|
| 547 |
+
logger.info('初始化', `已复制 properties.json 到 MacOS 目录`);
|
| 548 |
+
} else {
|
| 549 |
+
logger.warn('初始化', `未找到 properties.json: ${resourcesPath}`);
|
| 550 |
+
}
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
logger.info('初始化', `Camoufox 安装成功: ${camoufoxDir}`);
|
| 554 |
+
|
| 555 |
+
// 创建 version.json
|
| 556 |
+
const versionJsonPath = path.join(camoufoxDir, 'version.json');
|
| 557 |
+
const versionData = {
|
| 558 |
+
version: "135.0",
|
| 559 |
+
release: "beta.24"
|
| 560 |
+
};
|
| 561 |
+
fs.writeFileSync(versionJsonPath, JSON.stringify(versionData, null, 2), 'utf8');
|
| 562 |
+
logger.info('初始化', `已生成 version.json: ${versionJsonPath}`);
|
| 563 |
+
|
| 564 |
+
// 清理
|
| 565 |
+
fs.unlinkSync(downloadPath);
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
|
| 569 |
+
/**
|
| 570 |
+
* 主流程
|
| 571 |
+
*/
|
| 572 |
+
(async () => {
|
| 573 |
+
try {
|
| 574 |
+
logger.info('初始化', '========================================');
|
| 575 |
+
logger.info('初始化', '依赖初始化脚本启动');
|
| 576 |
+
logger.info('初始化', '========================================');
|
| 577 |
+
|
| 578 |
+
// 代理使用提示
|
| 579 |
+
if (!process.argv.some(arg => arg.startsWith('-proxy'))) {
|
| 580 |
+
logger.warn('初始化', '该脚本需连接 GitHub 下载资源。若网络受限,请使用代理:');
|
| 581 |
+
logger.warn('初始化', ' - 用法: npm run init -- -proxy 可交互式填写代理信息');
|
| 582 |
+
logger.warn('初始化', ' - 同时支持直接传入参数或者使用带鉴权的代理 (支持HTTP和SOCKS5)');
|
| 583 |
+
logger.warn('初始化', ' - 示例: npm run init -- -proxy=http://username:passwd@127.0.0.1:7890');
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
// 显示系统信息
|
| 587 |
+
const { platform, arch, nodeVersion, abi } = getPlatformInfo();
|
| 588 |
+
logger.info('初始化', `操作系统: ${platform}`);
|
| 589 |
+
logger.info('初始化', `芯片架构: ${arch}`);
|
| 590 |
+
logger.info('初始化', `Node.js 版本: ${nodeVersion}`);
|
| 591 |
+
logger.info('初始化', `Node.js ABI 版本: ${abi}`);
|
| 592 |
+
|
| 593 |
+
// 验证平台支持
|
| 594 |
+
if (!validatePlatform(platform, arch)) {
|
| 595 |
+
logger.error('初始化', '不支持的平台!');
|
| 596 |
+
logger.error('初始化', `因该项目使用了 Camoufox 浏览器,没有您设备可用的预编译版本`);
|
| 597 |
+
logger.error('初始化', `支持的平台: Windows x64, macOS x64/arm64, Linux x64/arm64`);
|
| 598 |
+
process.exit(1);
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
+
logger.info('初始化', '平台支持检查通过');
|
| 602 |
+
|
| 603 |
+
// 验证 ABI 版本支持
|
| 604 |
+
if (!validateABI(abi)) {
|
| 605 |
+
logger.error('初始化', '不支持的 Node.js ABI 版本!');
|
| 606 |
+
logger.error('初始化', `当前 ABI 版本: ${abi}`);
|
| 607 |
+
logger.error('初始化', `支持的 ABI 版本: 115, 121, 123, 125, 127, 128, 130, 131, 132, 133, 135, 136, 137, 139, 140, 141`);
|
| 608 |
+
logger.error('初始化', `建议使用 Node.js 20.10.0 或更高版本`);
|
| 609 |
+
process.exit(1);
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
logger.info('初始化', 'ABI 版本检查通过');
|
| 613 |
+
|
| 614 |
+
// 解析代理参数
|
| 615 |
+
const proxyUrl = await parseProxyArg();
|
| 616 |
+
|
| 617 |
+
// 检查是否为自定义模式
|
| 618 |
+
const isCustomMode = process.argv.includes('-custom');
|
| 619 |
+
|
| 620 |
+
if (isCustomMode) {
|
| 621 |
+
// 自定义模式:交互式选择步骤
|
| 622 |
+
const action = await select({
|
| 623 |
+
message: '请选择要执行的操作:',
|
| 624 |
+
choices: [
|
| 625 |
+
{ name: '安装 better-sqlite3 预编译文件', value: 'sqlite' },
|
| 626 |
+
{ name: '安装 Camoufox 浏览器', value: 'camoufox' },
|
| 627 |
+
{ name: '安装 GeoLite2-City.mmdb 数据库', value: 'geolite' },
|
| 628 |
+
{ name: '修复 macOS 环境下的 properties.json', value: 'macos_fix' },
|
| 629 |
+
{ name: '修复 version.json 缺失', value: 'version_fix' },
|
| 630 |
+
{ name: '退出', value: 'exit' }
|
| 631 |
+
]
|
| 632 |
+
});
|
| 633 |
+
|
| 634 |
+
switch (action) {
|
| 635 |
+
case 'sqlite':
|
| 636 |
+
await installBetterSqlite3(platform, arch, abi, proxyUrl);
|
| 637 |
+
break;
|
| 638 |
+
case 'camoufox':
|
| 639 |
+
await installCamoufox(platform, arch, proxyUrl);
|
| 640 |
+
break;
|
| 641 |
+
case 'geolite':
|
| 642 |
+
await downloadGeoLiteDb(proxyUrl, true); // 强制下载
|
| 643 |
+
break;
|
| 644 |
+
case 'macos_fix':
|
| 645 |
+
fixMacOSProperties();
|
| 646 |
+
break;
|
| 647 |
+
case 'version_fix':
|
| 648 |
+
fixVersionJson();
|
| 649 |
+
break;
|
| 650 |
+
case 'exit':
|
| 651 |
+
logger.info('初始化', '已退出');
|
| 652 |
+
break;
|
| 653 |
+
}
|
| 654 |
+
} else {
|
| 655 |
+
// 正常模式:执行所有步骤
|
| 656 |
+
await installBetterSqlite3(platform, arch, abi, proxyUrl);
|
| 657 |
+
await installCamoufox(platform, arch, proxyUrl);
|
| 658 |
+
await downloadGeoLiteDb(proxyUrl);
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
logger.info('初始化', '========================================');
|
| 662 |
+
logger.info('初始化', '操作完成!');
|
| 663 |
+
logger.info('初始化', '========================================');
|
| 664 |
+
process.exit(0);
|
| 665 |
+
|
| 666 |
+
} catch (err) {
|
| 667 |
+
logger.error('初始化', '初始化失败', { error: err.message });
|
| 668 |
+
process.exit(1);
|
| 669 |
+
}
|
| 670 |
+
})();
|
| 671 |
+
|
| 672 |
+
/**
|
| 673 |
+
* 下载 GeoLite2-City.mmdb 到 camoufox 目录
|
| 674 |
+
* @param {string|null} proxyUrl - 代理 URL
|
| 675 |
+
* @param {boolean} [force=false] - 是否强制下载(忽略已存在检查)
|
| 676 |
+
*/
|
| 677 |
+
async function downloadGeoLiteDb(proxyUrl, force = false) {
|
| 678 |
+
const camoufoxDir = path.join(PROJECT_ROOT, 'camoufox');
|
| 679 |
+
const destPath = path.join(camoufoxDir, 'GeoLite2-City.mmdb');
|
| 680 |
+
|
| 681 |
+
// 确保目录存在
|
| 682 |
+
if (!fs.existsSync(camoufoxDir)) {
|
| 683 |
+
fs.mkdirSync(camoufoxDir, { recursive: true });
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
// 如果已存在且非强制模式,跳过下载
|
| 687 |
+
if (!force && fs.existsSync(destPath)) {
|
| 688 |
+
logger.info('初始化', 'GeoLite2-City.mmdb 已存在,跳过下载');
|
| 689 |
+
return;
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
logger.info('初始化', '开始下载 GeoLite2-City.mmdb...');
|
| 693 |
+
const url = 'https://github.com/P3TERX/GeoLite.mmdb/releases/latest/download/GeoLite2-City.mmdb';
|
| 694 |
+
await downloadFile(url, destPath, proxyUrl);
|
| 695 |
+
logger.info('初始化', `GeoLite2-City.mmdb 下载完成: ${destPath}`);
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
/**
|
| 699 |
+
* 修复 macOS 环境下的 properties.json
|
| 700 |
+
*/
|
| 701 |
+
function fixMacOSProperties() {
|
| 702 |
+
const platform = os.platform();
|
| 703 |
+
if (platform !== 'darwin') {
|
| 704 |
+
logger.warn('初始化', '此操作仅适用于 macOS 系统');
|
| 705 |
+
return;
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
const camoufoxDir = path.join(PROJECT_ROOT, 'camoufox');
|
| 709 |
+
const resourcesPath = path.join(camoufoxDir, 'Camoufox.app', 'Contents', 'Resources', 'properties.json');
|
| 710 |
+
const macOSDir = path.join(camoufoxDir, 'Camoufox.app', 'Contents', 'MacOS');
|
| 711 |
+
const macOSPath = path.join(macOSDir, 'properties.json');
|
| 712 |
+
|
| 713 |
+
if (!fs.existsSync(resourcesPath)) {
|
| 714 |
+
logger.error('初始化', `源文件不存在: ${resourcesPath}`);
|
| 715 |
+
logger.error('初始化', '请先安装 Camoufox 浏览器');
|
| 716 |
+
return;
|
| 717 |
+
}
|
| 718 |
+
|
| 719 |
+
if (!fs.existsSync(macOSDir)) {
|
| 720 |
+
fs.mkdirSync(macOSDir, { recursive: true });
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
fs.copyFileSync(resourcesPath, macOSPath);
|
| 724 |
+
logger.info('初始化', `已复制 properties.json 到 MacOS 目录: ${macOSPath}`);
|
| 725 |
+
}
|
| 726 |
+
|
| 727 |
+
/**
|
| 728 |
+
* 修复 version.json 缺失
|
| 729 |
+
*/
|
| 730 |
+
function fixVersionJson() {
|
| 731 |
+
const camoufoxDir = path.join(PROJECT_ROOT, 'camoufox');
|
| 732 |
+
const versionJsonPath = path.join(camoufoxDir, 'version.json');
|
| 733 |
+
|
| 734 |
+
if (!fs.existsSync(camoufoxDir)) {
|
| 735 |
+
logger.error('初始化', `camoufox 目录不存在: ${camoufoxDir}`);
|
| 736 |
+
logger.error('初始化', '请先安装 Camoufox 浏览器');
|
| 737 |
+
return;
|
| 738 |
+
}
|
| 739 |
+
|
| 740 |
+
const versionData = {
|
| 741 |
+
version: "135.0",
|
| 742 |
+
release: "beta.24"
|
| 743 |
+
};
|
| 744 |
+
|
| 745 |
+
fs.writeFileSync(versionJsonPath, JSON.stringify(versionData, null, 2), 'utf8');
|
| 746 |
+
logger.info('初始化', `已生成 version.json: ${versionJsonPath}`);
|
| 747 |
+
}
|
scripts/postinstall.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview npm postinstall 钩子脚本
|
| 3 |
+
* @description 在 `npm install` 后自动应用 camoufox-js 补丁。
|
| 4 |
+
*
|
| 5 |
+
* 用法:在 package.json scripts 中配置 "postinstall": "node scripts/postinstall.js"
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import fs from 'fs';
|
| 9 |
+
import path from 'path';
|
| 10 |
+
import { fileURLToPath } from 'url';
|
| 11 |
+
|
| 12 |
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
| 13 |
+
const PROJECT_ROOT = path.join(__dirname, '..');
|
| 14 |
+
|
| 15 |
+
// 简易日志
|
| 16 |
+
const log = (msg) => console.log(`[postinstall] ${msg}`);
|
| 17 |
+
const warn = (msg) => console.warn(`[postinstall] ⚠️ ${msg}`);
|
| 18 |
+
const error = (msg) => console.error(`[postinstall] ❌ ${msg}`);
|
| 19 |
+
|
| 20 |
+
/**
|
| 21 |
+
* 复制 camoufox-js 补丁文件到 node_modules
|
| 22 |
+
*/
|
| 23 |
+
function patchCamoufoxJs() {
|
| 24 |
+
log('正在应用 camoufox-js 补丁...');
|
| 25 |
+
|
| 26 |
+
const patchDir = path.join(PROJECT_ROOT, 'patches');
|
| 27 |
+
const targetDir = path.join(PROJECT_ROOT, 'node_modules', 'camoufox-js', 'dist');
|
| 28 |
+
|
| 29 |
+
// 检查目标目录是否存在
|
| 30 |
+
if (!fs.existsSync(targetDir)) {
|
| 31 |
+
warn(`目标目录不存在: ${targetDir}`);
|
| 32 |
+
warn('camoufox-js 可能未安装,跳过补丁。');
|
| 33 |
+
return;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// 补丁文件映射: 源文件名 -> 目标文件名
|
| 37 |
+
const patches = {
|
| 38 |
+
'camoufox-js@0.8.3.locale.patched.js': 'locale.js',
|
| 39 |
+
'camoufox-js@0.8.3.pkgman.patched.js': 'pkgman.js'
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
for (const [srcName, destName] of Object.entries(patches)) {
|
| 43 |
+
const srcPath = path.join(patchDir, srcName);
|
| 44 |
+
const destPath = path.join(targetDir, destName);
|
| 45 |
+
|
| 46 |
+
if (!fs.existsSync(srcPath)) {
|
| 47 |
+
warn(`补丁文件不存在: ${srcPath}`);
|
| 48 |
+
continue;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
try {
|
| 52 |
+
fs.copyFileSync(srcPath, destPath);
|
| 53 |
+
log(`已应用补丁: ${srcName} -> ${destName}`);
|
| 54 |
+
} catch (e) {
|
| 55 |
+
error(`应用补丁失败: ${e.message}`);
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
log('补丁应用完成。');
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
// 执行
|
| 63 |
+
patchCamoufoxJs();
|
scripts/restore-data-webdav.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* 从 WebDAV 恢复浏览器数据
|
| 5 |
+
*
|
| 6 |
+
* 使用方法:
|
| 7 |
+
* node scripts/restore-data-webdav.js
|
| 8 |
+
*
|
| 9 |
+
* 环境变量:
|
| 10 |
+
* WEBDAV_URL: WebDAV 服务器地址
|
| 11 |
+
* WEBDAV_USER: WebDAV 用户名
|
| 12 |
+
* WEBDAV_PASS: WebDAV 密码
|
| 13 |
+
*/
|
| 14 |
+
|
| 15 |
+
import fs from 'fs';
|
| 16 |
+
import path from 'path';
|
| 17 |
+
import { fileURLToPath } from 'url';
|
| 18 |
+
import { createClient } from 'webdav';
|
| 19 |
+
|
| 20 |
+
const __filename = fileURLToPath(import.meta.url);
|
| 21 |
+
const __dirname = path.dirname(__filename);
|
| 22 |
+
|
| 23 |
+
// 数据目录
|
| 24 |
+
const DATA_DIR = path.join(__dirname, '../data/camoufoxUserData');
|
| 25 |
+
|
| 26 |
+
// 环境变量
|
| 27 |
+
const WEBDAV_URL = process.env.WEBDAV_URL;
|
| 28 |
+
const WEBDAV_USER = process.env.WEBDAV_USER;
|
| 29 |
+
const WEBDAV_PASS = process.env.WEBDAV_PASS;
|
| 30 |
+
|
| 31 |
+
console.log('==========================================');
|
| 32 |
+
console.log('WebAI2API - WebDAV 数据恢复脚本');
|
| 33 |
+
console.log('==========================================');
|
| 34 |
+
|
| 35 |
+
// 检查环境变量
|
| 36 |
+
if (!WEBDAV_URL || !WEBDAV_USER || !WEBDAV_PASS) {
|
| 37 |
+
console.error('❌ 错误:未设置 WebDAV 环境变量');
|
| 38 |
+
console.error('请在 Space Settings 中添加:');
|
| 39 |
+
console.error(' - WEBDAV_URL: https://rebun.infini-cloud.net/dav');
|
| 40 |
+
console.error(' - WEBDAV_USER: iyougame');
|
| 41 |
+
console.error(' - WEBDAV_PASS: exzgmqInkoFADbjOx1ak_reGVIf_ptIZxYUtBFp3mLw');
|
| 42 |
+
process.exit(1);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
console.log('🌐 WebDAV URL:', WEBDAV_URL);
|
| 46 |
+
console.log('👤 用户:', WEBDAV_USER);
|
| 47 |
+
console.log('📁 数据目录:', DATA_DIR);
|
| 48 |
+
console.log('');
|
| 49 |
+
|
| 50 |
+
try {
|
| 51 |
+
// 1. 创建 WebDAV 客户端
|
| 52 |
+
console.log('🔗 连接 WebDAV 服务器...');
|
| 53 |
+
const client = createClient(WEBDAV_URL, {
|
| 54 |
+
username: WEBDAV_USER,
|
| 55 |
+
password: WEBDAV_PASS
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
// 2. 检查远程目录是否存在
|
| 59 |
+
const remoteDir = '/webai2api-data';
|
| 60 |
+
console.log('🔍 检查远程目录:', remoteDir);
|
| 61 |
+
|
| 62 |
+
try {
|
| 63 |
+
await client.getDirectoryContents(remoteDir);
|
| 64 |
+
} catch (err) {
|
| 65 |
+
console.warn('⚠️ 远程目录不存在,跳过恢复');
|
| 66 |
+
console.warn('将使用新的浏览器实例(需要重新登录)');
|
| 67 |
+
process.exit(0);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
console.log('✅ 远程目录存在');
|
| 71 |
+
console.log('');
|
| 72 |
+
|
| 73 |
+
// 3. 创建本地数据目录
|
| 74 |
+
console.log('📁 创建本地数据目录...');
|
| 75 |
+
if (fs.existsSync(DATA_DIR)) {
|
| 76 |
+
fs.rmSync(DATA_DIR, { recursive: true, force: true });
|
| 77 |
+
}
|
| 78 |
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
| 79 |
+
|
| 80 |
+
// 4. 下载文件
|
| 81 |
+
console.log('📥 开始下载文件...');
|
| 82 |
+
let downloadedFiles = 0;
|
| 83 |
+
let totalSize = 0;
|
| 84 |
+
|
| 85 |
+
const downloadFile = async (remotePath, localPath) => {
|
| 86 |
+
try {
|
| 87 |
+
const content = await client.getFileContents(remotePath, { format: 'text' });
|
| 88 |
+
|
| 89 |
+
// 检查是否是目录(通过检查是否抛出错误)
|
| 90 |
+
const stats = await client.stat(remotePath);
|
| 91 |
+
|
| 92 |
+
if (stats.type === 'directory') {
|
| 93 |
+
// 创建本地目录
|
| 94 |
+
fs.mkdirSync(localPath, { recursive: true });
|
| 95 |
+
|
| 96 |
+
// 递归下载子文件
|
| 97 |
+
const contents = await client.getDirectoryContents(remotePath);
|
| 98 |
+
for (const item of contents) {
|
| 99 |
+
await downloadFile(
|
| 100 |
+
path.join(remotePath, item.basename),
|
| 101 |
+
path.join(localPath, item.basename)
|
| 102 |
+
);
|
| 103 |
+
}
|
| 104 |
+
} else {
|
| 105 |
+
// 下载文件
|
| 106 |
+
fs.writeFileSync(localPath, content);
|
| 107 |
+
downloadedFiles++;
|
| 108 |
+
totalSize += stats.size;
|
| 109 |
+
console.log(` ✓ ${path.relative(DATA_DIR, localPath)} (${(stats.size / 1024).toFixed(2)} KB)`);
|
| 110 |
+
}
|
| 111 |
+
} catch (err) {
|
| 112 |
+
console.error(` ✗ ${remotePath}: ${err.message}`);
|
| 113 |
+
throw err;
|
| 114 |
+
}
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
const contents = await client.getDirectoryContents(remoteDir);
|
| 118 |
+
for (const item of contents) {
|
| 119 |
+
await downloadFile(
|
| 120 |
+
path.join(remoteDir, item.basename),
|
| 121 |
+
path.join(DATA_DIR, item.basename)
|
| 122 |
+
);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
console.log('');
|
| 126 |
+
console.log('==========================================');
|
| 127 |
+
console.log('✅ 数据恢复成功!');
|
| 128 |
+
console.log('==========================================');
|
| 129 |
+
console.log('📊 下载统计:');
|
| 130 |
+
console.log(` - 文件数量: ${downloadedFiles}`);
|
| 131 |
+
console.log(` - 总大小: ${(totalSize / 1024).toFixed(2)} KB`);
|
| 132 |
+
console.log(` - 远程目录: ${remoteDir}`);
|
| 133 |
+
console.log('');
|
| 134 |
+
console.log('浏览器将使用恢复的登录状态。');
|
| 135 |
+
|
| 136 |
+
} catch (error) {
|
| 137 |
+
console.error('');
|
| 138 |
+
console.error('❌ 恢复失败:', error.message);
|
| 139 |
+
console.error('');
|
| 140 |
+
console.error('可能的原因:');
|
| 141 |
+
console.error('1. WebDAV 配置错误');
|
| 142 |
+
console.error('2. 网络连接问题');
|
| 143 |
+
console.error('3. WebDAV 服务器不可用');
|
| 144 |
+
console.error('4. 用户名或密码错误');
|
| 145 |
+
console.error('5. 远程数据损坏');
|
| 146 |
+
console.error('');
|
| 147 |
+
console.error('将使用新的浏览器实例(需要重新登录)');
|
| 148 |
+
process.exit(0); // 不退出,允许继续启动
|
| 149 |
+
}
|
scripts/restore-data.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* 从 Hugging Face Dataset 恢复浏览器数据
|
| 5 |
+
*
|
| 6 |
+
* 使用方法:
|
| 7 |
+
* node scripts/restore-data.js
|
| 8 |
+
*
|
| 9 |
+
* 环境变量:
|
| 10 |
+
* HF_DATASET_REPO: Dataset 仓库(如:iudd/webai2api-data)
|
| 11 |
+
* HF_TOKEN: Hugging Face Token(需要 read 权限)
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
import { execSync } from 'child_process';
|
| 15 |
+
import fs from 'fs';
|
| 16 |
+
import path from 'path';
|
| 17 |
+
import { fileURLToPath } from 'url';
|
| 18 |
+
|
| 19 |
+
const __filename = fileURLToPath(import.meta.url);
|
| 20 |
+
const __dirname = path.dirname(__filename);
|
| 21 |
+
|
| 22 |
+
// 数据目录
|
| 23 |
+
const DATA_DIR = path.join(__dirname, '../data/camoufoxUserData');
|
| 24 |
+
const TEMP_DIR = '/tmp/webai2api-data-restore';
|
| 25 |
+
|
| 26 |
+
// 环境变量
|
| 27 |
+
const HF_DATASET_REPO = process.env.HF_DATASET_REPO;
|
| 28 |
+
const HF_TOKEN = process.env.HF_TOKEN;
|
| 29 |
+
|
| 30 |
+
console.log('==========================================');
|
| 31 |
+
console.log('WebAI2API - 数据恢复脚本');
|
| 32 |
+
console.log('==========================================');
|
| 33 |
+
|
| 34 |
+
// 检查环境变量
|
| 35 |
+
if (!HF_DATASET_REPO) {
|
| 36 |
+
console.error('❌ 错误:未设置 HF_DATASET_REPO 环境变量');
|
| 37 |
+
console.error('请在 Space Settings 中添加:HF_DATASET_REPO=YOUR_USERNAME/webai2api-data');
|
| 38 |
+
process.exit(1);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
if (!HF_TOKEN) {
|
| 42 |
+
console.error('❌ 错误:未设置 HF_TOKEN 环境变量');
|
| 43 |
+
console.error('请在 Space Settings 中添加:HF_TOKEN(需要 read 权限)');
|
| 44 |
+
process.exit(1);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
console.log('📦 Dataset:', HF_DATASET_REPO);
|
| 48 |
+
console.log('📁 数据目录:', DATA_DIR);
|
| 49 |
+
console.log('');
|
| 50 |
+
|
| 51 |
+
try {
|
| 52 |
+
// 1. 创建临时目录
|
| 53 |
+
console.log('📂 创建临时目录...');
|
| 54 |
+
if (fs.existsSync(TEMP_DIR)) {
|
| 55 |
+
execSync(`rm -rf ${TEMP_DIR}`);
|
| 56 |
+
}
|
| 57 |
+
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
| 58 |
+
|
| 59 |
+
// 2. 克隆 Dataset
|
| 60 |
+
console.log('🔽 克隆 Dataset...');
|
| 61 |
+
const repoUrl = `https://hf.co/${HF_DATASET_REPO}`;
|
| 62 |
+
execSync(`cd ${TEMP_DIR} && git clone https://user:${HF_TOKEN}@${repoUrl} .`, {
|
| 63 |
+
stdio: 'inherit',
|
| 64 |
+
timeout: 60000 // 60 秒超时
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
// 3. 检查是否有数据
|
| 68 |
+
console.log('🔍 检查数据...');
|
| 69 |
+
const files = fs.readdirSync(TEMP_DIR);
|
| 70 |
+
if (files.length === 0 || (files.length === 1 && files[0] === '.git')) {
|
| 71 |
+
console.warn('⚠️ Dataset 中没有数据');
|
| 72 |
+
console.warn('跳过恢复,将使用新的浏览器实例');
|
| 73 |
+
process.exit(0);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
// 4. 创建数据目录
|
| 77 |
+
console.log('📁 创建数据目录...');
|
| 78 |
+
if (fs.existsSync(DATA_DIR)) {
|
| 79 |
+
execSync(`rm -rf ${DATA_DIR}`);
|
| 80 |
+
}
|
| 81 |
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
| 82 |
+
|
| 83 |
+
// 5. 复制数据
|
| 84 |
+
console.log('📋 恢复数据...');
|
| 85 |
+
execSync(`cp -r ${TEMP_DIR}/* ${DATA_DIR}/`);
|
| 86 |
+
console.log('✅ 数据恢复完成');
|
| 87 |
+
console.log('');
|
| 88 |
+
|
| 89 |
+
// 6. 显示恢复的文件
|
| 90 |
+
console.log('📄 恢复的文件:');
|
| 91 |
+
const restoredFiles = getAllFiles(DATA_DIR);
|
| 92 |
+
restoredFiles.forEach(file => {
|
| 93 |
+
const relativePath = path.relative(DATA_DIR, file);
|
| 94 |
+
const stats = fs.statSync(file);
|
| 95 |
+
const size = (stats.size / 1024).toFixed(2);
|
| 96 |
+
console.log(` - ${relativePath} (${size} KB)`);
|
| 97 |
+
});
|
| 98 |
+
|
| 99 |
+
console.log('');
|
| 100 |
+
console.log('✅ 数据恢复成功!');
|
| 101 |
+
console.log('📍 Dataset:', `https://huggingface.co/datasets/${HF_DATASET_REPO}`);
|
| 102 |
+
console.log('');
|
| 103 |
+
console.log('浏览器将使用恢复的登录状态。');
|
| 104 |
+
|
| 105 |
+
} catch (error) {
|
| 106 |
+
console.error('');
|
| 107 |
+
console.error('❌ 恢复失败:', error.message);
|
| 108 |
+
console.error('');
|
| 109 |
+
console.error('可能的原因:');
|
| 110 |
+
console.error('1. Dataset 不存在或为空');
|
| 111 |
+
console.error('2. HF_TOKEN 权限不足(需要 read 权限)');
|
| 112 |
+
console.error('3. HF_DATASET_REPO 名称错误');
|
| 113 |
+
console.error('4. 网络连接问题');
|
| 114 |
+
console.error('');
|
| 115 |
+
console.error('将使用新的浏览器实例(需要重新登录)');
|
| 116 |
+
process.exit(0); // 不退出,允许继续启动
|
| 117 |
+
} finally {
|
| 118 |
+
// 清理临时目录
|
| 119 |
+
if (fs.existsSync(TEMP_DIR)) {
|
| 120 |
+
execSync(`rm -rf ${TEMP_DIR}`);
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
/**
|
| 125 |
+
* 递归获取所有文件
|
| 126 |
+
*/
|
| 127 |
+
function getAllFiles(dir, fileList = []) {
|
| 128 |
+
const files = fs.readdirSync(dir);
|
| 129 |
+
files.forEach(file => {
|
| 130 |
+
const filePath = path.join(dir, file);
|
| 131 |
+
const stat = fs.statSync(filePath);
|
| 132 |
+
if (stat.isDirectory()) {
|
| 133 |
+
getAllFiles(filePath, fileList);
|
| 134 |
+
} else {
|
| 135 |
+
fileList.push(filePath);
|
| 136 |
+
}
|
| 137 |
+
});
|
| 138 |
+
return fileList;
|
| 139 |
+
}
|
scripts/save-data-webdav.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* 使用 WebDAV 保存浏览器数据
|
| 5 |
+
*
|
| 6 |
+
* 使用方法:
|
| 7 |
+
* node scripts/save-data-webdav.js
|
| 8 |
+
*
|
| 9 |
+
* 环境变量:
|
| 10 |
+
* WEBDAV_URL: WebDAV 服务器地址
|
| 11 |
+
* WEBDAV_USER: WebDAV 用户名
|
| 12 |
+
* WEBDAV_PASS: WebDAV 密码
|
| 13 |
+
*/
|
| 14 |
+
|
| 15 |
+
import fs from 'fs';
|
| 16 |
+
import path from 'path';
|
| 17 |
+
import { fileURLToPath } from 'url';
|
| 18 |
+
import { createClient } from 'webdav';
|
| 19 |
+
|
| 20 |
+
const __filename = fileURLToPath(import.meta.url);
|
| 21 |
+
const __dirname = path.dirname(__filename);
|
| 22 |
+
|
| 23 |
+
// 数据目录
|
| 24 |
+
const DATA_DIR = path.join(__dirname, '../data/camoufoxUserData');
|
| 25 |
+
const TEMP_DIR = '/tmp/webai2api-data-webdav';
|
| 26 |
+
|
| 27 |
+
// 环境变量
|
| 28 |
+
const WEBDAV_URL = process.env.WEBDAV_URL;
|
| 29 |
+
const WEBDAV_USER = process.env.WEBDAV_USER;
|
| 30 |
+
const WEBDAV_PASS = process.env.WEBDAV_PASS;
|
| 31 |
+
|
| 32 |
+
console.log('==========================================');
|
| 33 |
+
console.log('WebAI2API - WebDAV 数据保存脚本');
|
| 34 |
+
console.log('==========================================');
|
| 35 |
+
|
| 36 |
+
// 检查环境变量
|
| 37 |
+
if (!WEBDAV_URL || !WEBDAV_USER || !WEBDAV_PASS) {
|
| 38 |
+
console.error('❌ 错误:未设置 WebDAV 环境变量');
|
| 39 |
+
console.error('请在 Space Settings 中添加:');
|
| 40 |
+
console.error(' - WEBDAV_URL: https://rebun.infini-cloud.net/dav');
|
| 41 |
+
console.error(' - WEBDAV_USER: iyougame');
|
| 42 |
+
console.error(' - WEBDAV_PASS: exzgmqInkoFADbjOx1ak_reGVIf_ptIZxYUtBFp3mLw');
|
| 43 |
+
process.exit(1);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
// 检查数据目录是否存在
|
| 47 |
+
if (!fs.existsSync(DATA_DIR)) {
|
| 48 |
+
console.error('❌ 错误:数据目录不存在', DATA_DIR);
|
| 49 |
+
console.error('请先启动服务并完成登录');
|
| 50 |
+
process.exit(1);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
console.log('🌐 WebDAV URL:', WEBDAV_URL);
|
| 54 |
+
console.log('👤 用户:', WEBDAV_USER);
|
| 55 |
+
console.log('📁 数据目录:', DATA_DIR);
|
| 56 |
+
console.log('');
|
| 57 |
+
|
| 58 |
+
try {
|
| 59 |
+
// 1. 创建 WebDAV 客户端
|
| 60 |
+
console.log('🔗 连接 WebDAV 服务器...');
|
| 61 |
+
const client = createClient(WEBDAV_URL, {
|
| 62 |
+
username: WEBDAV_USER,
|
| 63 |
+
password: WEBDAV_PASS
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
// 2. 测试连接
|
| 67 |
+
await client.getDirectoryContents('/');
|
| 68 |
+
console.log('✅ WebDAV 连接成功');
|
| 69 |
+
console.log('');
|
| 70 |
+
|
| 71 |
+
// 3. 创建远程目录
|
| 72 |
+
const remoteDir = '/webai2api-data';
|
| 73 |
+
console.log('📁 创建远程目录:', remoteDir);
|
| 74 |
+
try {
|
| 75 |
+
await client.createDirectory(remoteDir);
|
| 76 |
+
} catch (err) {
|
| 77 |
+
// 目录可能已存在,忽略错误
|
| 78 |
+
if (!err.message.includes('405')) {
|
| 79 |
+
throw err;
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
console.log('✅ 远程目录准备完成');
|
| 83 |
+
console.log('');
|
| 84 |
+
|
| 85 |
+
// 4. 遍历本地文件并上传
|
| 86 |
+
console.log('📤 开始上传文件...');
|
| 87 |
+
let uploadedFiles = 0;
|
| 88 |
+
let totalSize = 0;
|
| 89 |
+
|
| 90 |
+
const uploadFile = async (localPath, remotePath) => {
|
| 91 |
+
const stats = fs.statSync(localPath);
|
| 92 |
+
if (stats.isDirectory()) {
|
| 93 |
+
// 创建远程目录
|
| 94 |
+
try {
|
| 95 |
+
await client.createDirectory(remotePath);
|
| 96 |
+
} catch (err) {
|
| 97 |
+
if (!err.message.includes('405')) {
|
| 98 |
+
throw err;
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
// 递归上传子文件
|
| 102 |
+
const files = fs.readdirSync(localPath);
|
| 103 |
+
for (const file of files) {
|
| 104 |
+
await uploadFile(
|
| 105 |
+
path.join(localPath, file),
|
| 106 |
+
path.join(remotePath, file)
|
| 107 |
+
);
|
| 108 |
+
}
|
| 109 |
+
} else {
|
| 110 |
+
// 上传文件
|
| 111 |
+
const content = fs.readFileSync(localPath);
|
| 112 |
+
await client.putFileContents(remotePath, content);
|
| 113 |
+
uploadedFiles++;
|
| 114 |
+
totalSize += stats.size;
|
| 115 |
+
console.log(` ✓ ${path.relative(DATA_DIR, localPath)} (${(stats.size / 1024).toFixed(2)} KB)`);
|
| 116 |
+
}
|
| 117 |
+
};
|
| 118 |
+
|
| 119 |
+
const files = fs.readdirSync(DATA_DIR);
|
| 120 |
+
for (const file of files) {
|
| 121 |
+
await uploadFile(
|
| 122 |
+
path.join(DATA_DIR, file),
|
| 123 |
+
path.join(remoteDir, file)
|
| 124 |
+
);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
console.log('');
|
| 128 |
+
console.log('==========================================');
|
| 129 |
+
console.log('✅ 数据保存成功!');
|
| 130 |
+
console.log('==========================================');
|
| 131 |
+
console.log('📊 上传统计:');
|
| 132 |
+
console.log(` - 文件数量: ${uploadedFiles}`);
|
| 133 |
+
console.log(` - 总大小: ${(totalSize / 1024).toFixed(2)} KB`);
|
| 134 |
+
console.log(` - 远程目录: ${remoteDir}`);
|
| 135 |
+
console.log('');
|
| 136 |
+
console.log('下次启动时,数据将自动恢复。');
|
| 137 |
+
|
| 138 |
+
} catch (error) {
|
| 139 |
+
console.error('');
|
| 140 |
+
console.error('❌ 保存失败:', error.message);
|
| 141 |
+
console.error('');
|
| 142 |
+
console.error('可能的原因:');
|
| 143 |
+
console.error('1. WebDAV 配置错误');
|
| 144 |
+
console.error('2. 网络连接问题');
|
| 145 |
+
console.error('3. WebDAV 服务器不可用');
|
| 146 |
+
console.error('4. 用户名或密码错误');
|
| 147 |
+
process.exit(1);
|
| 148 |
+
}
|
scripts/save-data.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* 保存浏览器数据到 Hugging Face Dataset
|
| 5 |
+
*
|
| 6 |
+
* 使用方法:
|
| 7 |
+
* node scripts/save-data.js
|
| 8 |
+
*
|
| 9 |
+
* 环境变量:
|
| 10 |
+
* HF_DATASET_REPO: Dataset 仓库(如:iudd/webai2api-data)
|
| 11 |
+
* HF_TOKEN: Hugging Face Token(需要 write 权限)
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
import { execSync } from 'child_process';
|
| 15 |
+
import fs from 'fs';
|
| 16 |
+
import path from 'path';
|
| 17 |
+
import { fileURLToPath } from 'url';
|
| 18 |
+
|
| 19 |
+
const __filename = fileURLToPath(import.meta.url);
|
| 20 |
+
const __dirname = path.dirname(__filename);
|
| 21 |
+
|
| 22 |
+
// 数据目录
|
| 23 |
+
const DATA_DIR = path.join(__dirname, '../data/camoufoxUserData');
|
| 24 |
+
const TEMP_DIR = '/tmp/webai2api-data';
|
| 25 |
+
|
| 26 |
+
// 环境变量
|
| 27 |
+
const HF_DATASET_REPO = process.env.HF_DATASET_REPO;
|
| 28 |
+
const HF_TOKEN = process.env.HF_TOKEN;
|
| 29 |
+
|
| 30 |
+
console.log('==========================================');
|
| 31 |
+
console.log('WebAI2API - 数据保存脚本');
|
| 32 |
+
console.log('==========================================');
|
| 33 |
+
|
| 34 |
+
// 检查环境变量
|
| 35 |
+
if (!HF_DATASET_REPO) {
|
| 36 |
+
console.error('❌ 错误:未设置 HF_DATASET_REPO 环境变量');
|
| 37 |
+
console.error('请在 Space Settings 中添加:HF_DATASET_REPO=YOUR_USERNAME/webai2api-data');
|
| 38 |
+
process.exit(1);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
if (!HF_TOKEN) {
|
| 42 |
+
console.error('❌ 错误:未设置 HF_TOKEN 环境变量');
|
| 43 |
+
console.error('请在 Space Settings 中添加:HF_TOKEN(需要 write 权限)');
|
| 44 |
+
process.exit(1);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
// 检查数据目录是否存在
|
| 48 |
+
if (!fs.existsSync(DATA_DIR)) {
|
| 49 |
+
console.error('❌ 错误:数据目录不存在', DATA_DIR);
|
| 50 |
+
console.error('请先启动服务并完成登录');
|
| 51 |
+
process.exit(1);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
console.log('📦 Dataset:', HF_DATASET_REPO);
|
| 55 |
+
console.log('📁 数据目录:', DATA_DIR);
|
| 56 |
+
console.log('');
|
| 57 |
+
|
| 58 |
+
try {
|
| 59 |
+
// 1. 创建临时目录
|
| 60 |
+
console.log('📂 创建临时目录...');
|
| 61 |
+
if (fs.existsSync(TEMP_DIR)) {
|
| 62 |
+
execSync(`rm -rf ${TEMP_DIR}`);
|
| 63 |
+
}
|
| 64 |
+
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
| 65 |
+
|
| 66 |
+
// 2. 复制数据到临时目录
|
| 67 |
+
console.log('📋 复制数据...');
|
| 68 |
+
execSync(`cp -r ${DATA_DIR}/* ${TEMP_DIR}/`);
|
| 69 |
+
console.log('✅ 数据复制完成');
|
| 70 |
+
console.log('');
|
| 71 |
+
|
| 72 |
+
// 3. 初始化 Git 仓库
|
| 73 |
+
console.log('🔧 初始化 Git 仓库...');
|
| 74 |
+
execSync(`cd ${TEMP_DIR} && git init`, { stdio: 'inherit' });
|
| 75 |
+
execSync(`cd ${TEMP_DIR} && git config user.name "WebAI2API"`, { stdio: 'inherit' });
|
| 76 |
+
execSync(`cd ${TEMP_DIR} && git config user.email "webai2api@huggingface.co"`, { stdio: 'inherit' });
|
| 77 |
+
|
| 78 |
+
// 4. 添加远程仓库
|
| 79 |
+
console.log('🔗 添加远程仓库...');
|
| 80 |
+
const repoUrl = `https://hf.co/${HF_DATASET_REPO}`;
|
| 81 |
+
execSync(`cd ${TEMP_DIR} && git remote add origin https://user:${HF_TOKEN}@${repoUrl}`, { stdio: 'inherit' });
|
| 82 |
+
|
| 83 |
+
// 5. 添加文件
|
| 84 |
+
console.log('📝 添加文件...');
|
| 85 |
+
execSync(`cd ${TEMP_DIR} && git add .`, { stdio: 'inherit' });
|
| 86 |
+
|
| 87 |
+
// 6. 提交
|
| 88 |
+
console.log('💾 提交更改...');
|
| 89 |
+
const timestamp = new Date().toISOString();
|
| 90 |
+
execSync(`cd ${TEMP_DIR} && git commit -m "Save browser data - ${timestamp}"`, { stdio: 'inherit' });
|
| 91 |
+
|
| 92 |
+
// 7. 推送
|
| 93 |
+
console.log('🚀 推送到 Hugging Face Dataset...');
|
| 94 |
+
execSync(`cd ${TEMP_DIR} && git push -u origin main --force`, { stdio: 'inherit' });
|
| 95 |
+
|
| 96 |
+
console.log('');
|
| 97 |
+
console.log('✅ 数据保存成功!');
|
| 98 |
+
console.log('📍 Dataset:', `https://huggingface.co/datasets/${HF_DATASET_REPO}`);
|
| 99 |
+
console.log('');
|
| 100 |
+
console.log('下次启动时,数据将自动恢复。');
|
| 101 |
+
|
| 102 |
+
} catch (error) {
|
| 103 |
+
console.error('');
|
| 104 |
+
console.error('❌ 保存失败:', error.message);
|
| 105 |
+
console.error('');
|
| 106 |
+
console.error('可能的原因:');
|
| 107 |
+
console.error('1. HF_TOKEN 权限不足(需要 write 权限)');
|
| 108 |
+
console.error('2. HF_DATASET_REPO 名称错误');
|
| 109 |
+
console.error('3. 网络连接问题');
|
| 110 |
+
console.error('4. Dataset 不存在或无权限访问');
|
| 111 |
+
process.exit(1);
|
| 112 |
+
} finally {
|
| 113 |
+
// 清理临时目录
|
| 114 |
+
if (fs.existsSync(TEMP_DIR)) {
|
| 115 |
+
execSync(`rm -rf ${TEMP_DIR}`);
|
| 116 |
+
}
|
| 117 |
+
}
|
src/backend/adapter/chatgpt.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview ChatGPT 图片生成适配器
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import {
|
| 6 |
+
sleep,
|
| 7 |
+
safeClick,
|
| 8 |
+
uploadFilesViaChooser
|
| 9 |
+
} from '../engine/utils.js';
|
| 10 |
+
import {
|
| 11 |
+
fillPrompt,
|
| 12 |
+
normalizePageError,
|
| 13 |
+
moveMouseAway,
|
| 14 |
+
waitForInput,
|
| 15 |
+
gotoWithCheck,
|
| 16 |
+
waitApiResponse,
|
| 17 |
+
useContextDownload
|
| 18 |
+
} from '../utils/index.js';
|
| 19 |
+
import { logger } from '../../utils/logger.js';
|
| 20 |
+
|
| 21 |
+
// --- 配置常量 ---
|
| 22 |
+
const TARGET_URL = 'https://chatgpt.com/images/';
|
| 23 |
+
const INPUT_SELECTOR = '.ProseMirror';
|
| 24 |
+
|
| 25 |
+
/**
|
| 26 |
+
* 执行生图任务
|
| 27 |
+
* @param {object} context - 浏览器上下文 { page, config }
|
| 28 |
+
* @param {string} prompt - 提示词
|
| 29 |
+
* @param {string[]} imgPaths - 图片路径数组
|
| 30 |
+
* @param {string} [modelId] - 模型 ID (此适配器未使用)
|
| 31 |
+
* @param {object} [meta={}] - 日志元数据
|
| 32 |
+
* @returns {Promise<{image?: string, error?: string}>}
|
| 33 |
+
*/
|
| 34 |
+
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
| 35 |
+
const { page } = context;
|
| 36 |
+
const sendBtnLocator = page.getByRole('button', { name: 'Send prompt' });
|
| 37 |
+
|
| 38 |
+
try {
|
| 39 |
+
logger.info('适配器', '开启新会话...', meta);
|
| 40 |
+
await gotoWithCheck(page, TARGET_URL);
|
| 41 |
+
|
| 42 |
+
// 1. 等待输入框加载
|
| 43 |
+
await waitForInput(page, INPUT_SELECTOR, { click: false });
|
| 44 |
+
await sleep(1500, 2500);
|
| 45 |
+
|
| 46 |
+
// 2. 上传图片
|
| 47 |
+
if (imgPaths && imgPaths.length > 0) {
|
| 48 |
+
const expectedUploads = imgPaths.length;
|
| 49 |
+
let uploadedCount = 0;
|
| 50 |
+
let processedCount = 0;
|
| 51 |
+
|
| 52 |
+
logger.debug('适配器', '点击添加文件按钮...', meta);
|
| 53 |
+
const addFilesBtn = page.getByRole('button', { name: 'Add files and more' });
|
| 54 |
+
|
| 55 |
+
await uploadFilesViaChooser(page, addFilesBtn, imgPaths, {
|
| 56 |
+
uploadValidator: (response) => {
|
| 57 |
+
const url = response.url();
|
| 58 |
+
if (response.status() === 200) {
|
| 59 |
+
// 上传请求
|
| 60 |
+
if (url.includes('backend-api/files') && !url.includes('process_upload_stream')) {
|
| 61 |
+
uploadedCount++;
|
| 62 |
+
logger.debug('适配器', `图片上传进度: ${uploadedCount}/${expectedUploads}`, meta);
|
| 63 |
+
return false;
|
| 64 |
+
}
|
| 65 |
+
// 处理完成请求
|
| 66 |
+
if (url.includes('backend-api/files/process_upload_stream')) {
|
| 67 |
+
processedCount++;
|
| 68 |
+
logger.info('适配器', `图片处理进度: ${processedCount}/${expectedUploads}`, meta);
|
| 69 |
+
|
| 70 |
+
if (processedCount >= expectedUploads) {
|
| 71 |
+
return true;
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
return false;
|
| 76 |
+
}
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
await sleep(1000, 2000);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
// 3. 填写提示词
|
| 83 |
+
await safeClick(page, INPUT_SELECTOR, { bias: 'input' });
|
| 84 |
+
await fillPrompt(page, INPUT_SELECTOR, prompt, meta);
|
| 85 |
+
await sleep(500, 1000);
|
| 86 |
+
|
| 87 |
+
// 4. 点击发送
|
| 88 |
+
logger.debug('适配器', '点击发送...', meta);
|
| 89 |
+
await safeClick(page, sendBtnLocator, { bias: 'button' });
|
| 90 |
+
|
| 91 |
+
logger.info('适配器', '等待生成结果...', meta);
|
| 92 |
+
|
| 93 |
+
// 5. 等待 conversation API 返回
|
| 94 |
+
let conversationResponse;
|
| 95 |
+
try {
|
| 96 |
+
conversationResponse = await waitApiResponse(page, {
|
| 97 |
+
urlMatch: 'backend-api/f/conversation',
|
| 98 |
+
method: 'POST',
|
| 99 |
+
timeout: 180000, // 图片生成可能较慢
|
| 100 |
+
meta
|
| 101 |
+
});
|
| 102 |
+
} catch (e) {
|
| 103 |
+
const pageError = normalizePageError(e, meta);
|
| 104 |
+
if (pageError) return pageError;
|
| 105 |
+
throw e;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
// 检查响应状态
|
| 109 |
+
if (conversationResponse.status() !== 200) {
|
| 110 |
+
logger.error('适配器', `API 返回错误: HTTP ${conversationResponse.status()}`, meta);
|
| 111 |
+
return { error: `API 返回错误: HTTP ${conversationResponse.status()}` };
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
logger.info('适配器', '生成中,等待图片就绪...', meta);
|
| 115 |
+
|
| 116 |
+
// 6. 监听文件状态接口,等待图片生成完成
|
| 117 |
+
// 通过 file_name 是否包含 .part 判断是否生成完成
|
| 118 |
+
let downloadUrl = null;
|
| 119 |
+
let fileName = null;
|
| 120 |
+
|
| 121 |
+
try {
|
| 122 |
+
await page.waitForResponse(async (response) => {
|
| 123 |
+
const url = response.url();
|
| 124 |
+
if (!url.includes('backend-api/files/download/file_')) return false;
|
| 125 |
+
if (response.status() !== 200) return false;
|
| 126 |
+
|
| 127 |
+
try {
|
| 128 |
+
const json = await response.json();
|
| 129 |
+
const fn = json.file_name;
|
| 130 |
+
const dl = json.download_url;
|
| 131 |
+
|
| 132 |
+
// 检查是否生成完成:
|
| 133 |
+
// 1. 必须有 file_name
|
| 134 |
+
// 2. file_name 不能包含 .part(表示中间状态)
|
| 135 |
+
// 3. 必须有 download_url
|
| 136 |
+
if (fn && !fn.includes('.part') && dl) {
|
| 137 |
+
fileName = fn;
|
| 138 |
+
downloadUrl = dl;
|
| 139 |
+
logger.info('适配器', `图片生成完成: ${fn}`, meta);
|
| 140 |
+
return true;
|
| 141 |
+
} else {
|
| 142 |
+
logger.debug('适配器', `图片生成中: ${fn || '无文件名'}`, meta);
|
| 143 |
+
return false;
|
| 144 |
+
}
|
| 145 |
+
} catch {
|
| 146 |
+
return false;
|
| 147 |
+
}
|
| 148 |
+
}, { timeout: 120000 });
|
| 149 |
+
} catch (e) {
|
| 150 |
+
const pageError = normalizePageError(e, meta);
|
| 151 |
+
if (pageError) return pageError;
|
| 152 |
+
throw e;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
if (!downloadUrl) {
|
| 156 |
+
logger.error('适配器', '未获取到图片下载链接', meta);
|
| 157 |
+
return { error: '未获取到图片下载链接' };
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
logger.info('适配器', '正在下载图片...', meta);
|
| 161 |
+
|
| 162 |
+
// 7. 使用 useContextDownload 下载图片
|
| 163 |
+
const result = await useContextDownload(downloadUrl, page);
|
| 164 |
+
if (result.error) {
|
| 165 |
+
logger.error('适配器', result.error, meta);
|
| 166 |
+
return result;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
logger.info('适配器', '已获取图片,任务完成', meta);
|
| 170 |
+
return result;
|
| 171 |
+
|
| 172 |
+
} catch (err) {
|
| 173 |
+
// 顶层错误处理
|
| 174 |
+
const pageError = normalizePageError(err, meta);
|
| 175 |
+
if (pageError) return pageError;
|
| 176 |
+
|
| 177 |
+
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
| 178 |
+
return { error: `生成任务失败: ${err.message}` };
|
| 179 |
+
} finally {
|
| 180 |
+
// 任务结束,将鼠标移至安全区域
|
| 181 |
+
await moveMouseAway(page);
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
/**
|
| 186 |
+
* 适配器 manifest
|
| 187 |
+
*/
|
| 188 |
+
export const manifest = {
|
| 189 |
+
id: 'chatgpt',
|
| 190 |
+
displayName: 'ChatGPT (图片生成)',
|
| 191 |
+
description: '使用 ChatGPT 官网生成图片,支持参考图片上传。需要已登录的 ChatGPT 账户,请使用会员账号 (包含 K12 教师认证),非会员账号会有速率限制。',
|
| 192 |
+
|
| 193 |
+
// 入口 URL
|
| 194 |
+
getTargetUrl(config, workerConfig) {
|
| 195 |
+
return TARGET_URL;
|
| 196 |
+
},
|
| 197 |
+
|
| 198 |
+
// 模型列表
|
| 199 |
+
models: [
|
| 200 |
+
{ id: 'gpt-image-1', imagePolicy: 'optional' }
|
| 201 |
+
],
|
| 202 |
+
|
| 203 |
+
// 无需导航处理器
|
| 204 |
+
navigationHandlers: [],
|
| 205 |
+
|
| 206 |
+
// 核心生图方法
|
| 207 |
+
generate
|
| 208 |
+
};
|
src/backend/adapter/chatgpt_text.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview ChatGPT 文本生成适配器
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import {
|
| 6 |
+
sleep,
|
| 7 |
+
safeClick,
|
| 8 |
+
uploadFilesViaChooser
|
| 9 |
+
} from '../engine/utils.js';
|
| 10 |
+
import {
|
| 11 |
+
fillPrompt,
|
| 12 |
+
normalizePageError,
|
| 13 |
+
moveMouseAway,
|
| 14 |
+
waitForInput,
|
| 15 |
+
gotoWithCheck,
|
| 16 |
+
waitApiResponse
|
| 17 |
+
} from '../utils/index.js';
|
| 18 |
+
import { logger } from '../../utils/logger.js';
|
| 19 |
+
|
| 20 |
+
// --- 配置常量 ---
|
| 21 |
+
const TARGET_URL = 'https://chatgpt.com/';
|
| 22 |
+
const INPUT_SELECTOR = '.ProseMirror';
|
| 23 |
+
|
| 24 |
+
/**
|
| 25 |
+
* 通过 UI 选择模型
|
| 26 |
+
* @param {import('playwright-core').Page} page - 页面对象
|
| 27 |
+
* @param {string} codeName - 模型 codeName
|
| 28 |
+
* @param {object} meta - 日志元数据
|
| 29 |
+
* @returns {Promise<boolean>} 是否成功选择了模型
|
| 30 |
+
*/
|
| 31 |
+
async function selectModel(page, codeName, meta = {}) {
|
| 32 |
+
try {
|
| 33 |
+
// 1. 点击 Model selector 按钮
|
| 34 |
+
const modelSelectorBtn = page.getByRole('button', { name: /^Model selector/ });
|
| 35 |
+
const btnExists = await modelSelectorBtn.count();
|
| 36 |
+
if (btnExists === 0) {
|
| 37 |
+
logger.debug('适配器', '未找到模型选择器按钮,跳过选择模型', meta);
|
| 38 |
+
return false;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
await modelSelectorBtn.waitFor({ timeout: 5000 });
|
| 42 |
+
await sleep(300, 500);
|
| 43 |
+
await safeClick(page, modelSelectorBtn, { bias: 'button' });
|
| 44 |
+
await sleep(500, 800);
|
| 45 |
+
|
| 46 |
+
// 2. 检查是否有 Legacy models 选项
|
| 47 |
+
const legacyMenuItem = page.getByRole('menuitem', { name: /^Legacy models/ });
|
| 48 |
+
const legacyExists = await legacyMenuItem.count();
|
| 49 |
+
if (legacyExists > 0) {
|
| 50 |
+
logger.debug('适配器', '发现 Legacy models 选项,正在点击...', meta);
|
| 51 |
+
await safeClick(page, legacyMenuItem, { bias: 'button' });
|
| 52 |
+
await sleep(500, 800);
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
// 3. 查找匹配 codeName 开头的 menuitem
|
| 56 |
+
const targetMenuItem = page.getByRole('menuitem', { name: new RegExp(`^${codeName}`) });
|
| 57 |
+
const targetExists = await targetMenuItem.count();
|
| 58 |
+
if (targetExists > 0) {
|
| 59 |
+
logger.info('适配器', `正在选择模型: ${codeName}`, meta);
|
| 60 |
+
await safeClick(page, targetMenuItem, { bias: 'button' });
|
| 61 |
+
await sleep(500, 1000);
|
| 62 |
+
return true;
|
| 63 |
+
} else {
|
| 64 |
+
logger.debug('适配器', `未找到模型 ${codeName},使用默认模型`, meta);
|
| 65 |
+
// 点击空白区域关闭菜单
|
| 66 |
+
await page.keyboard.press('Escape');
|
| 67 |
+
await sleep(300, 500);
|
| 68 |
+
return false;
|
| 69 |
+
}
|
| 70 |
+
} catch (e) {
|
| 71 |
+
logger.warn('适配器', `选择模型失败: ${e.message}`, meta);
|
| 72 |
+
// 尝试关闭菜单
|
| 73 |
+
await page.keyboard.press('Escape').catch(() => { });
|
| 74 |
+
return false;
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/**
|
| 79 |
+
* 执行文本生成任务
|
| 80 |
+
* @param {object} context - 浏览器上下文 { page, config }
|
| 81 |
+
* @param {string} prompt - 提示词
|
| 82 |
+
* @param {string[]} imgPaths - 图片路径数组
|
| 83 |
+
* @param {string} [modelId] - 模型 ID
|
| 84 |
+
* @param {object} [meta={}] - 日志元数据
|
| 85 |
+
* @returns {Promise<{text?: string, error?: string}>}
|
| 86 |
+
*/
|
| 87 |
+
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
| 88 |
+
const { page } = context;
|
| 89 |
+
const sendBtnLocator = page.getByRole('button', { name: 'Send prompt' });
|
| 90 |
+
|
| 91 |
+
try {
|
| 92 |
+
logger.info('适配器', '开启新会话...', meta);
|
| 93 |
+
await gotoWithCheck(page, TARGET_URL);
|
| 94 |
+
|
| 95 |
+
// 1. 等待输入框加载
|
| 96 |
+
await waitForInput(page, INPUT_SELECTOR, { click: false });
|
| 97 |
+
await sleep(1500, 2500);
|
| 98 |
+
|
| 99 |
+
// 2. 选择模型
|
| 100 |
+
const modelConfig = manifest.models.find(m => m.id === modelId);
|
| 101 |
+
const targetModel = modelConfig?.codeName || modelId;
|
| 102 |
+
if (targetModel) {
|
| 103 |
+
await selectModel(page, targetModel, meta);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
// 3. 上传图片 (双击 Add files and more 按钮)
|
| 107 |
+
if (imgPaths && imgPaths.length > 0) {
|
| 108 |
+
const expectedUploads = imgPaths.length;
|
| 109 |
+
let uploadedCount = 0;
|
| 110 |
+
let processedCount = 0;
|
| 111 |
+
|
| 112 |
+
logger.debug('适配器', '双击添加文件按钮...', meta);
|
| 113 |
+
const addFilesBtn = page.getByRole('button', { name: 'Add files and more' });
|
| 114 |
+
|
| 115 |
+
await uploadFilesViaChooser(page, addFilesBtn, imgPaths, {
|
| 116 |
+
clickAction: 'dblclick', // 使用双击
|
| 117 |
+
uploadValidator: (response) => {
|
| 118 |
+
const url = response.url();
|
| 119 |
+
if (response.status() === 200) {
|
| 120 |
+
// 上传请求
|
| 121 |
+
if (url.includes('backend-api/files') && !url.includes('process_upload_stream')) {
|
| 122 |
+
uploadedCount++;
|
| 123 |
+
logger.debug('适配器', `图片上传进度: ${uploadedCount}/${expectedUploads}`, meta);
|
| 124 |
+
return false;
|
| 125 |
+
}
|
| 126 |
+
// 处理完成请求
|
| 127 |
+
if (url.includes('backend-api/files/process_upload_stream')) {
|
| 128 |
+
processedCount++;
|
| 129 |
+
logger.info('适配器', `图片处理进度: ${processedCount}/${expectedUploads}`, meta);
|
| 130 |
+
|
| 131 |
+
if (processedCount >= expectedUploads) {
|
| 132 |
+
return true;
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
return false;
|
| 137 |
+
}
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
await sleep(1000, 2000);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
// 4. 填写提示词
|
| 144 |
+
await safeClick(page, INPUT_SELECTOR, { bias: 'input' });
|
| 145 |
+
await fillPrompt(page, INPUT_SELECTOR, prompt, meta);
|
| 146 |
+
await sleep(500, 1000);
|
| 147 |
+
|
| 148 |
+
// 5. 点击发送
|
| 149 |
+
logger.debug('适配器', '点击发送...', meta);
|
| 150 |
+
await safeClick(page, sendBtnLocator, { bias: 'button' });
|
| 151 |
+
|
| 152 |
+
logger.info('适配器', '等待生成结果...', meta);
|
| 153 |
+
|
| 154 |
+
// 6. 监听 conversation API 的 SSE 流,解析文本内容
|
| 155 |
+
logger.info('适配器', '监听 SSE 流获取文本...', meta);
|
| 156 |
+
|
| 157 |
+
let textContent = '';
|
| 158 |
+
let isComplete = false;
|
| 159 |
+
let targetMessageId = null; // 追踪目标消息 ID
|
| 160 |
+
|
| 161 |
+
try {
|
| 162 |
+
await page.waitForResponse(async (response) => {
|
| 163 |
+
const url = response.url();
|
| 164 |
+
if (!url.includes('backend-api/f/conversation')) return false;
|
| 165 |
+
if (response.request().method() !== 'POST') return false;
|
| 166 |
+
if (response.status() !== 200) return false;
|
| 167 |
+
|
| 168 |
+
try {
|
| 169 |
+
const body = await response.text();
|
| 170 |
+
const lines = body.split('\n');
|
| 171 |
+
|
| 172 |
+
for (const line of lines) {
|
| 173 |
+
// 跳过空行和事件行
|
| 174 |
+
if (!line.startsWith('data: ')) continue;
|
| 175 |
+
|
| 176 |
+
const dataStr = line.slice(6).trim();
|
| 177 |
+
if (dataStr === '[DONE]') {
|
| 178 |
+
isComplete = true;
|
| 179 |
+
continue;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
try {
|
| 183 |
+
const data = JSON.parse(dataStr);
|
| 184 |
+
|
| 185 |
+
// 检测目标消息 (assistant 角色, channel: "final", content_type: "text")
|
| 186 |
+
if (data.v?.message?.author?.role === 'assistant' &&
|
| 187 |
+
data.v?.message?.channel === 'final' &&
|
| 188 |
+
data.v?.message?.content?.content_type === 'text') {
|
| 189 |
+
targetMessageId = data.v.message.id;
|
| 190 |
+
// 初始内容
|
| 191 |
+
const parts = data.v.message.content.parts;
|
| 192 |
+
if (parts && parts[0]) {
|
| 193 |
+
textContent = parts[0];
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
// 累积 delta 内容 (append 操作)
|
| 198 |
+
if (data.o === 'append' && data.p === '/message/content/parts/0' && data.v) {
|
| 199 |
+
textContent += data.v;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
// 简单的 delta 追加 (没有 p/o,只有 v)
|
| 203 |
+
if (data.v && typeof data.v === 'string' && !data.o && !data.p && targetMessageId) {
|
| 204 |
+
textContent += data.v;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
// patch 操作中的 append
|
| 208 |
+
if (data.o === 'patch' && Array.isArray(data.v)) {
|
| 209 |
+
for (const patch of data.v) {
|
| 210 |
+
if (patch.o === 'append' && patch.p === '/message/content/parts/0' && patch.v) {
|
| 211 |
+
textContent += patch.v;
|
| 212 |
+
}
|
| 213 |
+
// 检查是否完成
|
| 214 |
+
if (patch.p === '/message/status' && patch.v === 'finished_successfully') {
|
| 215 |
+
isComplete = true;
|
| 216 |
+
}
|
| 217 |
+
}
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
// message_stream_complete 表示完成
|
| 221 |
+
if (data.type === 'message_stream_complete') {
|
| 222 |
+
isComplete = true;
|
| 223 |
+
}
|
| 224 |
+
} catch {
|
| 225 |
+
// 忽略解析错误
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
return isComplete;
|
| 230 |
+
} catch {
|
| 231 |
+
return false;
|
| 232 |
+
}
|
| 233 |
+
}, { timeout: 180000 });
|
| 234 |
+
} catch (e) {
|
| 235 |
+
const pageError = normalizePageError(e, meta);
|
| 236 |
+
if (pageError) return pageError;
|
| 237 |
+
throw e;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
if (!textContent || textContent.trim() === '') {
|
| 241 |
+
logger.warn('适配器', '回复内容为空', meta);
|
| 242 |
+
return { error: '回复内容为空' };
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
logger.info('适配器', `已获取文本内容 (${textContent.length} ���符)`, meta);
|
| 246 |
+
logger.info('适配器', '文本生成完成,任务完成', meta);
|
| 247 |
+
return { text: textContent.trim() };
|
| 248 |
+
|
| 249 |
+
} catch (err) {
|
| 250 |
+
// 顶层错误处理
|
| 251 |
+
const pageError = normalizePageError(err, meta);
|
| 252 |
+
if (pageError) return pageError;
|
| 253 |
+
|
| 254 |
+
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
| 255 |
+
return { error: `生成任务失败: ${err.message}` };
|
| 256 |
+
} finally {
|
| 257 |
+
// 任务结束,将鼠标移至安全区域
|
| 258 |
+
await moveMouseAway(page);
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
/**
|
| 263 |
+
* 适配器 manifest
|
| 264 |
+
*/
|
| 265 |
+
export const manifest = {
|
| 266 |
+
id: 'chatgpt_text',
|
| 267 |
+
displayName: 'ChatGPT (文本生成)',
|
| 268 |
+
description: '使用 ChatGPT 官网生成文本,支持多模型切换和图片上传。需要已登录的 ChatGPT 账户,若需要选择模型,请使用会员账号 (包含 K12 教室认证账号)。',
|
| 269 |
+
|
| 270 |
+
// 入口 URL
|
| 271 |
+
getTargetUrl(config, workerConfig) {
|
| 272 |
+
return TARGET_URL;
|
| 273 |
+
},
|
| 274 |
+
|
| 275 |
+
// 模型列表
|
| 276 |
+
models: [
|
| 277 |
+
{ id: 'gpt-5.2', codeName: 'GPT-5.2 Instant', imagePolicy: 'optional' },
|
| 278 |
+
{ id: 'gpt-5.2-thinking', codeName: 'GPT-5.2 Thinking', imagePolicy: 'optional' },
|
| 279 |
+
{ id: 'gpt-5.1', codeName: 'GPT-5.1 Instant', imagePolicy: 'optional' },
|
| 280 |
+
{ id: 'gpt-5.1-thinking', codeName: 'GPT-5.1 Thinking', imagePolicy: 'optional' },
|
| 281 |
+
{ id: 'gpt-5', codeName: 'GPT-5 Instant', imagePolicy: 'optional' },
|
| 282 |
+
{ id: 'gpt-5-thinking', codeName: 'GPT-5 Thinking', imagePolicy: 'optional' },
|
| 283 |
+
],
|
| 284 |
+
|
| 285 |
+
// 无需导航处理器
|
| 286 |
+
navigationHandlers: [],
|
| 287 |
+
|
| 288 |
+
// 核心文本生成方法
|
| 289 |
+
generate
|
| 290 |
+
};
|
src/backend/adapter/deepseek_text.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview DeepSeek 文本生成适配器
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import {
|
| 6 |
+
sleep,
|
| 7 |
+
safeClick
|
| 8 |
+
} from '../engine/utils.js';
|
| 9 |
+
import {
|
| 10 |
+
fillPrompt,
|
| 11 |
+
normalizePageError,
|
| 12 |
+
moveMouseAway,
|
| 13 |
+
waitForInput,
|
| 14 |
+
gotoWithCheck
|
| 15 |
+
} from '../utils/index.js';
|
| 16 |
+
import { logger } from '../../utils/logger.js';
|
| 17 |
+
|
| 18 |
+
// --- 配置常量 ---
|
| 19 |
+
const TARGET_URL = 'https://chat.deepseek.com/';
|
| 20 |
+
const INPUT_SELECTOR = 'textarea';
|
| 21 |
+
|
| 22 |
+
/**
|
| 23 |
+
* 切换功能按钮状态
|
| 24 |
+
* @param {import('playwright-core').Page} page - 页面对象
|
| 25 |
+
* @param {string} buttonName - 按钮名称 (DeepThink / Search)
|
| 26 |
+
* @param {boolean} targetState - 目标状态 (true=开启, false=关闭)
|
| 27 |
+
* @param {object} meta - 日志元数据
|
| 28 |
+
* @returns {Promise<boolean>} 是否成功切换
|
| 29 |
+
*/
|
| 30 |
+
async function toggleButton(page, buttonName, targetState, meta = {}) {
|
| 31 |
+
try {
|
| 32 |
+
const btn = page.getByRole('button', { name: buttonName });
|
| 33 |
+
const btnCount = await btn.count();
|
| 34 |
+
if (btnCount === 0) {
|
| 35 |
+
logger.debug('适配器', `未找到 ${buttonName} 按钮`, meta);
|
| 36 |
+
return false;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// 获取当前状态 (检查 class 是否包含 ds-toggle-button--selected)
|
| 40 |
+
const isSelected = await btn.evaluate(el => el.classList.contains('ds-toggle-button--selected'));
|
| 41 |
+
|
| 42 |
+
if (isSelected !== targetState) {
|
| 43 |
+
logger.info('适配器', `切换 ${buttonName}: ${isSelected} -> ${targetState}`, meta);
|
| 44 |
+
await safeClick(page, btn, { bias: 'button' });
|
| 45 |
+
await sleep(300, 500);
|
| 46 |
+
return true;
|
| 47 |
+
} else {
|
| 48 |
+
logger.debug('适配器', `${buttonName} 已是目标状态: ${targetState}`, meta);
|
| 49 |
+
return true;
|
| 50 |
+
}
|
| 51 |
+
} catch (e) {
|
| 52 |
+
logger.warn('适配器', `切换 ${buttonName} 失败: ${e.message}`, meta);
|
| 53 |
+
return false;
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/**
|
| 58 |
+
* 配置模型功能 (thinking / search)
|
| 59 |
+
* @param {import('playwright-core').Page} page - 页面对象
|
| 60 |
+
* @param {object} modelConfig - 模型配置
|
| 61 |
+
* @param {object} meta - 日志元数据
|
| 62 |
+
*/
|
| 63 |
+
async function configureModel(page, modelConfig, meta = {}) {
|
| 64 |
+
const thinking = modelConfig?.thinking || false;
|
| 65 |
+
const search = modelConfig?.search || false;
|
| 66 |
+
|
| 67 |
+
// 切换 DeepThink 状态
|
| 68 |
+
await toggleButton(page, 'DeepThink', thinking, meta);
|
| 69 |
+
await sleep(200, 400);
|
| 70 |
+
|
| 71 |
+
// 切换 Search 状态
|
| 72 |
+
await toggleButton(page, 'Search', search, meta);
|
| 73 |
+
await sleep(200, 400);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/**
|
| 77 |
+
* 执行文本生成任务
|
| 78 |
+
* @param {object} context - 浏览器上下文 { page, config }
|
| 79 |
+
* @param {string} prompt - 提示词
|
| 80 |
+
* @param {string[]} imgPaths - 图片路径数组 (此适配器不支持)
|
| 81 |
+
* @param {string} [modelId] - 模型 ID
|
| 82 |
+
* @param {object} [meta={}] - 日志元数据
|
| 83 |
+
* @returns {Promise<{text?: string, error?: string}>}
|
| 84 |
+
*/
|
| 85 |
+
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
| 86 |
+
const { page } = context;
|
| 87 |
+
|
| 88 |
+
try {
|
| 89 |
+
logger.info('适配器', '开启新会话...', meta);
|
| 90 |
+
await gotoWithCheck(page, TARGET_URL);
|
| 91 |
+
|
| 92 |
+
// 1. 等待输入框加载
|
| 93 |
+
await waitForInput(page, INPUT_SELECTOR, { click: false });
|
| 94 |
+
await sleep(1500, 2500);
|
| 95 |
+
|
| 96 |
+
// 2. 配置模型功能 (thinking / search)
|
| 97 |
+
const modelConfig = manifest.models.find(m => m.id === modelId);
|
| 98 |
+
if (modelConfig) {
|
| 99 |
+
await configureModel(page, modelConfig, meta);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// 3. 填写提示词
|
| 103 |
+
await safeClick(page, INPUT_SELECTOR, { bias: 'input' });
|
| 104 |
+
await fillPrompt(page, INPUT_SELECTOR, prompt, meta);
|
| 105 |
+
await sleep(500, 1000);
|
| 106 |
+
|
| 107 |
+
// 4. 按回车发送
|
| 108 |
+
logger.debug('适配器', '按回车发送...', meta);
|
| 109 |
+
await page.keyboard.press('Enter');
|
| 110 |
+
|
| 111 |
+
logger.info('适配器', '等待生成结果...', meta);
|
| 112 |
+
|
| 113 |
+
// 5. 监听 chat/completion SSE 流,解析文本内容
|
| 114 |
+
logger.info('适配器', '监听 SSE 流获取文本...', meta);
|
| 115 |
+
|
| 116 |
+
let textContent = '';
|
| 117 |
+
let isComplete = false;
|
| 118 |
+
let responseFragmentIndex = -1; // RESPONSE 类型 fragment 的数组索引
|
| 119 |
+
let currentFragmentIndex = -1; // 当前正在追加内容的 fragment 数组索引
|
| 120 |
+
let fragmentCount = 0; // fragments 数组的当前长度
|
| 121 |
+
|
| 122 |
+
try {
|
| 123 |
+
await page.waitForResponse(async (response) => {
|
| 124 |
+
const url = response.url();
|
| 125 |
+
if (!url.includes('chat/completion')) return false;
|
| 126 |
+
if (response.request().method() !== 'POST') return false;
|
| 127 |
+
if (response.status() !== 200) return false;
|
| 128 |
+
|
| 129 |
+
try {
|
| 130 |
+
const body = await response.text();
|
| 131 |
+
const lines = body.split('\n');
|
| 132 |
+
|
| 133 |
+
for (const line of lines) {
|
| 134 |
+
// 跳过事件行和空行
|
| 135 |
+
if (line.startsWith('event:') || !line.startsWith('data:')) continue;
|
| 136 |
+
|
| 137 |
+
const dataStr = line.slice(5).trim();
|
| 138 |
+
if (!dataStr || dataStr === '{}') continue;
|
| 139 |
+
|
| 140 |
+
try {
|
| 141 |
+
const data = JSON.parse(dataStr);
|
| 142 |
+
|
| 143 |
+
// 初始响应中可能已有 fragments (如 SEARCH)
|
| 144 |
+
if (data.v?.response?.fragments && Array.isArray(data.v.response.fragments)) {
|
| 145 |
+
for (const fragment of data.v.response.fragments) {
|
| 146 |
+
const idx = fragmentCount++;
|
| 147 |
+
if (fragment.type === 'RESPONSE') {
|
| 148 |
+
responseFragmentIndex = idx;
|
| 149 |
+
currentFragmentIndex = idx;
|
| 150 |
+
if (fragment.content) {
|
| 151 |
+
textContent += fragment.content;
|
| 152 |
+
}
|
| 153 |
+
} else {
|
| 154 |
+
currentFragmentIndex = idx;
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
// 简单的文本追加 (只有 v 字符串,没有 p 和 o)
|
| 160 |
+
// 只有当前活跃的 fragment 是 RESPONSE 类型时才收集
|
| 161 |
+
if (data.v && typeof data.v === 'string' && !data.p && !data.o) {
|
| 162 |
+
if (currentFragmentIndex === responseFragmentIndex && responseFragmentIndex >= 0) {
|
| 163 |
+
textContent += data.v;
|
| 164 |
+
}
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
// 带路径的 APPEND 操作 (如 response/fragments/1/content)
|
| 168 |
+
if (data.o === 'APPEND' && data.p && typeof data.v === 'string') {
|
| 169 |
+
const match = data.p.match(/response\/fragments\/(\d+)\/content/);
|
| 170 |
+
if (match) {
|
| 171 |
+
const fragIdx = parseInt(match[1], 10);
|
| 172 |
+
currentFragmentIndex = fragIdx;
|
| 173 |
+
if (fragIdx === responseFragmentIndex) {
|
| 174 |
+
textContent += data.v;
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
// 不带操作符的路径设置 (如 {"v": "xxx", "p": "response/fragments/1/content"})
|
| 180 |
+
if (data.p && typeof data.v === 'string' && !data.o) {
|
| 181 |
+
const match = data.p.match(/response\/fragments\/(\d+)\/content/);
|
| 182 |
+
if (match) {
|
| 183 |
+
const fragIdx = parseInt(match[1], 10);
|
| 184 |
+
currentFragmentIndex = fragIdx;
|
| 185 |
+
if (fragIdx === responseFragmentIndex) {
|
| 186 |
+
textContent += data.v;
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
// fragments APPEND - 新增 fragment (非 BATCH)
|
| 192 |
+
if (data.p === 'response/fragments' && data.o === 'APPEND' && Array.isArray(data.v)) {
|
| 193 |
+
for (const fragment of data.v) {
|
| 194 |
+
const idx = fragmentCount++;
|
| 195 |
+
if (fragment.type === 'RESPONSE') {
|
| 196 |
+
responseFragmentIndex = idx;
|
| 197 |
+
currentFragmentIndex = idx;
|
| 198 |
+
if (fragment.content) {
|
| 199 |
+
textContent += fragment.content;
|
| 200 |
+
}
|
| 201 |
+
} else {
|
| 202 |
+
// THINK 或 SEARCH
|
| 203 |
+
currentFragmentIndex = idx;
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
// BATCH 操作中的 fragments
|
| 209 |
+
if (data.o === 'BATCH' && data.p === 'response' && Array.isArray(data.v)) {
|
| 210 |
+
for (const item of data.v) {
|
| 211 |
+
// fragments 追加
|
| 212 |
+
if (item.p === 'fragments' && item.o === 'APPEND' && Array.isArray(item.v)) {
|
| 213 |
+
for (const fragment of item.v) {
|
| 214 |
+
const idx = fragmentCount++;
|
| 215 |
+
if (fragment.type === 'RESPONSE') {
|
| 216 |
+
responseFragmentIndex = idx;
|
| 217 |
+
currentFragmentIndex = idx;
|
| 218 |
+
if (fragment.content) {
|
| 219 |
+
textContent += fragment.content;
|
| 220 |
+
}
|
| 221 |
+
} else {
|
| 222 |
+
// THINK 或 SEARCH
|
| 223 |
+
currentFragmentIndex = idx;
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
}
|
| 227 |
+
// 检查是否完成
|
| 228 |
+
if (item.p === 'status' && item.v === 'FINISHED') {
|
| 229 |
+
isComplete = true;
|
| 230 |
+
}
|
| 231 |
+
}
|
| 232 |
+
}
|
| 233 |
+
} catch {
|
| 234 |
+
// 忽略解析错误
|
| 235 |
+
}
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
return isComplete;
|
| 239 |
+
} catch {
|
| 240 |
+
return false;
|
| 241 |
+
}
|
| 242 |
+
}, { timeout: 180000 });
|
| 243 |
+
} catch (e) {
|
| 244 |
+
const pageError = normalizePageError(e, meta);
|
| 245 |
+
if (pageError) return pageError;
|
| 246 |
+
throw e;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
if (!textContent || textContent.trim() === '') {
|
| 250 |
+
logger.warn('适配器', '回复内容为空', meta);
|
| 251 |
+
return { error: '回复内容为空' };
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
logger.info('适配器', `已获取文本内容 (${textContent.length} 字符)`, meta);
|
| 255 |
+
logger.info('适配器', '文本生成完成,任务完成', meta);
|
| 256 |
+
return { text: textContent.trim() };
|
| 257 |
+
|
| 258 |
+
} catch (err) {
|
| 259 |
+
// 顶层错误处理
|
| 260 |
+
const pageError = normalizePageError(err, meta);
|
| 261 |
+
if (pageError) return pageError;
|
| 262 |
+
|
| 263 |
+
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
| 264 |
+
return { error: `生成任务失败: ${err.message}` };
|
| 265 |
+
} finally {
|
| 266 |
+
// 任务结束,将鼠标移至安全区域
|
| 267 |
+
await moveMouseAway(page);
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
/**
|
| 272 |
+
* 适配器 manifest
|
| 273 |
+
*/
|
| 274 |
+
export const manifest = {
|
| 275 |
+
id: 'deepseek_text',
|
| 276 |
+
displayName: 'DeepSeek (文本生成)',
|
| 277 |
+
description: '使用 DeepSeek 官网生成文本,支持 DeepThink 深度思考和 Search 搜索模式。需要已登录的 DeepSeek 账户。',
|
| 278 |
+
|
| 279 |
+
// 入口 URL
|
| 280 |
+
getTargetUrl(config, workerConfig) {
|
| 281 |
+
return TARGET_URL;
|
| 282 |
+
},
|
| 283 |
+
|
| 284 |
+
// 模型列表
|
| 285 |
+
models: [
|
| 286 |
+
{ id: 'deepseek-v3.2', imagePolicy: 'forbidden' },
|
| 287 |
+
{ id: 'deepseek-v3.2-thinking', imagePolicy: 'forbidden', thinking: true },
|
| 288 |
+
{ id: 'deepseek-v3.2-search', imagePolicy: 'forbidden', search: true },
|
| 289 |
+
{ id: 'deepseek-v3.2-thinking-search', imagePolicy: 'forbidden', thinking: true, search: true },
|
| 290 |
+
],
|
| 291 |
+
|
| 292 |
+
// 无需导航处理器
|
| 293 |
+
navigationHandlers: [],
|
| 294 |
+
|
| 295 |
+
// 核心文本生成方法
|
| 296 |
+
generate
|
| 297 |
+
};
|
src/backend/adapter/doubao.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview 豆包 (Doubao) 图片生成适配器
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import {
|
| 6 |
+
sleep,
|
| 7 |
+
safeClick,
|
| 8 |
+
uploadFilesViaChooser
|
| 9 |
+
} from '../engine/utils.js';
|
| 10 |
+
import {
|
| 11 |
+
fillPrompt,
|
| 12 |
+
normalizePageError,
|
| 13 |
+
moveMouseAway,
|
| 14 |
+
waitForInput,
|
| 15 |
+
gotoWithCheck,
|
| 16 |
+
useContextDownload
|
| 17 |
+
} from '../utils/index.js';
|
| 18 |
+
import { logger } from '../../utils/logger.js';
|
| 19 |
+
|
| 20 |
+
// --- 配置常量 ---
|
| 21 |
+
const TARGET_URL = 'https://www.doubao.com/chat/';
|
| 22 |
+
|
| 23 |
+
/**
|
| 24 |
+
* 执行图片生成任务
|
| 25 |
+
* @param {object} context - 浏览器上下文 { page, config }
|
| 26 |
+
* @param {string} prompt - 提示词
|
| 27 |
+
* @param {string[]} imgPaths - 图片路径数组
|
| 28 |
+
* @param {string} [modelId] - 模型 ID
|
| 29 |
+
* @param {object} [meta={}] - 日志元数据
|
| 30 |
+
* @returns {Promise<{image?: string, error?: string}>}
|
| 31 |
+
*/
|
| 32 |
+
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
| 33 |
+
const { page } = context;
|
| 34 |
+
|
| 35 |
+
// 获取模型配置
|
| 36 |
+
const modelConfig = manifest.models.find(m => m.id === modelId) || manifest.models[0];
|
| 37 |
+
const { codeName } = modelConfig;
|
| 38 |
+
|
| 39 |
+
try {
|
| 40 |
+
logger.info('适配器', '开启新会话...', meta);
|
| 41 |
+
await gotoWithCheck(page, TARGET_URL);
|
| 42 |
+
await sleep(1500, 2500);
|
| 43 |
+
|
| 44 |
+
// 1. 点击进入图片生成模式
|
| 45 |
+
logger.debug('适配器', '进入图片生成模式...', meta);
|
| 46 |
+
const skillBtn = page.locator('button[data-testid="skill_bar_button_3"]');
|
| 47 |
+
await skillBtn.waitFor({ state: 'visible', timeout: 30000 });
|
| 48 |
+
await safeClick(page, skillBtn, { bias: 'button' });
|
| 49 |
+
await sleep(1000, 1500);
|
| 50 |
+
|
| 51 |
+
// 2. 选择模型
|
| 52 |
+
logger.debug('适配器', `选择模型: ${codeName}...`, meta);
|
| 53 |
+
const modelBtn = page.locator('button[data-testid="image-creation-chat-input-picture-model-button"]');
|
| 54 |
+
await modelBtn.waitFor({ state: 'visible', timeout: 10000 });
|
| 55 |
+
await safeClick(page, modelBtn, { bias: 'button' });
|
| 56 |
+
await sleep(500, 800);
|
| 57 |
+
|
| 58 |
+
const modelOption = page.getByRole('menuitem', { name: codeName });
|
| 59 |
+
await modelOption.waitFor({ state: 'visible', timeout: 5000 });
|
| 60 |
+
await safeClick(page, modelOption, { bias: 'button' });
|
| 61 |
+
await sleep(500, 800);
|
| 62 |
+
|
| 63 |
+
// 3. 上传参考图片 (如果有)
|
| 64 |
+
if (imgPaths && imgPaths.length > 0) {
|
| 65 |
+
logger.info('适配器', `开始上传 ${imgPaths.length} 张参考图片...`, meta);
|
| 66 |
+
|
| 67 |
+
const uploadBtn = page.locator('button[data-testid="image-creation-chat-input-picture-reference-button"]');
|
| 68 |
+
await uploadBtn.waitFor({ state: 'visible', timeout: 10000 });
|
| 69 |
+
|
| 70 |
+
await uploadFilesViaChooser(page, uploadBtn, imgPaths, {
|
| 71 |
+
uploadValidator: (response) => {
|
| 72 |
+
const url = response.url();
|
| 73 |
+
return response.status() === 200 &&
|
| 74 |
+
url.includes('bytedanceapi.com') &&
|
| 75 |
+
url.includes('Action=CommitImageUpload');
|
| 76 |
+
}
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
logger.info('适配器', '参考图片上传完成', meta);
|
| 80 |
+
await sleep(1000, 1500);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// 4. 填写提示词
|
| 84 |
+
const inputLocator = page.locator('div[data-testid="chat_input_input"][role="textbox"]');
|
| 85 |
+
await waitForInput(page, inputLocator, { click: true });
|
| 86 |
+
await fillPrompt(page, inputLocator, prompt, meta);
|
| 87 |
+
await sleep(500, 1000);
|
| 88 |
+
|
| 89 |
+
// 5. 设置 SSE 监听
|
| 90 |
+
logger.debug('适配器', '启动 SSE 监听...', meta);
|
| 91 |
+
|
| 92 |
+
let imageUrl = null;
|
| 93 |
+
let isResolved = false;
|
| 94 |
+
|
| 95 |
+
const resultPromise = new Promise((resolve, reject) => {
|
| 96 |
+
const timeout = setTimeout(() => {
|
| 97 |
+
if (!isResolved) {
|
| 98 |
+
isResolved = true;
|
| 99 |
+
reject(new Error('API_TIMEOUT: 响应超时 (180秒)'));
|
| 100 |
+
}
|
| 101 |
+
}, 180000);
|
| 102 |
+
|
| 103 |
+
const handleResponse = async (response) => {
|
| 104 |
+
try {
|
| 105 |
+
const url = response.url();
|
| 106 |
+
if (!url.includes('chat/completion')) return;
|
| 107 |
+
|
| 108 |
+
const contentType = response.headers()['content-type'] || '';
|
| 109 |
+
if (!contentType.includes('text/event-stream')) return;
|
| 110 |
+
|
| 111 |
+
const body = await response.text();
|
| 112 |
+
const extractedUrl = parseSSEForImage(body);
|
| 113 |
+
|
| 114 |
+
if (extractedUrl) {
|
| 115 |
+
imageUrl = extractedUrl;
|
| 116 |
+
if (!isResolved) {
|
| 117 |
+
isResolved = true;
|
| 118 |
+
clearTimeout(timeout);
|
| 119 |
+
page.off('response', handleResponse);
|
| 120 |
+
resolve();
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
} catch (e) {
|
| 124 |
+
// 忽略解析错误
|
| 125 |
+
}
|
| 126 |
+
};
|
| 127 |
+
|
| 128 |
+
page.on('response', handleResponse);
|
| 129 |
+
});
|
| 130 |
+
|
| 131 |
+
// 6. 点击发送
|
| 132 |
+
const sendBtn = page.locator('button[data-testid="chat_input_send_button"]');
|
| 133 |
+
await sendBtn.waitFor({ state: 'visible', timeout: 10000 });
|
| 134 |
+
logger.info('适配器', '点击发送...', meta);
|
| 135 |
+
await safeClick(page, sendBtn, { bias: 'button' });
|
| 136 |
+
|
| 137 |
+
// 7. 等待响应
|
| 138 |
+
logger.info('适配器', '等待图片生成...', meta);
|
| 139 |
+
await resultPromise;
|
| 140 |
+
|
| 141 |
+
if (!imageUrl) {
|
| 142 |
+
return { error: '未能从响应中提取图片链接' };
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
logger.info('适配器', '已获取图片链接,开始下载...', meta);
|
| 146 |
+
|
| 147 |
+
// 8. 下载图片
|
| 148 |
+
const downloadResult = await useContextDownload(imageUrl, page);
|
| 149 |
+
if (downloadResult.error) {
|
| 150 |
+
logger.error('适配器', downloadResult.error, meta);
|
| 151 |
+
return downloadResult;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
logger.info('适配器', '图片生成完成', meta);
|
| 155 |
+
return { image: downloadResult.image };
|
| 156 |
+
|
| 157 |
+
} catch (err) {
|
| 158 |
+
const pageError = normalizePageError(err, meta);
|
| 159 |
+
if (pageError) return pageError;
|
| 160 |
+
|
| 161 |
+
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
| 162 |
+
return { error: `生成任务失败: ${err.message}` };
|
| 163 |
+
} finally {
|
| 164 |
+
await moveMouseAway(page);
|
| 165 |
+
}
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
/**
|
| 169 |
+
* 解析 SSE 响应,提取图片链接
|
| 170 |
+
* @param {string} body - SSE 响应体
|
| 171 |
+
* @returns {string|null} 图片 URL
|
| 172 |
+
*/
|
| 173 |
+
function parseSSEForImage(body) {
|
| 174 |
+
const lines = body.split('\n');
|
| 175 |
+
|
| 176 |
+
for (let i = 0; i < lines.length; i++) {
|
| 177 |
+
const line = lines[i].trim();
|
| 178 |
+
|
| 179 |
+
if (line.startsWith('data:')) {
|
| 180 |
+
const dataLine = line.substring(5).trim();
|
| 181 |
+
if (!dataLine || dataLine === '{}') continue;
|
| 182 |
+
|
| 183 |
+
try {
|
| 184 |
+
const data = JSON.parse(dataLine);
|
| 185 |
+
const url = extractRawImage(data);
|
| 186 |
+
if (url) return url;
|
| 187 |
+
} catch (e) {
|
| 188 |
+
// JSON 解析失败,跳过
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
return null;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
/**
|
| 197 |
+
* 从 SSE 消息数据中提取原图 Raw 链接
|
| 198 |
+
* @param {Object} sseData - 解析后的 data JSON 对象
|
| 199 |
+
* @returns {string|null} - 返回图片 URL 或 null
|
| 200 |
+
*/
|
| 201 |
+
function extractRawImage(sseData) {
|
| 202 |
+
if (!sseData || !sseData.patch_op || !Array.isArray(sseData.patch_op)) {
|
| 203 |
+
return null;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
for (const op of sseData.patch_op) {
|
| 207 |
+
const contentBlocks = op.patch_value?.content_block;
|
| 208 |
+
|
| 209 |
+
if (Array.isArray(contentBlocks)) {
|
| 210 |
+
for (const block of contentBlocks) {
|
| 211 |
+
// block_type 2074 代表生成卡片
|
| 212 |
+
if (block.block_type === 2074) {
|
| 213 |
+
const creations = block.content?.creation_block?.creations;
|
| 214 |
+
|
| 215 |
+
if (Array.isArray(creations)) {
|
| 216 |
+
for (const creation of creations) {
|
| 217 |
+
// 提取 image_ori_raw,只有图片生成完成时才会出现
|
| 218 |
+
const rawUrl = creation.image?.image_ori_raw?.url;
|
| 219 |
+
if (rawUrl) {
|
| 220 |
+
return rawUrl;
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
return null;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
/**
|
| 233 |
+
* 适配器 manifest
|
| 234 |
+
*/
|
| 235 |
+
export const manifest = {
|
| 236 |
+
id: 'doubao',
|
| 237 |
+
displayName: '豆包 (图片生成)',
|
| 238 |
+
description: '使用字节跳动豆包生成图片,支持多种模型和参考图片上传。需要已登录的豆包账户。',
|
| 239 |
+
|
| 240 |
+
getTargetUrl(config, workerConfig) {
|
| 241 |
+
return TARGET_URL;
|
| 242 |
+
},
|
| 243 |
+
|
| 244 |
+
models: [
|
| 245 |
+
{ id: 'seedream-4.5', codeName: 'Seedream 4.5', imagePolicy: 'optional' },
|
| 246 |
+
{ id: 'seedream-4.0', codeName: 'Seedream 4.0', imagePolicy: 'optional' },
|
| 247 |
+
{ id: 'seedream-3.0', codeName: 'Seedream 3.0', imagePolicy: 'optional' }
|
| 248 |
+
],
|
| 249 |
+
|
| 250 |
+
navigationHandlers: [],
|
| 251 |
+
|
| 252 |
+
generate
|
| 253 |
+
};
|
src/backend/adapter/doubao_text.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview 豆包 (Doubao) 文本生成适配器
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import {
|
| 6 |
+
sleep,
|
| 7 |
+
safeClick,
|
| 8 |
+
uploadFilesViaChooser
|
| 9 |
+
} from '../engine/utils.js';
|
| 10 |
+
import {
|
| 11 |
+
fillPrompt,
|
| 12 |
+
normalizePageError,
|
| 13 |
+
moveMouseAway,
|
| 14 |
+
waitForInput,
|
| 15 |
+
gotoWithCheck
|
| 16 |
+
} from '../utils/index.js';
|
| 17 |
+
import { logger } from '../../utils/logger.js';
|
| 18 |
+
|
| 19 |
+
// --- 配置常量 ---
|
| 20 |
+
const TARGET_URL = 'https://www.doubao.com/chat/';
|
| 21 |
+
|
| 22 |
+
/**
|
| 23 |
+
* 执行文本生成任务
|
| 24 |
+
* @param {object} context - 浏览器上下文 { page, config }
|
| 25 |
+
* @param {string} prompt - 提示词
|
| 26 |
+
* @param {string[]} imgPaths - 图片路径数组
|
| 27 |
+
* @param {string} [modelId] - 模型 ID
|
| 28 |
+
* @param {object} [meta={}] - 日志元数据
|
| 29 |
+
* @returns {Promise<{text?: string, reasoning?: string, error?: string}>}
|
| 30 |
+
*/
|
| 31 |
+
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
| 32 |
+
const { page } = context;
|
| 33 |
+
|
| 34 |
+
// 是否使用深度思考模式
|
| 35 |
+
const useThinking = modelId === 'seed-thinking';
|
| 36 |
+
|
| 37 |
+
try {
|
| 38 |
+
logger.info('适配器', '开启新会话...', meta);
|
| 39 |
+
await gotoWithCheck(page, TARGET_URL);
|
| 40 |
+
await sleep(1500, 2500);
|
| 41 |
+
|
| 42 |
+
// 1. 等待输入框加载
|
| 43 |
+
const inputLocator = page.locator('textarea[data-testid="chat_input_input"]');
|
| 44 |
+
await waitForInput(page, inputLocator, { click: false });
|
| 45 |
+
await sleep(500, 1000);
|
| 46 |
+
|
| 47 |
+
// 2. 上传图片 (如果有)
|
| 48 |
+
if (imgPaths && imgPaths.length > 0) {
|
| 49 |
+
logger.info('适配器', `开始上传 ${imgPaths.length} 张图片...`, meta);
|
| 50 |
+
|
| 51 |
+
// 点击上传菜单按钮
|
| 52 |
+
const uploadMenuBtn = page.locator('button[aria-haspopup="menu"]').first();
|
| 53 |
+
await safeClick(page, uploadMenuBtn, { bias: 'button' });
|
| 54 |
+
await sleep(500, 1000);
|
| 55 |
+
|
| 56 |
+
// 点击上传文件选项
|
| 57 |
+
const uploadItem = page.locator('div[data-testid="upload_file_panel_upload_item"][role="menuitem"]');
|
| 58 |
+
await uploadFilesViaChooser(page, uploadItem, imgPaths, {
|
| 59 |
+
uploadValidator: (response) => {
|
| 60 |
+
const url = response.url();
|
| 61 |
+
return response.status() === 200 &&
|
| 62 |
+
url.includes('bytedanceapi.com') &&
|
| 63 |
+
url.includes('Action=CommitImageUpload');
|
| 64 |
+
}
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
logger.info('适配器', '图片上传完成', meta);
|
| 68 |
+
await sleep(1000, 1500);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// 3. 切换深度思考模式 (如需)
|
| 72 |
+
const deepThinkBtn = page.locator('div[data-testid="use-deep-thinking-switch-btn"] button');
|
| 73 |
+
const btnExists = await deepThinkBtn.count() > 0;
|
| 74 |
+
|
| 75 |
+
if (btnExists) {
|
| 76 |
+
const isChecked = await deepThinkBtn.getAttribute('data-checked') === 'true';
|
| 77 |
+
|
| 78 |
+
if (useThinking && !isChecked) {
|
| 79 |
+
logger.debug('适配器', '启用深度思考模式...', meta);
|
| 80 |
+
await safeClick(page, deepThinkBtn, { bias: 'button' });
|
| 81 |
+
await sleep(500, 800);
|
| 82 |
+
} else if (!useThinking && isChecked) {
|
| 83 |
+
logger.debug('适配器', '关闭深度思考模式...', meta);
|
| 84 |
+
await safeClick(page, deepThinkBtn, { bias: 'button' });
|
| 85 |
+
await sleep(500, 800);
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// 4. 填写提示词
|
| 90 |
+
await safeClick(page, inputLocator, { bias: 'input' });
|
| 91 |
+
await fillPrompt(page, inputLocator, prompt, meta);
|
| 92 |
+
await sleep(500, 1000);
|
| 93 |
+
|
| 94 |
+
// 5. 设置 SSE 监听
|
| 95 |
+
logger.debug('适配器', '启动 SSE 监听...', meta);
|
| 96 |
+
|
| 97 |
+
let resultText = '';
|
| 98 |
+
let reasoningText = '';
|
| 99 |
+
let isResolved = false;
|
| 100 |
+
|
| 101 |
+
const resultPromise = new Promise((resolve, reject) => {
|
| 102 |
+
const timeout = setTimeout(() => {
|
| 103 |
+
if (!isResolved) {
|
| 104 |
+
isResolved = true;
|
| 105 |
+
reject(new Error('API_TIMEOUT: 响应超时 (120秒)'));
|
| 106 |
+
}
|
| 107 |
+
}, 120000);
|
| 108 |
+
|
| 109 |
+
// 监听页面响应
|
| 110 |
+
const handleResponse = async (response) => {
|
| 111 |
+
try {
|
| 112 |
+
const url = response.url();
|
| 113 |
+
// 只处理 chat/completion 接口的 SSE 响应
|
| 114 |
+
if (!url.includes('chat/completion')) return;
|
| 115 |
+
|
| 116 |
+
const contentType = response.headers()['content-type'] || '';
|
| 117 |
+
if (!contentType.includes('text/event-stream')) return;
|
| 118 |
+
|
| 119 |
+
// 读取响应体并解析 SSE
|
| 120 |
+
const body = await response.text();
|
| 121 |
+
const result = parseSSEResponse(body, useThinking);
|
| 122 |
+
|
| 123 |
+
if (result.text) {
|
| 124 |
+
resultText = result.text;
|
| 125 |
+
reasoningText = result.reasoning || '';
|
| 126 |
+
|
| 127 |
+
if (!isResolved) {
|
| 128 |
+
isResolved = true;
|
| 129 |
+
clearTimeout(timeout);
|
| 130 |
+
page.off('response', handleResponse);
|
| 131 |
+
resolve();
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
} catch (e) {
|
| 135 |
+
// 忽略解析错误,继续等待
|
| 136 |
+
}
|
| 137 |
+
};
|
| 138 |
+
|
| 139 |
+
page.on('response', handleResponse);
|
| 140 |
+
});
|
| 141 |
+
|
| 142 |
+
// 6. 点击发送
|
| 143 |
+
const sendBtn = page.locator('button[data-testid="chat_input_send_button"]');
|
| 144 |
+
await sendBtn.waitFor({ state: 'visible', timeout: 10000 });
|
| 145 |
+
logger.info('适配器', '点击发送...', meta);
|
| 146 |
+
await safeClick(page, sendBtn, { bias: 'button' });
|
| 147 |
+
|
| 148 |
+
// 7. 等待响应
|
| 149 |
+
logger.info('适配器', '等待生成结果...', meta);
|
| 150 |
+
await resultPromise;
|
| 151 |
+
|
| 152 |
+
if (resultText) {
|
| 153 |
+
logger.info('适配器', `生成完成,文本长度: ${resultText.length}`, meta);
|
| 154 |
+
const result = { text: resultText };
|
| 155 |
+
if (reasoningText) {
|
| 156 |
+
result.reasoning = reasoningText;
|
| 157 |
+
}
|
| 158 |
+
return result;
|
| 159 |
+
} else {
|
| 160 |
+
return { error: '未能从响应中提取文本' };
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
} catch (err) {
|
| 164 |
+
const pageError = normalizePageError(err, meta);
|
| 165 |
+
if (pageError) return pageError;
|
| 166 |
+
|
| 167 |
+
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
| 168 |
+
return { error: `生成任务失败: ${err.message}` };
|
| 169 |
+
} finally {
|
| 170 |
+
await moveMouseAway(page);
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
/**
|
| 175 |
+
* 解析 SSE 响应体,提取最终文本
|
| 176 |
+
* @param {string} body - SSE 响应体
|
| 177 |
+
* @param {boolean} useThinking - 是否使用深度思考模式
|
| 178 |
+
* @returns {{text: string, reasoning?: string}}
|
| 179 |
+
*/
|
| 180 |
+
function parseSSEResponse(body, useThinking) {
|
| 181 |
+
const lines = body.split('\n');
|
| 182 |
+
let resultText = '';
|
| 183 |
+
let reasoningText = '';
|
| 184 |
+
let inThinkingBlock = false;
|
| 185 |
+
let thinkingBlockId = null;
|
| 186 |
+
|
| 187 |
+
for (let i = 0; i < lines.length; i++) {
|
| 188 |
+
const line = lines[i].trim();
|
| 189 |
+
|
| 190 |
+
// 解析事件类型
|
| 191 |
+
if (line.startsWith('event:')) {
|
| 192 |
+
const eventType = line.substring(6).trim();
|
| 193 |
+
|
| 194 |
+
// 找到对应的 data 行
|
| 195 |
+
if (i + 1 < lines.length && lines[i + 1].startsWith('data:')) {
|
| 196 |
+
const dataLine = lines[i + 1].substring(5).trim();
|
| 197 |
+
if (!dataLine || dataLine === '{}') continue;
|
| 198 |
+
|
| 199 |
+
try {
|
| 200 |
+
const data = JSON.parse(dataLine);
|
| 201 |
+
|
| 202 |
+
// SSE_REPLY_END with end_type: 1 包含完整回复
|
| 203 |
+
if (eventType === 'SSE_REPLY_END' && data.end_type === 1) {
|
| 204 |
+
resultText = data.msg_finish_attr?.brief || '';
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
// STREAM_MSG_NOTIFY 检测深度思考块
|
| 208 |
+
if (eventType === 'STREAM_MSG_NOTIFY' && useThinking) {
|
| 209 |
+
const blocks = data.content?.content_block || [];
|
| 210 |
+
for (const block of blocks) {
|
| 211 |
+
if (block.block_type === 10040 && block.content?.thinking_block) {
|
| 212 |
+
inThinkingBlock = true;
|
| 213 |
+
thinkingBlockId = block.block_id;
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
// STREAM_CHUNK 处理内容块
|
| 219 |
+
if (eventType === 'STREAM_CHUNK' && useThinking && data.patch_op) {
|
| 220 |
+
for (const op of data.patch_op) {
|
| 221 |
+
if (op.patch_object === 1 && op.patch_value?.content_block) {
|
| 222 |
+
for (const block of op.patch_value.content_block) {
|
| 223 |
+
// 如果有 parent_id 指向 thinking_block,则是思考内容
|
| 224 |
+
if (block.parent_id === thinkingBlockId) {
|
| 225 |
+
const text = block.content?.text_block?.text || '';
|
| 226 |
+
if (text) reasoningText += text;
|
| 227 |
+
}
|
| 228 |
+
// 思考块结束标记
|
| 229 |
+
if (block.block_type === 10040 && block.is_finish) {
|
| 230 |
+
inThinkingBlock = false;
|
| 231 |
+
}
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
// CHUNK_DELTA 增量文本 (思考过程中的增量)
|
| 238 |
+
if (eventType === 'CHUNK_DELTA' && useThinking && inThinkingBlock) {
|
| 239 |
+
const text = data.text || '';
|
| 240 |
+
if (text) reasoningText += text;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
} catch (e) {
|
| 244 |
+
// JSON 解析失败,跳过
|
| 245 |
+
}
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
return { text: resultText, reasoning: reasoningText };
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
/**
|
| 254 |
+
* 适配器 manifest
|
| 255 |
+
*/
|
| 256 |
+
export const manifest = {
|
| 257 |
+
id: 'doubao_text',
|
| 258 |
+
displayName: '豆包 (文本生成)',
|
| 259 |
+
description: '使用字节跳动豆包生成文本,支持深度思考模式和图片上传。需要已登录的豆包账户。',
|
| 260 |
+
|
| 261 |
+
getTargetUrl(config, workerConfig) {
|
| 262 |
+
return TARGET_URL;
|
| 263 |
+
},
|
| 264 |
+
|
| 265 |
+
models: [
|
| 266 |
+
{ id: 'seed', imagePolicy: 'optional', type: 'text' },
|
| 267 |
+
{ id: 'seed-thinking', imagePolicy: 'optional', type: 'text' }
|
| 268 |
+
],
|
| 269 |
+
|
| 270 |
+
navigationHandlers: [],
|
| 271 |
+
|
| 272 |
+
generate
|
| 273 |
+
};
|
src/backend/adapter/gemini.js
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview Google Gemini 图片、视频生成适配器
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import {
|
| 6 |
+
sleep,
|
| 7 |
+
safeClick,
|
| 8 |
+
uploadFilesViaChooser
|
| 9 |
+
} from '../engine/utils.js';
|
| 10 |
+
import {
|
| 11 |
+
fillPrompt,
|
| 12 |
+
normalizePageError,
|
| 13 |
+
normalizeHttpError,
|
| 14 |
+
moveMouseAway,
|
| 15 |
+
waitForInput,
|
| 16 |
+
gotoWithCheck,
|
| 17 |
+
waitApiResponse,
|
| 18 |
+
useContextDownload
|
| 19 |
+
} from '../utils/index.js';
|
| 20 |
+
import { logger } from '../../utils/logger.js';
|
| 21 |
+
|
| 22 |
+
// --- 配置常量 ---
|
| 23 |
+
const TARGET_URL = 'https://gemini.google.com/app?hl=en';
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* 执行生图任务
|
| 28 |
+
* @param {object} context - 浏览器上下文 { page, config }
|
| 29 |
+
* @param {string} prompt - 提示词
|
| 30 |
+
* @param {string[]} imgPaths - 图片路径数组
|
| 31 |
+
* @param {string} [modelId] - 模型 ID (此适配器未使用)
|
| 32 |
+
* @param {object} [meta={}] - 日志元数据
|
| 33 |
+
* @returns {Promise<{image?: string, error?: string}>}
|
| 34 |
+
*/
|
| 35 |
+
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
| 36 |
+
const { page } = context;
|
| 37 |
+
const inputLocator = page.getByRole('textbox');
|
| 38 |
+
const sendBtnLocator = page.getByRole('button', { name: 'Send message' });
|
| 39 |
+
|
| 40 |
+
try {
|
| 41 |
+
logger.info('适配器', '开启新会话...', meta);
|
| 42 |
+
await gotoWithCheck(page, TARGET_URL);
|
| 43 |
+
|
| 44 |
+
// 1. 等待输入框加载
|
| 45 |
+
await waitForInput(page, inputLocator, { click: false });
|
| 46 |
+
await sleep(1500, 2500);
|
| 47 |
+
|
| 48 |
+
// 2. 上传图片 (使用 filechooser 事件,因为 Firefox 不会创建 DOM input 元素)
|
| 49 |
+
if (imgPaths && imgPaths.length > 0) {
|
| 50 |
+
// 点击加号按钮打开菜单
|
| 51 |
+
logger.debug('适配器', '点击加号按钮...', meta);
|
| 52 |
+
const uploadMenuBtn = page.getByRole('button', { name: 'Open upload file menu' });
|
| 53 |
+
await safeClick(page, uploadMenuBtn, { bias: 'button' });
|
| 54 |
+
await sleep(500, 1000);
|
| 55 |
+
|
| 56 |
+
// 使用公共函数上传文件
|
| 57 |
+
const uploadFilesBtn = page.getByRole('button', { name: /Upload files/ });
|
| 58 |
+
await uploadFilesViaChooser(page, uploadFilesBtn, imgPaths, {
|
| 59 |
+
uploadValidator: (response) => {
|
| 60 |
+
const url = response.url();
|
| 61 |
+
return response.status() === 200 &&
|
| 62 |
+
url.includes('google.com/upload/') &&
|
| 63 |
+
url.includes('upload_id=');
|
| 64 |
+
}
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
await sleep(1000, 2000);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
// 3. 填写提示词
|
| 71 |
+
await safeClick(page, inputLocator, { bias: 'input' });
|
| 72 |
+
await fillPrompt(page, inputLocator, prompt, meta);
|
| 73 |
+
await sleep(500, 1000);
|
| 74 |
+
|
| 75 |
+
// 4. 点击 Tools 按钮启用图片/视频生成
|
| 76 |
+
logger.debug('适配器', '点击 Tools 按钮...', meta);
|
| 77 |
+
const toolsBtn = page.getByRole('button', { name: 'Tools' });
|
| 78 |
+
await safeClick(page, toolsBtn, { bias: 'button' });
|
| 79 |
+
await sleep(500, 1000);
|
| 80 |
+
|
| 81 |
+
// 检测是否是视频模型
|
| 82 |
+
const isVideoModel = modelId && modelId.startsWith('veo-');
|
| 83 |
+
|
| 84 |
+
// 5. 点击 Create images / Create videos 按钮
|
| 85 |
+
if (isVideoModel) {
|
| 86 |
+
logger.debug('适配器', '点击 Create videos 按钮...', meta);
|
| 87 |
+
const createVideosBtn = page.getByRole('button', { name: /^Create videos/ });
|
| 88 |
+
|
| 89 |
+
// 检查按钮是否存在(有些账号可能没有视频生成功能)
|
| 90 |
+
const btnCount = await createVideosBtn.count();
|
| 91 |
+
if (btnCount === 0) {
|
| 92 |
+
logger.error('适配器', '未找到 Create videos 按钮,该账号可能不支持视频生成', meta);
|
| 93 |
+
return { error: '该账号不支持视频生成功能 (未找到 Create videos 按钮)' };
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
await safeClick(page, createVideosBtn, { bias: 'button' });
|
| 97 |
+
} else {
|
| 98 |
+
logger.debug('适配器', '点击 Create images 按钮...', meta);
|
| 99 |
+
const createImagesBtn = page.getByRole('button', { name: 'Create images' });
|
| 100 |
+
await safeClick(page, createImagesBtn, { bias: 'button' });
|
| 101 |
+
}
|
| 102 |
+
await sleep(500, 1000);
|
| 103 |
+
|
| 104 |
+
// 6. 点击发送
|
| 105 |
+
logger.debug('适配器', '点击发送...', meta);
|
| 106 |
+
await safeClick(page, sendBtnLocator, { bias: 'button' });
|
| 107 |
+
|
| 108 |
+
logger.info('适配器', '等待生成结果...', meta);
|
| 109 |
+
|
| 110 |
+
// 7. 等待 StreamGenerate API
|
| 111 |
+
let streamApiResponse;
|
| 112 |
+
try {
|
| 113 |
+
streamApiResponse = await waitApiResponse(page, {
|
| 114 |
+
urlMatch: 'assistant.lamda.BardFrontendService/StreamGenerate',
|
| 115 |
+
method: 'POST',
|
| 116 |
+
timeout: 120000,
|
| 117 |
+
meta
|
| 118 |
+
});
|
| 119 |
+
} catch (e) {
|
| 120 |
+
const pageError = normalizePageError(e, meta);
|
| 121 |
+
if (pageError) return pageError;
|
| 122 |
+
throw e;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// 检查 HTTP 错误
|
| 126 |
+
const httpError = normalizeHttpError(streamApiResponse);
|
| 127 |
+
if (httpError) {
|
| 128 |
+
logger.error('适配器', `API 返回错误: ${httpError.error}`, meta);
|
| 129 |
+
return { error: `API 返回错误: ${httpError.error}` };
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
// 8. 等待图片/视频响应
|
| 133 |
+
if (isVideoModel) {
|
| 134 |
+
// 视频模式:等待视频下载链接
|
| 135 |
+
logger.info('适配器', '生成请求成功,等待视频...', meta);
|
| 136 |
+
|
| 137 |
+
let videoResponse;
|
| 138 |
+
try {
|
| 139 |
+
videoResponse = await waitApiResponse(page, {
|
| 140 |
+
urlMatch: 'contribution.usercontent.google.com/download',
|
| 141 |
+
urlContains: 'filename=video.mp4',
|
| 142 |
+
method: 'GET',
|
| 143 |
+
timeout: 180000, // 视频生成可能更慢
|
| 144 |
+
meta
|
| 145 |
+
});
|
| 146 |
+
} catch (e) {
|
| 147 |
+
const pageError = normalizePageError(e, meta);
|
| 148 |
+
if (pageError) return pageError;
|
| 149 |
+
throw e;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
// 获取视频数据
|
| 153 |
+
const buffer = await videoResponse.body();
|
| 154 |
+
const base64 = buffer.toString('base64');
|
| 155 |
+
const contentType = videoResponse.headers()['content-type'] || 'video/mp4';
|
| 156 |
+
const videoData = `data:${contentType};base64,${base64}`;
|
| 157 |
+
|
| 158 |
+
logger.info('适配器', '已获取视频,任务完成', meta);
|
| 159 |
+
return { image: videoData };
|
| 160 |
+
|
| 161 |
+
} else {
|
| 162 |
+
// 图片模式:直接从 StreamGenerate 响应体解析图片 URL
|
| 163 |
+
logger.info('适配器', '生成请求成功,正在解析响应...', meta);
|
| 164 |
+
|
| 165 |
+
// 解析响应体,提取图片 URL
|
| 166 |
+
const bodyBuffer = await streamApiResponse.body();
|
| 167 |
+
const imageUrls = extractImageUrlsFromResponse(bodyBuffer);
|
| 168 |
+
|
| 169 |
+
if (imageUrls.length === 0) {
|
| 170 |
+
// 没有找到图片 URL,尝试提取文本作为错误信息
|
| 171 |
+
const errorText = extractAiTextFromResponse(bodyBuffer);
|
| 172 |
+
const errorMsg = errorText.substring(0, 150) || '生成失败,响应中未包含图片';
|
| 173 |
+
logger.error('适配器', `未找到图片: ${errorMsg}`, meta);
|
| 174 |
+
return { error: errorMsg };
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
// 取第一张图片,追加 =s1024-rj 获取高分辨率
|
| 178 |
+
const imageUrl = imageUrls[0] + '=s1024-rj';
|
| 179 |
+
logger.info('适配器', `找到 ${imageUrls.length} 张图片,开始下载...`, meta);
|
| 180 |
+
|
| 181 |
+
// 使用封装的下载函数
|
| 182 |
+
const result = await useContextDownload(imageUrl, page);
|
| 183 |
+
if (result.error) {
|
| 184 |
+
logger.error('适配器', result.error, meta);
|
| 185 |
+
return result;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
logger.info('适配器', '已获取图片,任务完成', meta);
|
| 189 |
+
return result;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
} catch (err) {
|
| 193 |
+
// 顶层错误处理
|
| 194 |
+
const pageError = normalizePageError(err, meta);
|
| 195 |
+
if (pageError) return pageError;
|
| 196 |
+
|
| 197 |
+
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
| 198 |
+
return { error: `生成任务失败: ${err.message}` };
|
| 199 |
+
} finally {
|
| 200 |
+
// 任务结束,将鼠标移至安全区域
|
| 201 |
+
await moveMouseAway(page);
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
/**
|
| 206 |
+
* 适配器 manifest
|
| 207 |
+
*/
|
| 208 |
+
export const manifest = {
|
| 209 |
+
id: 'gemini',
|
| 210 |
+
displayName: 'Google Gemini (图片、视频生成)',
|
| 211 |
+
description: '使用 Google Gemini 官网生成图片和视频,支持参考图片上传。需要已登录的 Google 账户,免费账户图片生成有速率限制,视频生成必须为会员账户才可使用。',
|
| 212 |
+
|
| 213 |
+
// 入口 URL
|
| 214 |
+
getTargetUrl(config, workerConfig) {
|
| 215 |
+
return TARGET_URL;
|
| 216 |
+
},
|
| 217 |
+
|
| 218 |
+
// 模型列表
|
| 219 |
+
models: [
|
| 220 |
+
{ id: 'gemini-3-pro-image-preview', imagePolicy: 'optional' },
|
| 221 |
+
{ id: 'veo-3.1-generate-preview', imagePolicy: 'optional' }
|
| 222 |
+
],
|
| 223 |
+
|
| 224 |
+
// 无需导航处理器
|
| 225 |
+
navigationHandlers: [],
|
| 226 |
+
|
| 227 |
+
// 核心生图方法
|
| 228 |
+
generate
|
| 229 |
+
};
|
| 230 |
+
|
| 231 |
+
// ==========================================
|
| 232 |
+
// 解析 gRPC Batchexecute 响应
|
| 233 |
+
// ==========================================
|
| 234 |
+
|
| 235 |
+
/**
|
| 236 |
+
* 解析 batchexecute/batch RPC 响应(直接操作 Buffer)
|
| 237 |
+
* @param {Buffer} buf - 响应体 Buffer
|
| 238 |
+
*/
|
| 239 |
+
function parseLenFramedResponse(buf) {
|
| 240 |
+
let i = 0;
|
| 241 |
+
|
| 242 |
+
// 去掉 )]}' 这种 XSSI 前缀(通常是第一行)
|
| 243 |
+
if (buf.length >= 4 && buf[0] === 0x29 && buf[1] === 0x5d && buf[2] === 0x7d) {
|
| 244 |
+
const firstNl = buf.indexOf(0x0a);
|
| 245 |
+
if (firstNl !== -1) i = firstNl + 1;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
const frames = [];
|
| 249 |
+
|
| 250 |
+
const readLineBuf = () => {
|
| 251 |
+
if (i >= buf.length) return null;
|
| 252 |
+
const nl = buf.indexOf(0x0a, i);
|
| 253 |
+
let line;
|
| 254 |
+
if (nl === -1) {
|
| 255 |
+
line = buf.slice(i);
|
| 256 |
+
i = buf.length;
|
| 257 |
+
} else {
|
| 258 |
+
line = buf.slice(i, nl);
|
| 259 |
+
i = nl + 1;
|
| 260 |
+
}
|
| 261 |
+
if (line.length && line[line.length - 1] === 0x0d) line = line.slice(0, -1);
|
| 262 |
+
return line;
|
| 263 |
+
};
|
| 264 |
+
|
| 265 |
+
let pendingLen = null;
|
| 266 |
+
|
| 267 |
+
while (true) {
|
| 268 |
+
const lineBuf = readLineBuf();
|
| 269 |
+
if (lineBuf === null) break;
|
| 270 |
+
|
| 271 |
+
const lineStr = lineBuf.toString('utf8').trim();
|
| 272 |
+
if (!lineStr) continue;
|
| 273 |
+
|
| 274 |
+
if (pendingLen === null) {
|
| 275 |
+
if (/^\d+$/.test(lineStr)) pendingLen = Number(lineStr);
|
| 276 |
+
continue;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
let chunkBuf = lineBuf;
|
| 280 |
+
let chunkStr = chunkBuf.toString('utf8').trim();
|
| 281 |
+
|
| 282 |
+
while (true) {
|
| 283 |
+
try {
|
| 284 |
+
frames.push(JSON.parse(chunkStr));
|
| 285 |
+
break;
|
| 286 |
+
} catch (e) {
|
| 287 |
+
const msg = String(e && e.message || '');
|
| 288 |
+
const looksTruncated = /Unexpected end of JSON input|Unterminated string/.test(msg);
|
| 289 |
+
|
| 290 |
+
if (!looksTruncated) break;
|
| 291 |
+
|
| 292 |
+
const savedPos = i;
|
| 293 |
+
const next = readLineBuf();
|
| 294 |
+
if (next === null) break;
|
| 295 |
+
|
| 296 |
+
const nextStr = next.toString('utf8').trim();
|
| 297 |
+
if (/^\d+$/.test(nextStr)) {
|
| 298 |
+
i = savedPos;
|
| 299 |
+
break;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
chunkBuf = Buffer.concat([chunkBuf, Buffer.from('\n'), next]);
|
| 303 |
+
chunkStr = chunkBuf.toString('utf8').trim();
|
| 304 |
+
}
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
pendingLen = null;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
return frames;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
/**
|
| 314 |
+
* 把 frame 里的 payload 再 parse 一次
|
| 315 |
+
*/
|
| 316 |
+
function extractPayloads(frames) {
|
| 317 |
+
const payloads = [];
|
| 318 |
+
for (const frame of frames) {
|
| 319 |
+
if (!Array.isArray(frame)) continue;
|
| 320 |
+
|
| 321 |
+
for (const item of frame) {
|
| 322 |
+
if (!Array.isArray(item)) continue;
|
| 323 |
+
const payloadStr = item[2];
|
| 324 |
+
if (typeof payloadStr !== 'string') continue;
|
| 325 |
+
|
| 326 |
+
try {
|
| 327 |
+
payloads.push(JSON.parse(payloadStr));
|
| 328 |
+
} catch {
|
| 329 |
+
// ignore
|
| 330 |
+
}
|
| 331 |
+
}
|
| 332 |
+
}
|
| 333 |
+
return payloads;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
/**
|
| 337 |
+
* 深度遍历,查找 googleusercontent.com/gg-dl 开头的图片 URL
|
| 338 |
+
* @param {any} root - 要遍历的对象
|
| 339 |
+
* @returns {string[]} 图片 URL 数组
|
| 340 |
+
*/
|
| 341 |
+
function collectImageUrlsDeep(root) {
|
| 342 |
+
const urls = [];
|
| 343 |
+
const stack = [root];
|
| 344 |
+
|
| 345 |
+
while (stack.length) {
|
| 346 |
+
const cur = stack.pop();
|
| 347 |
+
if (!cur) continue;
|
| 348 |
+
|
| 349 |
+
if (typeof cur === 'string') {
|
| 350 |
+
// 匹配 googleusercontent.com/gg-dl 图片 URL
|
| 351 |
+
if (cur.includes('googleusercontent.com/gg-dl')) {
|
| 352 |
+
urls.push(cur);
|
| 353 |
+
}
|
| 354 |
+
} else if (Array.isArray(cur)) {
|
| 355 |
+
for (const v of cur) stack.push(v);
|
| 356 |
+
} else if (typeof cur === 'object') {
|
| 357 |
+
for (const v of Object.values(cur)) stack.push(v);
|
| 358 |
+
}
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
return urls;
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
/**
|
| 365 |
+
* 深度遍历,查找 rc_ 开头的文本内容
|
| 366 |
+
*/
|
| 367 |
+
function collectRcTextsDeep(root) {
|
| 368 |
+
const bestByRc = new Map();
|
| 369 |
+
const stack = [root];
|
| 370 |
+
|
| 371 |
+
while (stack.length) {
|
| 372 |
+
const cur = stack.pop();
|
| 373 |
+
if (!cur) continue;
|
| 374 |
+
|
| 375 |
+
if (Array.isArray(cur)) {
|
| 376 |
+
const maybeRc = cur[0];
|
| 377 |
+
const maybeArr = cur[1];
|
| 378 |
+
if (typeof maybeRc === 'string' && maybeRc.startsWith('rc_') && Array.isArray(maybeArr)) {
|
| 379 |
+
const text = maybeArr.filter(v => typeof v === 'string').join('');
|
| 380 |
+
if (text) {
|
| 381 |
+
const prev = bestByRc.get(maybeRc) || '';
|
| 382 |
+
if (text.length >= prev.length) bestByRc.set(maybeRc, text);
|
| 383 |
+
}
|
| 384 |
+
}
|
| 385 |
+
for (const v of cur) stack.push(v);
|
| 386 |
+
} else if (typeof cur === 'object') {
|
| 387 |
+
for (const v of Object.values(cur)) stack.push(v);
|
| 388 |
+
}
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
return bestByRc;
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
/**
|
| 395 |
+
* 从响应体 Buffer 中提取图片 URL
|
| 396 |
+
* @param {Buffer} bodyBuffer - 响应体 Buffer
|
| 397 |
+
* @returns {string[]} 图片 URL 数组
|
| 398 |
+
*/
|
| 399 |
+
function extractImageUrlsFromResponse(bodyBuffer) {
|
| 400 |
+
const frames = parseLenFramedResponse(bodyBuffer);
|
| 401 |
+
const payloads = extractPayloads(frames);
|
| 402 |
+
|
| 403 |
+
const allUrls = [];
|
| 404 |
+
for (const payload of payloads) {
|
| 405 |
+
const urls = collectImageUrlsDeep(payload);
|
| 406 |
+
allUrls.push(...urls);
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
// 去重
|
| 410 |
+
return [...new Set(allUrls)];
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
/**
|
| 414 |
+
* 从响应体 Buffer 中提取 AI 文本(用于错误提示)
|
| 415 |
+
* @param {Buffer} bodyBuffer - 响应体 Buffer
|
| 416 |
+
* @returns {string}
|
| 417 |
+
*/
|
| 418 |
+
function extractAiTextFromResponse(bodyBuffer) {
|
| 419 |
+
const frames = parseLenFramedResponse(bodyBuffer);
|
| 420 |
+
const payloads = extractPayloads(frames);
|
| 421 |
+
|
| 422 |
+
let best = '';
|
| 423 |
+
for (const payload of payloads) {
|
| 424 |
+
const m = collectRcTextsDeep(payload);
|
| 425 |
+
for (const text of m.values()) {
|
| 426 |
+
if (text.length > best.length) best = text;
|
| 427 |
+
}
|
| 428 |
+
}
|
| 429 |
+
return best;
|
| 430 |
+
}
|
src/backend/adapter/gemini_biz.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview Gemini Business 文本生成适配器
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import {
|
| 6 |
+
sleep,
|
| 7 |
+
safeClick,
|
| 8 |
+
pasteImages
|
| 9 |
+
} from '../engine/utils.js';
|
| 10 |
+
import {
|
| 11 |
+
fillPrompt,
|
| 12 |
+
submit,
|
| 13 |
+
normalizePageError,
|
| 14 |
+
normalizeHttpError,
|
| 15 |
+
waitApiResponse,
|
| 16 |
+
moveMouseAway,
|
| 17 |
+
waitForPageAuth,
|
| 18 |
+
lockPageAuth,
|
| 19 |
+
unlockPageAuth,
|
| 20 |
+
isPageAuthLocked,
|
| 21 |
+
waitForInput,
|
| 22 |
+
gotoWithCheck,
|
| 23 |
+
scrollToElement
|
| 24 |
+
} from '../utils/index.js';
|
| 25 |
+
import { logger } from '../../utils/logger.js';
|
| 26 |
+
|
| 27 |
+
// Gemini Biz 输入框选择器
|
| 28 |
+
const INPUT_SELECTOR = 'ucs-prosemirror-editor .ProseMirror';
|
| 29 |
+
|
| 30 |
+
/**
|
| 31 |
+
* 处理账户选择页面跳转
|
| 32 |
+
* @param {import('playwright-core').Page} page - Playwright 页面对象
|
| 33 |
+
* @param {string} targetUrl - 目标 URL,用于判断跳转完成
|
| 34 |
+
* @returns {Promise<boolean>} 是否处理了跳转
|
| 35 |
+
*/
|
| 36 |
+
async function handleAccountChooser(page) {
|
| 37 |
+
// 防止重复处理
|
| 38 |
+
if (isPageAuthLocked(page)) return false;
|
| 39 |
+
|
| 40 |
+
try {
|
| 41 |
+
const currentUrl = page.url();
|
| 42 |
+
if (currentUrl.includes('auth.business.gemini.google/account-chooser')) {
|
| 43 |
+
lockPageAuth(page);
|
| 44 |
+
logger.info('适配器', '[登录器(gemini_biz)] 检测到账户选择页面,尝试自动确认...');
|
| 45 |
+
|
| 46 |
+
// 尝试查找提交按钮 (通常是标准的 button[type="submit"])
|
| 47 |
+
const submitBtn = await page.$('button[type="submit"]');
|
| 48 |
+
if (submitBtn) {
|
| 49 |
+
// 确保按钮在可视区域
|
| 50 |
+
await submitBtn.scrollIntoViewIfNeeded();
|
| 51 |
+
await sleep(300, 500);
|
| 52 |
+
|
| 53 |
+
// 使用 safeClick 模拟人类点击行为
|
| 54 |
+
logger.info('适配器', '[登录器(gemini_biz)] 正在点击确认按钮...');
|
| 55 |
+
await safeClick(page, submitBtn, { bias: 'button' });
|
| 56 |
+
|
| 57 |
+
// 点击后等待跳转回目标页面
|
| 58 |
+
logger.info('适配器', '[登录器(gemini_biz)] 等待跳转回目标页面...');
|
| 59 |
+
try {
|
| 60 |
+
await page.waitForFunction(() => {
|
| 61 |
+
const href = window.location.href;
|
| 62 |
+
return !href.includes('accounts.google.com') &&
|
| 63 |
+
!href.includes('auth.business.gemini.google') &&
|
| 64 |
+
href.includes('business.gemini.google');
|
| 65 |
+
}, { timeout: 60000, polling: 1000 });
|
| 66 |
+
|
| 67 |
+
logger.info('适配器', `[登录器(gemini_biz)] 已跳转回目标页面`);
|
| 68 |
+
} catch (timeoutErr) {
|
| 69 |
+
const finalUrl = page.url();
|
| 70 |
+
logger.warn('适配器', `[登录器(gemini_biz)] 等待跳转回目标页面超时,尝试继续... 当前URL: ${finalUrl}`);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// 额外缓冲时间,确保页面完全加载
|
| 74 |
+
await sleep(2000, 3000);
|
| 75 |
+
unlockPageAuth(page);
|
| 76 |
+
return true;
|
| 77 |
+
} else {
|
| 78 |
+
// 按钮还没加载出来,保持锁,等待下次检查
|
| 79 |
+
logger.debug('适配器', '[登录器(gemini_biz)] 按钮尚未加载,等待中...');
|
| 80 |
+
await sleep(500, 1000);
|
| 81 |
+
unlockPageAuth(page); // 释放锁让下次尝试
|
| 82 |
+
return true; // 返回 true 表示"仍在处理中"
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
} catch (err) {
|
| 86 |
+
logger.warn('适配器', `[登录器(gemini_biz)] 处理账户选择页面失败: ${err.message}`);
|
| 87 |
+
unlockPageAuth(page);
|
| 88 |
+
}
|
| 89 |
+
return false;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
/**
|
| 94 |
+
* 生成图片
|
| 95 |
+
* @param {object} context - 浏览器上下文 { page, client, config }
|
| 96 |
+
* @param {string} prompt - 提示词
|
| 97 |
+
* @param {string[]} imgPaths - 参考图片路径数组
|
| 98 |
+
* @param {string} modelId - 模型 ID (目前未使用,固定为 gemini-3-pro-preview)
|
| 99 |
+
* @returns {Promise<{image?: string, error?: string}>} 生成结果
|
| 100 |
+
*/
|
| 101 |
+
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
| 102 |
+
const { page, config } = context;
|
| 103 |
+
|
| 104 |
+
try {
|
| 105 |
+
// 支持新路径 adapter.gemini_biz.entryUrl,向下兼容旧路径 geminiBiz.entryUrl
|
| 106 |
+
const targetUrl = config.backend?.adapter?.gemini_biz?.entryUrl || config.backend?.geminiBiz?.entryUrl;
|
| 107 |
+
|
| 108 |
+
if (!targetUrl) {
|
| 109 |
+
throw new Error('GeminiBiz backend missing entry URL');
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// 开启新对话 - 先等待可能正在进行的登录处理完成
|
| 113 |
+
await waitForPageAuth(page);
|
| 114 |
+
|
| 115 |
+
logger.info('适配器', '开启新会话', meta);
|
| 116 |
+
await gotoWithCheck(page, targetUrl);
|
| 117 |
+
|
| 118 |
+
// 如果触发了账户选择跳转,等待全局处理器完成
|
| 119 |
+
await waitForPageAuth(page);
|
| 120 |
+
|
| 121 |
+
// 1. 等待输入框加载
|
| 122 |
+
logger.debug('适配器', '正在寻找输入框...', meta);
|
| 123 |
+
await waitForInput(page, INPUT_SELECTOR, { click: false });
|
| 124 |
+
await sleep(1500, 2500);
|
| 125 |
+
|
| 126 |
+
// 2. 上传图片 (uploadImages - 使用自定义验证器)
|
| 127 |
+
if (imgPaths && imgPaths.length > 0) {
|
| 128 |
+
const expectedUploads = imgPaths.length;
|
| 129 |
+
let uploadedCount = 0;
|
| 130 |
+
let metadataCount = 0;
|
| 131 |
+
|
| 132 |
+
await pasteImages(page, INPUT_SELECTOR, imgPaths, {
|
| 133 |
+
uploadValidator: (response) => {
|
| 134 |
+
const url = response.url();
|
| 135 |
+
if (response.status() === 200) {
|
| 136 |
+
if (url.includes('global/widgetAddContextFile')) {
|
| 137 |
+
uploadedCount++;
|
| 138 |
+
logger.debug('适配器', `图片上传进度 (Add): ${uploadedCount}/${expectedUploads}`, meta);
|
| 139 |
+
return false;
|
| 140 |
+
} else if (url.includes('global/widgetListSessionFileMetadata')) {
|
| 141 |
+
metadataCount++;
|
| 142 |
+
logger.info('适配器', `图片上传进度: ${metadataCount}/${expectedUploads}`, meta);
|
| 143 |
+
|
| 144 |
+
if (uploadedCount >= expectedUploads && metadataCount >= expectedUploads) {
|
| 145 |
+
return true;
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
return false;
|
| 150 |
+
}
|
| 151 |
+
});
|
| 152 |
+
|
| 153 |
+
await sleep(1000, 2000);
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
// 3. 填写提示词 (fillPrompt)
|
| 157 |
+
await safeClick(page, INPUT_SELECTOR, { bias: 'input' });
|
| 158 |
+
await fillPrompt(page, INPUT_SELECTOR, prompt, meta);
|
| 159 |
+
await sleep(500, 1000);
|
| 160 |
+
|
| 161 |
+
// 4. 设置拦截器
|
| 162 |
+
logger.debug('适配器', '已启用请求拦截', meta);
|
| 163 |
+
await page.unroute('**/*').catch(() => { });
|
| 164 |
+
|
| 165 |
+
await page.route(url => url.href.includes('global/widgetStreamAssist'), async (route) => {
|
| 166 |
+
const request = route.request();
|
| 167 |
+
if (request.method() !== 'POST') return route.continue();
|
| 168 |
+
|
| 169 |
+
try {
|
| 170 |
+
const postData = request.postDataJSON();
|
| 171 |
+
if (postData) {
|
| 172 |
+
logger.debug('适配器', '已拦截请求,正在修改...', meta);
|
| 173 |
+
if (!postData.streamAssistRequest) postData.streamAssistRequest = {};
|
| 174 |
+
if (!postData.streamAssistRequest.assistGenerationConfig) postData.streamAssistRequest.assistGenerationConfig = {};
|
| 175 |
+
|
| 176 |
+
// 根据模型 ID 选择 toolsSpec
|
| 177 |
+
if (modelId && modelId.startsWith('veo-')) {
|
| 178 |
+
postData.streamAssistRequest.toolsSpec = { videoGenerationSpec: {} };
|
| 179 |
+
logger.info('适配器', '已拦截请求,使用视频生成规格', meta);
|
| 180 |
+
} else {
|
| 181 |
+
postData.streamAssistRequest.toolsSpec = { imageGenerationSpec: {} };
|
| 182 |
+
logger.info('适配器', '已拦截请求,使用图片生成规格', meta);
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
await route.continue({ postData: JSON.stringify(postData) });
|
| 186 |
+
return;
|
| 187 |
+
}
|
| 188 |
+
} catch (e) {
|
| 189 |
+
logger.error('适配器', '请求拦截处理失败', { ...meta, error: e.message });
|
| 190 |
+
}
|
| 191 |
+
await route.continue();
|
| 192 |
+
});
|
| 193 |
+
|
| 194 |
+
// 5. 提交
|
| 195 |
+
logger.debug('适配器', '点击发送...', meta);
|
| 196 |
+
await submit(page, {
|
| 197 |
+
btnSelector: 'md-icon-button.send-button.submit, button[aria-label="提交"], button[aria-label="Send"], .send-button',
|
| 198 |
+
inputTarget: INPUT_SELECTOR,
|
| 199 |
+
meta
|
| 200 |
+
});
|
| 201 |
+
|
| 202 |
+
logger.info('适配器', '等待生成结果中...', meta);
|
| 203 |
+
|
| 204 |
+
// 6. 等待 API 响应
|
| 205 |
+
let apiResponse;
|
| 206 |
+
try {
|
| 207 |
+
apiResponse = await waitApiResponse(page, {
|
| 208 |
+
urlMatch: 'global/widgetStreamAssist',
|
| 209 |
+
method: 'POST',
|
| 210 |
+
timeout: 120000,
|
| 211 |
+
errorText: ['modelArmorViolation'],
|
| 212 |
+
meta
|
| 213 |
+
});
|
| 214 |
+
} catch (e) {
|
| 215 |
+
const pageError = normalizePageError(e, meta);
|
| 216 |
+
if (pageError) return pageError;
|
| 217 |
+
throw e;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
// 检查 API 响应状态
|
| 221 |
+
const httpError = normalizeHttpError(apiResponse);
|
| 222 |
+
if (httpError) {
|
| 223 |
+
logger.error('适配器', `请求生成时返回错误: ${httpError.error}`, meta);
|
| 224 |
+
return { error: `请求生成时返回错误: ${httpError.error}` };
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
// 7. 等待图片下载响应
|
| 228 |
+
logger.info('适配器', '已获取结果,正在下载图片...', meta);
|
| 229 |
+
|
| 230 |
+
let imageResponse;
|
| 231 |
+
try {
|
| 232 |
+
// 先启动监听器,再滚动触发懒加载,避免错过请求
|
| 233 |
+
const imageResponsePromise = waitApiResponse(page, {
|
| 234 |
+
urlMatch: 'download/v1alpha/projects',
|
| 235 |
+
method: 'GET',
|
| 236 |
+
timeout: 120000,
|
| 237 |
+
errorText: ['is unable to reply as the prompt'],
|
| 238 |
+
meta
|
| 239 |
+
});
|
| 240 |
+
|
| 241 |
+
// 等待图片元素出现并滚动到可视范围,触发懒加载
|
| 242 |
+
await scrollToElement(page, 'ucs-markdown-image', { timeout: 20000 });
|
| 243 |
+
|
| 244 |
+
imageResponse = await imageResponsePromise;
|
| 245 |
+
} catch (e) {
|
| 246 |
+
const pageError = normalizePageError(e, meta);
|
| 247 |
+
if (pageError) {
|
| 248 |
+
if (e.name === 'TimeoutError') {
|
| 249 |
+
return { error: '已获取结果, 但图片下载时超时 (120秒)' };
|
| 250 |
+
}
|
| 251 |
+
return pageError;
|
| 252 |
+
}
|
| 253 |
+
throw e;
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
const base64 = await imageResponse.text();
|
| 258 |
+
|
| 259 |
+
// 从响应头获取内容类型
|
| 260 |
+
const contentType = imageResponse.headers()['x-goog-safety-content-type'] || 'image/png';
|
| 261 |
+
logger.info('适配器', `已下载内容,类型: ${contentType}`, meta);
|
| 262 |
+
|
| 263 |
+
const dataUri = `data:${contentType};base64,${base64}`;
|
| 264 |
+
return { image: dataUri };
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
} catch (err) {
|
| 268 |
+
// 顶层错误处理
|
| 269 |
+
const pageError = normalizePageError(err, meta);
|
| 270 |
+
if (pageError) return pageError;
|
| 271 |
+
|
| 272 |
+
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
| 273 |
+
return { error: `生成任务失败: ${err.message}` };
|
| 274 |
+
} finally {
|
| 275 |
+
// 清理拦截器
|
| 276 |
+
await page.unroute('**/*').catch(() => { });
|
| 277 |
+
// 任务结束,将鼠标移至安全区域
|
| 278 |
+
await moveMouseAway(page);
|
| 279 |
+
}
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
/**
|
| 283 |
+
* 适配器 manifest
|
| 284 |
+
*/
|
| 285 |
+
export const manifest = {
|
| 286 |
+
id: 'gemini_biz',
|
| 287 |
+
displayName: 'Gemini Business (图片、视频生成)',
|
| 288 |
+
description: '使用 Gemini Business 企业版生成图片和视频。需要提供入口 URL 并已登录企业账户 (每个谷歌账户首次可以在官网点击免费试用获取30天使用资格)。',
|
| 289 |
+
|
| 290 |
+
// 配置表单定义
|
| 291 |
+
configSchema: [
|
| 292 |
+
{
|
| 293 |
+
key: 'entryUrl',
|
| 294 |
+
label: '入口 URL',
|
| 295 |
+
type: 'string',
|
| 296 |
+
required: true,
|
| 297 |
+
placeholder: 'https://business.gemini.google/home/cid/8888a888-b6e0-88be-86e1-888cf3ee8cf4'
|
| 298 |
+
}
|
| 299 |
+
],
|
| 300 |
+
|
| 301 |
+
// 入口 URL (从配置读取,支持新旧路径)
|
| 302 |
+
getTargetUrl(config, workerConfig) {
|
| 303 |
+
return config?.backend?.adapter?.gemini_biz?.entryUrl || config?.backend?.geminiBiz?.entryUrl || null;
|
| 304 |
+
},
|
| 305 |
+
|
| 306 |
+
// 模型列表
|
| 307 |
+
models: [
|
| 308 |
+
{ id: 'gemini-3-pro-image-preview', imagePolicy: 'optional' },
|
| 309 |
+
{ id: 'veo-3.1-generate-preview', imagePolicy: 'optional' },
|
| 310 |
+
],
|
| 311 |
+
|
| 312 |
+
// 导航处理器
|
| 313 |
+
navigationHandlers: [handleAccountChooser],
|
| 314 |
+
|
| 315 |
+
// 核心生图方法
|
| 316 |
+
generate
|
| 317 |
+
};
|
src/backend/adapter/gemini_biz_text.js
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview Gemini Business 图片、视频生成适配器
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import {
|
| 6 |
+
sleep,
|
| 7 |
+
safeClick,
|
| 8 |
+
pasteImages
|
| 9 |
+
} from '../engine/utils.js';
|
| 10 |
+
import {
|
| 11 |
+
fillPrompt,
|
| 12 |
+
submit,
|
| 13 |
+
normalizePageError,
|
| 14 |
+
normalizeHttpError,
|
| 15 |
+
waitApiResponse,
|
| 16 |
+
moveMouseAway,
|
| 17 |
+
waitForPageAuth,
|
| 18 |
+
lockPageAuth,
|
| 19 |
+
unlockPageAuth,
|
| 20 |
+
isPageAuthLocked,
|
| 21 |
+
waitForInput,
|
| 22 |
+
gotoWithCheck
|
| 23 |
+
} from '../utils/index.js';
|
| 24 |
+
import { logger } from '../../utils/logger.js';
|
| 25 |
+
|
| 26 |
+
// Gemini Biz 输入框选择器
|
| 27 |
+
const INPUT_SELECTOR = 'ucs-prosemirror-editor .ProseMirror';
|
| 28 |
+
|
| 29 |
+
/**
|
| 30 |
+
* 处理账户选择页面跳转
|
| 31 |
+
* @param {import('playwright-core').Page} page - Playwright 页面对象
|
| 32 |
+
* @param {string} targetUrl - 目标 URL,用于判断跳转完成
|
| 33 |
+
* @returns {Promise<boolean>} 是否处理了跳转
|
| 34 |
+
*/
|
| 35 |
+
async function handleAccountChooser(page) {
|
| 36 |
+
// 防止重复处理
|
| 37 |
+
if (isPageAuthLocked(page)) return false;
|
| 38 |
+
|
| 39 |
+
try {
|
| 40 |
+
const currentUrl = page.url();
|
| 41 |
+
if (currentUrl.includes('auth.business.gemini.google/account-chooser')) {
|
| 42 |
+
lockPageAuth(page);
|
| 43 |
+
logger.info('适配器', '[登录器(gemini_biz)] 检测到账户选择页面,尝试自动确认...');
|
| 44 |
+
|
| 45 |
+
// 尝试查找提交按钮 (通常是标准的 button[type="submit"])
|
| 46 |
+
const submitBtn = await page.$('button[type="submit"]');
|
| 47 |
+
if (submitBtn) {
|
| 48 |
+
// 确保按钮在可视区域
|
| 49 |
+
await submitBtn.scrollIntoViewIfNeeded();
|
| 50 |
+
await sleep(300, 500);
|
| 51 |
+
|
| 52 |
+
// 使用 safeClick 模拟人类点击行为
|
| 53 |
+
logger.info('适配器', '[登录器(gemini_biz)] 正在点击确认按钮...');
|
| 54 |
+
await safeClick(page, submitBtn, { bias: 'button' });
|
| 55 |
+
|
| 56 |
+
// 点击后等待跳转回目标页面
|
| 57 |
+
logger.info('适配器', '[登录器(gemini_biz)] 等待跳转回目标页面...');
|
| 58 |
+
try {
|
| 59 |
+
await page.waitForFunction(() => {
|
| 60 |
+
const href = window.location.href;
|
| 61 |
+
return !href.includes('accounts.google.com') &&
|
| 62 |
+
!href.includes('auth.business.gemini.google') &&
|
| 63 |
+
href.includes('business.gemini.google');
|
| 64 |
+
}, { timeout: 60000, polling: 1000 });
|
| 65 |
+
|
| 66 |
+
logger.info('适配器', `[登录器(gemini_biz)] 已跳转回目标页面`);
|
| 67 |
+
} catch (timeoutErr) {
|
| 68 |
+
const finalUrl = page.url();
|
| 69 |
+
logger.warn('适配器', `[登录器(gemini_biz)] 等待跳转回目标页面超时,尝试继续... 当前URL: ${finalUrl}`);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// 额外缓冲时间,确保页面完全加载
|
| 73 |
+
await sleep(2000, 3000);
|
| 74 |
+
unlockPageAuth(page);
|
| 75 |
+
return true;
|
| 76 |
+
} else {
|
| 77 |
+
// 按钮还没加载出来,保持锁,等待下次检查
|
| 78 |
+
logger.debug('适配器', '[登录器(gemini_biz)] 按钮尚未加载,等待中...');
|
| 79 |
+
await sleep(500, 1000);
|
| 80 |
+
unlockPageAuth(page); // 释放锁让下次尝试
|
| 81 |
+
return true; // 返回 true 表示"仍在处理中"
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
} catch (err) {
|
| 85 |
+
logger.warn('适配器', `[登录器(gemini_biz)] 处理账户选择页面失败: ${err.message}`);
|
| 86 |
+
unlockPageAuth(page);
|
| 87 |
+
}
|
| 88 |
+
return false;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
/**
|
| 93 |
+
* 生成图片
|
| 94 |
+
* @param {object} context - 浏览器上下文 { page, client, config }
|
| 95 |
+
* @param {string} prompt - 提示词
|
| 96 |
+
* @param {string[]} imgPaths - 参考图片路径数组
|
| 97 |
+
* @param {string} modelId - 模型 ID (目前未使用,固定为 gemini-3-pro-preview)
|
| 98 |
+
* @returns {Promise<{image?: string, error?: string}>} 生成结果
|
| 99 |
+
*/
|
| 100 |
+
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
| 101 |
+
const { page, config } = context;
|
| 102 |
+
|
| 103 |
+
try {
|
| 104 |
+
// 支持新路径 adapter.gemini_biz.entryUrl,向下兼容旧路径 geminiBiz.entryUrl
|
| 105 |
+
const targetUrl = config.backend?.adapter?.gemini_biz?.entryUrl || config.backend?.geminiBiz?.entryUrl;
|
| 106 |
+
|
| 107 |
+
if (!targetUrl) {
|
| 108 |
+
throw new Error('GeminiBiz backend missing entry URL');
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// 开启新对话 - 先等待可能正在进行的登录处理完成
|
| 112 |
+
await waitForPageAuth(page);
|
| 113 |
+
|
| 114 |
+
logger.info('适配器', '开启新会话', meta);
|
| 115 |
+
await gotoWithCheck(page, targetUrl);
|
| 116 |
+
|
| 117 |
+
// 如果触发了账户选择跳转,等待全局处理器完成
|
| 118 |
+
await waitForPageAuth(page);
|
| 119 |
+
|
| 120 |
+
// 1. 等待输入框加载
|
| 121 |
+
logger.debug('适配器', '正在寻找输入框...', meta);
|
| 122 |
+
await waitForInput(page, INPUT_SELECTOR, { click: false });
|
| 123 |
+
await sleep(1500, 2500);
|
| 124 |
+
|
| 125 |
+
// 2. 上传图片 (uploadImages - 使用自定义验证器)
|
| 126 |
+
if (imgPaths && imgPaths.length > 0) {
|
| 127 |
+
const expectedUploads = imgPaths.length;
|
| 128 |
+
let uploadedCount = 0;
|
| 129 |
+
let metadataCount = 0;
|
| 130 |
+
|
| 131 |
+
await pasteImages(page, INPUT_SELECTOR, imgPaths, {
|
| 132 |
+
uploadValidator: (response) => {
|
| 133 |
+
const url = response.url();
|
| 134 |
+
if (response.status() === 200) {
|
| 135 |
+
if (url.includes('global/widgetAddContextFile')) {
|
| 136 |
+
uploadedCount++;
|
| 137 |
+
logger.debug('适配器', `图片上传进度 (Add): ${uploadedCount}/${expectedUploads}`, meta);
|
| 138 |
+
return false;
|
| 139 |
+
} else if (url.includes('global/widgetListSessionFileMetadata')) {
|
| 140 |
+
metadataCount++;
|
| 141 |
+
logger.info('适配器', `图片上传进度: ${metadataCount}/${expectedUploads}`, meta);
|
| 142 |
+
|
| 143 |
+
if (uploadedCount >= expectedUploads && metadataCount >= expectedUploads) {
|
| 144 |
+
return true;
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
return false;
|
| 149 |
+
}
|
| 150 |
+
});
|
| 151 |
+
|
| 152 |
+
await sleep(1000, 2000);
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
// 3. 填写提示词 (fillPrompt)
|
| 156 |
+
await safeClick(page, INPUT_SELECTOR, { bias: 'input' });
|
| 157 |
+
await fillPrompt(page, INPUT_SELECTOR, prompt, meta);
|
| 158 |
+
await sleep(500, 1000);
|
| 159 |
+
|
| 160 |
+
// 4. 设置请求拦截器(根据模型类型修改请求)
|
| 161 |
+
logger.debug('适配器', '已启用请求拦截', meta);
|
| 162 |
+
await page.unroute('**/*').catch(() => { });
|
| 163 |
+
|
| 164 |
+
// 判断是否为 grounding 模式
|
| 165 |
+
const isGrounding = modelId.endsWith('-grounding');
|
| 166 |
+
const actualModelId = isGrounding ? modelId.replace('-grounding', '') : modelId;
|
| 167 |
+
|
| 168 |
+
await page.route(url => url.href.includes('global/widgetStreamAssist'), async (route) => {
|
| 169 |
+
const request = route.request();
|
| 170 |
+
if (request.method() !== 'POST') return route.continue();
|
| 171 |
+
|
| 172 |
+
try {
|
| 173 |
+
const postData = request.postDataJSON();
|
| 174 |
+
if (postData) {
|
| 175 |
+
logger.debug('适配器', '已拦截请求,正在修改...', meta);
|
| 176 |
+
if (!postData.streamAssistRequest) postData.streamAssistRequest = {};
|
| 177 |
+
if (!postData.streamAssistRequest.assistGenerationConfig) postData.streamAssistRequest.assistGenerationConfig = {};
|
| 178 |
+
|
| 179 |
+
// 设置模型 ID
|
| 180 |
+
postData.streamAssistRequest.assistGenerationConfig.modelId = actualModelId;
|
| 181 |
+
|
| 182 |
+
// 根据模式设置 toolsSpec
|
| 183 |
+
if (isGrounding) {
|
| 184 |
+
postData.streamAssistRequest.toolsSpec = { webGroundingSpec: {} };
|
| 185 |
+
logger.info('适配器', `已拦截请求,使用 Grounding 模式 (模型: ${actualModelId})`, meta);
|
| 186 |
+
} else {
|
| 187 |
+
// 文本模式不需要额外工具
|
| 188 |
+
postData.streamAssistRequest.toolsSpec = {};
|
| 189 |
+
logger.info('适配器', `已拦截请求,使用文本模式 (模型: ${actualModelId})`, meta);
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
await route.continue({ postData: JSON.stringify(postData) });
|
| 193 |
+
return;
|
| 194 |
+
}
|
| 195 |
+
} catch (e) {
|
| 196 |
+
logger.error('适配器', '请求拦截处理失败', { ...meta, error: e.message });
|
| 197 |
+
}
|
| 198 |
+
await route.continue();
|
| 199 |
+
});
|
| 200 |
+
|
| 201 |
+
// 5. 提交 (submit - 使用公共函数)
|
| 202 |
+
logger.debug('适配器', '点击发送...', meta);
|
| 203 |
+
await submit(page, {
|
| 204 |
+
btnSelector: 'md-icon-button.send-button.submit, button[aria-label="提交"], button[aria-label="Send"], .send-button',
|
| 205 |
+
inputTarget: INPUT_SELECTOR,
|
| 206 |
+
meta
|
| 207 |
+
});
|
| 208 |
+
|
| 209 |
+
logger.info('适配器', '等待生成结果中...', meta);
|
| 210 |
+
|
| 211 |
+
// 6. 等待 API 响应
|
| 212 |
+
let apiResponse;
|
| 213 |
+
try {
|
| 214 |
+
apiResponse = await waitApiResponse(page, {
|
| 215 |
+
urlMatch: 'global/widgetStreamAssist',
|
| 216 |
+
method: 'POST',
|
| 217 |
+
timeout: 120000,
|
| 218 |
+
errorText: ['modelArmorViolation'],
|
| 219 |
+
meta
|
| 220 |
+
});
|
| 221 |
+
} catch (e) {
|
| 222 |
+
const pageError = normalizePageError(e, meta);
|
| 223 |
+
if (pageError) return pageError;
|
| 224 |
+
throw e;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
// 检查 API 响应状态
|
| 228 |
+
const httpError = normalizeHttpError(apiResponse);
|
| 229 |
+
if (httpError) {
|
| 230 |
+
logger.error('适配器', `请求生成时返回错误: ${httpError.error}`, meta);
|
| 231 |
+
return { error: `请求生成时返回错误: ${httpError.error}` };
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
// 7. 解析文本响应
|
| 235 |
+
const content = await apiResponse.text();
|
| 236 |
+
logger.debug('适配器', `收到响应,长度: ${content.length}`, meta);
|
| 237 |
+
|
| 238 |
+
// 解析 JSON 数组响应
|
| 239 |
+
// 格式: [{uToken, streamAssistResponse: {answer: {replies: [...], state: "..."}}}, ...]
|
| 240 |
+
let fullText = '';
|
| 241 |
+
try {
|
| 242 |
+
const parsed = JSON.parse(content);
|
| 243 |
+
|
| 244 |
+
if (!Array.isArray(parsed)) {
|
| 245 |
+
logger.error('适配器', '响应不是数组格式', meta);
|
| 246 |
+
return { error: '响应格式错误:不是数组' };
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
for (const item of parsed) {
|
| 250 |
+
const response = item?.streamAssistResponse;
|
| 251 |
+
const answer = response?.answer;
|
| 252 |
+
const state = answer?.state;
|
| 253 |
+
|
| 254 |
+
// 如果是 SUCCEEDED 状态,跳过(只是告知会话结束)
|
| 255 |
+
if (state === 'SUCCEEDED') {
|
| 256 |
+
continue;
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
// 只处理 IN_PROGRESS 状态
|
| 260 |
+
if (state === 'IN_PROGRESS') {
|
| 261 |
+
const replies = answer?.replies;
|
| 262 |
+
if (replies && replies.length > 0) {
|
| 263 |
+
const groundedContent = replies[0]?.groundedContent?.content;
|
| 264 |
+
|
| 265 |
+
// 如果是思考过程,跳过
|
| 266 |
+
if (groundedContent?.thought === true) {
|
| 267 |
+
continue;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
// 提取文本内容
|
| 271 |
+
const text = groundedContent?.text;
|
| 272 |
+
if (text) {
|
| 273 |
+
fullText += text;
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
+
}
|
| 277 |
+
}
|
| 278 |
+
} catch (e) {
|
| 279 |
+
logger.error('适配器', '解析响应失败', { ...meta, error: e.message });
|
| 280 |
+
return { error: `解析响应失败: ${e.message}` };
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
if (fullText) {
|
| 284 |
+
logger.info('适配器', `获取文本成功,长度: ${fullText.length}`, meta);
|
| 285 |
+
return { text: fullText };
|
| 286 |
+
} else {
|
| 287 |
+
logger.warn('适配器', '未解析到有效文本内容', { ...meta, preview: content.substring(0, 200) });
|
| 288 |
+
return { error: '未解析到有效文本内容' };
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
} catch (err) {
|
| 292 |
+
// 顶层错误处理
|
| 293 |
+
const pageError = normalizePageError(err, meta);
|
| 294 |
+
if (pageError) return pageError;
|
| 295 |
+
|
| 296 |
+
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
| 297 |
+
return { error: `生成任务失败: ${err.message}` };
|
| 298 |
+
} finally {
|
| 299 |
+
// 清理拦截器
|
| 300 |
+
await page.unroute('**/*').catch(() => { });
|
| 301 |
+
// 任务结束,将鼠标移至安全区域
|
| 302 |
+
await moveMouseAway(page);
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
/**
|
| 307 |
+
* 适配器 manifest
|
| 308 |
+
*/
|
| 309 |
+
export const manifest = {
|
| 310 |
+
id: 'gemini_biz_text',
|
| 311 |
+
displayName: 'Gemini Business (文本生成)',
|
| 312 |
+
description: '使用 Gemini Business 企业版生成文本,支持 Grounding 搜索模式。需要提供入口 URL 并已登录企业账户 (每个谷歌账户首次可以在官网点击免费试用获取30天使用资格),与 gemini_biz 共享配置。',
|
| 313 |
+
|
| 314 |
+
// 配置表单定义(与 gemini_biz 共享配置)
|
| 315 |
+
configSchema: [
|
| 316 |
+
{
|
| 317 |
+
key: 'entryUrl',
|
| 318 |
+
label: '入口 URL',
|
| 319 |
+
type: 'string',
|
| 320 |
+
required: true,
|
| 321 |
+
placeholder: 'https://business.gemini.google/home/cid/8888a888-b6e0-88be-86e1-888cf3ee8cf4',
|
| 322 |
+
note: '与 gemini_biz 共享配置'
|
| 323 |
+
}
|
| 324 |
+
],
|
| 325 |
+
|
| 326 |
+
// 入口 URL (从配置读取,与 gemini_biz 共享)
|
| 327 |
+
getTargetUrl(config, workerConfig) {
|
| 328 |
+
return config?.backend?.adapter?.gemini_biz?.entryUrl || config?.backend?.geminiBiz?.entryUrl || null;
|
| 329 |
+
},
|
| 330 |
+
|
| 331 |
+
// 模型列表
|
| 332 |
+
models: [
|
| 333 |
+
{ id: 'gemini-3-pro', imagePolicy: 'optional', type: 'text' },
|
| 334 |
+
{ id: 'gemini-2.5-pro', imagePolicy: 'optional', type: 'text' },
|
| 335 |
+
{ id: 'gemini-3-flash-preview', imagePolicy: 'optional', type: 'text' },
|
| 336 |
+
{ id: 'gemini-2.5-flash', imagePolicy: 'optional', type: 'text' },
|
| 337 |
+
{ id: 'gemini-3-pro-grounding', imagePolicy: 'optional', type: 'text' },
|
| 338 |
+
{ id: 'gemini-2.5-pro-grounding', imagePolicy: 'optional', type: 'text' },
|
| 339 |
+
{ id: 'gemini-2.5-flash-grounding', imagePolicy: 'optional', type: 'text' },
|
| 340 |
+
{ id: 'gemini-3-flash-preview-grounding', imagePolicy: 'optional', type: 'text' },
|
| 341 |
+
],
|
| 342 |
+
|
| 343 |
+
// 导航处理器
|
| 344 |
+
navigationHandlers: [handleAccountChooser],
|
| 345 |
+
|
| 346 |
+
// 核心生图方法
|
| 347 |
+
generate
|
| 348 |
+
};
|
src/backend/adapter/gemini_text.js
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview Google Gemini 文本生成适配器
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import {
|
| 6 |
+
sleep,
|
| 7 |
+
safeClick,
|
| 8 |
+
uploadFilesViaChooser
|
| 9 |
+
} from '../engine/utils.js';
|
| 10 |
+
import {
|
| 11 |
+
fillPrompt,
|
| 12 |
+
normalizePageError,
|
| 13 |
+
normalizeHttpError,
|
| 14 |
+
moveMouseAway,
|
| 15 |
+
waitForInput,
|
| 16 |
+
gotoWithCheck,
|
| 17 |
+
waitApiResponse
|
| 18 |
+
} from '../utils/index.js';
|
| 19 |
+
import { logger } from '../../utils/logger.js';
|
| 20 |
+
|
| 21 |
+
// --- 配置常量 ---
|
| 22 |
+
const TARGET_URL = 'https://gemini.google.com/app?hl=en';
|
| 23 |
+
|
| 24 |
+
/**
|
| 25 |
+
* 执行文本生成任务
|
| 26 |
+
* @param {object} context - 浏览器上下文 { page, config }
|
| 27 |
+
* @param {string} prompt - 提示词
|
| 28 |
+
* @param {string[]} imgPaths - 图片路径数组
|
| 29 |
+
* @param {string} [modelId] - 模型 ID (此适配器未使用)
|
| 30 |
+
* @param {object} [meta={}] - 日志元数据
|
| 31 |
+
* @returns {Promise<{text?: string, error?: string}>}
|
| 32 |
+
*/
|
| 33 |
+
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
| 34 |
+
const { page } = context;
|
| 35 |
+
const inputLocator = page.getByRole('textbox');
|
| 36 |
+
const sendBtnLocator = page.getByRole('button', { name: 'Send message' });
|
| 37 |
+
|
| 38 |
+
try {
|
| 39 |
+
logger.info('适配器', '开启新会话...', meta);
|
| 40 |
+
await gotoWithCheck(page, TARGET_URL);
|
| 41 |
+
|
| 42 |
+
// 1. 等待输入框加载
|
| 43 |
+
await waitForInput(page, inputLocator, { click: false });
|
| 44 |
+
await sleep(1500, 2500);
|
| 45 |
+
|
| 46 |
+
// 2. 上传图片
|
| 47 |
+
if (imgPaths && imgPaths.length > 0) {
|
| 48 |
+
logger.debug('适配器', '点击加号按钮...', meta);
|
| 49 |
+
const uploadMenuBtn = page.getByRole('button', { name: 'Open upload file menu' });
|
| 50 |
+
await safeClick(page, uploadMenuBtn, { bias: 'button' });
|
| 51 |
+
await sleep(500, 1000);
|
| 52 |
+
|
| 53 |
+
const uploadFilesBtn = page.getByRole('button', { name: /Upload files/ });
|
| 54 |
+
await uploadFilesViaChooser(page, uploadFilesBtn, imgPaths, {
|
| 55 |
+
uploadValidator: (response) => {
|
| 56 |
+
const url = response.url();
|
| 57 |
+
return response.status() === 200 &&
|
| 58 |
+
url.includes('google.com/upload/') &&
|
| 59 |
+
url.includes('upload_id=');
|
| 60 |
+
}
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
await sleep(1000, 2000);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// 3. 填写提示词
|
| 67 |
+
await safeClick(page, inputLocator, { bias: 'input' });
|
| 68 |
+
await fillPrompt(page, inputLocator, prompt, meta);
|
| 69 |
+
await sleep(500, 1000);
|
| 70 |
+
|
| 71 |
+
// 4. 选择模型(如果指定了 modelId)
|
| 72 |
+
if (modelId) {
|
| 73 |
+
try {
|
| 74 |
+
logger.debug('适配器', `准备选择模型: ${modelId}`, meta);
|
| 75 |
+
|
| 76 |
+
// 点击输入框确保焦点
|
| 77 |
+
await inputLocator.focus();
|
| 78 |
+
await sleep(300, 500);
|
| 79 |
+
|
| 80 |
+
// 按 3 次 Tab 键到达模型选择按钮
|
| 81 |
+
await page.keyboard.press('Tab');
|
| 82 |
+
await sleep(100, 200);
|
| 83 |
+
await page.keyboard.press('Tab');
|
| 84 |
+
await sleep(100, 200);
|
| 85 |
+
await page.keyboard.press('Tab');
|
| 86 |
+
await sleep(200, 300);
|
| 87 |
+
|
| 88 |
+
// 按回车打开模型菜单
|
| 89 |
+
await page.keyboard.press('Enter');
|
| 90 |
+
await sleep(500, 800);
|
| 91 |
+
|
| 92 |
+
// 获取所有 menuitemradio 选项
|
| 93 |
+
const menuItems = await page.getByRole('menuitemradio').all();
|
| 94 |
+
|
| 95 |
+
if (menuItems.length === 0) {
|
| 96 |
+
logger.warn('适配器', '未找到模型选项,使用默认模型', meta);
|
| 97 |
+
} else {
|
| 98 |
+
// 获取所有选项的文本(去除前后空白)
|
| 99 |
+
const itemTexts = [];
|
| 100 |
+
for (const item of menuItems) {
|
| 101 |
+
const text = await item.textContent();
|
| 102 |
+
itemTexts.push((text || '').trim());
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
logger.debug('适配器', `可用模型选项: [${itemTexts.join('], [')}]`, meta);
|
| 106 |
+
|
| 107 |
+
// 判断是否有 Pro 选项
|
| 108 |
+
const hasPro = itemTexts.some(text => text.startsWith('Pro'));
|
| 109 |
+
|
| 110 |
+
// 确定要选择的目标选项文本前缀
|
| 111 |
+
let targetPrefix = null;
|
| 112 |
+
|
| 113 |
+
if (hasPro) {
|
| 114 |
+
// 有 Pro 选项的情况
|
| 115 |
+
if (modelId === 'gemini-3-pro' || modelId === 'gemini-exp-1206') {
|
| 116 |
+
targetPrefix = 'Pro';
|
| 117 |
+
} else if (modelId === 'gemini-3-flash' || modelId === 'gemini-2.0-flash-exp') {
|
| 118 |
+
targetPrefix = 'Thinking';
|
| 119 |
+
} else {
|
| 120 |
+
targetPrefix = 'Fast';
|
| 121 |
+
}
|
| 122 |
+
} else {
|
| 123 |
+
// 没有 Pro 选项的情况
|
| 124 |
+
if (modelId === 'gemini-3-pro' || modelId === 'gemini-exp-1206') {
|
| 125 |
+
targetPrefix = 'Thinking';
|
| 126 |
+
} else {
|
| 127 |
+
targetPrefix = 'Fast';
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
logger.debug('适配器', `目标模型前缀: "${targetPrefix}"`, meta);
|
| 132 |
+
|
| 133 |
+
// 查找并点击对应的选项
|
| 134 |
+
let found = false;
|
| 135 |
+
for (let i = 0; i < menuItems.length; i++) {
|
| 136 |
+
if (itemTexts[i].startsWith(targetPrefix)) {
|
| 137 |
+
await safeClick(page, menuItems[i], { bias: 'button' });
|
| 138 |
+
logger.info('适配器', `已选择模型: "${itemTexts[i]}"`, meta);
|
| 139 |
+
found = true;
|
| 140 |
+
break;
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
if (!found) {
|
| 145 |
+
logger.warn('适配器', `未找到匹配的模型选项 (${targetPrefix}),使用默认模型`, meta);
|
| 146 |
+
// 按 Escape 关闭菜单
|
| 147 |
+
await page.keyboard.press('Escape');
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
await sleep(300, 500);
|
| 151 |
+
}
|
| 152 |
+
} catch (e) {
|
| 153 |
+
logger.warn('适配器', `模型选择失败: ${e.message},继续使用默认模型`, meta);
|
| 154 |
+
// 尝试关闭可能打开的菜单
|
| 155 |
+
try {
|
| 156 |
+
await page.keyboard.press('Escape');
|
| 157 |
+
} catch { }
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
// 5. 点击发送
|
| 162 |
+
logger.debug('适配器', '点击发送...', meta);
|
| 163 |
+
await safeClick(page, sendBtnLocator, { bias: 'button' });
|
| 164 |
+
|
| 165 |
+
logger.info('适配器', '等待生成结果...', meta);
|
| 166 |
+
|
| 167 |
+
// 5. 等待 API 响应
|
| 168 |
+
let apiResponse;
|
| 169 |
+
try {
|
| 170 |
+
apiResponse = await waitApiResponse(page, {
|
| 171 |
+
urlMatch: 'assistant.lamda.BardFrontendService/StreamGenerate',
|
| 172 |
+
method: 'POST',
|
| 173 |
+
timeout: 120000,
|
| 174 |
+
meta
|
| 175 |
+
});
|
| 176 |
+
} catch (e) {
|
| 177 |
+
const pageError = normalizePageError(e, meta);
|
| 178 |
+
if (pageError) return pageError;
|
| 179 |
+
throw e;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
// 检查 HTTP 错误
|
| 183 |
+
const httpError = normalizeHttpError(apiResponse);
|
| 184 |
+
if (httpError) {
|
| 185 |
+
logger.error('适配器', `API 返回错误: ${httpError.error}`, meta);
|
| 186 |
+
return { error: `API 返回错误: ${httpError.error}` };
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
// 6. 解析响应体
|
| 190 |
+
const bodyBuffer = await apiResponse.body();
|
| 191 |
+
logger.debug('适配器', `收到响应体,字节数: ${bodyBuffer.length}`, meta);
|
| 192 |
+
|
| 193 |
+
const text = getFinalAiTextFromResponse(bodyBuffer);
|
| 194 |
+
|
| 195 |
+
if (text) {
|
| 196 |
+
logger.info('适配器', `解析成功,文本长度: ${text.length}`, meta);
|
| 197 |
+
return { text };
|
| 198 |
+
} else {
|
| 199 |
+
return { error: '未能从响应中提取文本' };
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
} catch (err) {
|
| 203 |
+
const pageError = normalizePageError(err, meta);
|
| 204 |
+
if (pageError) return pageError;
|
| 205 |
+
|
| 206 |
+
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
| 207 |
+
return { error: `生成任务失败: ${err.message}` };
|
| 208 |
+
} finally {
|
| 209 |
+
await moveMouseAway(page);
|
| 210 |
+
}
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
/**
|
| 214 |
+
* 适配器 manifest
|
| 215 |
+
*/
|
| 216 |
+
export const manifest = {
|
| 217 |
+
id: 'gemini_text',
|
| 218 |
+
displayName: 'Google Gemini (文本生成)',
|
| 219 |
+
description: '使用 Google Gemini 官网生成文本,支持多模型切换和图片上传。需要已登录的 Google 账户。',
|
| 220 |
+
|
| 221 |
+
getTargetUrl(config, workerConfig) {
|
| 222 |
+
return TARGET_URL;
|
| 223 |
+
},
|
| 224 |
+
|
| 225 |
+
models: [
|
| 226 |
+
{ id: 'gemini-2.0-flash-exp', imagePolicy: 'optional', type: 'text' },
|
| 227 |
+
{ id: 'gemini-exp-1206', imagePolicy: 'optional', type: 'text' },
|
| 228 |
+
{ id: 'gemini-3-pro', imagePolicy: 'optional', type: 'text' },
|
| 229 |
+
{ id: 'gemini-3-flash', imagePolicy: 'optional', type: 'text' }
|
| 230 |
+
],
|
| 231 |
+
|
| 232 |
+
navigationHandlers: [],
|
| 233 |
+
|
| 234 |
+
generate
|
| 235 |
+
};
|
| 236 |
+
|
| 237 |
+
// ==========================================
|
| 238 |
+
// 解析 gRPC Batchexecute
|
| 239 |
+
// ==========================================
|
| 240 |
+
|
| 241 |
+
/**
|
| 242 |
+
* 解析 batchexecute/batch RPC 响应(直接操作 Buffer)
|
| 243 |
+
* @param {Buffer} buf - 响应体 Buffer
|
| 244 |
+
*/
|
| 245 |
+
function parseLenFramedResponse(buf) {
|
| 246 |
+
let i = 0;
|
| 247 |
+
|
| 248 |
+
// 去掉 )]}\' 这种 XSSI 前缀(通常是第一行)
|
| 249 |
+
if (buf.length >= 4 && buf[0] === 0x29 && buf[1] === 0x5d && buf[2] === 0x7d) {
|
| 250 |
+
const firstNl = buf.indexOf(0x0a);
|
| 251 |
+
if (firstNl !== -1) i = firstNl + 1;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
const frames = [];
|
| 255 |
+
|
| 256 |
+
const readLineBuf = () => {
|
| 257 |
+
if (i >= buf.length) return null;
|
| 258 |
+
const nl = buf.indexOf(0x0a, i);
|
| 259 |
+
let line;
|
| 260 |
+
if (nl === -1) {
|
| 261 |
+
line = buf.slice(i);
|
| 262 |
+
i = buf.length;
|
| 263 |
+
} else {
|
| 264 |
+
line = buf.slice(i, nl);
|
| 265 |
+
i = nl + 1;
|
| 266 |
+
}
|
| 267 |
+
// strip trailing \r
|
| 268 |
+
if (line.length && line[line.length - 1] === 0x0d) line = line.slice(0, -1);
|
| 269 |
+
return line;
|
| 270 |
+
};
|
| 271 |
+
|
| 272 |
+
let pendingLen = null;
|
| 273 |
+
|
| 274 |
+
while (true) {
|
| 275 |
+
const lineBuf = readLineBuf();
|
| 276 |
+
if (lineBuf === null) break;
|
| 277 |
+
|
| 278 |
+
const lineStr = lineBuf.toString('utf8').trim();
|
| 279 |
+
if (!lineStr) continue;
|
| 280 |
+
|
| 281 |
+
// 先找长度行(纯数字)
|
| 282 |
+
if (pendingLen === null) {
|
| 283 |
+
if (/^\d+$/.test(lineStr)) pendingLen = Number(lineStr);
|
| 284 |
+
continue;
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
// 读到 payload 行;大多数情况下 payload 是单行 JSON。
|
| 288 |
+
// 这里**不依赖** pendingLen 的数值(它有时会不准),而是:
|
| 289 |
+
// 1) 先尝试解析当前行
|
| 290 |
+
// 2) 若报“JSON 未结束”一类错误,再把后续行拼上重试(极少见)
|
| 291 |
+
let chunkBuf = lineBuf;
|
| 292 |
+
let chunkStr = chunkBuf.toString('utf8').trim();
|
| 293 |
+
|
| 294 |
+
while (true) {
|
| 295 |
+
try {
|
| 296 |
+
frames.push(JSON.parse(chunkStr));
|
| 297 |
+
break;
|
| 298 |
+
} catch (e) {
|
| 299 |
+
// 只有在明显是“截断/未结束”的情况下,才继续拼下一行
|
| 300 |
+
const msg = String(e && e.message || '');
|
| 301 |
+
const looksTruncated = /Unexpected end of JSON input|Unterminated string/.test(msg);
|
| 302 |
+
|
| 303 |
+
if (!looksTruncated) {
|
| 304 |
+
const head = chunkStr.slice(0, 220);
|
| 305 |
+
const tail = chunkStr.slice(-220);
|
| 306 |
+
throw new Error(
|
| 307 |
+
`Chunk JSON parse failed: ${msg}\n` +
|
| 308 |
+
`Chunk head: ${head}\n` +
|
| 309 |
+
`Chunk tail: ${tail}\n` +
|
| 310 |
+
`LenHeader: ${pendingLen} | ActualBytes: ${Buffer.byteLength(chunkStr, 'utf8')}`
|
| 311 |
+
);
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
// 读取下一行进行拼接,但如果下一行是“纯数字长度行”,不要吞掉它
|
| 315 |
+
const savedPos = i;
|
| 316 |
+
const next = readLineBuf();
|
| 317 |
+
if (next === null) {
|
| 318 |
+
const head = chunkStr.slice(0, 220);
|
| 319 |
+
const tail = chunkStr.slice(-220);
|
| 320 |
+
throw new Error(
|
| 321 |
+
`Chunk JSON parse failed: ${msg} (EOF)\n` +
|
| 322 |
+
`Chunk head: ${head}\n` +
|
| 323 |
+
`Chunk tail: ${tail}\n` +
|
| 324 |
+
`LenHeader: ${pendingLen} | ActualBytes: ${Buffer.byteLength(chunkStr, 'utf8')}`
|
| 325 |
+
);
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
const nextStr = next.toString('utf8').trim();
|
| 329 |
+
if (/^\d+$/.test(nextStr)) {
|
| 330 |
+
// 回退,交给外层当作下一段的 length line
|
| 331 |
+
i = savedPos;
|
| 332 |
+
const head = chunkStr.slice(0, 220);
|
| 333 |
+
const tail = chunkStr.slice(-220);
|
| 334 |
+
throw new Error(
|
| 335 |
+
`Chunk JSON parse failed: ${msg} (hit next length line)\n` +
|
| 336 |
+
`Chunk head: ${head}\n` +
|
| 337 |
+
`Chunk tail: ${tail}\n` +
|
| 338 |
+
`LenHeader: ${pendingLen} | ActualBytes: ${Buffer.byteLength(chunkStr, 'utf8')}`
|
| 339 |
+
);
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
// 把分隔符 \n 加回去
|
| 343 |
+
chunkBuf = Buffer.concat([chunkBuf, Buffer.from('\n'), next]);
|
| 344 |
+
chunkStr = chunkBuf.toString('utf8').trim();
|
| 345 |
+
}
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
pendingLen = null;
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
return frames;
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
/**
|
| 355 |
+
* 把 frame 里的 payload 再 parse 一次
|
| 356 |
+
*/
|
| 357 |
+
function extractPayloads(frames) {
|
| 358 |
+
const payloads = [];
|
| 359 |
+
for (const frame of frames) {
|
| 360 |
+
if (!Array.isArray(frame)) continue;
|
| 361 |
+
|
| 362 |
+
// frame 可能是 [["wrb.fr", null, "<jsonstr>"]] 也可能有多个 item
|
| 363 |
+
for (const item of frame) {
|
| 364 |
+
if (!Array.isArray(item)) continue;
|
| 365 |
+
const payloadStr = item[2];
|
| 366 |
+
if (typeof payloadStr !== "string") continue;
|
| 367 |
+
|
| 368 |
+
try {
|
| 369 |
+
payloads.push(JSON.parse(payloadStr));
|
| 370 |
+
} catch {
|
| 371 |
+
// ignore non-payload frames
|
| 372 |
+
}
|
| 373 |
+
}
|
| 374 |
+
}
|
| 375 |
+
return payloads;
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
/**
|
| 379 |
+
* 在任意嵌套结构里,找形如 ["rc_xxx", ["text..."], ...] 的节点
|
| 380 |
+
*/
|
| 381 |
+
function collectRcTextsDeep(root) {
|
| 382 |
+
const bestByRc = new Map();
|
| 383 |
+
|
| 384 |
+
const stack = [root];
|
| 385 |
+
while (stack.length) {
|
| 386 |
+
const cur = stack.pop();
|
| 387 |
+
if (!cur) continue;
|
| 388 |
+
|
| 389 |
+
if (Array.isArray(cur)) {
|
| 390 |
+
const maybeRc = cur[0];
|
| 391 |
+
const maybeArr = cur[1];
|
| 392 |
+
if (
|
| 393 |
+
typeof maybeRc === "string" &&
|
| 394 |
+
maybeRc.startsWith("rc_") &&
|
| 395 |
+
Array.isArray(maybeArr)
|
| 396 |
+
) {
|
| 397 |
+
const text = maybeArr.filter(v => typeof v === "string").join("");
|
| 398 |
+
if (text) {
|
| 399 |
+
const prev = bestByRc.get(maybeRc) || "";
|
| 400 |
+
if (text.length >= prev.length) bestByRc.set(maybeRc, text);
|
| 401 |
+
}
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
for (const v of cur) stack.push(v);
|
| 405 |
+
} else if (typeof cur === "object") {
|
| 406 |
+
for (const v of Object.values(cur)) stack.push(v);
|
| 407 |
+
}
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
return bestByRc;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
/**
|
| 414 |
+
* 从响应体 Buffer 中提取最终 AI 文本
|
| 415 |
+
* @param {Buffer} bodyBuffer - 响应体 Buffer
|
| 416 |
+
*/
|
| 417 |
+
function getFinalAiTextFromResponse(bodyBuffer) {
|
| 418 |
+
const frames = parseLenFramedResponse(bodyBuffer);
|
| 419 |
+
const payloads = extractPayloads(frames);
|
| 420 |
+
|
| 421 |
+
let best = "";
|
| 422 |
+
for (const payload of payloads) {
|
| 423 |
+
const m = collectRcTextsDeep(payload);
|
| 424 |
+
for (const text of m.values()) {
|
| 425 |
+
if (text.length > best.length) best = text;
|
| 426 |
+
}
|
| 427 |
+
}
|
| 428 |
+
return best;
|
| 429 |
+
}
|
src/backend/adapter/google_flow.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview Google Flow 图片生成适配器
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import {
|
| 6 |
+
sleep,
|
| 7 |
+
safeClick,
|
| 8 |
+
uploadFilesViaChooser
|
| 9 |
+
} from '../engine/utils.js';
|
| 10 |
+
import {
|
| 11 |
+
fillPrompt,
|
| 12 |
+
normalizePageError,
|
| 13 |
+
moveMouseAway,
|
| 14 |
+
waitForInput,
|
| 15 |
+
gotoWithCheck,
|
| 16 |
+
waitApiResponse,
|
| 17 |
+
useContextDownload
|
| 18 |
+
} from '../utils/index.js';
|
| 19 |
+
import { logger } from '../../utils/logger.js';
|
| 20 |
+
import sharp from 'sharp';
|
| 21 |
+
|
| 22 |
+
// --- 配置常量 ---
|
| 23 |
+
const TARGET_URL = 'https://labs.google/fx/zh/tools/flow';
|
| 24 |
+
|
| 25 |
+
/**
|
| 26 |
+
* 根据图片路径检测其宽高比,返回 '16:9' 或 '9:16'
|
| 27 |
+
* @param {string} imgPath - 图片路径
|
| 28 |
+
* @returns {Promise<string>} 尺寸比例
|
| 29 |
+
*/
|
| 30 |
+
async function detectImageAspect(imgPath) {
|
| 31 |
+
try {
|
| 32 |
+
const metadata = await sharp(imgPath).metadata();
|
| 33 |
+
const { width, height } = metadata;
|
| 34 |
+
// 宽 >= 高 为横版,否则为竖版
|
| 35 |
+
return width >= height ? '16:9' : '9:16';
|
| 36 |
+
} catch (e) {
|
| 37 |
+
// 检测失败默认横版
|
| 38 |
+
return '16:9';
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
/**
|
| 43 |
+
* 执行图片生成任务
|
| 44 |
+
* @param {object} context - 浏览器上下文 { page, config }
|
| 45 |
+
* @param {string} prompt - 提示词
|
| 46 |
+
* @param {string[]} imgPaths - 图片路径数组
|
| 47 |
+
* @param {string} modelId - 模型 ID
|
| 48 |
+
* @param {object} [meta={}] - 日志元数据
|
| 49 |
+
* @returns {Promise<{image?: string, error?: string}>}
|
| 50 |
+
*/
|
| 51 |
+
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
| 52 |
+
const { page } = context;
|
| 53 |
+
|
| 54 |
+
// 获取模型配置
|
| 55 |
+
const modelConfig = manifest.models.find(m => m.id === modelId) || manifest.models[0];
|
| 56 |
+
let { codeName, imageSize } = modelConfig;
|
| 57 |
+
|
| 58 |
+
// 如果 imageSize 为 '0',根据第一张图片动态决定尺寸
|
| 59 |
+
if (imageSize === '0' && imgPaths && imgPaths.length > 0) {
|
| 60 |
+
imageSize = await detectImageAspect(imgPaths[0]);
|
| 61 |
+
logger.info('适配器', `根据图片检测尺寸: ${imageSize}`, meta);
|
| 62 |
+
} else if (imageSize === '0') {
|
| 63 |
+
// 没有图片时默认横版
|
| 64 |
+
imageSize = '16:9';
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
try {
|
| 68 |
+
// 1. 导航到入口页面
|
| 69 |
+
logger.info('适配器', '开启新会话...', meta);
|
| 70 |
+
await gotoWithCheck(page, TARGET_URL);
|
| 71 |
+
await sleep(1500, 2500);
|
| 72 |
+
|
| 73 |
+
// 2. 创建项目 - 点击 add_2 按钮
|
| 74 |
+
logger.debug('适配器', '创建新项目...', meta);
|
| 75 |
+
const addProjectBtn = page.getByRole('button', { name: /^add_2/ });
|
| 76 |
+
await addProjectBtn.waitFor({ state: 'visible', timeout: 30000 });
|
| 77 |
+
await safeClick(page, addProjectBtn, { bias: 'button' });
|
| 78 |
+
await sleep(1000, 1500);
|
| 79 |
+
|
| 80 |
+
// 3. 选择 Images 模式 (通过 combobox + option 选择)
|
| 81 |
+
logger.debug('适配器', '选择图片制作模式...', meta);
|
| 82 |
+
const modeCombo = page.getByRole('combobox').filter({
|
| 83 |
+
has: page.locator('i', { hasText: 'arrow_drop_down' })
|
| 84 |
+
});
|
| 85 |
+
await modeCombo.first().waitFor({ state: 'visible', timeout: 10000 });
|
| 86 |
+
await safeClick(page, modeCombo.first(), { bias: 'button' });
|
| 87 |
+
await sleep(500, 800);
|
| 88 |
+
|
| 89 |
+
const imageOption = page.getByRole('option').filter({
|
| 90 |
+
has: page.locator('i', { hasText: 'add_photo_alternate' })
|
| 91 |
+
});
|
| 92 |
+
await safeClick(page, imageOption.first(), { bias: 'button' });
|
| 93 |
+
await sleep(1000, 1500);
|
| 94 |
+
|
| 95 |
+
// 4. 打开 Tune 菜单进行配置
|
| 96 |
+
logger.debug('适配器', '打开设置菜单...', meta);
|
| 97 |
+
const tuneBtn = page.getByRole('button', { name: /^tune/ });
|
| 98 |
+
await tuneBtn.waitFor({ state: 'visible', timeout: 10000 });
|
| 99 |
+
await safeClick(page, tuneBtn, { bias: 'button' });
|
| 100 |
+
await sleep(800, 1200);
|
| 101 |
+
|
| 102 |
+
// 4.1 设置生成数量为 1 (链式 filter:包含数字1-4,排除模型和尺寸关键词)
|
| 103 |
+
logger.debug('适配器', '设置生成数量为 1...', meta);
|
| 104 |
+
const countCombobox = page.getByRole('combobox')
|
| 105 |
+
.filter({ hasText: /[1-4]/ })
|
| 106 |
+
.filter({ hasNotText: /Banana|Imagen/i })
|
| 107 |
+
.filter({ hasNotText: /16:9|9:16|1:1|4:3|3:4/ });
|
| 108 |
+
|
| 109 |
+
if (await countCombobox.count() > 0) {
|
| 110 |
+
await safeClick(page, countCombobox.first(), { bias: 'button' });
|
| 111 |
+
await sleep(300, 500);
|
| 112 |
+
await safeClick(page, page.getByRole('option', { name: '1' }), { bias: 'button' });
|
| 113 |
+
await sleep(300, 500);
|
| 114 |
+
logger.debug('适配器', '生成数量已设置为 1', meta);
|
| 115 |
+
} else {
|
| 116 |
+
logger.warn('适配器', '未找到数量选择 combobox,跳过', meta);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
// 4.2 选择模型 (查找包含模型名称的 combobox)
|
| 120 |
+
logger.debug('适配器', `选择模型: ${codeName}...`, meta);
|
| 121 |
+
const modelCombobox = page.getByRole('combobox')
|
| 122 |
+
.filter({ hasText: /Nano Banana|Imagen 4/ });
|
| 123 |
+
|
| 124 |
+
if (await modelCombobox.count() > 0) {
|
| 125 |
+
await safeClick(page, modelCombobox.first(), { bias: 'button' });
|
| 126 |
+
await sleep(300, 500);
|
| 127 |
+
await safeClick(page, page.getByRole('option', { name: codeName, exact: true }), { bias: 'button' });
|
| 128 |
+
await sleep(300, 500);
|
| 129 |
+
logger.debug('适配器', `模型已设置为 ${codeName}`, meta);
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
// 4.3 选择横竖版 (查找包含比例的 combobox)
|
| 133 |
+
logger.debug('适配器', `选择尺寸: ${imageSize}...`, meta);
|
| 134 |
+
const sizeCombobox = page.getByRole('combobox')
|
| 135 |
+
.filter({ hasText: /16:9|9:16/ });
|
| 136 |
+
|
| 137 |
+
if (await sizeCombobox.count() > 0) {
|
| 138 |
+
await safeClick(page, sizeCombobox.first(), { bias: 'button' });
|
| 139 |
+
await sleep(300, 500);
|
| 140 |
+
const sizeOption = page.getByRole('option').filter({ hasText: imageSize });
|
| 141 |
+
await safeClick(page, sizeOption.first(), { bias: 'button' });
|
| 142 |
+
await sleep(300, 500);
|
| 143 |
+
logger.debug('适配器', `尺寸已设置为 ${imageSize}`, meta);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
// 5. 上传图片 (如果有)
|
| 147 |
+
if (imgPaths && imgPaths.length > 0) {
|
| 148 |
+
logger.info('适配器', `开始上传 ${imgPaths.length} 张图片...`, meta);
|
| 149 |
+
|
| 150 |
+
for (let i = 0; i < imgPaths.length; i++) {
|
| 151 |
+
const imgPath = imgPaths[i];
|
| 152 |
+
logger.debug('适配器', `上传图片 ${i + 1}/${imgPaths.length}...`, meta);
|
| 153 |
+
|
| 154 |
+
// 5.1 点击 add 按钮
|
| 155 |
+
const addBtn = page.getByRole('button', { name: 'add' });
|
| 156 |
+
await addBtn.waitFor({ state: 'visible', timeout: 10000 });
|
| 157 |
+
await safeClick(page, addBtn, { bias: 'button' });
|
| 158 |
+
await sleep(500, 1000);
|
| 159 |
+
|
| 160 |
+
// 5.2 点击 upload 按钮并选择文件(不等待上传完成)
|
| 161 |
+
const uploadBtn = page.getByRole('button', { name: /^upload/ });
|
| 162 |
+
await uploadFilesViaChooser(page, uploadBtn, [imgPath]);
|
| 163 |
+
await sleep(500, 1000);
|
| 164 |
+
|
| 165 |
+
// 5.3 先启动上传监听,再点击 crop 按钮
|
| 166 |
+
const uploadResponsePromise = waitApiResponse(page, {
|
| 167 |
+
urlMatch: 'v1:uploadUserImage',
|
| 168 |
+
method: 'POST',
|
| 169 |
+
timeout: 60000
|
| 170 |
+
});
|
| 171 |
+
|
| 172 |
+
const cropBtn = page.getByRole('button', { name: /^crop/ });
|
| 173 |
+
await cropBtn.waitFor({ state: 'visible', timeout: 10000 });
|
| 174 |
+
await safeClick(page, cropBtn, { bias: 'button' });
|
| 175 |
+
|
| 176 |
+
// 5.4 等待上传完成
|
| 177 |
+
await uploadResponsePromise;
|
| 178 |
+
logger.info('适配器', `图片 ${i + 1} 上传完成`, meta);
|
| 179 |
+
await sleep(1000, 1500);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
logger.info('适配器', '所有图片上传完成', meta);
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
// 6. 输入提示词
|
| 186 |
+
logger.info('适配器', '输入提示词...', meta);
|
| 187 |
+
const textarea = page.locator('textarea[placeholder]');
|
| 188 |
+
await waitForInput(page, textarea, { click: true });
|
| 189 |
+
await fillPrompt(page, textarea, prompt, meta);
|
| 190 |
+
await sleep(500, 1000);
|
| 191 |
+
|
| 192 |
+
// 7. 先启动 API 监听,再点击发送
|
| 193 |
+
logger.debug('适配器', '启动 API 监听...', meta);
|
| 194 |
+
const apiResponsePromise = waitApiResponse(page, {
|
| 195 |
+
urlMatch: 'flowMedia:batchGenerateImages',
|
| 196 |
+
method: 'POST',
|
| 197 |
+
timeout: 120000,
|
| 198 |
+
meta
|
| 199 |
+
});
|
| 200 |
+
|
| 201 |
+
// 8. 点击发送按钮
|
| 202 |
+
logger.info('适配器', '点击发送...', meta);
|
| 203 |
+
const sendBtn = page.getByRole('button', { name: /^arrow_forward/ });
|
| 204 |
+
await sendBtn.waitFor({ state: 'visible', timeout: 10000 });
|
| 205 |
+
await safeClick(page, sendBtn, { bias: 'button' });
|
| 206 |
+
|
| 207 |
+
// 9. 等待 API 响应
|
| 208 |
+
logger.info('适配器', '等待生成结果...', meta);
|
| 209 |
+
const apiResponse = await apiResponsePromise;
|
| 210 |
+
|
| 211 |
+
// 10. 解析响应获取图片 URL
|
| 212 |
+
let imageUrl;
|
| 213 |
+
try {
|
| 214 |
+
const responseBody = await apiResponse.json();
|
| 215 |
+
imageUrl = responseBody?.media?.[0]?.image?.generatedImage?.fifeUrl;
|
| 216 |
+
|
| 217 |
+
if (!imageUrl) {
|
| 218 |
+
logger.error('适配器', '响应中没有图片 URL', meta);
|
| 219 |
+
return { error: '生成成功但响应中没有图片 URL' };
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
logger.info('适配器', '已获取图片链接', meta);
|
| 223 |
+
} catch (e) {
|
| 224 |
+
logger.error('适配器', '解析响应失败', { ...meta, error: e.message });
|
| 225 |
+
return { error: `解析响应失败: ${e.message}` };
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
// 11. 下载图片并转为 base64
|
| 229 |
+
logger.info('适配器', '正在下载图片...', meta);
|
| 230 |
+
const downloadResult = await useContextDownload(imageUrl, page);
|
| 231 |
+
|
| 232 |
+
if (downloadResult.error) {
|
| 233 |
+
logger.error('适配器', downloadResult.error, meta);
|
| 234 |
+
return downloadResult;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
logger.info('适配器', '图片生成完成', meta);
|
| 238 |
+
return { image: downloadResult.image };
|
| 239 |
+
|
| 240 |
+
} catch (err) {
|
| 241 |
+
// 顶层错误处理
|
| 242 |
+
const pageError = normalizePageError(err, meta);
|
| 243 |
+
if (pageError) return pageError;
|
| 244 |
+
|
| 245 |
+
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
| 246 |
+
return { error: `生成任务失败: ${err.message}` };
|
| 247 |
+
} finally {
|
| 248 |
+
// 任务结束,将鼠标移至安全区域
|
| 249 |
+
await moveMouseAway(page);
|
| 250 |
+
}
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
/**
|
| 254 |
+
* 适配器 manifest
|
| 255 |
+
*/
|
| 256 |
+
export const manifest = {
|
| 257 |
+
id: 'google_flow',
|
| 258 |
+
displayName: 'Google Flow (图片生成)',
|
| 259 |
+
description: '使用 Google Labs Flow 工具生成图片,支持多张参考图片上传和横竖版选择。需要已登录的 Google 账户。',
|
| 260 |
+
|
| 261 |
+
// 入口 URL
|
| 262 |
+
getTargetUrl(config, workerConfig) {
|
| 263 |
+
return TARGET_URL;
|
| 264 |
+
},
|
| 265 |
+
|
| 266 |
+
// 模型列表
|
| 267 |
+
models: [
|
| 268 |
+
// 根据上传的第一张图片动态获取图片比例
|
| 269 |
+
{ id: 'gemini-3-pro-image-preview', codeName: '🍌 Nano Banana Pro', imageSize: '0', imagePolicy: 'optional' },
|
| 270 |
+
{ id: 'gemini-2.5-flash-image-preview', codeName: '🍌 Nano Banana', imageSize: '0', imagePolicy: 'optional' },
|
| 271 |
+
{ id: 'imagen-4', codeName: 'Imagen 4', imageSize: '0', imagePolicy: 'optional' },
|
| 272 |
+
// 指定图片比例
|
| 273 |
+
{ id: 'gemini-3-pro-image-preview-landspace', codeName: '🍌 Nano Banana Pro', imageSize: '16:9', imagePolicy: 'optional' },
|
| 274 |
+
{ id: 'gemini-3-pro-image-preview-portrait', codeName: '🍌 Nano Banana Pro', imageSize: '9:16', imagePolicy: 'optional' },
|
| 275 |
+
{ id: 'gemini-2.5-flash-image-preview-landspace', codeName: '🍌 Nano Banana', imageSize: '16:9', imagePolicy: 'optional' },
|
| 276 |
+
{ id: 'gemini-2.5-flash-image-preview-portrait', codeName: '🍌 Nano Banana', imageSize: '9:16', imagePolicy: 'optional' },
|
| 277 |
+
{ id: 'imagen-4-landspace', codeName: 'Imagen 4', imageSize: '16:9', imagePolicy: 'optional' },
|
| 278 |
+
{ id: 'imagen-4-portrait', codeName: 'Imagen 4', imageSize: '9:16', imagePolicy: 'optional' }
|
| 279 |
+
],
|
| 280 |
+
|
| 281 |
+
// 无需导航处理器
|
| 282 |
+
navigationHandlers: [],
|
| 283 |
+
|
| 284 |
+
// 核心图片生成方法
|
| 285 |
+
generate
|
| 286 |
+
};
|
src/backend/adapter/lmarena.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview LMArena 图片生成适配器
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import {
|
| 6 |
+
sleep,
|
| 7 |
+
safeClick,
|
| 8 |
+
pasteImages
|
| 9 |
+
} from '../engine/utils.js';
|
| 10 |
+
import {
|
| 11 |
+
fillPrompt,
|
| 12 |
+
submit,
|
| 13 |
+
waitApiResponse,
|
| 14 |
+
normalizePageError,
|
| 15 |
+
normalizeHttpError,
|
| 16 |
+
moveMouseAway,
|
| 17 |
+
waitForInput,
|
| 18 |
+
gotoWithCheck,
|
| 19 |
+
useContextDownload
|
| 20 |
+
} from '../utils/index.js';
|
| 21 |
+
import { logger } from '../../utils/logger.js';
|
| 22 |
+
|
| 23 |
+
// --- 配置常量 ---
|
| 24 |
+
const TARGET_URL = 'https://lmarena.ai/c/new?mode=direct&chat-modality=image';
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* 从响应文本中提取图片 URL
|
| 28 |
+
* @param {string} text - 响应文本内容
|
| 29 |
+
* @returns {string|null} 提取到的图片 URL,如果未找到则返回 null
|
| 30 |
+
*/
|
| 31 |
+
function extractImage(text) {
|
| 32 |
+
if (!text) return null;
|
| 33 |
+
const lines = text.split('\n');
|
| 34 |
+
for (const line of lines) {
|
| 35 |
+
if (line.startsWith('a2:')) {
|
| 36 |
+
try {
|
| 37 |
+
const data = JSON.parse(line.substring(3));
|
| 38 |
+
if (data?.[0]?.image) return data[0].image;
|
| 39 |
+
} catch (e) { }
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
return null;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
/**
|
| 47 |
+
* 执行生图任务
|
| 48 |
+
* @param {object} context - 浏览器上下文 { page, client }
|
| 49 |
+
* @param {string} prompt - 提示词
|
| 50 |
+
* @param {string[]} imgPaths - 图片路径数组
|
| 51 |
+
* @param {string} [modelId] - 指定的模型 ID (可选)
|
| 52 |
+
* @param {object} [meta={}] - 日志元数据
|
| 53 |
+
* @returns {Promise<{image?: string, text?: string, error?: string}>} 生成结果
|
| 54 |
+
*/
|
| 55 |
+
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
| 56 |
+
const { page, config } = context;
|
| 57 |
+
const textareaSelector = 'textarea';
|
| 58 |
+
|
| 59 |
+
// Worker 已验证,直接解析模型配置
|
| 60 |
+
//const modelConfig = manifest.models.find(m => m.id === modelId);
|
| 61 |
+
//const codeName = modelConfig?.codeName;
|
| 62 |
+
|
| 63 |
+
try {
|
| 64 |
+
logger.info('适配器', '开启新会话...', meta);
|
| 65 |
+
await gotoWithCheck(page, TARGET_URL);
|
| 66 |
+
|
| 67 |
+
// 1. 等待输入框加载
|
| 68 |
+
await waitForInput(page, textareaSelector, { click: false });
|
| 69 |
+
await sleep(1500, 2500);
|
| 70 |
+
|
| 71 |
+
// 2. 选择模型(必须在上传图片之前,因为能否上传图片取决于模型 imagePolicy)
|
| 72 |
+
if (modelId) {
|
| 73 |
+
logger.debug('适配器', `选择模型: ${modelId}`, meta);
|
| 74 |
+
const modelCombobox = page.locator('#chat-area')
|
| 75 |
+
.locator('button[role="combobox"][aria-haspopup="dialog"]')
|
| 76 |
+
.last();
|
| 77 |
+
|
| 78 |
+
await modelCombobox.waitFor({ state: 'visible', timeout: 10000 });
|
| 79 |
+
await safeClick(page, modelCombobox, { bias: 'button' });
|
| 80 |
+
await sleep(500, 800);
|
| 81 |
+
|
| 82 |
+
// 模拟粘贴输入模型 ID 并回车
|
| 83 |
+
await page.evaluate((text) => {
|
| 84 |
+
document.execCommand('insertText', false, text);
|
| 85 |
+
}, modelId);
|
| 86 |
+
await sleep(300, 500);
|
| 87 |
+
await page.keyboard.press('Enter');
|
| 88 |
+
await sleep(500, 800);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
// 3. 上传图片 (uploadImages)
|
| 92 |
+
if (imgPaths && imgPaths.length > 0) {
|
| 93 |
+
await pasteImages(page, textareaSelector, imgPaths);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// 4. 填写提示词 (fillPrompt)
|
| 97 |
+
await safeClick(page, textareaSelector, { bias: 'input' });
|
| 98 |
+
await fillPrompt(page, textareaSelector, prompt, meta);
|
| 99 |
+
|
| 100 |
+
// 5. 提交表单 (submit)
|
| 101 |
+
logger.debug('适配器', '点击发送...', meta);
|
| 102 |
+
await submit(page, {
|
| 103 |
+
btnSelector: 'button[type="submit"]',
|
| 104 |
+
inputTarget: textareaSelector,
|
| 105 |
+
meta
|
| 106 |
+
});
|
| 107 |
+
|
| 108 |
+
logger.info('适配器', '等待生成结果...', meta);
|
| 109 |
+
|
| 110 |
+
// 6. 等待 API 响应 (waitApiResponse)
|
| 111 |
+
let response;
|
| 112 |
+
try {
|
| 113 |
+
response = await waitApiResponse(page, {
|
| 114 |
+
urlMatch: '/nextjs-api/stream',
|
| 115 |
+
method: 'POST',
|
| 116 |
+
timeout: 120000,
|
| 117 |
+
meta
|
| 118 |
+
});
|
| 119 |
+
} catch (e) {
|
| 120 |
+
// 使用公共错误处理
|
| 121 |
+
const pageError = normalizePageError(e, meta);
|
| 122 |
+
if (pageError) return pageError;
|
| 123 |
+
throw e;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
// 7. 解析响应结果
|
| 127 |
+
const content = await response.text();
|
| 128 |
+
|
| 129 |
+
// 8. 检查 HTTP 错误 (normalizeHttpError)
|
| 130 |
+
const httpError = normalizeHttpError(response, content);
|
| 131 |
+
if (httpError) {
|
| 132 |
+
logger.error('适配器', `请求生成时返回错误: ${httpError.error}`, meta);
|
| 133 |
+
return { error: `请求生成时返回错误: ${httpError.error}` };
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
// 9. 提取图片 URL
|
| 137 |
+
const img = extractImage(content);
|
| 138 |
+
if (img) {
|
| 139 |
+
// 检查是否配置了返回 URL
|
| 140 |
+
const returnUrl = config?.backend?.adapter?.lmarena?.returnUrl || false;
|
| 141 |
+
if (returnUrl) {
|
| 142 |
+
logger.info('适配器', '已获取结果,返回 URL', meta);
|
| 143 |
+
return { image: img };
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
logger.info('适配器', '已获取结果,正在下载图片...', meta);
|
| 147 |
+
const result = await useContextDownload(img, page);
|
| 148 |
+
if (result.image) {
|
| 149 |
+
logger.info('适配器', '已下载图片,任务完成', meta);
|
| 150 |
+
}
|
| 151 |
+
return result;
|
| 152 |
+
} else {
|
| 153 |
+
logger.warn('适配器', '未获得结果,响应中无图片数据', { ...meta, preview: content.substring(0, 150) });
|
| 154 |
+
return { text: `未获得结果,响应中无图片数据: ${content}` };
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
} catch (err) {
|
| 158 |
+
// 顶层错误处理
|
| 159 |
+
const pageError = normalizePageError(err, meta);
|
| 160 |
+
if (pageError) return pageError;
|
| 161 |
+
|
| 162 |
+
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
| 163 |
+
return { error: `生成任务失败: ${err.message}` };
|
| 164 |
+
} finally {
|
| 165 |
+
// 任务结束,将鼠标移至安全区域
|
| 166 |
+
await moveMouseAway(page);
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
/**
|
| 171 |
+
* 适配器 manifest
|
| 172 |
+
*/
|
| 173 |
+
export const manifest = {
|
| 174 |
+
id: 'lmarena',
|
| 175 |
+
displayName: 'LMArena (图片生成)',
|
| 176 |
+
description: '使用 LMArena 平台生成图片,支持多种图片生成模型。需要已登录的 LMArena 账户,若不登录会频繁弹出人机验证码且有速率限制。',
|
| 177 |
+
|
| 178 |
+
// 配置项模式
|
| 179 |
+
configSchema: [
|
| 180 |
+
{
|
| 181 |
+
key: 'returnUrl',
|
| 182 |
+
label: '返回图片 URL',
|
| 183 |
+
type: 'boolean',
|
| 184 |
+
default: false,
|
| 185 |
+
note: '开启后直接返回图片 URL (但其他不支持该选项的适配器仍然会返回 Base64)'
|
| 186 |
+
}
|
| 187 |
+
],
|
| 188 |
+
|
| 189 |
+
// 入口 URL
|
| 190 |
+
getTargetUrl(config, workerConfig) {
|
| 191 |
+
return TARGET_URL;
|
| 192 |
+
},
|
| 193 |
+
|
| 194 |
+
// 模型列表
|
| 195 |
+
models: [
|
| 196 |
+
{ id: 'gemini-3-pro-image-preview-2k', codeName: '019abc10-e78d-7932-b725-7f1563ed8a12', imagePolicy: 'optional' },
|
| 197 |
+
{ id: 'gemini-3-pro-image-preview', codeName: '019aa208-5c19-7162-ae3b-0a9ddbb1e16a', imagePolicy: 'optional' },
|
| 198 |
+
{ id: 'hunyuan-image-3.0', codeName: '7766a45c-1b6b-4fb8-9823-2557291e1ddd', imagePolicy: 'forbidden' },
|
| 199 |
+
{ id: 'vidu-q2-image', codeName: '019adb32-afa4-749e-9992-39653b52fe13', imagePolicy: 'optional' },
|
| 200 |
+
{ id: 'mai-image-1', codeName: '1b407d5c-1806-477c-90a5-e5c5a114f3bc', imagePolicy: 'forbidden' },
|
| 201 |
+
{ id: 'imagen-4.0-fast-generate-001', codeName: 'f44fd4f8-af30-480f-8ce2-80b2bdfea55e', imagePolicy: 'forbidden' },
|
| 202 |
+
{ id: 'flux-2-pro', codeName: '019abcf4-5600-7a8b-864d-9b8ab7ab7328', imagePolicy: 'optional' },
|
| 203 |
+
{ id: 'recraft-v3', codeName: 'b88d5814-1d20-49cc-9eb6-e362f5851661', imagePolicy: 'forbidden' },
|
| 204 |
+
{ id: 'flux-2-flex', codeName: '019abed6-d96e-7a2b-bf69-198c28bef281', imagePolicy: 'optional' },
|
| 205 |
+
{ id: 'imagen-3.0-generate-002', codeName: '51ad1d79-61e2-414c-99e3-faeb64bb6b1b', imagePolicy: 'forbidden' },
|
| 206 |
+
{ id: 'photon', codeName: 'e7c9fa2d-6f5d-40eb-8305-0980b11c7cab', imagePolicy: 'forbidden' },
|
| 207 |
+
{ id: 'imagen-4.0-ultra-generate-001', codeName: '019ae6da-6438-7077-9d2d-b311a35645f8', imagePolicy: 'forbidden' },
|
| 208 |
+
{ id: 'flux-2-dev', codeName: '019ae6a0-4773-77d5-8ffb-cc35813e063c', imagePolicy: 'optional' },
|
| 209 |
+
{ id: 'imagen-4.0-generate-001', codeName: '019ae6da-6788-761a-8253-e0bb2bf2e3a9', imagePolicy: 'forbidden' },
|
| 210 |
+
{ id: 'flux-2-max', codeName: '', imagePolicy: 'optional' },
|
| 211 |
+
{ id: 'qwen-image-prompt-extend', codeName: '9fe82ee1-c84f-417f-b0e7-cab4ae4cf3f3', imagePolicy: 'forbidden' },
|
| 212 |
+
{ id: 'qwen-image-edit', codeName: '995cf221-af30-466d-a809-8e0985f83649', imagePolicy: 'required' },
|
| 213 |
+
{ id: 'ideogram-v3-quality', codeName: '73378be5-cdba-49e7-b3d0-027949871aa6', imagePolicy: 'forbidden' },
|
| 214 |
+
{ id: 'hunyuan-image-2.1', codeName: 'a9a26426-5377-4efa-bef9-de71e29ad943', imagePolicy: 'forbidden' },
|
| 215 |
+
{ id: 'qwen-image-2512', codeName: '', imagePolicy: 'forbidden' },
|
| 216 |
+
{ id: 'wan2.5-t2i-preview', codeName: '019a5050-2875-78ed-ae3a-d9a51a438685', imagePolicy: 'forbidden' },
|
| 217 |
+
{ id: 'reve-v1.1', codeName: '', imagePolicy: 'required' },
|
| 218 |
+
{ id: 'chatgpt-image-latest', codeName: '', imagePolicy: 'optional' },
|
| 219 |
+
{ id: 'seedream-4.5', codeName: '019abd43-b052-7eec-aa57-e895e45c9723', imagePolicy: 'optional' },
|
| 220 |
+
{ id: 'gpt-image-1-mini', codeName: '0199c238-f8ee-7f7d-afc1-7e28fcfd21cf', imagePolicy: 'optional' },
|
| 221 |
+
{ id: 'gpt-image-1', codeName: '6e855f13-55d7-4127-8656-9168a9f4dcc0', imagePolicy: 'optional' },
|
| 222 |
+
{ id: 'gemini-2.0-flash-preview-image-generation', codeName: '69bbf7d4-9f44-447e-a868-abc4f7a31810', imagePolicy: 'optional' },
|
| 223 |
+
{ id: 'gemini-2.5-flash-image-preview', codeName: '0199ef2a-583f-7088-b704-b75fd169401d', imagePolicy: 'optional' },
|
| 224 |
+
{ id: 'seedream-3', codeName: 'd8771262-8248-4372-90d5-eb41910db034', imagePolicy: 'forbidden' },
|
| 225 |
+
{ id: 'seedream-4-high-res-fal', codeName: '32974d8d-333c-4d2e-abf3-f258c0ac1310', imagePolicy: 'optional' },
|
| 226 |
+
{ id: 'gpt-image-1.5', codeName: '', imagePolicy: 'optional' },
|
| 227 |
+
{ id: 'flux-1-kontext-pro', codeName: '28a8f330-3554-448c-9f32-2c0a08ec6477', imagePolicy: 'optional' },
|
| 228 |
+
{ id: 'wan2.5-i2i-preview', codeName: '019aeb62-c6ea-788e-88f9-19b1b48325b5', imagePolicy: 'required' },
|
| 229 |
+
{ id: 'flux-1-kontext-dev', codeName: 'eb90ae46-a73a-4f27-be8b-40f090592c9a', imagePolicy: 'optional' },
|
| 230 |
+
{ id: 'lucid-origin', codeName: '5a3b3520-c87d-481f-953c-1364687b6e8f', imagePolicy: 'forbidden' }
|
| 231 |
+
],
|
| 232 |
+
|
| 233 |
+
// 无需导航处理器
|
| 234 |
+
navigationHandlers: [],
|
| 235 |
+
|
| 236 |
+
// 核心生图方法
|
| 237 |
+
generate
|
| 238 |
+
};
|
src/backend/adapter/lmarena_text.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview LMArena 文本生成适配器
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import {
|
| 6 |
+
sleep,
|
| 7 |
+
safeClick,
|
| 8 |
+
pasteImages
|
| 9 |
+
} from '../engine/utils.js';
|
| 10 |
+
import {
|
| 11 |
+
fillPrompt,
|
| 12 |
+
submit,
|
| 13 |
+
waitApiResponse,
|
| 14 |
+
normalizePageError,
|
| 15 |
+
normalizeHttpError,
|
| 16 |
+
moveMouseAway,
|
| 17 |
+
waitForInput,
|
| 18 |
+
gotoWithCheck
|
| 19 |
+
} from '../utils/index.js';
|
| 20 |
+
import { logger } from '../../utils/logger.js';
|
| 21 |
+
|
| 22 |
+
// --- 配置常量 ---
|
| 23 |
+
const TARGET_URL = 'https://lmarena.ai/c/new?mode=direct';
|
| 24 |
+
const TARGET_URL_SEARCH = 'https://lmarena.ai/zh/c/new?mode=direct&chat-modality=search';
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* 执行生图任务
|
| 28 |
+
* @param {object} context - 浏览器上下文 { page, client }
|
| 29 |
+
* @param {string} prompt - 提示词
|
| 30 |
+
* @param {string[]} imgPaths - 图片路径数组
|
| 31 |
+
* @param {string} [modelId] - 指定的模型 ID (可选)
|
| 32 |
+
* @param {object} [meta={}] - 日志元数据
|
| 33 |
+
* @returns {Promise<{image?: string, text?: string, error?: string}>} 生成结果
|
| 34 |
+
*/
|
| 35 |
+
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
| 36 |
+
const { page, config } = context;
|
| 37 |
+
const textareaSelector = 'textarea';
|
| 38 |
+
|
| 39 |
+
// Worker 已验证,直接解析模型配置
|
| 40 |
+
const modelConfig = manifest.models.find(m => m.id === modelId);
|
| 41 |
+
const { codeName, search } = modelConfig || {};
|
| 42 |
+
const targetUrl = search ? TARGET_URL_SEARCH : TARGET_URL;
|
| 43 |
+
|
| 44 |
+
try {
|
| 45 |
+
logger.info('适配器', `开启新会话... (搜索模式: ${!!search})`, meta);
|
| 46 |
+
await gotoWithCheck(page, targetUrl);
|
| 47 |
+
|
| 48 |
+
// 1. 等待输入框加载
|
| 49 |
+
await waitForInput(page, textareaSelector, { click: false });
|
| 50 |
+
await sleep(1500, 2500);
|
| 51 |
+
|
| 52 |
+
// 2. 选择模型(必须在上传图片之前,因为能否上传图片取决于模型 imagePolicy)
|
| 53 |
+
if (modelId) {
|
| 54 |
+
logger.debug('适配器', `选择模型: ${modelId}`, meta);
|
| 55 |
+
const modelCombobox = page.locator('#chat-area')
|
| 56 |
+
.locator('button[role="combobox"][aria-haspopup="dialog"]')
|
| 57 |
+
.last();
|
| 58 |
+
|
| 59 |
+
await modelCombobox.waitFor({ state: 'visible', timeout: 10000 });
|
| 60 |
+
await safeClick(page, modelCombobox, { bias: 'button' });
|
| 61 |
+
await sleep(500, 800);
|
| 62 |
+
|
| 63 |
+
// 模拟粘贴输入模型 ID 并回车
|
| 64 |
+
await page.evaluate((text) => {
|
| 65 |
+
document.execCommand('insertText', false, text);
|
| 66 |
+
}, modelId);
|
| 67 |
+
await sleep(300, 500);
|
| 68 |
+
await page.keyboard.press('Enter');
|
| 69 |
+
await sleep(500, 800);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// 3. 上传图片 (uploadImages)
|
| 73 |
+
if (imgPaths && imgPaths.length > 0) {
|
| 74 |
+
await pasteImages(page, textareaSelector, imgPaths);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// 4. 填写提示词 (fillPrompt)
|
| 78 |
+
await safeClick(page, textareaSelector, { bias: 'input' });
|
| 79 |
+
await fillPrompt(page, textareaSelector, prompt, meta);
|
| 80 |
+
|
| 81 |
+
// 5. 提交表单 (submit)
|
| 82 |
+
logger.debug('适配器', '点击发送...', meta);
|
| 83 |
+
await submit(page, {
|
| 84 |
+
btnSelector: 'button[type="submit"]',
|
| 85 |
+
inputTarget: textareaSelector,
|
| 86 |
+
meta
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
logger.info('适配器', '等待生成结果...', meta);
|
| 90 |
+
|
| 91 |
+
// 6. 等待 API 响应 (waitApiResponse)
|
| 92 |
+
let response;
|
| 93 |
+
try {
|
| 94 |
+
response = await waitApiResponse(page, {
|
| 95 |
+
urlMatch: '/nextjs-api/stream',
|
| 96 |
+
method: 'POST',
|
| 97 |
+
timeout: 120000,
|
| 98 |
+
meta
|
| 99 |
+
});
|
| 100 |
+
} catch (e) {
|
| 101 |
+
// 使用公共错误处理
|
| 102 |
+
const pageError = normalizePageError(e, meta);
|
| 103 |
+
if (pageError) return pageError;
|
| 104 |
+
throw e;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
// 7. 解析响应结果
|
| 108 |
+
const content = await response.text();
|
| 109 |
+
|
| 110 |
+
// 8. 检查 HTTP 错误
|
| 111 |
+
const httpError = normalizeHttpError(response, content);
|
| 112 |
+
if (httpError) {
|
| 113 |
+
logger.error('适配器', `请求生成时返回错误: ${httpError.error}`, meta);
|
| 114 |
+
return { error: `请求生成时返回错误: ${httpError.error}` };
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// 9. 解析文本流
|
| 118 |
+
// 格式示例:
|
| 119 |
+
// a0:"Hello"
|
| 120 |
+
// a0:" World"
|
| 121 |
+
// d:{"finishReason":"stop"}
|
| 122 |
+
let fullText = '';
|
| 123 |
+
const lines = content.split('\n');
|
| 124 |
+
|
| 125 |
+
for (const line of lines) {
|
| 126 |
+
if (line.startsWith('a0:')) {
|
| 127 |
+
try {
|
| 128 |
+
// 尝试解析 JSON 字符串内容
|
| 129 |
+
// line.substring(3) 应该是 JSON 字符串,如 "Hello"
|
| 130 |
+
const textPart = JSON.parse(line.substring(3));
|
| 131 |
+
fullText += textPart;
|
| 132 |
+
} catch (e) {
|
| 133 |
+
// 如果解析失败,可能是原生文本或其他格式
|
| 134 |
+
logger.warn('适配器', `解析文本块失败: ${line}`, meta);
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
if (fullText) {
|
| 140 |
+
logger.info('适配器', `获取文本成功,长度: ${fullText.length}`, meta);
|
| 141 |
+
return { text: fullText };
|
| 142 |
+
} else {
|
| 143 |
+
logger.warn('适配器', '未解析到有效文本内容', { ...meta, preview: content.substring(0, 150) });
|
| 144 |
+
// 如果没解析到 a0,尝试直接返回原始内容防空
|
| 145 |
+
return { error: '未解析到有效文本内容' };
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
} catch (err) {
|
| 149 |
+
// 顶层错误处理
|
| 150 |
+
const pageError = normalizePageError(err, meta);
|
| 151 |
+
if (pageError) return pageError;
|
| 152 |
+
|
| 153 |
+
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
| 154 |
+
return { error: `生成任务失败: ${err.message}` };
|
| 155 |
+
} finally {
|
| 156 |
+
// 任务结束,将鼠标移至安全区域
|
| 157 |
+
await moveMouseAway(page);
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
/**
|
| 162 |
+
* 适配器 manifest
|
| 163 |
+
*/
|
| 164 |
+
export const manifest = {
|
| 165 |
+
id: 'lmarena_text',
|
| 166 |
+
displayName: 'LMArena (文本生成)',
|
| 167 |
+
description: '使用 LMArena 平台生成文本,支持多种大语言模型和搜索模式。需要已登录的 LMArena 账户,若不登录会频繁弹出人机验证码且有速率限制。',
|
| 168 |
+
|
| 169 |
+
// 入口 URL
|
| 170 |
+
getTargetUrl(config, workerConfig) {
|
| 171 |
+
return TARGET_URL;
|
| 172 |
+
},
|
| 173 |
+
|
| 174 |
+
// 模型列表(根据最新支持列表整理)
|
| 175 |
+
models: [
|
| 176 |
+
// --- 文本模型 ---
|
| 177 |
+
{ id: 'claude-opus-4-5-20251101-thinking-32k', codeName: '019ab8b2-9bcf-79b5-9fb5-149a7c67b7c0', imagePolicy: 'forbidden', type: 'text' },
|
| 178 |
+
{ id: 'claude-opus-4-5-20251101', codeName: '019adbec-8396-71cc-87d5-b47f8431a6a6', imagePolicy: 'forbidden', type: 'text' },
|
| 179 |
+
{ id: 'gemini-3-pro', codeName: '019a98f7-afcd-779f-8dcb-856cc3b3f078', imagePolicy: 'optional', type: 'text' },
|
| 180 |
+
{ id: 'grok-4.1-thinking', codeName: '019a9389-a9d3-77a8-afbb-4fe4dd3d8630', imagePolicy: 'forbidden', type: 'text' },
|
| 181 |
+
{ id: 'grok-4.1', codeName: '019a9389-a4d8-748d-9939-b4640198302e', imagePolicy: 'forbidden', type: 'text' },
|
| 182 |
+
{ id: 'gpt-5.1-high', codeName: '019a8548-a2b1-70ce-b1be-eba096d41f58', imagePolicy: 'optional', type: 'text' },
|
| 183 |
+
{ id: 'gemini-2.5-pro', codeName: '0199f060-b306-7e1f-aeae-0ebb4e3f1122', imagePolicy: 'optional', type: 'text' },
|
| 184 |
+
{ id: 'claude-sonnet-4-5-20250929-thinking-32k', codeName: 'b0ea1407-2f92-4515-b9cc-b22a6d6c14f2', imagePolicy: 'forbidden', type: 'text' },
|
| 185 |
+
{ id: 'claude-opus-4-1-20250805-thinking-16k', codeName: 'f1a2eb6f-fc30-4806-9e00-1efd0d73cbc4', imagePolicy: 'forbidden', type: 'text' },
|
| 186 |
+
{ id: 'claude-sonnet-4-5-20250929', codeName: '019a2d13-28a5-7205-908c-0a58de904617', imagePolicy: 'forbidden', type: 'text' },
|
| 187 |
+
{ id: 'claude-opus-4-1-20250805', codeName: '96ae95fd-b70d-49c3-91cc-b58c7da1090b', imagePolicy: 'forbidden', type: 'text' },
|
| 188 |
+
{ id: 'chatgpt-4o-latest-20250326', codeName: '0199c1e0-3720-742d-91c8-787788b0a19b', imagePolicy: 'optional', type: 'text' },
|
| 189 |
+
{ id: 'gpt-5.1', codeName: '019a7ebf-0f3f-7518-8899-fca13e32d9dc', imagePolicy: 'optional', type: 'text' },
|
| 190 |
+
{ id: 'gpt-5-high', codeName: '983bc566-b783-4d28-b24c-3c8b08eb1086', imagePolicy: 'optional', type: 'text' },
|
| 191 |
+
{ id: 'o3-2025-04-16', codeName: 'cb0f1e24-e8e9-4745-aabc-b926ffde7475', imagePolicy: 'optional', type: 'text' },
|
| 192 |
+
{ id: 'qwen3-max-preview', codeName: '812c93cc-5f88-4cff-b9ca-c11a26599b0e', imagePolicy: 'forbidden', type: 'text' },
|
| 193 |
+
{ id: 'grok-4-1-fast-reasoning', codeName: '019aa41a-0a13-714a-beb1-be4a918a4b56', imagePolicy: 'forbidden', type: 'text' },
|
| 194 |
+
{ id: 'ernie-5.0-preview-1103', codeName: '019a4ca9-720d-75f5-9012-883ce8ff61df', imagePolicy: 'forbidden', type: 'text' },
|
| 195 |
+
{ id: 'kimi-k2-thinking-turbo', codeName: '019a59bc-8bb8-7933-92eb-fe143770c211', imagePolicy: 'forbidden', type: 'text' },
|
| 196 |
+
{ id: 'gpt-5-chat', codeName: '4b11c78c-08c8-461c-938e-5fc97d56a40d', imagePolicy: 'optional', type: 'text' },
|
| 197 |
+
{ id: 'glm-4.6', codeName: 'f595e6f1-6175-4880-a9eb-377e390819e4', imagePolicy: 'forbidden', type: 'text' },
|
| 198 |
+
{ id: 'qwen3-max-2025-09-23', codeName: '98ad8b8b-12cd-46cd-98de-99edde7e03eb', imagePolicy: 'forbidden', type: 'text' },
|
| 199 |
+
{ id: 'claude-opus-4-20250514-thinking-16k', codeName: '3b5e9593-3dc0-4492-a3da-19784c4bde75', imagePolicy: 'forbidden', type: 'text' },
|
| 200 |
+
{ id: 'qwen3-235b-a22b-instruct-2507', codeName: 'ee7cb86e-8601-4585-b1d0-7c7380f8f6f4', imagePolicy: 'forbidden', type: 'text' },
|
| 201 |
+
{ id: 'grok-4-fast-chat', codeName: 'grok-4-fast-chat', imagePolicy: 'forbidden', type: 'text' },
|
| 202 |
+
{ id: 'deepseek-v3.2-thinking', codeName: '019adb32-bb7a-77eb-882f-b8e3aaa2b2fd', imagePolicy: 'forbidden', type: 'text' },
|
| 203 |
+
{ id: 'kimi-k2-0905-preview', codeName: 'b88e983b-9459-473d-8bf1-753932f1679a', imagePolicy: 'forbidden', type: 'text' },
|
| 204 |
+
{ id: 'kimi-k2-0711-preview', codeName: '7a3626fc-4e64-4c9e-821f-b449a4b43b6a', imagePolicy: 'forbidden', type: 'text' },
|
| 205 |
+
{ id: 'deepseek-v3.2', codeName: '019adb32-b716-7591-9a2f-c6882973e340', imagePolicy: 'forbidden', type: 'text' },
|
| 206 |
+
{ id: 'qwen3-vl-235b-a22b-instruct', codeName: '716aa8ca-d729-427f-93ab-9579e4a13e98', imagePolicy: 'optional', type: 'text' },
|
| 207 |
+
{ id: 'mistral-large-3', codeName: '019acbac-df7c-73dc-9716-ebe040daaa4e', imagePolicy: 'forbidden', type: 'text' },
|
| 208 |
+
{ id: 'gpt-4.1-2025-04-14', codeName: '14e9311c-94d2-40c2-8c54-273947e208b0', imagePolicy: 'optional', type: 'text' },
|
| 209 |
+
{ id: 'claude-opus-4-20250514', codeName: 'ee116d12-64d6-48a8-88e5-b2d06325cdd2', imagePolicy: 'forbidden', type: 'text' },
|
| 210 |
+
{ id: 'mistral-medium-2508', codeName: '27035fb8-a25b-4ec9-8410-34be18328afd', imagePolicy: 'optional', type: 'text' },
|
| 211 |
+
{ id: 'grok-4-0709', codeName: 'b9edb8e9-4e98-49e7-8aaf-ae67e9797a11', imagePolicy: 'optional', type: 'text' },
|
| 212 |
+
{ id: 'glm-4.5', codeName: 'd079ef40-3b20-4c58-ab5e-243738dbada5', imagePolicy: 'forbidden', type: 'text' },
|
| 213 |
+
{ id: 'gemini-2.5-flash', codeName: '0199f059-3877-7cfe-bc80-e01b1a4a83de', imagePolicy: 'optional', type: 'text' },
|
| 214 |
+
{ id: 'gemini-2.5-flash-preview-09-2025', codeName: 'fc700d46-c4c1-4fec-88b5-f086876ae0bb', imagePolicy: 'optional', type: 'text' },
|
| 215 |
+
{ id: 'claude-haiku-4-5-20251001', codeName: '0199e8e9-01ed-73e0-96ba-cf43b286bf10', imagePolicy: 'forbidden', type: 'text' },
|
| 216 |
+
{ id: 'qwen3-next-80b-a3b-instruct', codeName: '351fe482-eb6c-4536-857b-909e16c0bf52', imagePolicy: 'forbidden', type: 'text' },
|
| 217 |
+
{ id: 'longcat-flash-chat', codeName: '6fcbe051-f521-4dc7-8986-c429eb6191bf', imagePolicy: 'forbidden', type: 'text' },
|
| 218 |
+
{ id: 'qwen3-235b-a22b-no-thinking', codeName: '1a400d9a-f61c-4bc2-89b4-a9b7e77dff12', imagePolicy: 'forbidden', type: 'text' },
|
| 219 |
+
{ id: 'qwen3-235b-a22b-thinking-2507', codeName: '16b8e53a-cc7b-4608-a29a-20d4dac77cf2', imagePolicy: 'forbidden', type: 'text' },
|
| 220 |
+
{ id: 'qwen3-vl-235b-a22b-thinking', codeName: '03c511f5-0d35-4751-aae6-24f918b0d49e', imagePolicy: 'optional', type: 'text' },
|
| 221 |
+
{ id: 'gpt-5-mini-high', codeName: '5fd3caa8-fe4c-41a5-a22c-0025b58f4b42', imagePolicy: 'optional', type: 'text' },
|
| 222 |
+
{ id: 'deepseek-v3-0324', codeName: '2f5253e4-75be-473c-bcfc-baeb3df0f8ad', imagePolicy: 'forbidden', type: 'text' },
|
| 223 |
+
{ id: 'hunyuan-vision-1.5-thinking', codeName: '6a3a1e04-050e-4cb4-9052-b9ac4bec0c38', imagePolicy: 'optional', type: 'text' },
|
| 224 |
+
{ id: 'o4-mini-2025-04-16', codeName: 'f1102bbf-34ca-468f-a9fc-14bcf63f315b', imagePolicy: 'optional', type: 'text' },
|
| 225 |
+
{ id: 'claude-sonnet-4-20250514', codeName: 'ac44dd10-0666-451c-b824-386ccfea7bcc', imagePolicy: 'forbidden', type: 'text' },
|
| 226 |
+
{ id: 'claude-3-7-sonnet-20250219-thinking-32k', codeName: 'be98fcfd-345c-4ae1-9a82-a19123ebf1d2', imagePolicy: 'forbidden', type: 'text' },
|
| 227 |
+
{ id: 'qwen3-coder-480b-a35b-instruct', codeName: 'af033cbd-ec6c-42cc-9afa-e227fc12efe8', imagePolicy: 'forbidden', type: 'text' },
|
| 228 |
+
{ id: 'hunyuan-t1-20250711', codeName: 'ba8c2392-4c47-42af-bfee-c6c057615a91', imagePolicy: 'forbidden', type: 'text' },
|
| 229 |
+
{ id: 'mistral-medium-2505', codeName: '27b9f8c6-3ee1-464a-9479-a8b3c2a48fd4', imagePolicy: 'optional', type: 'text' },
|
| 230 |
+
{ id: 'qwen3-30b-a3b-instruct-2507', codeName: 'a8d1d310-e485-4c50-8f27-4bff18292a99', imagePolicy: 'forbidden', type: 'text' },
|
| 231 |
+
{ id: 'gpt-4.1-mini-2025-04-14', codeName: '6a5437a7-c786-467b-b701-17b0bc8c8231', imagePolicy: 'optional', type: 'text' },
|
| 232 |
+
{ id: 'gemini-2.5-flash-lite-preview-09-2025-no-thinking', codeName: '75555628-8c14-402a-8d6e-43c19cb40116', imagePolicy: 'optional', type: 'text' },
|
| 233 |
+
{ id: 'gemini-2.5-flash-lite-preview-06-17-thinking', codeName: '04ec9a17-c597-49df-acf0-963da275c246', imagePolicy: 'optional', type: 'text' },
|
| 234 |
+
{ id: 'qwen3-235b-a22b', codeName: '2595a594-fa54-4299-97cd-2d7380d21c80', imagePolicy: 'forbidden', type: 'text' },
|
| 235 |
+
{ id: 'claude-3-5-sonnet-20241022', codeName: 'f44e280a-7914-43ca-a25d-ecfcc5d48d09', imagePolicy: 'forbidden', type: 'text' },
|
| 236 |
+
{ id: 'claude-3-7-sonnet-20250219', codeName: 'c5a11495-081a-4dc6-8d9a-64a4fd6f7bbc', imagePolicy: 'forbidden', type: 'text' },
|
| 237 |
+
{ id: 'glm-4.5-air', codeName: '7bfb254a-5d32-4ce2-b6dc-2c7faf1d5fe8', imagePolicy: 'forbidden', type: 'text' },
|
| 238 |
+
{ id: 'qwen3-next-80b-a3b-thinking', codeName: '73cf8705-98c8-4b75-8d04-e3746e1c1565', imagePolicy: 'forbidden', type: 'text' },
|
| 239 |
+
{ id: 'minimax-m1', codeName: '87e8d160-049e-4b4e-adc4-7f2511348539', imagePolicy: 'forbidden', type: 'text' },
|
| 240 |
+
{ id: 'gemma-3-27b-it', codeName: '789e245f-eafe-4c72-b563-d135e93988fc', imagePolicy: 'optional', type: 'text' },
|
| 241 |
+
{ id: 'grok-3-mini-high', codeName: '149619f1-f1d5-45fd-a53e-7d790f156f20', imagePolicy: 'forbidden', type: 'text' },
|
| 242 |
+
{ id: 'gemini-2.0-flash-001', codeName: '7a55108b-b997-4cff-a72f-5aa83beee918', imagePolicy: 'optional', type: 'text' },
|
| 243 |
+
{ id: 'grok-3-mini-beta', codeName: '7699c8d4-0742-42f9-a117-d10e84688dab', imagePolicy: 'forbidden', type: 'text' },
|
| 244 |
+
{ id: 'mistral-small-2506', codeName: 'bbad1d17-6aa5-4321-949c-d11fb6289241', imagePolicy: 'optional', type: 'text' },
|
| 245 |
+
{ id: 'gpt-oss-120b', codeName: '6ee9f901-17b5-4fbe-9cc2-13c16497c23b', imagePolicy: 'forbidden', type: 'text' },
|
| 246 |
+
{ id: 'glm-4.5v', codeName: '9dab0475-a0cc-4524-84a2-3fd25aa8c768', imagePolicy: 'optional', type: 'text' },
|
| 247 |
+
{ id: 'command-a-03-2025', codeName: '0f785ba1-efcb-472d-961e-69f7b251c7e3', imagePolicy: 'forbidden', type: 'text' },
|
| 248 |
+
{ id: 'amazon-nova-experimental-chat-10-20', codeName: '019a4c75-256c-790b-9088-4694cc63c507', imagePolicy: 'forbidden', type: 'text' },
|
| 249 |
+
{ id: 'intellect-3', codeName: '019aebfd-af0e-7f0c-8f0d-96c588e4cd3b', imagePolicy: 'forbidden', type: 'text' },
|
| 250 |
+
{ id: 'o3-mini', codeName: 'c680645e-efac-4a81-b0af-da16902b2541', imagePolicy: 'forbidden', type: 'text' },
|
| 251 |
+
{ id: 'ling-flash-2.0', codeName: '71f96ca9-4cf8-4be7-bac2-2231613930a6', imagePolicy: 'forbidden', type: 'text' },
|
| 252 |
+
{ id: 'minimax-m2', codeName: '019a27e0-e7d8-7b0b-877c-a2106c6eb87d', imagePolicy: 'forbidden', type: 'text' },
|
| 253 |
+
{ id: 'step-3', codeName: '1ea13a81-93a7-4804-bcdd-693cd72e302d', imagePolicy: 'forbidden', type: 'text' },
|
| 254 |
+
{ id: 'gpt-5-nano-high', codeName: '2dc249b3-98da-44b4-8d1e-6666346a8012', imagePolicy: 'optional', type: 'text' },
|
| 255 |
+
{ id: 'nova-2-lite', codeName: '019ae300-83b7-7717-a1e0-31accd1ff6fa', imagePolicy: 'forbidden', type: 'text' },
|
| 256 |
+
{ id: 'qwq-32b', codeName: '885976d3-d178-48f5-a3f4-6e13e0718872', imagePolicy: 'forbidden', type: 'text' },
|
| 257 |
+
{ id: 'llama-4-maverick-17b-128e-instruct', codeName: 'b5ad3ab7-fc56-4ecd-8921-bd56b55c1159', imagePolicy: 'optional', type: 'text' },
|
| 258 |
+
{ id: 'qwen3-30b-a3b', codeName: '9a066f6a-7205-4325-8d0b-d81cc4b049c0', imagePolicy: 'forbidden', type: 'text' },
|
| 259 |
+
{ id: 'claude-3-5-haiku-20241022', codeName: 'claude-3-5-haiku-20241022', imagePolicy: 'forbidden', type: 'text' },
|
| 260 |
+
{ id: 'ring-flash-2.0', codeName: '11ad4114-c868-4fed-b6e7-d535dc9c62f8', imagePolicy: 'forbidden', type: 'text' },
|
| 261 |
+
{ id: 'llama-3.3-70b-instruct', codeName: 'dcbd7897-5a37-4a34-93f1-76a24c7bb028', imagePolicy: 'forbidden', type: 'text' },
|
| 262 |
+
{ id: 'gemma-3n-e4b-it', codeName: '896a3848-ae03-4651-963b-7d8f54b61ae8', imagePolicy: 'forbidden', type: 'text' },
|
| 263 |
+
{ id: 'gpt-oss-20b', codeName: 'ec3beb4b-7229-4232-bab9-670ee52dd711', imagePolicy: 'forbidden', type: 'text' },
|
| 264 |
+
{ id: 'mercury', codeName: '019a6f77-e20d-7c1d-a7cd-8bd926e7395d', imagePolicy: 'forbidden', type: 'text' },
|
| 265 |
+
{ id: 'olmo-3-32b-think', codeName: '019ac2ef-27e1-769f-8258-d131f79e28ef', imagePolicy: 'forbidden', type: 'text' },
|
| 266 |
+
{ id: 'mistral-small-3.1-24b-instruct-2503', codeName: '69f5d38a-45f5-4d3a-9320-b866a4035ed9', imagePolicy: 'optional', type: 'text' },
|
| 267 |
+
{ id: 'ibm-granite-h-small', codeName: '4ddb69f5-391a-4f78-af92-7d7328c18ab1', imagePolicy: 'forbidden', type: 'text' },
|
| 268 |
+
{ id: 'qwen3-vl-8b-thinking', codeName: '0199e3d1-a308-77b9-a650-41453e8ef2fb', imagePolicy: 'optional', type: 'text' },
|
| 269 |
+
{ id: 'qwen3-vl-8b-instruct', codeName: '0199e3d1-a713-7de2-a5dd-a1583cad9532', imagePolicy: 'optional', type: 'text' },
|
| 270 |
+
{ id: 'amazon.nova-pro-v1:0', codeName: 'a14546b5-d78d-4cf6-bb61-ab5b8510a9d6', imagePolicy: 'optional', type: 'text' },
|
| 271 |
+
{ id: 'glm-4.6v', codeName: '019b151a-7c3b-72a2-8811-0bf9317c2ef5', imagePolicy: 'optional', type: 'text' },
|
| 272 |
+
{ id: 'gpt-5.2-high', codeName: '019b1448-dafa-7f92-90c3-50e159c2263c', imagePolicy: 'optional', type: 'text' },
|
| 273 |
+
{ id: 'gpt-5.2', codeName: '019b1448-d548-78f4-8b98-788d72cbd057', imagePolicy: 'optional', type: 'text' },
|
| 274 |
+
{ id: 'glm-4.6v-flash', codeName: '019b1536-49c0-73b2-8d45-403b8571568d', imagePolicy: 'optional', type: 'text' },
|
| 275 |
+
{ id: 'mimo-vl-7b-rl-2508', codeName: '1c0259b5-dff7-48ce-bca1-b6957675463b', imagePolicy: 'optional', type: 'text' },
|
| 276 |
+
{ id: 'minimax-m2.1-preview', codeName: '', imagePolicy: 'forbidden', type: 'text' },
|
| 277 |
+
{ id: 'mimo-v2-flash (thinking)', codeName: '', imagePolicy: 'forbidden', type: 'text' },
|
| 278 |
+
{ id: 'glm-4.7', codeName: '', imagePolicy: 'forbidden', type: 'text' },
|
| 279 |
+
{ id: 'amazon-nova-experimental-chat-11-10', codeName: '', imagePolicy: 'forbidden', type: 'text' },
|
| 280 |
+
{ id: 'grok-4-1-fast-non-reasoning', codeName: '', imagePolicy: 'forbidden', type: 'text' },
|
| 281 |
+
{ id: 'gemini-3-flash', codeName: '', imagePolicy: 'optional', type: 'text' },
|
| 282 |
+
{ id: 'nvidia-nemotron-3-nano-30b-a3b-bf16', codeName: '', imagePolicy: 'forbidden', type: 'text' },
|
| 283 |
+
{ id: 'olmo-3.1-32b-instruct', codeName: '', imagePolicy: 'forbidden', type: 'text' },
|
| 284 |
+
{ id: 'olmo-3.1-32b-think', codeName: '', imagePolicy: 'forbidden', type: 'text' },
|
| 285 |
+
{ id: 'gemini-3-flash (thinking-minimal)', codeName: '', imagePolicy: 'forbidden', type: 'text' },
|
| 286 |
+
{ id: 'mimo-v2-flash', codeName: '', imagePolicy: 'optional', type: 'text' },
|
| 287 |
+
{ id: 'ernie-5.0-preview-1220', codeName: '', imagePolicy: 'forbidden', type: 'text' },
|
| 288 |
+
{ id: 'qwen3-max-2025-09-26', codeName: '', imagePolicy: 'forbidden', type: 'text' },
|
| 289 |
+
{ id: 'ernie-5.0-preview-1203', codeName: '', imagePolicy: 'forbidden', type: 'text' },
|
| 290 |
+
{ id: 'mimo-7b', codeName: '', imagePolicy: 'forbidden', type: 'text' },
|
| 291 |
+
{ id: 'qwen-vl-max-2025-08-13', codeName: '', imagePolicy: 'optional', type: 'text' },
|
| 292 |
+
{ id: 'claude-sonnet-4-20250514-thinking-32k', codeName: '', imagePolicy: 'forbidden', type: 'text' },
|
| 293 |
+
{ id: 'minimax-m2-preview', codeName: '', imagePolicy: 'forbidden', type: 'text' },
|
| 294 |
+
{ id: 'ernie-5.0-preview-1120', codeName: '', imagePolicy: 'forbidden', type: 'text' },
|
| 295 |
+
{ id: 'gpt-5-high-new-system-prompt', codeName: '', imagePolicy: 'optional', type: 'text' },
|
| 296 |
+
|
| 297 |
+
// --- 搜索模型 ---
|
| 298 |
+
{ id: 'gemini-3-pro-grounding', codeName: '019abdb7-6957-71c1-96a2-bfa79e8a094f', imagePolicy: 'forbidden', type: 'text', search: true },
|
| 299 |
+
{ id: 'gpt-5.1-search', codeName: '019abdb7-50a5-7c05-9308-4491d069578b', imagePolicy: 'forbidden', type: 'text', search: true },
|
| 300 |
+
{ id: 'grok-4-fast-search', codeName: '9217ac2d-91bc-4391-aa07-b8f9e2cf11f2', imagePolicy: 'forbidden', type: 'text', search: true },
|
| 301 |
+
{ id: 'gemini-2.5-pro-grounding', codeName: 'b222be23-bd55-4b20-930b-a30cc84d3afd', imagePolicy: 'forbidden', type: 'text', search: true },
|
| 302 |
+
{ id: 'o3-search', codeName: 'fbe08e9a-3805-4f9f-a085-7bc38e4b51d1', imagePolicy: 'forbidden', type: 'text', search: true },
|
| 303 |
+
{ id: 'grok-4-search', codeName: '86d767b0-2574-4e47-a256-a22bcace9f56', imagePolicy: 'forbidden', type: 'text', search: true },
|
| 304 |
+
{ id: 'ppl-sonar-reasoning-pro-high', codeName: '24145149-86c9-4690-b7c9-79c7db216e5c', imagePolicy: 'forbidden', type: 'text', search: true },
|
| 305 |
+
{ id: 'claude-opus-4-1-search', codeName: 'd942b564-191c-41c5-ae22-400a930a2cfe', imagePolicy: 'forbidden', type: 'text', search: true },
|
| 306 |
+
{ id: 'gpt-5-search', codeName: 'd14d9b23-1e46-4659-b157-a3804ba7e2ef', imagePolicy: 'forbidden', type: 'text', search: true },
|
| 307 |
+
{ id: 'claude-opus-4-search', codeName: '25bcb878-749e-49f4-ac05-de84d964bcee', imagePolicy: 'forbidden', type: 'text', search: true },
|
| 308 |
+
{ id: 'diffbot-small-xl', codeName: '0862885e-ef53-4d0d-b9c4-4c8f68f453ce', imagePolicy: 'forbidden', type: 'text', search: true },
|
| 309 |
+
{ id: 'grok-4-1-fast-search', codeName: '019af19c-0658-7566-9c60-112ae5bdb8db', imagePolicy: 'forbidden', type: 'text', search: true },
|
| 310 |
+
{ id: 'gpt-5.2-search', codeName: '019b1448-f74a-72de-b25d-8666618f8c5a', imagePolicy: 'forbidden', type: 'text', search: true },
|
| 311 |
+
{ id: 'gpt-5.1-search-sp', codeName: '', imagePolicy: 'forbidden', type: 'text', search: true }
|
| 312 |
+
],
|
| 313 |
+
|
| 314 |
+
// 无需导航处理器
|
| 315 |
+
navigationHandlers: [],
|
| 316 |
+
|
| 317 |
+
// 核心生图方法
|
| 318 |
+
generate
|
| 319 |
+
};
|
src/backend/adapter/nanobananafree_ai.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview NanoBananaFree 图片生成适配器
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import {
|
| 6 |
+
sleep,
|
| 7 |
+
safeClick,
|
| 8 |
+
pasteImages
|
| 9 |
+
} from '../engine/utils.js';
|
| 10 |
+
import {
|
| 11 |
+
fillPrompt,
|
| 12 |
+
submit,
|
| 13 |
+
waitApiResponse,
|
| 14 |
+
normalizePageError,
|
| 15 |
+
normalizeHttpError,
|
| 16 |
+
moveMouseAway,
|
| 17 |
+
waitForInput,
|
| 18 |
+
gotoWithCheck
|
| 19 |
+
} from '../utils/index.js';
|
| 20 |
+
import { logger } from '../../utils/logger.js';
|
| 21 |
+
|
| 22 |
+
// --- 配置常量 ---
|
| 23 |
+
const TARGET_URL = 'https://nanobananafree.ai/';
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* 执行生图任务
|
| 28 |
+
* @param {object} context - 浏览器上下文 { page, client }
|
| 29 |
+
* @param {string} prompt - 提示词
|
| 30 |
+
* @param {string[]} imgPaths - 图片路径数组 (仅取第一张)
|
| 31 |
+
* @param {string} [modelId] - 指定的模型 ID (可选,目前未使用)
|
| 32 |
+
* @param {object} [meta={}] - 日志元数据
|
| 33 |
+
* @returns {Promise<{image?: string, text?: string, error?: string}>} 生成结果
|
| 34 |
+
*/
|
| 35 |
+
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
| 36 |
+
const { page } = context;
|
| 37 |
+
const textareaSelector = 'textarea';
|
| 38 |
+
|
| 39 |
+
try {
|
| 40 |
+
logger.info('适配器', '开启新会话', meta);
|
| 41 |
+
await gotoWithCheck(page, TARGET_URL);
|
| 42 |
+
|
| 43 |
+
// 1. 等待输入框加载
|
| 44 |
+
await waitForInput(page, textareaSelector, { click: false });
|
| 45 |
+
await sleep(1500, 2500);
|
| 46 |
+
|
| 47 |
+
// 2. 上传图片 (uploadImages - 仅取第一张)
|
| 48 |
+
if (imgPaths && imgPaths.length > 0) {
|
| 49 |
+
const singleImage = [imgPaths[0]];
|
| 50 |
+
if (imgPaths.length > 1) {
|
| 51 |
+
logger.warn('适配器', `此后端仅支持1张图片, 已丢弃 ${imgPaths.length - 1} 张`, meta);
|
| 52 |
+
}
|
| 53 |
+
await pasteImages(page, textareaSelector, singleImage);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// 3. 填写提示词 (fillPrompt)
|
| 57 |
+
await safeClick(page, textareaSelector, { bias: 'input' });
|
| 58 |
+
await fillPrompt(page, textareaSelector, prompt, meta);
|
| 59 |
+
|
| 60 |
+
// 4. 提交表单 (submit)
|
| 61 |
+
logger.debug('适配器', '点击发送...', meta);
|
| 62 |
+
await submit(page, {
|
| 63 |
+
btnSelector: 'div[class*="_sendButton_"]',
|
| 64 |
+
inputTarget: textareaSelector,
|
| 65 |
+
meta
|
| 66 |
+
});
|
| 67 |
+
|
| 68 |
+
logger.info('适配器', '等待生成结果...', meta);
|
| 69 |
+
|
| 70 |
+
// 5. 等待 API 响应 (waitApiResponse)
|
| 71 |
+
let response;
|
| 72 |
+
try {
|
| 73 |
+
response = await waitApiResponse(page, {
|
| 74 |
+
urlMatch: 'v1/generateContent',
|
| 75 |
+
method: 'POST',
|
| 76 |
+
timeout: 120000,
|
| 77 |
+
meta
|
| 78 |
+
});
|
| 79 |
+
} catch (e) {
|
| 80 |
+
// 使用公共错误处理
|
| 81 |
+
const pageError = normalizePageError(e, meta);
|
| 82 |
+
if (pageError) return pageError;
|
| 83 |
+
throw e;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// 6. 解析响应结果
|
| 87 |
+
// 先尝试获取响应内容用于错误解析
|
| 88 |
+
let content = null;
|
| 89 |
+
try {
|
| 90 |
+
content = await response.text();
|
| 91 |
+
} catch (e) { }
|
| 92 |
+
|
| 93 |
+
// 检查 HTTP 错误
|
| 94 |
+
const httpError = normalizeHttpError(response, content);
|
| 95 |
+
if (httpError) {
|
| 96 |
+
logger.error('适配器', `请求生成时返回错误: ${httpError.error}`, meta);
|
| 97 |
+
return { error: `请求生成时返回错误: ${httpError.error}` };
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// 解析成功响应(使用已读取的 content)
|
| 101 |
+
let body;
|
| 102 |
+
try {
|
| 103 |
+
body = JSON.parse(content);
|
| 104 |
+
} catch (e) {
|
| 105 |
+
logger.error('适配器', '解析响应JSON时出错', meta);
|
| 106 |
+
return { error: '解析响应JSON时出错' };
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
// 7. 提取 base64 图片
|
| 110 |
+
const inlineData = body?.data?.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
|
| 111 |
+
|
| 112 |
+
if (inlineData) {
|
| 113 |
+
logger.info('适配器', '已获取生结果, 且已获取图片数据', meta);
|
| 114 |
+
return { image: `data:image/png;base64,${inlineData}` };
|
| 115 |
+
} else {
|
| 116 |
+
logger.info('适配器', 'AI 返回非图片响应', { ...meta, preview: JSON.stringify(body).substring(0, 150) });
|
| 117 |
+
return { text: JSON.stringify(body) };
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
} catch (err) {
|
| 121 |
+
// 顶层错误处理
|
| 122 |
+
const pageError = normalizePageError(err, meta);
|
| 123 |
+
if (pageError) return pageError;
|
| 124 |
+
|
| 125 |
+
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
| 126 |
+
return { error: `生成任务失败: ${err.message}` };
|
| 127 |
+
} finally {
|
| 128 |
+
// 任务结束,将鼠标移至安全区域
|
| 129 |
+
await moveMouseAway(page);
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
/**
|
| 134 |
+
* 适配器 manifest
|
| 135 |
+
*/
|
| 136 |
+
export const manifest = {
|
| 137 |
+
id: 'nanobananafree_ai',
|
| 138 |
+
displayName: 'NanoBananaFree (图片生成)',
|
| 139 |
+
description: '使用 NanoBananaFree 平台生成图片,仅支持上传单张图片。需要已登录的 Google 账户。',
|
| 140 |
+
|
| 141 |
+
// 入口 URL
|
| 142 |
+
getTargetUrl(config, workerConfig) {
|
| 143 |
+
return TARGET_URL;
|
| 144 |
+
},
|
| 145 |
+
|
| 146 |
+
// 模型列表
|
| 147 |
+
models: [
|
| 148 |
+
{ id: 'gemini-2.5-flash-image', imagePolicy: 'optional' }
|
| 149 |
+
],
|
| 150 |
+
|
| 151 |
+
// 无需导航处理器
|
| 152 |
+
navigationHandlers: [],
|
| 153 |
+
|
| 154 |
+
// 核心生���方法
|
| 155 |
+
generate
|
| 156 |
+
};
|
src/backend/adapter/sora.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview Sora 视频生成适配器
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import {
|
| 6 |
+
sleep,
|
| 7 |
+
safeClick,
|
| 8 |
+
uploadFilesViaChooser
|
| 9 |
+
} from '../engine/utils.js';
|
| 10 |
+
import {
|
| 11 |
+
fillPrompt,
|
| 12 |
+
normalizePageError,
|
| 13 |
+
moveMouseAway,
|
| 14 |
+
waitForInput,
|
| 15 |
+
gotoWithCheck,
|
| 16 |
+
useContextDownload
|
| 17 |
+
} from '../utils/index.js';
|
| 18 |
+
import { logger } from '../../utils/logger.js';
|
| 19 |
+
|
| 20 |
+
// --- 配置常量 ---
|
| 21 |
+
const TARGET_URL = 'https://sora.chatgpt.com/profile';
|
| 22 |
+
const INPUT_SELECTOR = 'textarea';
|
| 23 |
+
|
| 24 |
+
/**
|
| 25 |
+
* 执行视频生成任务
|
| 26 |
+
* @param {object} context - 浏览器上下文 { page, config }
|
| 27 |
+
* @param {string} prompt - 提示词
|
| 28 |
+
* @param {string[]} imgPaths - 图片路径数组 (只使用第一张)
|
| 29 |
+
* @param {string} [modelId] - 模型 ID (此适配器未使用)
|
| 30 |
+
* @param {object} [meta={}] - 日志元数据
|
| 31 |
+
* @returns {Promise<{video?: string, error?: string}>}
|
| 32 |
+
*/
|
| 33 |
+
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
| 34 |
+
const { page } = context;
|
| 35 |
+
|
| 36 |
+
// 只使用第一张图片
|
| 37 |
+
const singleImgPath = imgPaths && imgPaths.length > 0 ? [imgPaths[0]] : [];
|
| 38 |
+
if (imgPaths && imgPaths.length > 1) {
|
| 39 |
+
logger.warn('适配器', `Sora 只支持一张图片,已丢弃 ${imgPaths.length - 1} 张`, meta);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// 用于存储任务 ID 和视频 URL
|
| 43 |
+
let taskId = null;
|
| 44 |
+
let videoUrl = null;
|
| 45 |
+
|
| 46 |
+
try {
|
| 47 |
+
logger.info('适配器', '开启新会话...', meta);
|
| 48 |
+
await gotoWithCheck(page, TARGET_URL);
|
| 49 |
+
|
| 50 |
+
// 1. 等待输入框加载
|
| 51 |
+
await waitForInput(page, INPUT_SELECTOR, { click: false });
|
| 52 |
+
await sleep(1500, 2500);
|
| 53 |
+
|
| 54 |
+
// 2. 上传图片 (如果有)
|
| 55 |
+
if (singleImgPath.length > 0) {
|
| 56 |
+
logger.debug('适配器', '点击上传文件按钮...', meta);
|
| 57 |
+
const attachBtn = page.getByRole('button', { name: 'Attach media' });
|
| 58 |
+
|
| 59 |
+
await uploadFilesViaChooser(page, attachBtn, singleImgPath, {
|
| 60 |
+
uploadValidator: (response) => {
|
| 61 |
+
const url = response.url();
|
| 62 |
+
if (response.status() === 200 && url.includes('project_y/file/upload')) {
|
| 63 |
+
logger.info('适配器', '图片上传完成', meta);
|
| 64 |
+
return true;
|
| 65 |
+
}
|
| 66 |
+
return false;
|
| 67 |
+
}
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
await sleep(1000, 2000);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// 3. 填写提示词
|
| 74 |
+
await safeClick(page, INPUT_SELECTOR, { bias: 'input' });
|
| 75 |
+
await fillPrompt(page, INPUT_SELECTOR, prompt, meta);
|
| 76 |
+
await sleep(500, 1000);
|
| 77 |
+
|
| 78 |
+
// 4. 提前设置响应监听器 (drafts 接口)
|
| 79 |
+
// 因为 drafts 请求在 pending/v2 检测到任务消失后立即出现,需要提前监听
|
| 80 |
+
let draftsResponsePromise = null;
|
| 81 |
+
const startDraftsListener = () => {
|
| 82 |
+
draftsResponsePromise = page.waitForResponse(async (response) => {
|
| 83 |
+
const url = response.url();
|
| 84 |
+
if (!url.includes('project_y/profile/drafts')) return false;
|
| 85 |
+
if (response.request().method() !== 'GET') return false;
|
| 86 |
+
if (response.status() !== 200) return false;
|
| 87 |
+
return true;
|
| 88 |
+
}, { timeout: 600000 }); // 10 分钟超时
|
| 89 |
+
};
|
| 90 |
+
|
| 91 |
+
// 5. 点击 Create video 按钮并监听 nf/create 请求
|
| 92 |
+
logger.debug('适配器', '点击创建视频...', meta);
|
| 93 |
+
const createBtn = page.getByRole('button', { name: 'Create video' });
|
| 94 |
+
|
| 95 |
+
// 设置 create 请求监听
|
| 96 |
+
const createResponsePromise = page.waitForResponse(async (response) => {
|
| 97 |
+
const url = response.url();
|
| 98 |
+
if (!url.includes('nf/create')) return false;
|
| 99 |
+
if (response.request().method() !== 'POST') return false;
|
| 100 |
+
if (response.status() !== 200) return false;
|
| 101 |
+
return true;
|
| 102 |
+
}, { timeout: 60000 });
|
| 103 |
+
|
| 104 |
+
await safeClick(page, createBtn, { bias: 'button' });
|
| 105 |
+
|
| 106 |
+
// 等待 create 响应
|
| 107 |
+
logger.info('适配器', '等待创建任务...', meta);
|
| 108 |
+
const createResponse = await createResponsePromise;
|
| 109 |
+
|
| 110 |
+
try {
|
| 111 |
+
const createBody = await createResponse.json();
|
| 112 |
+
taskId = createBody.id;
|
| 113 |
+
if (!taskId) {
|
| 114 |
+
logger.error('适配器', '创建响应中没有 id', meta);
|
| 115 |
+
return { error: '创建任务失败:响应中没有 id' };
|
| 116 |
+
}
|
| 117 |
+
logger.info('适配器', `任务已创建, id: ${taskId}`, meta);
|
| 118 |
+
} catch (e) {
|
| 119 |
+
logger.error('适配器', '解析 create 响应失败', { ...meta, error: e.message });
|
| 120 |
+
return { error: '解析创建响应失败' };
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
// 6. 启动 drafts 监听器 (提前监听)
|
| 124 |
+
startDraftsListener();
|
| 125 |
+
|
| 126 |
+
// 7. 监听 nf/pending/v2 等待任务完成
|
| 127 |
+
logger.info('适配器', '等待视频生成完成...', meta);
|
| 128 |
+
|
| 129 |
+
let taskCompleted = false;
|
| 130 |
+
const maxWaitTime = 300000; // 5 分钟
|
| 131 |
+
const startTime = Date.now();
|
| 132 |
+
|
| 133 |
+
while (!taskCompleted && (Date.now() - startTime) < maxWaitTime) {
|
| 134 |
+
try {
|
| 135 |
+
const pendingResponse = await page.waitForResponse(async (response) => {
|
| 136 |
+
const url = response.url();
|
| 137 |
+
if (!url.includes('nf/pending/v2')) return false;
|
| 138 |
+
if (response.request().method() !== 'GET') return false;
|
| 139 |
+
if (response.status() !== 200) return false;
|
| 140 |
+
return true;
|
| 141 |
+
}, { timeout: 30000 });
|
| 142 |
+
|
| 143 |
+
const pendingBody = await pendingResponse.json();
|
| 144 |
+
|
| 145 |
+
// 检查任务是否还在列表中
|
| 146 |
+
const taskInList = pendingBody.find(item => item.id === taskId);
|
| 147 |
+
|
| 148 |
+
if (taskInList) {
|
| 149 |
+
const status = taskInList.status;
|
| 150 |
+
logger.debug('适配器', `任务状态: ${status}`, meta);
|
| 151 |
+
// preprocessing, queued, running, processing 都表示进行中
|
| 152 |
+
} else {
|
| 153 |
+
// 任务不在列表中,说明已完成
|
| 154 |
+
logger.info('适配器', '任务已完成,等待获取视频链接...', meta);
|
| 155 |
+
taskCompleted = true;
|
| 156 |
+
}
|
| 157 |
+
} catch (e) {
|
| 158 |
+
// 超时重试
|
| 159 |
+
if (e.name === 'TimeoutError') {
|
| 160 |
+
logger.debug('适配器', '等待 pending 响应超时,继续等待...', meta);
|
| 161 |
+
} else {
|
| 162 |
+
throw e;
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
if (!taskCompleted) {
|
| 168 |
+
logger.error('适配器', '等待视频生成超时 (5分钟)', meta);
|
| 169 |
+
return { error: '等待视频生成超时 (5分钟)' };
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
// 8. 获取 drafts 响应中的视频 URL
|
| 173 |
+
logger.debug('适配器', '获取视频链接...', meta);
|
| 174 |
+
|
| 175 |
+
try {
|
| 176 |
+
const draftsResponse = await draftsResponsePromise;
|
| 177 |
+
const draftsBody = await draftsResponse.json();
|
| 178 |
+
|
| 179 |
+
// 在 items 数组中查找 task_id 匹配的项目
|
| 180 |
+
const items = draftsBody.items || draftsBody;
|
| 181 |
+
const targetItem = (Array.isArray(items) ? items : []).find(
|
| 182 |
+
item => item.task_id === taskId
|
| 183 |
+
);
|
| 184 |
+
|
| 185 |
+
if (!targetItem) {
|
| 186 |
+
logger.error('适配器', '未找到匹配的视频任务', meta);
|
| 187 |
+
return { error: '未找到匹配的视频任务' };
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
videoUrl = targetItem.url;
|
| 191 |
+
if (!videoUrl) {
|
| 192 |
+
logger.error('适配器', '视频项目中没有 url', meta);
|
| 193 |
+
return { error: '视频项目中没有 url' };
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
logger.info('适配器', '已获取视频链接', meta);
|
| 197 |
+
} catch (e) {
|
| 198 |
+
logger.error('适配器', '获取视频链接失败', { ...meta, error: e.message });
|
| 199 |
+
return { error: `获取视频链接失败: ${e.message}` };
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
// 9. 下载视频并转为 base64
|
| 203 |
+
logger.info('适配器', '正在下载视频...', meta);
|
| 204 |
+
const downloadResult = await useContextDownload(videoUrl, page);
|
| 205 |
+
|
| 206 |
+
if (downloadResult.error) {
|
| 207 |
+
logger.error('适配器', downloadResult.error, meta);
|
| 208 |
+
return downloadResult;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
logger.info('适配器', '视频生成完成,任务完成', meta);
|
| 212 |
+
return { image: downloadResult.image }; // 复用 image 字段存储 base64
|
| 213 |
+
|
| 214 |
+
} catch (err) {
|
| 215 |
+
// 顶层错误处理
|
| 216 |
+
const pageError = normalizePageError(err, meta);
|
| 217 |
+
if (pageError) return pageError;
|
| 218 |
+
|
| 219 |
+
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
| 220 |
+
return { error: `生成任务失败: ${err.message}` };
|
| 221 |
+
} finally {
|
| 222 |
+
// 任务结束,将鼠标移至安全区域
|
| 223 |
+
await moveMouseAway(page);
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
/**
|
| 228 |
+
* 适配器 manifest
|
| 229 |
+
*/
|
| 230 |
+
export const manifest = {
|
| 231 |
+
id: 'sora',
|
| 232 |
+
displayName: 'Sora (视频生成)',
|
| 233 |
+
description: '使用 OpenAI Sora 生成视频,仅支持上传单张参考图片。需要已登录的 ChatGPT 账户。',
|
| 234 |
+
|
| 235 |
+
// 入口 URL
|
| 236 |
+
getTargetUrl(config, workerConfig) {
|
| 237 |
+
return TARGET_URL;
|
| 238 |
+
},
|
| 239 |
+
|
| 240 |
+
// 模型列表
|
| 241 |
+
models: [
|
| 242 |
+
{ id: 'sora-2', imagePolicy: 'optional' }
|
| 243 |
+
],
|
| 244 |
+
|
| 245 |
+
// 无需导航处理器
|
| 246 |
+
navigationHandlers: [],
|
| 247 |
+
|
| 248 |
+
// 核心视频生成方法
|
| 249 |
+
generate
|
| 250 |
+
};
|
src/backend/adapter/test.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview 浏览器测试适配器
|
| 3 |
+
* 提供多种浏览器测试功能,包括 Cloudflare Turnstile 验证、指纹检测等
|
| 4 |
+
*
|
| 5 |
+
* 模型类型:
|
| 6 |
+
* - cloudflare-turnstile: 点击验证后截屏
|
| 7 |
+
* - 其他 image 类型: 加载页面后截屏
|
| 8 |
+
* - text 类型: 返回页面文本内容
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import { sleep } from '../engine/utils.js';
|
| 12 |
+
import {
|
| 13 |
+
gotoWithCheck,
|
| 14 |
+
normalizePageError,
|
| 15 |
+
moveMouseAway,
|
| 16 |
+
} from '../utils/index.js';
|
| 17 |
+
import { clickTurnstile } from '../utils/CloudflareBypass.js';
|
| 18 |
+
import { logger } from '../../utils/logger.js';
|
| 19 |
+
|
| 20 |
+
/**
|
| 21 |
+
* 执行 Turnstile 验证并截屏
|
| 22 |
+
*/
|
| 23 |
+
async function handleTurnstile(page, meta) {
|
| 24 |
+
const TARGET_URL = 'https://nopecha.com/captcha/turnstile';
|
| 25 |
+
const HOST_SELECTOR = '#example-container5';
|
| 26 |
+
|
| 27 |
+
logger.info('适配器', '开启 Turnstile 测试...', meta);
|
| 28 |
+
await gotoWithCheck(page, TARGET_URL);
|
| 29 |
+
|
| 30 |
+
// 等待页面加载
|
| 31 |
+
await sleep(3000, 4000);
|
| 32 |
+
|
| 33 |
+
// 使用通用 Cloudflare 验证码点击器
|
| 34 |
+
const result = await clickTurnstile(page, HOST_SELECTOR, {
|
| 35 |
+
timeout: 10000,
|
| 36 |
+
waitAfterClick: 3000,
|
| 37 |
+
meta
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
if (!result.success) {
|
| 41 |
+
return { error: result.error };
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// 截屏并返回
|
| 45 |
+
logger.info('适配器', '正在截屏...', meta);
|
| 46 |
+
const screenshot = await page.screenshot({ type: 'png', fullPage: true });
|
| 47 |
+
const base64 = screenshot.toString('base64');
|
| 48 |
+
|
| 49 |
+
return { image: `data:image/png;base64,${base64}` };
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/**
|
| 53 |
+
* 处理普通 image 类型:加载页面后截屏
|
| 54 |
+
*/
|
| 55 |
+
async function handleImagePage(page, url, meta) {
|
| 56 |
+
logger.info('适配器', `正在加载页面: ${url}`, meta);
|
| 57 |
+
await gotoWithCheck(page, url);
|
| 58 |
+
|
| 59 |
+
// 等待页面加载完成
|
| 60 |
+
await sleep(3000, 5000);
|
| 61 |
+
|
| 62 |
+
// 截屏并返回
|
| 63 |
+
logger.info('适配器', '正在截屏...', meta);
|
| 64 |
+
const screenshot = await page.screenshot({ type: 'png', fullPage: true });
|
| 65 |
+
const base64 = screenshot.toString('base64');
|
| 66 |
+
|
| 67 |
+
return { image: `data:image/png;base64,${base64}` };
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/**
|
| 71 |
+
* 处理 ping0.cc:检测并处理 Cloudflare 验证后截屏
|
| 72 |
+
*/
|
| 73 |
+
async function handlePing0(page, url, meta) {
|
| 74 |
+
logger.info('适配器', `正在加载页面: ${url}`, meta);
|
| 75 |
+
await gotoWithCheck(page, url);
|
| 76 |
+
|
| 77 |
+
// 等待页面加载
|
| 78 |
+
await sleep(2000, 3000);
|
| 79 |
+
|
| 80 |
+
// 检测是否有 Cloudflare 验证码
|
| 81 |
+
const cfElement = await page.$('#captcha-element');
|
| 82 |
+
if (cfElement) {
|
| 83 |
+
logger.info('适配器', '检测到 Cloudflare 验证码,正在处理...', meta);
|
| 84 |
+
|
| 85 |
+
const result = await clickTurnstile(page, '#captcha-element', {
|
| 86 |
+
timeout: 10000,
|
| 87 |
+
waitAfterClick: 5000,
|
| 88 |
+
meta
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
if (!result.success) {
|
| 92 |
+
logger.warn('适配器', `Cloudflare 验证失败: ${result.error}`, meta);
|
| 93 |
+
// 继续截屏,可能验证页面也有价值
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// 等待页面跳转或刷新
|
| 97 |
+
await sleep(3000, 5000);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// 截屏并返回
|
| 101 |
+
logger.info('适配器', '正在截屏...', meta);
|
| 102 |
+
const screenshot = await page.screenshot({ type: 'png', fullPage: true });
|
| 103 |
+
const base64 = screenshot.toString('base64');
|
| 104 |
+
|
| 105 |
+
return { image: `data:image/png;base64,${base64}` };
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/**
|
| 109 |
+
* 处理 text 类型:返回页面文本内容
|
| 110 |
+
*/
|
| 111 |
+
async function handleTextPage(page, url, meta) {
|
| 112 |
+
logger.info('适配器', `正在加载页面: ${url}`, meta);
|
| 113 |
+
await gotoWithCheck(page, url);
|
| 114 |
+
|
| 115 |
+
// 等待页面加载完成
|
| 116 |
+
await sleep(1000, 2000);
|
| 117 |
+
|
| 118 |
+
// 获取页面文本内容
|
| 119 |
+
const textContent = await page.evaluate(() => document.body.innerText);
|
| 120 |
+
logger.info('适配器', `获取文本内容,长度: ${textContent.length}`, meta);
|
| 121 |
+
|
| 122 |
+
return { text: textContent.trim() };
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
/**
|
| 126 |
+
* 主生成函数
|
| 127 |
+
*/
|
| 128 |
+
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
| 129 |
+
const { page } = context;
|
| 130 |
+
|
| 131 |
+
try {
|
| 132 |
+
// 查找模型配置
|
| 133 |
+
const modelConfig = manifest.models.find(m => m.id === modelId);
|
| 134 |
+
if (!modelConfig) {
|
| 135 |
+
return { error: `未找到模型配置: ${modelId}` };
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
const { url, type } = modelConfig;
|
| 139 |
+
|
| 140 |
+
// 根据模型 ID 和类型分发处理
|
| 141 |
+
if (modelId === 'cloudflare-turnstile') {
|
| 142 |
+
// Turnstile 验证特殊处理
|
| 143 |
+
return await handleTurnstile(page, meta);
|
| 144 |
+
} else if (modelId === 'ping0') {
|
| 145 |
+
// ping0.cc 需要 Cloudflare 验证
|
| 146 |
+
return await handlePing0(page, url, meta);
|
| 147 |
+
} else if (type === 'text') {
|
| 148 |
+
// text 类型返回页面文本
|
| 149 |
+
return await handleTextPage(page, url, meta);
|
| 150 |
+
} else {
|
| 151 |
+
// 其他 image 类型截屏返回
|
| 152 |
+
return await handleImagePage(page, url, meta);
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
} catch (err) {
|
| 156 |
+
const pageError = normalizePageError(err, meta);
|
| 157 |
+
if (pageError) return pageError;
|
| 158 |
+
|
| 159 |
+
logger.error('适配器', '任务失败', { ...meta, error: err.message });
|
| 160 |
+
return { error: `任务失败: ${err.message}` };
|
| 161 |
+
} finally {
|
| 162 |
+
await moveMouseAway(page);
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
/**
|
| 167 |
+
* 适配器 manifest
|
| 168 |
+
*/
|
| 169 |
+
export const manifest = {
|
| 170 |
+
id: 'test',
|
| 171 |
+
displayName: '浏览器检测,仅供调试使用',
|
| 172 |
+
description: '包含 Cloudflare Turnstile 验证测试、浏览器指纹检测、IP 纯净度查询等功能,仅供调试使用。',
|
| 173 |
+
|
| 174 |
+
getTargetUrl(config, workerConfig) {
|
| 175 |
+
return 'https://abrahamjuliot.github.io/creepjs/';
|
| 176 |
+
},
|
| 177 |
+
|
| 178 |
+
models: [
|
| 179 |
+
{ id: 'cloudflare-turnstile', imagePolicy: 'forbidden', type: 'image', url: 'https://nopecha.com/captcha/turnstile' },
|
| 180 |
+
{ id: 'creepjs', imagePolicy: 'forbidden', type: 'image', url: 'https://abrahamjuliot.github.io/creepjs/' },
|
| 181 |
+
{ id: 'antibot', imagePolicy: 'forbidden', type: 'image', url: 'https://bot.sannysoft.com/' },
|
| 182 |
+
{ id: 'browserleaks-js', imagePolicy: 'forbidden', type: 'image', url: 'https://browserleaks.com/javascript' },
|
| 183 |
+
{ id: 'browserleaks-ip', imagePolicy: 'forbidden', type: 'image', url: 'https://browserleaks.com/ip' },
|
| 184 |
+
{ id: 'ip', imagePolicy: 'forbidden', type: 'text', url: 'https://api.ip.sb/ip' },
|
| 185 |
+
{ id: 'webgl', imagePolicy: 'forbidden', type: 'image', url: 'https://get.webgl.org/' },
|
| 186 |
+
{ id: 'ping0', imagePolicy: 'forbidden', type: 'image', url: 'https://ping0.cc/' },
|
| 187 |
+
],
|
| 188 |
+
|
| 189 |
+
navigationHandlers: [],
|
| 190 |
+
generate
|
| 191 |
+
};
|
src/backend/adapter/zai_is.js
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview zAI 图片生成适配器
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import {
|
| 6 |
+
sleep,
|
| 7 |
+
safeClick,
|
| 8 |
+
pasteImages
|
| 9 |
+
} from '../engine/utils.js';
|
| 10 |
+
import {
|
| 11 |
+
fillPrompt,
|
| 12 |
+
submit,
|
| 13 |
+
normalizePageError,
|
| 14 |
+
normalizeHttpError,
|
| 15 |
+
waitApiResponse,
|
| 16 |
+
moveMouseAway,
|
| 17 |
+
useContextDownload,
|
| 18 |
+
waitForPageAuth,
|
| 19 |
+
lockPageAuth,
|
| 20 |
+
unlockPageAuth,
|
| 21 |
+
isPageAuthLocked,
|
| 22 |
+
waitForInput,
|
| 23 |
+
gotoWithCheck
|
| 24 |
+
} from '../utils/index.js';
|
| 25 |
+
import { logger } from '../../utils/logger.js';
|
| 26 |
+
|
| 27 |
+
// zai.is 输入框选择器
|
| 28 |
+
const INPUT_SELECTOR = '.tiptap.ProseMirror';
|
| 29 |
+
|
| 30 |
+
// 入口 URL
|
| 31 |
+
const TARGET_URL = 'https://zai.is/';
|
| 32 |
+
|
| 33 |
+
/**
|
| 34 |
+
* 处理 Discord OAuth2 登录流程
|
| 35 |
+
* @param {import('playwright-core').Page} page
|
| 36 |
+
* @returns {Promise<boolean>} 是否处理了登录
|
| 37 |
+
*/
|
| 38 |
+
async function handleDiscordAuth(page) {
|
| 39 |
+
// 防止重复处理
|
| 40 |
+
if (isPageAuthLocked(page)) return false;
|
| 41 |
+
|
| 42 |
+
const currentUrl = page.url();
|
| 43 |
+
|
| 44 |
+
// 1. 检查是否在 zai.is/auth 页面
|
| 45 |
+
if (currentUrl.includes('zai.is/auth')) {
|
| 46 |
+
lockPageAuth(page);
|
| 47 |
+
logger.info('适配器', '[登录器(zai_is)] 检测到登录页面,正在处理 Discord 登录...');
|
| 48 |
+
|
| 49 |
+
try {
|
| 50 |
+
// 等待页面加载完成,点击唯一的 button 标签
|
| 51 |
+
await page.waitForSelector('button', { timeout: 30000 });
|
| 52 |
+
await sleep(1000, 1500);
|
| 53 |
+
await safeClick(page, 'button', { bias: 'button' });
|
| 54 |
+
logger.info('适配器', '[登录器(zai_is)] 已点击登录按钮,等待跳转到 Discord...');
|
| 55 |
+
|
| 56 |
+
// 2. 等待跳转到 Discord OAuth2 授权页面
|
| 57 |
+
await page.waitForURL(url => url.href.includes('discord.com/oauth2/authorize'), { timeout: 60000 });
|
| 58 |
+
logger.info('适配器', '[登录器(zai_is)] 已到达 Discord 授权页面');
|
| 59 |
+
await sleep(2000, 3000);
|
| 60 |
+
|
| 61 |
+
// 3. 使用鼠标滚轮滚动 main 元素,直到授权按钮可用
|
| 62 |
+
// 授权按钮选择器: data-align="stretch" 的 div 中的最后一个按钮 (授权按钮在右边)
|
| 63 |
+
const authorizeBtnSelector = 'div[data-align="stretch"] button:last-child';
|
| 64 |
+
|
| 65 |
+
for (let i = 0; i < 15; i++) {
|
| 66 |
+
const authorizeBtn = await page.$(authorizeBtnSelector);
|
| 67 |
+
if (authorizeBtn) {
|
| 68 |
+
const isDisabled = await authorizeBtn.evaluate(el => el.disabled).catch(() => true);
|
| 69 |
+
if (!isDisabled) {
|
| 70 |
+
logger.info('适配器', '[登录器(zai_is)] 授权按钮已可用,正在点击...');
|
| 71 |
+
await sleep(500, 1000);
|
| 72 |
+
await safeClick(page, authorizeBtn, { bias: 'button' });
|
| 73 |
+
break;
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
// 使用鼠标滚轮在 main 元素中滚动
|
| 77 |
+
const mainElement = await page.$('main');
|
| 78 |
+
if (mainElement) {
|
| 79 |
+
const box = await mainElement.boundingBox();
|
| 80 |
+
if (box) {
|
| 81 |
+
// 将鼠标移动到 main 元素中心并滚动
|
| 82 |
+
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
| 83 |
+
await page.mouse.wheel(0, 200);
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
await sleep(800, 1200);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// 4. 等待跳转回 zai.is (不包含 auth 和 discord)
|
| 90 |
+
logger.info('适配器', '[登录器(zai_is)] 等待跳转回目标页面...');
|
| 91 |
+
await page.waitForURL(url => {
|
| 92 |
+
const href = url.href;
|
| 93 |
+
return href.includes('zai.is') &&
|
| 94 |
+
!href.includes('/auth') &&
|
| 95 |
+
!href.includes('discord.com');
|
| 96 |
+
}, { timeout: 60000 });
|
| 97 |
+
|
| 98 |
+
logger.info('适配器', '[登录器(zai_is)] Discord 登录完成');
|
| 99 |
+
await sleep(2000, 3000);
|
| 100 |
+
unlockPageAuth(page);
|
| 101 |
+
return true;
|
| 102 |
+
} catch (err) {
|
| 103 |
+
logger.warn('适配器', `[登录器(zai_is)] Discord 登录处理失败: ${err.message}`);
|
| 104 |
+
unlockPageAuth(page);
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
return false;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
/**
|
| 114 |
+
* 生成图片
|
| 115 |
+
* @param {object} context - 浏览器上下文 { page, client, config }
|
| 116 |
+
* @param {string} prompt - 提示词
|
| 117 |
+
* @param {string[]} imgPaths - 参考图片路径数组
|
| 118 |
+
* @param {string} modelId - 模型 ID
|
| 119 |
+
* @param {object} meta - 日志元数据
|
| 120 |
+
* @returns {Promise<{image?: string, error?: string}>} 生成结果
|
| 121 |
+
*/
|
| 122 |
+
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
| 123 |
+
const { page, config } = context;
|
| 124 |
+
|
| 125 |
+
try {
|
| 126 |
+
// 开启新对话 - 先等待可能正在进行的登录处理完成
|
| 127 |
+
await waitForPageAuth(page);
|
| 128 |
+
|
| 129 |
+
logger.info('适配器', '开启新会话', meta);
|
| 130 |
+
await gotoWithCheck(page, TARGET_URL);
|
| 131 |
+
|
| 132 |
+
// 如果触发了登录跳转,等待全局处理器完成
|
| 133 |
+
await waitForPageAuth(page);
|
| 134 |
+
|
| 135 |
+
// 1. 等待输入框加载
|
| 136 |
+
logger.debug('适配器', '正在寻找输入框...', meta);
|
| 137 |
+
await waitForInput(page, INPUT_SELECTOR, { click: false });
|
| 138 |
+
await sleep(1500, 2500);
|
| 139 |
+
|
| 140 |
+
// 2. 上传图片
|
| 141 |
+
if (imgPaths && imgPaths.length > 0) {
|
| 142 |
+
await pasteImages(page, INPUT_SELECTOR, imgPaths, {
|
| 143 |
+
uploadValidator: (response) => {
|
| 144 |
+
const url = response.url();
|
| 145 |
+
return response.status() === 200 && url.includes('v1/files');
|
| 146 |
+
}
|
| 147 |
+
});
|
| 148 |
+
await sleep(500, 1000);
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
// 3. 填写提示词
|
| 152 |
+
await safeClick(page, INPUT_SELECTOR, { bias: 'input' });
|
| 153 |
+
await fillPrompt(page, INPUT_SELECTOR, prompt, meta);
|
| 154 |
+
await sleep(500, 1000);
|
| 155 |
+
|
| 156 |
+
// 4. 通过 UI 交互选择模型
|
| 157 |
+
const modelConfig = manifest.models.find(m => m.id === modelId);
|
| 158 |
+
const targetModel = modelConfig?.codeName || modelId;
|
| 159 |
+
|
| 160 |
+
logger.debug('适配器', `正在选择模型: ${targetModel}`, meta);
|
| 161 |
+
|
| 162 |
+
// 点击 "Select a models" 按钮
|
| 163 |
+
const selectModelBtn = page.getByRole('button', { name: 'Select a model' });
|
| 164 |
+
await selectModelBtn.waitFor({ timeout: 5000 });
|
| 165 |
+
await sleep(300, 500);
|
| 166 |
+
await safeClick(page, selectModelBtn, { bias: 'button' });
|
| 167 |
+
await sleep(500, 800);
|
| 168 |
+
|
| 169 |
+
// 在 "Search In Models" 文本框中输入模型名称
|
| 170 |
+
const searchInput = page.getByRole('textbox', { name: 'Search In Models' });
|
| 171 |
+
await searchInput.waitFor({ timeout: 5000 });
|
| 172 |
+
await searchInput.fill(targetModel);
|
| 173 |
+
await sleep(300, 500);
|
| 174 |
+
|
| 175 |
+
// 按回车确认选择
|
| 176 |
+
await searchInput.press('Enter');
|
| 177 |
+
await sleep(500, 1000);
|
| 178 |
+
|
| 179 |
+
logger.info('适配器', `已选择模型: ${targetModel}`, meta);
|
| 180 |
+
|
| 181 |
+
// 5. 检查 GIF Generation 按钮状态,确保为 OFF
|
| 182 |
+
const gifBtn = page.getByRole('button', { name: /^GIF Generation/ });
|
| 183 |
+
const gifBtnExists = await gifBtn.count();
|
| 184 |
+
if (gifBtnExists > 0) {
|
| 185 |
+
const gifState = await gifBtn.evaluate(el => {
|
| 186 |
+
const generic = el.querySelector('[role="generic"]') || el;
|
| 187 |
+
return generic.textContent?.trim() || '';
|
| 188 |
+
});
|
| 189 |
+
|
| 190 |
+
if (!gifState.includes('OFF')) {
|
| 191 |
+
logger.debug('适配器', `GIF Generation 当前为 ${gifState},正在切换为 OFF`, meta);
|
| 192 |
+
await safeClick(page, gifBtn, { bias: 'button' });
|
| 193 |
+
await sleep(300, 500);
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
// 6. 设置图片大小 (如果模型配置了 imageSize)
|
| 198 |
+
if (modelConfig?.imageSize) {
|
| 199 |
+
const targetSize = modelConfig.imageSize; // 例如 "1K", "2K", "4K"
|
| 200 |
+
logger.debug('适配器', `正在设置图片大小: ${targetSize}`, meta);
|
| 201 |
+
|
| 202 |
+
const imageSizeBtn = page.getByRole('button', { name: /^Image Size/ });
|
| 203 |
+
const btnExists = await imageSizeBtn.count();
|
| 204 |
+
|
| 205 |
+
if (btnExists > 0) {
|
| 206 |
+
// 最多点击 4 次切换
|
| 207 |
+
for (let i = 0; i < 4; i++) {
|
| 208 |
+
// 获取当前图片大小 (从按钮下的 generic 元素中的 text leaf 获取)
|
| 209 |
+
const currentSize = await imageSizeBtn.evaluate(el => {
|
| 210 |
+
const generic = el.querySelector('[role="generic"]') || el;
|
| 211 |
+
return generic.textContent?.trim() || '';
|
| 212 |
+
});
|
| 213 |
+
|
| 214 |
+
if (currentSize.includes(targetSize)) {
|
| 215 |
+
logger.info('适配器', `图片大小已设置为: ${targetSize}`, meta);
|
| 216 |
+
break;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
// 点击切换
|
| 220 |
+
await safeClick(page, imageSizeBtn, { bias: 'button' });
|
| 221 |
+
await sleep(300, 500);
|
| 222 |
+
}
|
| 223 |
+
} else {
|
| 224 |
+
logger.debug('适配器', '未找到 Image Size 按钮', meta);
|
| 225 |
+
}
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
// 7. 提交
|
| 229 |
+
logger.debug('适配器', '点击发送...', meta);
|
| 230 |
+
await submit(page, {
|
| 231 |
+
btnSelector: 'button[type="submit"]',
|
| 232 |
+
inputTarget: INPUT_SELECTOR,
|
| 233 |
+
meta
|
| 234 |
+
});
|
| 235 |
+
|
| 236 |
+
logger.info('适配器', '等待生成结果中...', meta);
|
| 237 |
+
|
| 238 |
+
// 8. 等待 v1/chats/new 响应 (状态码 200 且响应体中有 id)
|
| 239 |
+
let chatsNewResponse;
|
| 240 |
+
try {
|
| 241 |
+
chatsNewResponse = await waitApiResponse(page, {
|
| 242 |
+
urlMatch: 'v1/chats/new',
|
| 243 |
+
method: 'POST',
|
| 244 |
+
timeout: 60000,
|
| 245 |
+
meta
|
| 246 |
+
});
|
| 247 |
+
} catch (e) {
|
| 248 |
+
const pageError = normalizePageError(e, meta);
|
| 249 |
+
if (pageError) return pageError;
|
| 250 |
+
throw e;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
// 检查 chats/new 响应
|
| 254 |
+
const httpError = normalizeHttpError(chatsNewResponse);
|
| 255 |
+
if (httpError) {
|
| 256 |
+
logger.error('适配器', `创建对话失败: ${httpError.error}`, meta);
|
| 257 |
+
return { error: `创建对话失败: ${httpError.error}` };
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
try {
|
| 261 |
+
const chatsNewBody = await chatsNewResponse.json();
|
| 262 |
+
if (!chatsNewBody.id) {
|
| 263 |
+
logger.error('适配器', '创建对话响应中没有无 id', meta);
|
| 264 |
+
return { error: '创建对话响应中没有 id' };
|
| 265 |
+
}
|
| 266 |
+
logger.debug('适配器', `对话创建成功, id: ${chatsNewBody.id}`, meta);
|
| 267 |
+
} catch (e) {
|
| 268 |
+
logger.error('适配器', '解析 chats/new 响应失败', { ...meta, error: e.message });
|
| 269 |
+
return { error: '解析对话响应失败' };
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
// 9. 等待 chat/completions 响应 (状态码 200 且 status: true)
|
| 274 |
+
let completionsResponse;
|
| 275 |
+
try {
|
| 276 |
+
completionsResponse = await waitApiResponse(page, {
|
| 277 |
+
urlMatch: 'chat/completions',
|
| 278 |
+
method: 'POST',
|
| 279 |
+
timeout: 120000,
|
| 280 |
+
errorText: ['Model is unable to process your request', 'Rate limit reached'],
|
| 281 |
+
meta
|
| 282 |
+
});
|
| 283 |
+
} catch (e) {
|
| 284 |
+
const pageError = normalizePageError(e, meta);
|
| 285 |
+
if (pageError) return pageError;
|
| 286 |
+
throw e;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
const completionsHttpError = normalizeHttpError(completionsResponse);
|
| 290 |
+
if (completionsHttpError) {
|
| 291 |
+
logger.error('适配器', `生成请求失败: ${completionsHttpError.error}`, meta);
|
| 292 |
+
return { error: `生成请求失败: ${completionsHttpError.error}` };
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
try {
|
| 296 |
+
const completionsBody = await completionsResponse.json();
|
| 297 |
+
if (!completionsBody.status) {
|
| 298 |
+
logger.error('适配器', '生成响应 status 不为 true', meta);
|
| 299 |
+
return { error: '生成失败,响应状态异常' };
|
| 300 |
+
}
|
| 301 |
+
logger.debug('适配器', '生成请求成功', meta);
|
| 302 |
+
} catch (e) {
|
| 303 |
+
logger.error('适配器', '解析 completions 响应失败', { ...meta, error: e.message });
|
| 304 |
+
return { error: '解析生成响应失败' };
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
// 10. 等待 chat/completed 响应,从中提取图片链接
|
| 308 |
+
logger.debug('适配器', '正在等待完成响应...', meta);
|
| 309 |
+
|
| 310 |
+
let completedResponse;
|
| 311 |
+
try {
|
| 312 |
+
completedResponse = await waitApiResponse(page, {
|
| 313 |
+
urlMatch: 'chat/completed',
|
| 314 |
+
method: 'POST',
|
| 315 |
+
timeout: 120000,
|
| 316 |
+
errorText: ['Model is unable to process your request', 'Rate limit reached'],
|
| 317 |
+
meta
|
| 318 |
+
});
|
| 319 |
+
} catch (e) {
|
| 320 |
+
const pageError = normalizePageError(e, meta);
|
| 321 |
+
if (pageError) {
|
| 322 |
+
if (e.name === 'TimeoutError') {
|
| 323 |
+
return { error: '等待完成响应超时 (120秒)' };
|
| 324 |
+
}
|
| 325 |
+
return pageError;
|
| 326 |
+
}
|
| 327 |
+
throw e;
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
// 解析 chat/completed 响应
|
| 331 |
+
let completedBody;
|
| 332 |
+
try {
|
| 333 |
+
completedBody = await completedResponse.json();
|
| 334 |
+
} catch (e) {
|
| 335 |
+
logger.error('适配器', '解析 chat/completed 响应失败', { ...meta, error: e.message });
|
| 336 |
+
return { error: '解析完成响应失败' };
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
// 在 messages 数组中查找匹配的消息 (id 与响应体的 id 相同)
|
| 340 |
+
const targetMessage = (completedBody.messages || []).find(msg => msg.id === completedBody.id);
|
| 341 |
+
if (!targetMessage) {
|
| 342 |
+
logger.error('适配器', `未找到匹配的消息`, meta);
|
| 343 |
+
return { error: '未找到匹配的消息' };
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
// 检查 content
|
| 347 |
+
const content = targetMessage.content;
|
| 348 |
+
if (!content || content.trim() === '') {
|
| 349 |
+
logger.warn('适配器', '回复内容为空可能触发违规/限流', meta);
|
| 350 |
+
return { error: '回复内容为空可能触发违规/限流' };
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
// 从 content 中提取图片链接 (格式: )
|
| 354 |
+
const imageUrlMatch = content.match(/!\[.*?\]\((https:\/\/zai\.is\/[^)]+)\)/);
|
| 355 |
+
if (!imageUrlMatch || !imageUrlMatch[1]) {
|
| 356 |
+
logger.warn('适配器', '回复中未找到图片链接', meta);
|
| 357 |
+
return { error: '回复中未找到图片链接' };
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
const imageUrl = imageUrlMatch[1];
|
| 361 |
+
logger.info('适配器', `已提取图片链接: ${imageUrl}`, meta);
|
| 362 |
+
|
| 363 |
+
// 下载图片
|
| 364 |
+
const downloadResult = await useContextDownload(imageUrl, page);
|
| 365 |
+
if (downloadResult.error) {
|
| 366 |
+
return downloadResult;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
logger.info('适配器', '已下载图片,任务完成', meta);
|
| 370 |
+
return { image: downloadResult.image };
|
| 371 |
+
|
| 372 |
+
} catch (err) {
|
| 373 |
+
// 顶层错误处理
|
| 374 |
+
const pageError = normalizePageError(err, meta);
|
| 375 |
+
if (pageError) return pageError;
|
| 376 |
+
|
| 377 |
+
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
| 378 |
+
return { error: `生成任务失败: ${err.message}` };
|
| 379 |
+
} finally {
|
| 380 |
+
// 任务结束,将鼠标移至安全区域
|
| 381 |
+
await moveMouseAway(page);
|
| 382 |
+
}
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
/**
|
| 386 |
+
* 适配器 manifest
|
| 387 |
+
*/
|
| 388 |
+
export const manifest = {
|
| 389 |
+
id: 'zai_is',
|
| 390 |
+
displayName: 'zAI (图片生成)',
|
| 391 |
+
description: '使用 zAI 平台生成图片,支持多种图片生成模型和分辨率选择。需要 Discord 账户登录授权。',
|
| 392 |
+
|
| 393 |
+
// 入口 URL
|
| 394 |
+
getTargetUrl(config, workerConfig) {
|
| 395 |
+
return TARGET_URL;
|
| 396 |
+
},
|
| 397 |
+
|
| 398 |
+
// 模型列表
|
| 399 |
+
models: [
|
| 400 |
+
{ id: 'gemini-3-pro-image-preview', codeName: 'Nano Banana Pro', imagePolicy: 'optional', imageSize: '1K' },
|
| 401 |
+
{ id: 'gemini-3-pro-image-preview-2k', codeName: 'Nano Banana Pro', imagePolicy: 'optional', imageSize: '2K' },
|
| 402 |
+
{ id: 'gemini-3-pro-image-preview-4k', codeName: 'Nano Banana Pro', imagePolicy: 'optional', imageSize: '4K' },
|
| 403 |
+
{ id: 'gemini-2.5-flash-image', codeName: 'Nano Banana', imagePolicy: 'optional' }
|
| 404 |
+
],
|
| 405 |
+
|
| 406 |
+
// 导航处理器
|
| 407 |
+
navigationHandlers: [handleDiscordAuth],
|
| 408 |
+
|
| 409 |
+
// 核心生图方法
|
| 410 |
+
generate
|
| 411 |
+
};
|
src/backend/adapter/zai_is_text.js
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview zAI 文本生成适配器
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import {
|
| 6 |
+
sleep,
|
| 7 |
+
safeClick,
|
| 8 |
+
pasteImages
|
| 9 |
+
} from '../engine/utils.js';
|
| 10 |
+
import {
|
| 11 |
+
fillPrompt,
|
| 12 |
+
submit,
|
| 13 |
+
normalizePageError,
|
| 14 |
+
normalizeHttpError,
|
| 15 |
+
waitApiResponse,
|
| 16 |
+
moveMouseAway,
|
| 17 |
+
waitForPageAuth,
|
| 18 |
+
lockPageAuth,
|
| 19 |
+
unlockPageAuth,
|
| 20 |
+
isPageAuthLocked,
|
| 21 |
+
waitForInput,
|
| 22 |
+
gotoWithCheck
|
| 23 |
+
} from '../utils/index.js';
|
| 24 |
+
import { logger } from '../../utils/logger.js';
|
| 25 |
+
|
| 26 |
+
// zai.is 输入框选择器
|
| 27 |
+
const INPUT_SELECTOR = '.tiptap.ProseMirror';
|
| 28 |
+
|
| 29 |
+
// 入口 URL
|
| 30 |
+
const TARGET_URL = 'https://zai.is/';
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* 处理 Discord OAuth2 登录流程
|
| 34 |
+
* @param {import('playwright-core').Page} page
|
| 35 |
+
* @returns {Promise<boolean>} 是否处理了登录
|
| 36 |
+
*/
|
| 37 |
+
async function handleDiscordAuth(page) {
|
| 38 |
+
// 防止重复处理
|
| 39 |
+
if (isPageAuthLocked(page)) return false;
|
| 40 |
+
|
| 41 |
+
const currentUrl = page.url();
|
| 42 |
+
|
| 43 |
+
// 1. 检查是否在 zai.is/auth 页面
|
| 44 |
+
if (currentUrl.includes('zai.is/auth')) {
|
| 45 |
+
lockPageAuth(page);
|
| 46 |
+
logger.info('适配器', '[登录器(zai)] 检测到登录页面,正在处理 Discord 登录...');
|
| 47 |
+
|
| 48 |
+
try {
|
| 49 |
+
// 等待页面加载完成,点击唯一的 button 标签
|
| 50 |
+
await page.waitForSelector('button', { timeout: 30000 });
|
| 51 |
+
await sleep(1000, 1500);
|
| 52 |
+
await safeClick(page, 'button', { bias: 'button' });
|
| 53 |
+
logger.info('适配器', '[登录器(zai)] 已点击登录按钮,等待跳转到 Discord...');
|
| 54 |
+
|
| 55 |
+
// 2. 等待跳转到 Discord OAuth2 授权页面
|
| 56 |
+
await page.waitForURL(url => url.href.includes('discord.com/oauth2/authorize'), { timeout: 60000 });
|
| 57 |
+
logger.info('适配器', '[登录器(zai)] 已到达 Discord 授权页面');
|
| 58 |
+
await sleep(2000, 3000);
|
| 59 |
+
|
| 60 |
+
// 3. 使用鼠标滚轮滚动 main 元素,直到授权按钮可用
|
| 61 |
+
// 授权按钮选择器: data-align="stretch" 的 div 中的最后一个按钮 (授权按钮在右边)
|
| 62 |
+
const authorizeBtnSelector = 'div[data-align="stretch"] button:last-child';
|
| 63 |
+
|
| 64 |
+
for (let i = 0; i < 15; i++) {
|
| 65 |
+
const authorizeBtn = await page.$(authorizeBtnSelector);
|
| 66 |
+
if (authorizeBtn) {
|
| 67 |
+
const isDisabled = await authorizeBtn.evaluate(el => el.disabled).catch(() => true);
|
| 68 |
+
if (!isDisabled) {
|
| 69 |
+
logger.info('适配器', '[登录器(zai)] 授权按钮已可用,正在点击...');
|
| 70 |
+
await sleep(500, 1000);
|
| 71 |
+
await safeClick(page, authorizeBtn, { bias: 'button' });
|
| 72 |
+
break;
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
// 使用鼠标滚轮在 main 元素中滚动
|
| 76 |
+
const mainElement = await page.$('main');
|
| 77 |
+
if (mainElement) {
|
| 78 |
+
const box = await mainElement.boundingBox();
|
| 79 |
+
if (box) {
|
| 80 |
+
// 将鼠标移动到 main 元素中心并滚动
|
| 81 |
+
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
| 82 |
+
await page.mouse.wheel(0, 200);
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
await sleep(800, 1200);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// 4. 等待跳转回 zai.is (不包含 auth 和 discord)
|
| 89 |
+
logger.info('适配器', '[登录器(zai)] 等待跳转回目标页面...');
|
| 90 |
+
await page.waitForURL(url => {
|
| 91 |
+
const href = url.href;
|
| 92 |
+
return href.includes('zai.is') &&
|
| 93 |
+
!href.includes('/auth') &&
|
| 94 |
+
!href.includes('discord.com');
|
| 95 |
+
}, { timeout: 60000 });
|
| 96 |
+
|
| 97 |
+
logger.info('适配器', '[登录器(zai)] Discord 登录完成');
|
| 98 |
+
await sleep(2000, 3000);
|
| 99 |
+
unlockPageAuth(page);
|
| 100 |
+
return true;
|
| 101 |
+
} catch (err) {
|
| 102 |
+
logger.warn('适配器', `[登录器(zai)] Discord 登录处理失败: ${err.message}`);
|
| 103 |
+
unlockPageAuth(page);
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
return false;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
/**
|
| 112 |
+
* 从 content 中移除开头的 <details> 思考块
|
| 113 |
+
* @param {string} content - 原始内容
|
| 114 |
+
* @returns {string} 处理后的内容
|
| 115 |
+
*/
|
| 116 |
+
function extractTextContent(content) {
|
| 117 |
+
if (!content) return '';
|
| 118 |
+
|
| 119 |
+
// 匹配开头的 <details type="reasoning" ...>...</details> 块
|
| 120 |
+
// 使用非贪婪匹配和 dotAll 模式 (s flag)
|
| 121 |
+
const detailsPattern = /^<details\s+type="reasoning"[^>]*>[\s\S]*?<\/details>\s*/;
|
| 122 |
+
|
| 123 |
+
// 移除开头的 details 块,返回剩余内容
|
| 124 |
+
const result = content.replace(detailsPattern, '').trim();
|
| 125 |
+
|
| 126 |
+
return result;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
/**
|
| 131 |
+
* 生成文本
|
| 132 |
+
* @param {object} context - 浏览器上下文 { page, client, config }
|
| 133 |
+
* @param {string} prompt - 提示词
|
| 134 |
+
* @param {string[]} imgPaths - 参考图片路径数组
|
| 135 |
+
* @param {string} modelId - 模型 ID
|
| 136 |
+
* @param {object} meta - 日志元数据
|
| 137 |
+
* @returns {Promise<{text?: string, error?: string}>} 生成结果
|
| 138 |
+
*/
|
| 139 |
+
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
| 140 |
+
const { page, config } = context;
|
| 141 |
+
|
| 142 |
+
try {
|
| 143 |
+
// 开启新对话 - 先等待可能正在进行的登录处理完成
|
| 144 |
+
await waitForPageAuth(page);
|
| 145 |
+
|
| 146 |
+
logger.info('适配器', '开启新会话', meta);
|
| 147 |
+
await gotoWithCheck(page, TARGET_URL);
|
| 148 |
+
|
| 149 |
+
// 如果触发了登录跳转,等待全局处理器完成
|
| 150 |
+
await waitForPageAuth(page);
|
| 151 |
+
|
| 152 |
+
// 1. 等待输入框加载
|
| 153 |
+
logger.debug('适配器', '正在寻找输入框...', meta);
|
| 154 |
+
await waitForInput(page, INPUT_SELECTOR, { click: false });
|
| 155 |
+
await sleep(1500, 2500);
|
| 156 |
+
|
| 157 |
+
// 2. 上传图片 (如果有多张图片,会一张一张上传,每次都是 v1/files POST 请求)
|
| 158 |
+
if (imgPaths && imgPaths.length > 0) {
|
| 159 |
+
const expectedUploads = imgPaths.length;
|
| 160 |
+
let uploadedCount = 0;
|
| 161 |
+
|
| 162 |
+
await pasteImages(page, INPUT_SELECTOR, imgPaths, {
|
| 163 |
+
uploadValidator: (response) => {
|
| 164 |
+
const url = response.url();
|
| 165 |
+
if (response.status() === 200 && url.includes('v1/files')) {
|
| 166 |
+
uploadedCount++;
|
| 167 |
+
logger.info('适配器', `图片上传进度: ${uploadedCount}/${expectedUploads}`, meta);
|
| 168 |
+
if (uploadedCount >= expectedUploads) {
|
| 169 |
+
return true;
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
return false;
|
| 173 |
+
}
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
+
await sleep(1000, 2000);
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
// 3. 填写提示词
|
| 180 |
+
await safeClick(page, INPUT_SELECTOR, { bias: 'input' });
|
| 181 |
+
await fillPrompt(page, INPUT_SELECTOR, prompt, meta);
|
| 182 |
+
await sleep(500, 1000);
|
| 183 |
+
|
| 184 |
+
// 4. 通过 UI 交互选择模型
|
| 185 |
+
const modelConfig = manifest.models.find(m => m.id === modelId);
|
| 186 |
+
const targetModel = modelConfig?.codeName || modelId;
|
| 187 |
+
|
| 188 |
+
logger.debug('适配器', `正在选择模型: ${targetModel}`, meta);
|
| 189 |
+
|
| 190 |
+
// 点击 "Select a models" 按钮
|
| 191 |
+
const selectModelBtn = page.getByRole('button', { name: 'Select a model' });
|
| 192 |
+
await selectModelBtn.waitFor({ timeout: 5000 });
|
| 193 |
+
await sleep(300, 500);
|
| 194 |
+
await safeClick(page, selectModelBtn, { bias: 'button' });
|
| 195 |
+
await sleep(500, 800);
|
| 196 |
+
|
| 197 |
+
// 在 "Search In Models" 文本框中输入模型名称
|
| 198 |
+
const searchInput = page.getByRole('textbox', { name: 'Search In Models' });
|
| 199 |
+
await searchInput.waitFor({ timeout: 5000 });
|
| 200 |
+
await searchInput.fill(targetModel);
|
| 201 |
+
await sleep(300, 500);
|
| 202 |
+
|
| 203 |
+
// 按回车确认选择
|
| 204 |
+
await searchInput.press('Enter');
|
| 205 |
+
await sleep(500, 1000);
|
| 206 |
+
|
| 207 |
+
logger.info('适配器', `已选择模型: ${targetModel}`, meta);
|
| 208 |
+
|
| 209 |
+
// 5. 提交
|
| 210 |
+
logger.debug('适配器', '点击发送...', meta);
|
| 211 |
+
await submit(page, {
|
| 212 |
+
btnSelector: 'button[type="submit"]',
|
| 213 |
+
inputTarget: INPUT_SELECTOR,
|
| 214 |
+
meta
|
| 215 |
+
});
|
| 216 |
+
|
| 217 |
+
logger.info('适配器', '等待生成结果中...', meta);
|
| 218 |
+
|
| 219 |
+
// 6. 等待 v1/chats/new 响应 (状态码 200 且响应体中有 id)
|
| 220 |
+
let chatsNewResponse;
|
| 221 |
+
try {
|
| 222 |
+
chatsNewResponse = await waitApiResponse(page, {
|
| 223 |
+
urlMatch: 'v1/chats/new',
|
| 224 |
+
method: 'POST',
|
| 225 |
+
timeout: 60000,
|
| 226 |
+
meta
|
| 227 |
+
});
|
| 228 |
+
} catch (e) {
|
| 229 |
+
const pageError = normalizePageError(e, meta);
|
| 230 |
+
if (pageError) return pageError;
|
| 231 |
+
throw e;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
// 检查 chats/new 响应
|
| 235 |
+
const httpError = normalizeHttpError(chatsNewResponse);
|
| 236 |
+
if (httpError) {
|
| 237 |
+
logger.error('适配器', `创建对话失败: ${httpError.error}`, meta);
|
| 238 |
+
return { error: `创建对话失败: ${httpError.error}` };
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
try {
|
| 242 |
+
const chatsNewBody = await chatsNewResponse.json();
|
| 243 |
+
if (!chatsNewBody.id) {
|
| 244 |
+
logger.error('适配器', '创建对话响应中没有无 id', meta);
|
| 245 |
+
return { error: '创建对话响应中没有 id' };
|
| 246 |
+
}
|
| 247 |
+
logger.debug('适配器', `对话创建成功, id: ${chatsNewBody.id}`, meta);
|
| 248 |
+
} catch (e) {
|
| 249 |
+
logger.error('适配器', '解析 chats/new 响应失败', { ...meta, error: e.message });
|
| 250 |
+
return { error: '解析对话响应失败' };
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
// 7. 等待 chat/completions 响应 (状态码 200 且 status: true)
|
| 254 |
+
let completionsResponse;
|
| 255 |
+
try {
|
| 256 |
+
completionsResponse = await waitApiResponse(page, {
|
| 257 |
+
urlMatch: 'chat/completions',
|
| 258 |
+
method: 'POST',
|
| 259 |
+
timeout: 120000,
|
| 260 |
+
errorText: ['Model is unable to process your request', 'Rate limit reached'],
|
| 261 |
+
meta
|
| 262 |
+
});
|
| 263 |
+
} catch (e) {
|
| 264 |
+
const pageError = normalizePageError(e, meta);
|
| 265 |
+
if (pageError) return pageError;
|
| 266 |
+
throw e;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
const completionsHttpError = normalizeHttpError(completionsResponse);
|
| 270 |
+
if (completionsHttpError) {
|
| 271 |
+
logger.error('适配器', `生成请求失败: ${completionsHttpError.error}`, meta);
|
| 272 |
+
return { error: `生成请求失败: ${completionsHttpError.error}` };
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
try {
|
| 276 |
+
const completionsBody = await completionsResponse.json();
|
| 277 |
+
if (!completionsBody.status) {
|
| 278 |
+
logger.error('适配器', '生成响应 status 不为 true', meta);
|
| 279 |
+
return { error: '生成失败,响应状态异常' };
|
| 280 |
+
}
|
| 281 |
+
logger.debug('适配器', '生成请求成功', meta);
|
| 282 |
+
} catch (e) {
|
| 283 |
+
logger.error('适配器', '解析 completions 响应失败', { ...meta, error: e.message });
|
| 284 |
+
return { error: '解析生成响应失败' };
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
// 8. 等待 chat/completed 响应,从中提取文本内容
|
| 288 |
+
logger.debug('适配器', '正在等待完成响应...', meta);
|
| 289 |
+
|
| 290 |
+
let completedResponse;
|
| 291 |
+
try {
|
| 292 |
+
completedResponse = await waitApiResponse(page, {
|
| 293 |
+
urlMatch: 'chat/completed',
|
| 294 |
+
method: 'POST',
|
| 295 |
+
timeout: 120000,
|
| 296 |
+
errorText: ['Model is unable to process your request', 'Rate limit reached'],
|
| 297 |
+
meta
|
| 298 |
+
});
|
| 299 |
+
} catch (e) {
|
| 300 |
+
const pageError = normalizePageError(e, meta);
|
| 301 |
+
if (pageError) {
|
| 302 |
+
if (e.name === 'TimeoutError') {
|
| 303 |
+
return { error: '等待完成响应超时 (120秒)' };
|
| 304 |
+
}
|
| 305 |
+
return pageError;
|
| 306 |
+
}
|
| 307 |
+
throw e;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
// 解析 chat/completed 响应
|
| 311 |
+
let completedBody;
|
| 312 |
+
try {
|
| 313 |
+
completedBody = await completedResponse.json();
|
| 314 |
+
} catch (e) {
|
| 315 |
+
logger.error('适配器', '解析 chat/completed 响应失败', { ...meta, error: e.message });
|
| 316 |
+
return { error: '解析完成响应失败' };
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
// 在 messages 数组中查找匹配的消息 (id 与响应体的 id 相同)
|
| 320 |
+
const targetMessage = (completedBody.messages || []).find(msg => msg.id === completedBody.id);
|
| 321 |
+
if (!targetMessage) {
|
| 322 |
+
logger.error('适配器', `未找到匹配的消息`, meta);
|
| 323 |
+
return { error: '未找到匹配的消息' };
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
// 检查 content
|
| 327 |
+
const content = targetMessage.content;
|
| 328 |
+
if (!content || content.trim() === '') {
|
| 329 |
+
logger.warn('适配器', '回复内容为空可能触发违规/限流', meta);
|
| 330 |
+
return { error: '回复内容为空可能触发违规/限流' };
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
// 提取文本内容 (移除开头的 <details> 思考块)
|
| 334 |
+
const textContent = extractTextContent(content);
|
| 335 |
+
if (!textContent) {
|
| 336 |
+
logger.warn('适配器', '提取文本内容为空', meta);
|
| 337 |
+
return { error: '提取文本内容为空' };
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
logger.info('适配器', `已提取文本内容 (${textContent.length} 字符)`, meta);
|
| 341 |
+
logger.info('适配器', '文本生成完成,任务完成', meta);
|
| 342 |
+
return { text: textContent };
|
| 343 |
+
|
| 344 |
+
} catch (err) {
|
| 345 |
+
// 顶层错误处理
|
| 346 |
+
const pageError = normalizePageError(err, meta);
|
| 347 |
+
if (pageError) return pageError;
|
| 348 |
+
|
| 349 |
+
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
| 350 |
+
return { error: `生成任务失败: ${err.message}` };
|
| 351 |
+
} finally {
|
| 352 |
+
// 任务结束,将鼠标移至安全区域
|
| 353 |
+
await moveMouseAway(page);
|
| 354 |
+
}
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
/**
|
| 358 |
+
* 适配器 manifest
|
| 359 |
+
*/
|
| 360 |
+
export const manifest = {
|
| 361 |
+
id: 'zai_is_text',
|
| 362 |
+
displayName: 'zAI (文本生成)',
|
| 363 |
+
description: '使用 zAI 平台生成文本,支持多种大语言模型。需要 Discord 账户登录授权。',
|
| 364 |
+
|
| 365 |
+
// 入口 URL
|
| 366 |
+
getTargetUrl(config, workerConfig) {
|
| 367 |
+
return TARGET_URL;
|
| 368 |
+
},
|
| 369 |
+
|
| 370 |
+
// 模型列表 - 文本生成模型
|
| 371 |
+
models: [
|
| 372 |
+
{ id: 'glm-4.6', codeName: 'GLM 4.6', imagePolicy: 'optional' },
|
| 373 |
+
{ id: 'gemini-3-pro-preview', codeName: 'Gemini 3 Pro Preview', imagePolicy: 'optional' },
|
| 374 |
+
{ id: 'gemini-2.5-pro', codeName: 'Gemini 2.5 Pro', imagePolicy: 'optional' },
|
| 375 |
+
{ id: 'gemini-3-flash-preview', codeName: 'Gemini 3 Flash Preview', imagePolicy: 'optional' },
|
| 376 |
+
{ id: 'claude-sonnet-4.5', codeName: 'Claude Sonnet 4.5', imagePolicy: 'optional' },
|
| 377 |
+
{ id: 'claude-sonnet-4', codeName: 'Claude Sonnet 4', imagePolicy: 'optional' },
|
| 378 |
+
{ id: 'claude-haiku-4.5', codeName: 'Claude Haiku 4.5', imagePolicy: 'optional' },
|
| 379 |
+
{ id: 'gpt-5.1', codeName: 'GPT-5.1', imagePolicy: 'optional' },
|
| 380 |
+
{ id: 'gpt-5', codeName: 'GPT-5', imagePolicy: 'optional' },
|
| 381 |
+
{ id: 'gpt-4.1', codeName: 'GPT-4.1', imagePolicy: 'optional' },
|
| 382 |
+
{ id: 'gpt-5.2', codeName: 'GPT-5.2 Chat', imagePolicy: 'optional' },
|
| 383 |
+
{ id: 'o3-high', codeName: 'o3-high', imagePolicy: 'optional' },
|
| 384 |
+
{ id: 'o3-mini', codeName: 'o3-mini', imagePolicy: 'optional' },
|
| 385 |
+
{ id: 'o4-mini', codeName: 'o4-mini', imagePolicy: 'optional' },
|
| 386 |
+
{ id: 'grok-4.1-fast', codeName: 'Grok 4.1 Fast', imagePolicy: 'optional' },
|
| 387 |
+
{ id: 'grok-4', codeName: 'Grok 4', imagePolicy: 'optional' },
|
| 388 |
+
{ id: 'kimi-k2-thinking', codeName: 'Kimi K2 Thinking', imagePolicy: 'optional' },
|
| 389 |
+
],
|
| 390 |
+
|
| 391 |
+
// 导航处理器
|
| 392 |
+
navigationHandlers: [handleDiscordAuth],
|
| 393 |
+
|
| 394 |
+
// 核心文本生成方法
|
| 395 |
+
generate
|
| 396 |
+
};
|
src/backend/adapter/zenmux_ai_text.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview ZenMux 文本生成适配器
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import {
|
| 6 |
+
sleep,
|
| 7 |
+
safeClick,
|
| 8 |
+
pasteImages
|
| 9 |
+
} from '../engine/utils.js';
|
| 10 |
+
import {
|
| 11 |
+
fillPrompt,
|
| 12 |
+
submit,
|
| 13 |
+
normalizePageError,
|
| 14 |
+
normalizeHttpError,
|
| 15 |
+
waitApiResponse,
|
| 16 |
+
moveMouseAway,
|
| 17 |
+
waitForInput,
|
| 18 |
+
gotoWithCheck
|
| 19 |
+
} from '../utils/index.js';
|
| 20 |
+
import { logger } from '../../utils/logger.js';
|
| 21 |
+
|
| 22 |
+
// Zenmux AI 输入框选择器
|
| 23 |
+
const INPUT_SELECTOR = '.chat-input-container textarea';
|
| 24 |
+
const SEND_BUTTON_SELECTOR = '.input-actions-send button';
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* 生成文本
|
| 28 |
+
* @param {object} context - 浏览器上下文 { page, client, config }
|
| 29 |
+
* @param {string} prompt - 提示词
|
| 30 |
+
* @param {string[]} imgPaths - 参考图片路径数组
|
| 31 |
+
* @param {string} modelId - 模型 ID
|
| 32 |
+
* @returns {Promise<{text?: string, error?: string}>} 生成结果
|
| 33 |
+
*/
|
| 34 |
+
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
| 35 |
+
const { page } = context;
|
| 36 |
+
|
| 37 |
+
try {
|
| 38 |
+
const targetUrl = 'https://zenmux.ai/settings/chat';
|
| 39 |
+
|
| 40 |
+
// 解析模型ID
|
| 41 |
+
const modelConfig = manifest.models.find(m => m.id === modelId);
|
| 42 |
+
const { codeName, providers } = modelConfig;
|
| 43 |
+
|
| 44 |
+
// 导航到目标页面
|
| 45 |
+
logger.info('适配器', '开启新会话', meta);
|
| 46 |
+
await gotoWithCheck(page, targetUrl);
|
| 47 |
+
|
| 48 |
+
// 点击 New Chat 按钮开启新对话
|
| 49 |
+
try {
|
| 50 |
+
const newChatBtn = page.locator('span').filter({ hasText: /New Chat/ }).locator('..').first();
|
| 51 |
+
// 等待按钮出现(最多等待 5 秒)
|
| 52 |
+
await newChatBtn.waitFor({ state: 'visible', timeout: 5000 });
|
| 53 |
+
await safeClick(page, newChatBtn, { bias: 'button' });
|
| 54 |
+
logger.debug('适配器', '已点击 New Chat 按钮', meta);
|
| 55 |
+
await sleep(500, 1000);
|
| 56 |
+
} catch (e) {
|
| 57 |
+
logger.debug('适配器', `New Chat 按钮未找到或已在新会话中: ${e.message}`, meta);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
// 1. 等待输入框加载
|
| 61 |
+
logger.debug('适配器', '正在寻找输入框...', meta);
|
| 62 |
+
await waitForInput(page, INPUT_SELECTOR, { click: false });
|
| 63 |
+
await sleep(1000, 1500);
|
| 64 |
+
|
| 65 |
+
// 2. 上传图片 (如果有)
|
| 66 |
+
if (imgPaths && imgPaths.length > 0) {
|
| 67 |
+
const expectedUploads = imgPaths.length;
|
| 68 |
+
let uploadedCount = 0;
|
| 69 |
+
|
| 70 |
+
logger.info('适配器', `准备上传 ${expectedUploads} 张图片`, meta);
|
| 71 |
+
|
| 72 |
+
await pasteImages(page, INPUT_SELECTOR, imgPaths, {
|
| 73 |
+
uploadValidator: (response) => {
|
| 74 |
+
const url = response.url();
|
| 75 |
+
// 监听 oss/upload POST 请求
|
| 76 |
+
if (response.request().method() === 'POST' && url.includes('oss/upload')) {
|
| 77 |
+
if (response.status() === 200) {
|
| 78 |
+
uploadedCount++;
|
| 79 |
+
logger.info('适配器', `图片上传进度: ${uploadedCount}/${expectedUploads}`, meta);
|
| 80 |
+
|
| 81 |
+
// 所有图片上传完成
|
| 82 |
+
if (uploadedCount >= expectedUploads) {
|
| 83 |
+
return true;
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
return false;
|
| 88 |
+
}
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
await sleep(1000, 2000);
|
| 92 |
+
logger.info('适配器', '图片上传完成', meta);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// 3. 填写提示词
|
| 96 |
+
await safeClick(page, INPUT_SELECTOR, { bias: 'input' });
|
| 97 |
+
await fillPrompt(page, INPUT_SELECTOR, prompt, meta);
|
| 98 |
+
await sleep(500, 1000);
|
| 99 |
+
|
| 100 |
+
// 4. 设置请求拦截器(修改模型ID和providers)
|
| 101 |
+
logger.debug('适配器', '已启用请求拦截', meta);
|
| 102 |
+
await page.unroute('**/*').catch(() => { });
|
| 103 |
+
|
| 104 |
+
await page.route(url => url.href.includes('v1/chat/completions'), async (route) => {
|
| 105 |
+
const request = route.request();
|
| 106 |
+
if (request.method() !== 'POST') return route.continue();
|
| 107 |
+
|
| 108 |
+
try {
|
| 109 |
+
const postData = request.postDataJSON();
|
| 110 |
+
if (postData) {
|
| 111 |
+
let modified = false;
|
| 112 |
+
|
| 113 |
+
// 修改模型 ID(使用 codeName)
|
| 114 |
+
if (postData.model) {
|
| 115 |
+
logger.info('适配器', `已拦截请求,修改模型 ID: ${postData.model} -> ${codeName}`, meta);
|
| 116 |
+
postData.model = codeName;
|
| 117 |
+
modified = true;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
// 修改 providers(如果模型配置中有 providers)
|
| 121 |
+
if (providers && providers.length > 0) {
|
| 122 |
+
if (!postData.provider) postData.provider = {};
|
| 123 |
+
if (!postData.provider.routing) postData.provider.routing = {};
|
| 124 |
+
|
| 125 |
+
logger.info('适配器', `已拦截请求,修改 providers: ${JSON.stringify(postData.provider.routing.providers)} -> ${JSON.stringify(providers)}`, meta);
|
| 126 |
+
postData.provider.routing.providers = providers;
|
| 127 |
+
modified = true;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
if (modified) {
|
| 131 |
+
await route.continue({ postData: JSON.stringify(postData) });
|
| 132 |
+
return;
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
} catch (e) {
|
| 136 |
+
logger.error('适配器', '请求拦截处理失败', { ...meta, error: e.message });
|
| 137 |
+
}
|
| 138 |
+
await route.continue();
|
| 139 |
+
});
|
| 140 |
+
|
| 141 |
+
// 5. 提交
|
| 142 |
+
logger.debug('适配器', '点击发送...', meta);
|
| 143 |
+
await submit(page, {
|
| 144 |
+
btnSelector: SEND_BUTTON_SELECTOR,
|
| 145 |
+
inputTarget: INPUT_SELECTOR,
|
| 146 |
+
meta
|
| 147 |
+
});
|
| 148 |
+
|
| 149 |
+
logger.info('适配器', '等待生成结果中...', meta);
|
| 150 |
+
|
| 151 |
+
// 5. 等待 API 响应
|
| 152 |
+
let apiResponse;
|
| 153 |
+
try {
|
| 154 |
+
apiResponse = await waitApiResponse(page, {
|
| 155 |
+
urlMatch: 'v1/chat/completions',
|
| 156 |
+
method: 'POST',
|
| 157 |
+
timeout: 120000,
|
| 158 |
+
meta
|
| 159 |
+
});
|
| 160 |
+
} catch (e) {
|
| 161 |
+
const pageError = normalizePageError(e, meta);
|
| 162 |
+
if (pageError) return pageError;
|
| 163 |
+
throw e;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
// 检查 API 响应状态
|
| 167 |
+
const httpError = normalizeHttpError(apiResponse);
|
| 168 |
+
if (httpError) {
|
| 169 |
+
logger.error('适配器', `请求生成时返回错误: ${httpError.error}`, meta);
|
| 170 |
+
return { error: `请求生成时返回错误: ${httpError.error}` };
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
// 6. 解析流式响应
|
| 174 |
+
const content = await apiResponse.text();
|
| 175 |
+
logger.debug('适配器', `收到响应,长度: ${content.length}`, meta);
|
| 176 |
+
|
| 177 |
+
// 解析 EventStream 格式响应
|
| 178 |
+
let fullText = '';
|
| 179 |
+
try {
|
| 180 |
+
const lines = content.split('\n');
|
| 181 |
+
|
| 182 |
+
for (const line of lines) {
|
| 183 |
+
// 跳过空行和 [DONE] 标记
|
| 184 |
+
if (!line.trim() || line.includes('[DONE]')) {
|
| 185 |
+
continue;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
// 解析 data: 开头的行
|
| 189 |
+
if (line.startsWith('data: ')) {
|
| 190 |
+
const jsonStr = line.substring(6); // 去掉 "data: " 前缀
|
| 191 |
+
|
| 192 |
+
try {
|
| 193 |
+
const parsed = JSON.parse(jsonStr);
|
| 194 |
+
|
| 195 |
+
// 提取 choices 中的 content
|
| 196 |
+
if (parsed.choices && Array.isArray(parsed.choices)) {
|
| 197 |
+
for (const choice of parsed.choices) {
|
| 198 |
+
const content = choice?.delta?.content;
|
| 199 |
+
|
| 200 |
+
// 只提取有内容的文本(跳过空字符串和思考过程)
|
| 201 |
+
if (content && content.trim()) {
|
| 202 |
+
fullText += content;
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
} catch (parseErr) {
|
| 207 |
+
// 单个 JSON 解析失败不影响整体
|
| 208 |
+
logger.debug('适配器', `解析单个数据块失败: ${parseErr.message}`, meta);
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
}
|
| 212 |
+
} catch (e) {
|
| 213 |
+
logger.error('适配器', '解析响应失败', { ...meta, error: e.message });
|
| 214 |
+
return { error: `解析响应失败: ${e.message}` };
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
if (fullText) {
|
| 218 |
+
logger.info('适配器', `获取文本成功,长度: ${fullText.length}`, meta);
|
| 219 |
+
return { text: fullText };
|
| 220 |
+
} else {
|
| 221 |
+
logger.warn('适配器', '未解析到有效文本内容', { ...meta, preview: content.substring(0, 200) });
|
| 222 |
+
return { error: '未解析到有效文本内容' };
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
} catch (err) {
|
| 226 |
+
// 顶层错误处理
|
| 227 |
+
const pageError = normalizePageError(err, meta);
|
| 228 |
+
if (pageError) return pageError;
|
| 229 |
+
|
| 230 |
+
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
| 231 |
+
return { error: `生成任务失败: ${err.message}` };
|
| 232 |
+
} finally {
|
| 233 |
+
// 清理拦截器
|
| 234 |
+
await page.unroute('**/*').catch(() => { });
|
| 235 |
+
// 任务结束,将鼠标移至安全区域
|
| 236 |
+
await moveMouseAway(page);
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
/**
|
| 241 |
+
* 适配器 manifest
|
| 242 |
+
*/
|
| 243 |
+
export const manifest = {
|
| 244 |
+
id: 'zenmux_ai',
|
| 245 |
+
displayName: 'Zenmux AI (文本生成)',
|
| 246 |
+
description: '使用 Zenmux AI 平台生成文本,支持多种大语言模型。需要已登录的 ZenMux 账户。',
|
| 247 |
+
|
| 248 |
+
// 无需额外配置
|
| 249 |
+
configSchema: [],
|
| 250 |
+
|
| 251 |
+
// 入口 URL
|
| 252 |
+
getTargetUrl() {
|
| 253 |
+
return 'https://zenmux.ai/settings/chat';
|
| 254 |
+
},
|
| 255 |
+
|
| 256 |
+
// 模型列表(仅支持非会员账户可用的模型)
|
| 257 |
+
models: [
|
| 258 |
+
{ id: 'gemini-3-flash-preview', codeName: 'google/gemini-3-flash-preview-free', imagePolicy: 'optional', type: 'text', providers: ["google-vertex"] },
|
| 259 |
+
{ id: 'mimo-v2-flash', codeName: 'xiaomi/mimo-v2-flash', imagePolicy: 'forbidden', type: 'text', providers: ["xiaomi"] },
|
| 260 |
+
{ id: 'glm-4.6v-flash', codeName: 'z-ai/glm-4.6v-flash', imagePolicy: 'optional', type: 'text', providers: ["z-ai"] },
|
| 261 |
+
{ id: 'mistral-large-2512', codeName: 'mistralai/mistral-large-2512', imagePolicy: 'optional', type: 'text', providers: ["azure"] },
|
| 262 |
+
{ id: 'deepseek-v3.2', codeName: 'deepseek/deepseek-chat', imagePolicy: 'forbidden', type: 'text', providers: ["deepseek"] },
|
| 263 |
+
{ id: 'deepseek-v3.2-thinking', codeName: 'deepseek/deepseek-reasoner', imagePolicy: 'forbidden', type: 'text', providers: ["deepseek"] },
|
| 264 |
+
{ id: 'grok-4.1-fast', codeName: 'x-ai/grok-4.1-fast', imagePolicy: 'optional', type: 'text', providers: ["x-ai"] },
|
| 265 |
+
{ id: 'grok-4.1-fast-non-reasoning', codeName: 'x-ai/grok-4.1-fast-non-reasoning', imagePolicy: 'optional', type: 'text', providers: ["x-ai"] },
|
| 266 |
+
{ id: 'gpt-5.1-codex-mini', codeName: 'openai/gpt-5.1-codex-mini', imagePolicy: 'optional', type: 'text', providers: ["openai"] },
|
| 267 |
+
{ id: 'ernie-5.0-thinking-preview', codeName: 'baidu/ernie-5.0-thinking-preview', imagePolicy: 'optional', type: 'text', providers: ["baidu"] },
|
| 268 |
+
{ id: 'doubao-seed-code', codeName: 'volcengine/doubao-seed-code', imagePolicy: 'optional', type: 'text', providers: ["volcengine"] },
|
| 269 |
+
{ id: 'kimi-k2-thinking', codeName: 'moonshotai/kimi-k2-thinking', imagePolicy: 'forbidden', type: 'text', providers: ["moonshotai"] },
|
| 270 |
+
{ id: 'minimax-m2', codeName: 'minimax/minimax-m2', imagePolicy: 'forbidden', type: 'text', providers: ["minimax"] },
|
| 271 |
+
{ id: 'kat-coder-pro-v1', codeName: 'kuaishou/kat-coder-pro-v1', imagePolicy: 'forbidden', type: 'text', providers: ["streamlake"] },
|
| 272 |
+
{ id: 'glm-4.6', codeName: 'z-ai/glm-4.6', imagePolicy: 'forbidden', type: 'text', providers: ["z-ai"] },
|
| 273 |
+
{ id: 'claude-sonnet-4.5', codeName: 'anthropic/claude-sonnet-4.5', imagePolicy: 'optional', type: 'text', providers: ["anthropic"] },
|
| 274 |
+
{ id: 'qwen3-max', codeName: 'qwen/qwen3-max', imagePolicy: 'forbidden', type: 'text', providers: ["alibaba"] },
|
| 275 |
+
{ id: 'grok-4-fast', codeName: 'x-ai/grok-4-fast', imagePolicy: 'optional', type: 'text', providers: ["x-ai"] },
|
| 276 |
+
{ id: 'grok-4-fast-non-reasoning', codeName: 'x-ai/grok-4-fast-non-reasoning', imagePolicy: 'optional', type: 'text', providers: ["x-ai"] },
|
| 277 |
+
{ id: 'grok-code-fast-1', codeName: 'x-ai/grok-code-fast-1', imagePolicy: 'forbidden', type: 'text', providers: ["x-ai"] },
|
| 278 |
+
{ id: 'deepseek-v3.1', codeName: 'deepseek/deepseek-chat-v3.1', imagePolicy: 'forbidden', type: 'text', providers: ["theta"] },
|
| 279 |
+
{ id: 'gpt-5-mini', codeName: 'openai/gpt-5-mini', imagePolicy: 'optional', type: 'text', providers: ["openai"] },
|
| 280 |
+
{ id: 'gpt-5-nano', codeName: 'openai/gpt-5-nano', imagePolicy: 'optional', type: 'text', providers: ["openai"] },
|
| 281 |
+
{ id: 'glm-4.5-air', codeName: 'z-ai/glm-4.5-air', imagePolicy: 'forbidden', type: 'text', providers: ["z-ai"] },
|
| 282 |
+
{ id: 'gemini-2.5-flash-lite', codeName: 'google/gemini-2.5-flash-lite', imagePolicy: 'optional', type: 'text', providers: ["google-vertex"] },
|
| 283 |
+
{ id: 'gemini-2.5-flash', codeName: 'google/gemini-2.5-flash', imagePolicy: 'optional', type: 'text', providers: ["google-vertex"] },
|
| 284 |
+
{ id: 'deepseek-r1-0528', codeName: 'deepseek/deepseek-r1-0528', imagePolicy: 'forbidden', type: 'text', providers: ["theta"] },
|
| 285 |
+
{ id: 'claude-sonnet-4', codeName: 'anthropic/claude-sonnet-4', imagePolicy: 'optional', type: 'text', providers: ["anthropic"] },
|
| 286 |
+
{ id: 'qwen3-14b', codeName: 'qwen/qwen3-14b', imagePolicy: 'optional', type: 'text', providers: ["theta"] },
|
| 287 |
+
{ id: 'o4-mini', codeName: 'openai/o4-mini', imagePolicy: 'optional', type: 'text', providers: ["openai"] },
|
| 288 |
+
{ id: 'gpt-4.1-mini', codeName: 'openai/gpt-4.1-mini', imagePolicy: 'optional', type: 'text', providers: ["openai"] },
|
| 289 |
+
{ id: 'gpt-4.1-nano', codeName: 'openai/gpt-4.1-nano', imagePolicy: 'optional', type: 'text', providers: ["openai"] },
|
| 290 |
+
{ id: 'gemini-2.0-flash-lite', codeName: 'google/gemini-2.0-flash-lite-001', imagePolicy: 'optional', type: 'text', providers: ["google-vertex"] },
|
| 291 |
+
{ id: 'claude-3.7-sonnet', codeName: 'anthropic/claude-3.7-sonnet', imagePolicy: 'optional', type: 'text', providers: ["anthropic"] },
|
| 292 |
+
{ id: 'gemini-2.0-flash', codeName: 'google/gemini-2.0-flash', imagePolicy: 'optional', type: 'text', providers: ["google-vertex"] },
|
| 293 |
+
{ id: 'claude-3.5-sonnet', codeName: 'anthropic/claude-3.5-sonnet', imagePolicy: 'optional', type: 'text', providers: ["anthropic"] },
|
| 294 |
+
],
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
// 无需导航处理器
|
| 298 |
+
navigationHandlers: [],
|
| 299 |
+
|
| 300 |
+
// 核心生成方法
|
| 301 |
+
generate
|
| 302 |
+
};
|
src/backend/engine/launcher.js
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview 浏览器启动与生命周期管理
|
| 3 |
+
* @description 负责启动 Camoufox(Playwright 内核)、注入指纹与代理,并在进程退出时做资源清理。
|
| 4 |
+
* 导航和预热行为由工作池负责,本模块只负责启动浏览器。
|
| 5 |
+
*
|
| 6 |
+
* 约定:
|
| 7 |
+
* - 登录模式会尽量保留 Profile(用户数据目录)
|
| 8 |
+
* - 清理采用三级退出:Playwright close -> SIGTERM -> SIGKILL
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import { Camoufox } from 'camoufox-js';
|
| 12 |
+
import { sampleWebGL } from 'camoufox-js/dist/webgl/sample.js';
|
| 13 |
+
import { FingerprintGenerator } from 'fingerprint-generator';
|
| 14 |
+
import fs from 'fs';
|
| 15 |
+
import path from 'path';
|
| 16 |
+
import os from 'os';
|
| 17 |
+
import { createCursor } from 'ghost-cursor-playwright-port';
|
| 18 |
+
import { getRealViewport, clamp, random, sleep } from './utils.js';
|
| 19 |
+
import { logger } from '../../utils/logger.js';
|
| 20 |
+
import { getBrowserProxy, cleanupProxy } from '../../utils/proxy.js';
|
| 21 |
+
|
| 22 |
+
// 全局状态:用于在登录模式下管理残留进程与复用上下文
|
| 23 |
+
let globalBrowserProcess = null;
|
| 24 |
+
let globalContext = null; // 替代 globalBrowser
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* 清理浏览器资源和进程
|
| 28 |
+
* 实现三级退出机制: Playwright close -> SIGTERM -> SIGKILL
|
| 29 |
+
* @returns {Promise<void>}
|
| 30 |
+
*/
|
| 31 |
+
export async function cleanup() {
|
| 32 |
+
|
| 33 |
+
// Level 1: 通过 Playwright 协议优雅关闭 Context,保存 Profile
|
| 34 |
+
if (globalContext) {
|
| 35 |
+
try {
|
| 36 |
+
logger.debug('浏览器', '正在断开远程调试连接并保存 Profile...');
|
| 37 |
+
await globalContext.close();
|
| 38 |
+
globalContext = null;
|
| 39 |
+
logger.debug('浏览器', '已关闭浏览器上下文');
|
| 40 |
+
} catch (e) {
|
| 41 |
+
logger.warn('浏览器', `关闭上下文失败: ${e.message}`);
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// Level 2 & 3: 处理残留进程 (主要用于登录模式)
|
| 46 |
+
if (globalBrowserProcess && !globalBrowserProcess.killed) {
|
| 47 |
+
logger.info('浏览器', '正在终止浏览器进程...');
|
| 48 |
+
try {
|
| 49 |
+
// Level 2: 发送 SIGTERM (软杀)
|
| 50 |
+
globalBrowserProcess.kill('SIGTERM');
|
| 51 |
+
|
| 52 |
+
// 等待进程退出
|
| 53 |
+
const start = Date.now();
|
| 54 |
+
while (Date.now() - start < 2000) {
|
| 55 |
+
try {
|
| 56 |
+
process.kill(globalBrowserProcess.pid, 0);
|
| 57 |
+
await new Promise(r => setTimeout(r, 200));
|
| 58 |
+
} catch (e) {
|
| 59 |
+
break;
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
} catch (e) { }
|
| 63 |
+
|
| 64 |
+
// Level 3: 强制查杀 (SIGKILL)
|
| 65 |
+
try {
|
| 66 |
+
process.kill(globalBrowserProcess.pid, 0);
|
| 67 |
+
logger.debug('浏览器', '浏览器进程无响应,执行强制终止 (SIGKILL)...');
|
| 68 |
+
process.kill(-globalBrowserProcess.pid, 'SIGKILL');
|
| 69 |
+
} catch (e) { }
|
| 70 |
+
|
| 71 |
+
globalBrowserProcess = null;
|
| 72 |
+
logger.info('浏览器', '浏览器进程已终止');
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
// 清理代理
|
| 76 |
+
await cleanupProxy();
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
// 防止重复注册
|
| 80 |
+
let signalHandlersRegistered = false;
|
| 81 |
+
|
| 82 |
+
/**
|
| 83 |
+
* 注册进程退出信号处理
|
| 84 |
+
* @private
|
| 85 |
+
*/
|
| 86 |
+
function registerCleanupHandlers() {
|
| 87 |
+
if (signalHandlersRegistered) return;
|
| 88 |
+
|
| 89 |
+
process.on('exit', () => {
|
| 90 |
+
if (globalBrowserProcess) globalBrowserProcess.kill();
|
| 91 |
+
});
|
| 92 |
+
|
| 93 |
+
process.on('SIGINT', async () => {
|
| 94 |
+
await cleanup();
|
| 95 |
+
process.exit();
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
process.on('SIGTERM', async () => {
|
| 99 |
+
await cleanup();
|
| 100 |
+
process.exit();
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
signalHandlersRegistered = true;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
/**
|
| 107 |
+
* 获取当前操作系统名称
|
| 108 |
+
* 将 Node.js 的 platform 转换为 Camoufox/FingerprintGenerator 支持的格式
|
| 109 |
+
*/
|
| 110 |
+
function getCurrentOS() {
|
| 111 |
+
const platform = os.platform();
|
| 112 |
+
if (platform === 'win32') return 'windows';
|
| 113 |
+
if (platform === 'darwin') return 'macos';
|
| 114 |
+
// 其他情况默认为 linux
|
| 115 |
+
return 'linux';
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
/**
|
| 119 |
+
* 获取 WebGL 平台标识
|
| 120 |
+
* 将操作系统名称转换为 sampleWebGL 支持的格式
|
| 121 |
+
*/
|
| 122 |
+
function getWebGLPlatform(osName) {
|
| 123 |
+
if (osName === 'windows') return 'win';
|
| 124 |
+
if (osName === 'macos') return 'mac';
|
| 125 |
+
return 'lin';
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
/**
|
| 129 |
+
* 获取或生成持久化指纹 (含 WebGL 配置校验)
|
| 130 |
+
* @param {string} filePath - JSON文件保存路径
|
| 131 |
+
*/
|
| 132 |
+
async function getPersistentFingerprint(filePath) {
|
| 133 |
+
// 确保 data 目录存在
|
| 134 |
+
const dir = path.dirname(filePath);
|
| 135 |
+
if (!fs.existsSync(dir)) {
|
| 136 |
+
fs.mkdirSync(dir, { recursive: true });
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
let fingerprintData = null;
|
| 140 |
+
let webglPair = null;
|
| 141 |
+
let shouldSave = false;
|
| 142 |
+
const currentOS = getCurrentOS();
|
| 143 |
+
const targetWebGLOS = getWebGLPlatform(currentOS);
|
| 144 |
+
|
| 145 |
+
// 1. 尝试读取现有指纹
|
| 146 |
+
if (fs.existsSync(filePath)) {
|
| 147 |
+
try {
|
| 148 |
+
const fileContent = fs.readFileSync(filePath, 'utf8');
|
| 149 |
+
fingerprintData = JSON.parse(fileContent);
|
| 150 |
+
} catch (e) {
|
| 151 |
+
logger.warn('浏览器', `指纹文件损坏: ${e.message}`);
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
// 2. 校验 WebGL 配置的有效性 (从 videoCard 读取)
|
| 156 |
+
if (fingerprintData?.videoCard?.['webGl:vendor'] && fingerprintData?.videoCard?.['webGl:renderer']) {
|
| 157 |
+
const savedVendor = fingerprintData.videoCard['webGl:vendor'];
|
| 158 |
+
const savedRenderer = fingerprintData.videoCard['webGl:renderer'];
|
| 159 |
+
try {
|
| 160 |
+
// 拿着保存的配置,去数据库里"试探"一下是否存在
|
| 161 |
+
await sampleWebGL(targetWebGLOS, savedVendor, savedRenderer);
|
| 162 |
+
|
| 163 |
+
// 如果没报错,说明配置有效,保留使用
|
| 164 |
+
webglPair = [savedVendor, savedRenderer];
|
| 165 |
+
logger.debug('浏览器', `加载 WebGL 配置成功: ${savedRenderer}`);
|
| 166 |
+
} catch (e) {
|
| 167 |
+
// 数据库里没找到 -> 配置失效
|
| 168 |
+
logger.warn('浏览器', `保存的 WebGL 配置与当前系统(${targetWebGLOS})不匹配,将重新生成`);
|
| 169 |
+
webglPair = null;
|
| 170 |
+
shouldSave = true;
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
// 3. 如果指纹完全不存在,生成新的基础指纹
|
| 175 |
+
if (!fingerprintData) {
|
| 176 |
+
logger.info('浏览器', `正在为系统 [${currentOS}] 生成新指纹...`);
|
| 177 |
+
const generatorOptions = {
|
| 178 |
+
browsers: ['firefox'],
|
| 179 |
+
operatingSystems: [currentOS],
|
| 180 |
+
devices: ['desktop'],
|
| 181 |
+
locales: ['en-US'],
|
| 182 |
+
screen: { minWidth: 1280, maxWidth: 1366, minHeight: 720, maxHeight: 768 }
|
| 183 |
+
};
|
| 184 |
+
const generator = new FingerprintGenerator(generatorOptions);
|
| 185 |
+
fingerprintData = generator.getFingerprint().fingerprint;
|
| 186 |
+
|
| 187 |
+
// 清洗 UA 版本
|
| 188 |
+
if (fingerprintData.navigator) {
|
| 189 |
+
let ua = fingerprintData.navigator.userAgent;
|
| 190 |
+
const TARGET_VERSION = "135.0";
|
| 191 |
+
ua = ua.replace(/rv:[\d\.]+/g, `rv:${TARGET_VERSION}`);
|
| 192 |
+
ua = ua.replace(/Firefox\/[\d\.]+/g, `Firefox/${TARGET_VERSION}`);
|
| 193 |
+
fingerprintData.navigator.userAgent = ua;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
// 清洗插件数据
|
| 197 |
+
if (fingerprintData.pluginsData) {
|
| 198 |
+
fingerprintData.pluginsData.plugins = [];
|
| 199 |
+
fingerprintData.pluginsData.mimeTypes = [];
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
shouldSave = true;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
// 4. 如果 WebGL 配置为空,重新生成
|
| 206 |
+
if (!webglPair) {
|
| 207 |
+
try {
|
| 208 |
+
logger.info('浏览器', `正在生成新的 WebGL 配置 (${targetWebGLOS})...`);
|
| 209 |
+
const webglData = await sampleWebGL(targetWebGLOS);
|
| 210 |
+
webglPair = [webglData['webGl:vendor'], webglData['webGl:renderer']];
|
| 211 |
+
|
| 212 |
+
// 覆盖 videoCard
|
| 213 |
+
fingerprintData.videoCard = {
|
| 214 |
+
'webGl:vendor': webglPair[0],
|
| 215 |
+
'webGl:renderer': webglPair[1]
|
| 216 |
+
};
|
| 217 |
+
|
| 218 |
+
shouldSave = true;
|
| 219 |
+
} catch (e) {
|
| 220 |
+
logger.error('浏览器', `致命错误:无法生成 WebGL 配置: ${e.message}`);
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
// 5. 如果 Canvas 噪点不存在,生成新的
|
| 225 |
+
if (fingerprintData.canvasOffset === undefined) {
|
| 226 |
+
const offset = Math.floor(Math.random() * 41) - 20;
|
| 227 |
+
fingerprintData.canvasOffset = offset;
|
| 228 |
+
logger.info('浏览器', `已生成 Canvas 噪点偏移: ${offset}`);
|
| 229 |
+
shouldSave = true;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
// 5. 如果有变动,保存回文件
|
| 233 |
+
if (shouldSave) {
|
| 234 |
+
fs.writeFileSync(filePath, JSON.stringify(fingerprintData, null, 2));
|
| 235 |
+
logger.info('浏览器', `指纹已更新并保存至: ${filePath}`);
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
return fingerprintData;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
/**
|
| 242 |
+
* 启动浏览器实例 (仅负责启动,不负责导航和预热)
|
| 243 |
+
*
|
| 244 |
+
* 导航到目标页面、注册导航处理器、预热行为由工作池 (pool.js) 负责。
|
| 245 |
+
*
|
| 246 |
+
* @param {object} config - 全局配置对象
|
| 247 |
+
* @param {object} options - 启动选项
|
| 248 |
+
* @param {string} options.userDataDir - 用户数据目录路径
|
| 249 |
+
* @param {string} [options.userDataMark] - 用户数据目录标识 (用于日志显示)
|
| 250 |
+
* @param {object} [options.proxyConfig] - Worker 级代理配置
|
| 251 |
+
* @returns {Promise<{context: object, page: object}>} 浏览器上下文和初始页面
|
| 252 |
+
*/
|
| 253 |
+
export async function initBrowserBase(config, options = {}) {
|
| 254 |
+
const {
|
| 255 |
+
userDataDir,
|
| 256 |
+
instanceName = null,
|
| 257 |
+
proxyConfig = null
|
| 258 |
+
} = options;
|
| 259 |
+
|
| 260 |
+
// 日志标识 (优先使用实例名称)
|
| 261 |
+
const markLabel = instanceName || '默认';
|
| 262 |
+
|
| 263 |
+
// 检测登录模式和 Xvfb 模式
|
| 264 |
+
const isLoginMode = process.argv.some(arg => arg.startsWith('-login'));
|
| 265 |
+
const isXvfbMode = process.env.XVFB_RUNNING === 'true';
|
| 266 |
+
const headlessMode = config?.browser?.headless && !isLoginMode && !isXvfbMode;
|
| 267 |
+
|
| 268 |
+
// 如果配置了无头模式但被强制禁用,输出原因
|
| 269 |
+
if (config?.browser?.headless && !headlessMode) {
|
| 270 |
+
const reasons = [];
|
| 271 |
+
if (isLoginMode) reasons.push('登录模式');
|
| 272 |
+
if (isXvfbMode) reasons.push('Xvfb 模式');
|
| 273 |
+
logger.info('浏览器', `[${markLabel}] 无头模式已被禁用 (${reasons.join(' + ')})`);
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
logger.info('浏览器', `[${markLabel}] 启动浏览器实例...`);
|
| 277 |
+
|
| 278 |
+
const browserConfig = config?.browser || {};
|
| 279 |
+
|
| 280 |
+
// 获取指纹对象(指纹文件放在对应的 userDataDir 内)
|
| 281 |
+
const fingerprintPath = path.join(userDataDir, 'fingerprint.json');
|
| 282 |
+
const myFingerprint = await getPersistentFingerprint(fingerprintPath);
|
| 283 |
+
|
| 284 |
+
// 构造 Camoufox 启动选项
|
| 285 |
+
const currentOS = getCurrentOS();
|
| 286 |
+
const camoufoxLaunchOptions = {
|
| 287 |
+
executable_path: browserConfig.path || undefined,
|
| 288 |
+
headless: headlessMode,
|
| 289 |
+
user_data_dir: userDataDir,
|
| 290 |
+
ff_version: 135,
|
| 291 |
+
fingerprint: myFingerprint,
|
| 292 |
+
os: currentOS,
|
| 293 |
+
i_know_what_im_doing: true,
|
| 294 |
+
webgl_config: myFingerprint.videoCard ? [myFingerprint.videoCard['webGl:vendor'], myFingerprint.videoCard['webGl:renderer']] : undefined,
|
| 295 |
+
block_webrtc: true,
|
| 296 |
+
exclude_addons: ['UBO'],
|
| 297 |
+
geoip: true,
|
| 298 |
+
config: {
|
| 299 |
+
forceScopeAccess: true,
|
| 300 |
+
// Canvas 抗指纹:注入固定噪点偏移
|
| 301 |
+
'canvas:aaOffset': myFingerprint.canvasOffset ?? 0,
|
| 302 |
+
'canvas:aaCapOffset': true
|
| 303 |
+
},
|
| 304 |
+
// 关闭动画减轻资源压力
|
| 305 |
+
firefox_user_prefs: {
|
| 306 |
+
// 禁用背景模糊滤镜 (高 CPU 消耗)
|
| 307 |
+
'layout.css.backdrop-filter.enabled': false,
|
| 308 |
+
// 告诉网页用户倾向于减少动画 (触发网页自身的优化)
|
| 309 |
+
'ui.prefersReducedMotion': 1,
|
| 310 |
+
// 站点隔离
|
| 311 |
+
...(browserConfig.fission === false ? { 'fission.autostart': false } : {})
|
| 312 |
+
}
|
| 313 |
+
};
|
| 314 |
+
|
| 315 |
+
// 代理配置
|
| 316 |
+
const proxyObj = await getBrowserProxy(proxyConfig);
|
| 317 |
+
if (proxyObj) {
|
| 318 |
+
camoufoxLaunchOptions.proxy = proxyObj;
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
// 启动 Camoufox
|
| 322 |
+
const context = await Camoufox(camoufoxLaunchOptions);
|
| 323 |
+
globalContext = context;
|
| 324 |
+
|
| 325 |
+
// 构建状态描述
|
| 326 |
+
const statusParts = [];
|
| 327 |
+
statusParts.push(`无头模式: ${headlessMode ? '是' : '否'}`);
|
| 328 |
+
if (proxyObj) statusParts.push('代理: 已配置');
|
| 329 |
+
logger.info('浏览器', `[${markLabel}] 浏览器已启动 (${statusParts.join(', ')})`);
|
| 330 |
+
|
| 331 |
+
// 注册清理处理器
|
| 332 |
+
registerCleanupHandlers();
|
| 333 |
+
|
| 334 |
+
// 注册断开连接事件
|
| 335 |
+
context.on('close', async () => {
|
| 336 |
+
logger.warn('浏览器', `[${markLabel}] 浏览器已断开连接`);
|
| 337 |
+
await cleanup();
|
| 338 |
+
process.exit(0);
|
| 339 |
+
});
|
| 340 |
+
|
| 341 |
+
// 获取或创建 Page
|
| 342 |
+
let page;
|
| 343 |
+
const existingPages = context.pages();
|
| 344 |
+
if (existingPages.length > 0) {
|
| 345 |
+
page = existingPages[0];
|
| 346 |
+
} else {
|
| 347 |
+
page = await context.newPage();
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
// 强制刷新视口大小 (使用指纹中的屏幕尺寸)
|
| 351 |
+
const screenWidth = myFingerprint.screen?.availWidth || 1366;
|
| 352 |
+
const screenHeight = myFingerprint.screen?.availHeight || 768;
|
| 353 |
+
await page.setViewportSize({ width: screenWidth, height: screenHeight });
|
| 354 |
+
|
| 355 |
+
// 返回 context 和 page(导航、预热、cursor 初始化由工作池负责)
|
| 356 |
+
return {
|
| 357 |
+
context,
|
| 358 |
+
page
|
| 359 |
+
};
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
// 导出工具函数供 pool.js 使用
|
| 363 |
+
export { createCursor, getRealViewport, clamp, random, sleep };
|
src/backend/engine/utils.js
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview 浏览器自动化工具函数
|
| 3 |
+
* @description 封装 Playwright 页面常用操作,供后端适配器复用。
|
| 4 |
+
*
|
| 5 |
+
* 职责边界:
|
| 6 |
+
* - 浏览器原子操作(点击、输入、上传等)
|
| 7 |
+
* - 页面状态检测(isPageValid、createPageCloseWatcher)
|
| 8 |
+
* - 拟人化交互(humanType、safeClick、safeScroll)
|
| 9 |
+
* - 工具函数(random、sleep、getMimeType)
|
| 10 |
+
*
|
| 11 |
+
* 注意:业务逻辑应放在 backend/utils.js
|
| 12 |
+
*
|
| 13 |
+
* 主要函数:
|
| 14 |
+
* - `random` / `sleep`:随机与延迟工具
|
| 15 |
+
* - `getMimeType`:根据文件扩展名推断 MIME
|
| 16 |
+
* - `getRealViewport` / `clamp`:视口与坐标工具(防止越界)
|
| 17 |
+
* - `queryDeep`:深层查询(包含 Shadow DOM / iframe)
|
| 18 |
+
* - `safeClick` / `humanType`:拟人化点击与输入
|
| 19 |
+
* - `pasteImages` / `uploadFilesViaChooser`:图片粘贴/上传辅助
|
| 20 |
+
* - `isPageValid` / `createPageCloseWatcher`:页面有效性与关闭/崩溃监听
|
| 21 |
+
*/
|
| 22 |
+
|
| 23 |
+
import path from 'path';
|
| 24 |
+
import { logger } from '../../utils/logger.js';
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* 生成指定范围内的随机数
|
| 28 |
+
* @param {number} min - 最小值
|
| 29 |
+
* @param {number} max - 最大值
|
| 30 |
+
* @returns {number} 随机数
|
| 31 |
+
*/
|
| 32 |
+
export function random(min, max) {
|
| 33 |
+
return Math.random() * (max - min) + min;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/**
|
| 37 |
+
* 随机休眠一段时间
|
| 38 |
+
* @param {number} min - 最小毫秒数
|
| 39 |
+
* @param {number} max - 最大毫秒数
|
| 40 |
+
* @returns {Promise<void>}
|
| 41 |
+
*/
|
| 42 |
+
export function sleep(min, max) {
|
| 43 |
+
return new Promise(r => setTimeout(r, Math.floor(random(min, max))));
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/**
|
| 47 |
+
* 根据文件扩展名获取 MIME 类型
|
| 48 |
+
* @param {string} filePath - 文件路径
|
| 49 |
+
* @returns {string} MIME 类型
|
| 50 |
+
*/
|
| 51 |
+
export function getMimeType(filePath) {
|
| 52 |
+
const ext = path.extname(filePath).toLowerCase();
|
| 53 |
+
const map = {
|
| 54 |
+
'.png': 'image/png',
|
| 55 |
+
'.jpg': 'image/jpeg',
|
| 56 |
+
'.jpeg': 'image/jpeg',
|
| 57 |
+
'.gif': 'image/gif',
|
| 58 |
+
'.webp': 'image/webp'
|
| 59 |
+
};
|
| 60 |
+
return map[ext] || 'application/octet-stream';
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/**
|
| 64 |
+
* 无痕获取当前页面实时视口
|
| 65 |
+
* 使用纯净的匿名函数执行,不污染 Global Scope
|
| 66 |
+
* @param {import('playwright-core').Page} page - Playwright 页面实例
|
| 67 |
+
* @returns {Promise<{width: number, height: number, safeWidth: number, safeHeight: number}>} 视口尺寸及安全区域
|
| 68 |
+
*/
|
| 69 |
+
export async function getRealViewport(page) {
|
| 70 |
+
try {
|
| 71 |
+
return await page.evaluate(() => {
|
| 72 |
+
// 仅读取标准属性,不进行任何写入操作
|
| 73 |
+
const w = window.innerWidth;
|
| 74 |
+
const h = window.innerHeight;
|
| 75 |
+
return {
|
| 76 |
+
width: w,
|
| 77 |
+
height: h,
|
| 78 |
+
// 预留 20px 缓冲,防止鼠标移到滚动条上或贴边触发浏览器原生手势
|
| 79 |
+
safeWidth: w - 20,
|
| 80 |
+
safeHeight: h
|
| 81 |
+
};
|
| 82 |
+
});
|
| 83 |
+
} catch (e) {
|
| 84 |
+
// Fallback: 如果上下文丢失,返回安全保守值
|
| 85 |
+
return { width: 1280, height: 720, safeWidth: 1260, safeHeight: 720 };
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/**
|
| 90 |
+
* 坐标钳位函数
|
| 91 |
+
* 强制将坐标限制在合法视口范围内,防止 "Node is not visible" 报错
|
| 92 |
+
* @param {number} value - 原始坐标值
|
| 93 |
+
* @param {number} min - 最小值
|
| 94 |
+
* @param {number} max - 最大值
|
| 95 |
+
* @returns {number} 修正后的坐标值
|
| 96 |
+
*/
|
| 97 |
+
export function clamp(value, min, max) {
|
| 98 |
+
return Math.min(Math.max(value, min), max);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/**
|
| 102 |
+
* 深度查找 Shadow DOM 中的元素
|
| 103 |
+
* @param {import('playwright-core').Page} page - Playwright 页面实例
|
| 104 |
+
* @param {string} selector - CSS 选择器
|
| 105 |
+
* @param {import('playwright-core').ElementHandle} [rootHandle=null] - 可选的根节点句柄
|
| 106 |
+
* @returns {Promise<import('playwright-core').ElementHandle|null>} 找到的元素句柄或 null
|
| 107 |
+
*/
|
| 108 |
+
export async function queryDeep(page, selector, rootHandle = null) {
|
| 109 |
+
// Playwright evaluateHandle 只接受一个参数,包装成数组传递
|
| 110 |
+
return await page.evaluateHandle(([sel, root]) => {
|
| 111 |
+
function find(node, s) {
|
| 112 |
+
if (!node) return null;
|
| 113 |
+
if (node instanceof Element && node.matches(s)) return node;
|
| 114 |
+
let found = node.querySelector(s);
|
| 115 |
+
if (found) return found;
|
| 116 |
+
if (node.shadowRoot) {
|
| 117 |
+
found = find(node.shadowRoot, s);
|
| 118 |
+
if (found) return found;
|
| 119 |
+
}
|
| 120 |
+
const walker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT, null, false);
|
| 121 |
+
while (walker.nextNode()) {
|
| 122 |
+
const child = walker.currentNode;
|
| 123 |
+
if (child.shadowRoot) {
|
| 124 |
+
found = find(child.shadowRoot, s);
|
| 125 |
+
if (found) return found;
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
return null;
|
| 129 |
+
}
|
| 130 |
+
return find(root || document.body, sel);
|
| 131 |
+
}, [selector, rootHandle]);
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
/**
|
| 135 |
+
* 计算拟人化的随机点击坐标
|
| 136 |
+
* @param {object} box - 元素边界框 {x, y, width, height}
|
| 137 |
+
* @param {string} [type='random'] - 点击类型: 'input'(偏左) 或 'random'/'button'(随机)
|
| 138 |
+
* @returns {{x: number, y: number}} ��算出的坐标
|
| 139 |
+
*/
|
| 140 |
+
export function getHumanClickPoint(box, type = 'random') {
|
| 141 |
+
let x, y;
|
| 142 |
+
if (type === 'input') {
|
| 143 |
+
// 输入框: 偏左 (5% - 40% 宽度), 垂直居中附近 (20% - 80% 高度)
|
| 144 |
+
x = box.x + box.width * random(0.05, 0.4);
|
| 145 |
+
y = box.y + box.height * random(0.2, 0.8);
|
| 146 |
+
} else {
|
| 147 |
+
// 按钮/其他: 中心附近随机 (20% - 80% 宽度/高度)
|
| 148 |
+
x = box.x + box.width * random(0.2, 0.8);
|
| 149 |
+
y = box.y + box.height * random(0.2, 0.8);
|
| 150 |
+
}
|
| 151 |
+
return { x, y };
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
/**
|
| 155 |
+
* 安全点击元素 (包含拟人化移动和点击)
|
| 156 |
+
* 支持 CSS selector、ElementHandle 和 Locator 三种输入
|
| 157 |
+
* @param {import('playwright-core').Page} page - Playwright 页面对象
|
| 158 |
+
* @param {string|import('playwright-core').ElementHandle|import('playwright-core').Locator} target - CSS 选择器、元素句柄或 Locator
|
| 159 |
+
* @param {object} [options] - 点击选项
|
| 160 |
+
* @param {string} [options.bias='random'] - 偏移偏好: 'input' 或 'random'
|
| 161 |
+
* @returns {Promise<void>}
|
| 162 |
+
*/
|
| 163 |
+
export async function safeClick(page, target, options = {}) {
|
| 164 |
+
try {
|
| 165 |
+
let el;
|
| 166 |
+
|
| 167 |
+
// 判断输入类型
|
| 168 |
+
if (typeof target === 'string') {
|
| 169 |
+
// CSS selector
|
| 170 |
+
el = await page.$(target);
|
| 171 |
+
if (!el) throw new Error(`未找到: ${target}`);
|
| 172 |
+
} else if (typeof target.elementHandle === 'function') {
|
| 173 |
+
// Locator (来自 page.getByRole, page.getByText 等)
|
| 174 |
+
el = await target.elementHandle();
|
| 175 |
+
if (!el) throw new Error(`Locator 未匹配到元素`);
|
| 176 |
+
} else {
|
| 177 |
+
// ElementHandle
|
| 178 |
+
el = target;
|
| 179 |
+
if (!el || !el.asElement()) throw new Error(`Element handle invalid`);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
// 使用 ghost-cursor 点击
|
| 183 |
+
if (page.cursor) {
|
| 184 |
+
const box = await el.boundingBox();
|
| 185 |
+
if (box) {
|
| 186 |
+
const { x, y } = getHumanClickPoint(box, options.bias || 'random');
|
| 187 |
+
await page.cursor.moveTo({ x, y });
|
| 188 |
+
await page.mouse.click(x, y);
|
| 189 |
+
return;
|
| 190 |
+
}
|
| 191 |
+
// 如果无法获取 box,降级到默认点击
|
| 192 |
+
await page.cursor.click(el);
|
| 193 |
+
return;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
// 降级逻辑
|
| 197 |
+
await el.click();
|
| 198 |
+
} catch (err) {
|
| 199 |
+
throw err;
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
/**
|
| 204 |
+
* 安全滚动 (包含拟人化移动和滚轮滚动)
|
| 205 |
+
* 支持 CSS selector、ElementHandle 和 Locator 三种输入
|
| 206 |
+
* @param {import('playwright-core').Page} page - Playwright 页面对象
|
| 207 |
+
* @param {string|import('playwright-core').ElementHandle|import('playwright-core').Locator} target - CSS 选择器、元素句柄或 Locator
|
| 208 |
+
* @param {object} [options] - 滚动选项
|
| 209 |
+
* @param {number} [options.deltaX=0] - 水平滚动距离 (正值向右)
|
| 210 |
+
* @param {number} [options.deltaY=0] - 垂直滚动距离 (正值向下)
|
| 211 |
+
* @param {string} [options.bias='random'] - 偏移偏好: 'input' 或 'random'
|
| 212 |
+
* @returns {Promise<void>}
|
| 213 |
+
*/
|
| 214 |
+
export async function safeScroll(page, target, options = {}) {
|
| 215 |
+
try {
|
| 216 |
+
let el;
|
| 217 |
+
|
| 218 |
+
// 判断输入类型
|
| 219 |
+
if (typeof target === 'string') {
|
| 220 |
+
// CSS selector
|
| 221 |
+
el = await page.$(target);
|
| 222 |
+
if (!el) throw new Error(`未找到: ${target}`);
|
| 223 |
+
} else if (typeof target.elementHandle === 'function') {
|
| 224 |
+
// Locator (来自 page.getByRole, page.getByText 等)
|
| 225 |
+
el = await target.elementHandle();
|
| 226 |
+
if (!el) throw new Error(`Locator 未匹配到元素`);
|
| 227 |
+
} else {
|
| 228 |
+
// ElementHandle
|
| 229 |
+
el = target;
|
| 230 |
+
if (!el || !el.asElement()) throw new Error(`Element handle invalid`);
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
const deltaX = options.deltaX || 0;
|
| 234 |
+
const deltaY = options.deltaY || 0;
|
| 235 |
+
|
| 236 |
+
// 使用 ghost-cursor hover 后滚动
|
| 237 |
+
if (page.cursor) {
|
| 238 |
+
const box = await el.boundingBox();
|
| 239 |
+
if (box) {
|
| 240 |
+
const { x, y } = getHumanClickPoint(box, options.bias || 'random');
|
| 241 |
+
await page.cursor.moveTo({ x, y });
|
| 242 |
+
await page.mouse.wheel(deltaX, deltaY);
|
| 243 |
+
return;
|
| 244 |
+
}
|
| 245 |
+
// 如果无法获取 box,降级到元素中心点滚动
|
| 246 |
+
await page.cursor.move(el);
|
| 247 |
+
await page.mouse.wheel(deltaX, deltaY);
|
| 248 |
+
return;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
// 降级逻辑: 直接在元素上 hover 并滚动
|
| 252 |
+
await el.hover();
|
| 253 |
+
await page.mouse.wheel(deltaX, deltaY);
|
| 254 |
+
} catch (err) {
|
| 255 |
+
throw err;
|
| 256 |
+
}
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
/**
|
| 260 |
+
* 模拟人类键盘输入
|
| 261 |
+
* 支持 CSS selector 和 ElementHandle 两种输入
|
| 262 |
+
* @param {import('playwright-core').Page} page - Playwright 页面对象
|
| 263 |
+
* @param {string|import('playwright-core').ElementHandle|null} target - CSS 选择器、元素句柄,或 null(需配合 skipFocus 使用)
|
| 264 |
+
* @param {string} text - 要输入的文本
|
| 265 |
+
* @param {object} [options] - 可选配置
|
| 266 |
+
* @param {boolean} [options.skipFocus=false] - 跳过元素定位和 focus,直接输入(适用于已获得焦点的场景)
|
| 267 |
+
* @returns {Promise<void>}
|
| 268 |
+
*/
|
| 269 |
+
export async function humanType(page, target, text, options = {}) {
|
| 270 |
+
const { skipFocus = false } = options;
|
| 271 |
+
|
| 272 |
+
// 如果不跳过 focus,需要定位并聚焦元素
|
| 273 |
+
if (!skipFocus) {
|
| 274 |
+
let el;
|
| 275 |
+
|
| 276 |
+
// 判断是 selector 还是 ElementHandle
|
| 277 |
+
if (typeof target === 'string') {
|
| 278 |
+
el = await page.$(target);
|
| 279 |
+
if (!el) throw new Error(`Element not found: ${target}`);
|
| 280 |
+
} else {
|
| 281 |
+
el = target;
|
| 282 |
+
if (!el) throw new Error(`Element handle invalid`);
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
await el.focus();
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
// 智能输入策略
|
| 289 |
+
if (text.length < 50) {
|
| 290 |
+
// 短文本: 保持拟人化逐字输入
|
| 291 |
+
for (let i = 0; i < text.length; i++) {
|
| 292 |
+
const char = text[i];
|
| 293 |
+
const nextChar = text[i + 1];
|
| 294 |
+
|
| 295 |
+
// 处理换行符 (避免触发发送)
|
| 296 |
+
if (char === '\r' && nextChar === '\n') {
|
| 297 |
+
// Windows 换行符 (\r\n)
|
| 298 |
+
await page.keyboard.down('Shift');
|
| 299 |
+
await page.keyboard.press('Enter');
|
| 300 |
+
await page.keyboard.up('Shift');
|
| 301 |
+
i++; // 跳过 \n
|
| 302 |
+
await sleep(30, 100);
|
| 303 |
+
continue;
|
| 304 |
+
} else if (char === '\n' || char === '\r') {
|
| 305 |
+
// Unix/Mac 换行符 (\n 或 \r)
|
| 306 |
+
await page.keyboard.down('Shift');
|
| 307 |
+
await page.keyboard.press('Enter');
|
| 308 |
+
await page.keyboard.up('Shift');
|
| 309 |
+
await sleep(30, 100);
|
| 310 |
+
continue;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
// 模拟错字 (5% 概率)
|
| 314 |
+
if (Math.random() < 0.05) {
|
| 315 |
+
await page.keyboard.type('x', { delay: random(50, 150) });
|
| 316 |
+
await sleep(100, 300);
|
| 317 |
+
await page.keyboard.press('Backspace', { delay: random(50, 100) });
|
| 318 |
+
}
|
| 319 |
+
await page.keyboard.type(char, { delay: random(30, 100) });
|
| 320 |
+
// 随机击键间隔
|
| 321 |
+
await sleep(30, 100);
|
| 322 |
+
}
|
| 323 |
+
} else {
|
| 324 |
+
// 长文本: 假装打字 -> 停顿 -> 粘贴
|
| 325 |
+
const fakeCount = Math.floor(random(3, 8));
|
| 326 |
+
const fakeText = text.substring(0, fakeCount);
|
| 327 |
+
|
| 328 |
+
// 1. 假装打字几个字符
|
| 329 |
+
for (let i = 0; i < fakeText.length; i++) {
|
| 330 |
+
await page.keyboard.type(fakeText[i], { delay: random(30, 100) });
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
// 2. 停顿思考
|
| 334 |
+
await sleep(500, 1000);
|
| 335 |
+
|
| 336 |
+
// 3. 全选删除 (macOS 使用 Meta/Command, Windows/Linux 使用 Control)
|
| 337 |
+
const modifierKey = process.platform === 'darwin' ? 'Meta' : 'Control';
|
| 338 |
+
await page.keyboard.down(modifierKey);
|
| 339 |
+
await page.keyboard.press('A');
|
| 340 |
+
await page.keyboard.up(modifierKey);
|
| 341 |
+
await sleep(100, 300);
|
| 342 |
+
await page.keyboard.press('Backspace');
|
| 343 |
+
await sleep(100, 300);
|
| 344 |
+
|
| 345 |
+
// 4. 瞬间粘贴全部文本 (始终使用已获取的 ElementHandle,支持 Shadow DOM)
|
| 346 |
+
await page.evaluate((content) => {
|
| 347 |
+
document.execCommand('insertText', false, content);
|
| 348 |
+
}, text);
|
| 349 |
+
}
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
/**
|
| 353 |
+
* 查找页面上所有的文件输入框 (包括 Shadow DOM)
|
| 354 |
+
* @private
|
| 355 |
+
* @param {import('playwright-core').Page} page - Playwright 页面对象
|
| 356 |
+
* @returns {Promise<import('playwright-core').ElementHandle[]>} 文件输入框 ElementHandle 数组
|
| 357 |
+
*/
|
| 358 |
+
async function findAllFileInputs(page) {
|
| 359 |
+
// 使用 Playwright 的 evaluateHandle 在浏览器上下文中深度遍历
|
| 360 |
+
const inputsHandle = await page.evaluateHandle(() => {
|
| 361 |
+
const inputs = [];
|
| 362 |
+
|
| 363 |
+
function traverse(root) {
|
| 364 |
+
if (!root) return;
|
| 365 |
+
|
| 366 |
+
// 1. 检查当前节点下的 input
|
| 367 |
+
const nodes = root.querySelectorAll('input[type="file"]');
|
| 368 |
+
nodes.forEach(n => inputs.push(n));
|
| 369 |
+
|
| 370 |
+
// 2. 遍历 Shadow DOM
|
| 371 |
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, null, false);
|
| 372 |
+
while (walker.nextNode()) {
|
| 373 |
+
const node = walker.currentNode;
|
| 374 |
+
if (node.shadowRoot) {
|
| 375 |
+
traverse(node.shadowRoot);
|
| 376 |
+
}
|
| 377 |
+
}
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
traverse(document.body);
|
| 381 |
+
return inputs;
|
| 382 |
+
});
|
| 383 |
+
|
| 384 |
+
const properties = await inputsHandle.getProperties();
|
| 385 |
+
const handles = [];
|
| 386 |
+
for (const prop of properties.values()) {
|
| 387 |
+
const elementHandle = prop.asElement();
|
| 388 |
+
if (elementHandle) handles.push(elementHandle);
|
| 389 |
+
}
|
| 390 |
+
return handles;
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
/**
|
| 394 |
+
* 统一图片上传入口 (Camoufox/Playwright 专用稳定版)
|
| 395 |
+
* 策略: 深度搜索原生 input[type="file"] -> setInputFiles
|
| 396 |
+
* @param {import('playwright-core').Page} page - Playwright 页面对象
|
| 397 |
+
* @param {string|import('playwright-core').ElementHandle} target - CSS 选择器或元素句柄 (用于聚焦)
|
| 398 |
+
* @param {string[]} filePaths - 图片文件路径数组
|
| 399 |
+
* @param {Object} [options] - 可选配置
|
| 400 |
+
* @param {Function} [options.uploadValidator] - 自定义上传确认回调函数, 接收 response 参数
|
| 401 |
+
* @returns {Promise<void>}
|
| 402 |
+
*/
|
| 403 |
+
export async function pasteImages(page, target, filePaths, options = {}) {
|
| 404 |
+
if (!filePaths || filePaths.length === 0) return;
|
| 405 |
+
logger.info('浏览器', `正在处理 ${filePaths.length} 张图片...`);
|
| 406 |
+
|
| 407 |
+
// 1. 拟人化: 先点击一下目标区域 (让后台看起来像是用户聚焦了输入框)
|
| 408 |
+
await safeClick(page, target, { bias: 'input' });
|
| 409 |
+
await sleep(500, 1000);
|
| 410 |
+
|
| 411 |
+
try {
|
| 412 |
+
logger.debug('浏览器', '正在深度扫描文件上传控件...');
|
| 413 |
+
const fileInputs = await findAllFileInputs(page);
|
| 414 |
+
|
| 415 |
+
if (fileInputs.length === 0) {
|
| 416 |
+
throw new Error('未找到任何 input[type="file"] 控件,无法上传');
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
logger.info('浏览器', `找到 ${fileInputs.length} 个文件输入框,尝试上传...`);
|
| 420 |
+
|
| 421 |
+
// LMArena 通常只有一个用于聊天的上传控件,或者我们尝试第一个可用的
|
| 422 |
+
// 如果有多个,通常最后一个是当前对话框的,或者我们可以尝试全部 (比较暴力但有效)
|
| 423 |
+
let uploaded = false;
|
| 424 |
+
|
| 425 |
+
for (const handle of fileInputs) {
|
| 426 |
+
try {
|
| 427 |
+
// 检查元素是否连接在 DOM 上
|
| 428 |
+
const isConnected = await handle.evaluate(el => el.isConnected);
|
| 429 |
+
if (!isConnected) continue;
|
| 430 |
+
|
| 431 |
+
// 使用 Playwright 原生上传 (绕过所有事件拦截)
|
| 432 |
+
await handle.setInputFiles(filePaths);
|
| 433 |
+
uploaded = true;
|
| 434 |
+
logger.debug('浏览器', '已通过原生控件提交图片');
|
| 435 |
+
break; // 只要有一个成功就停止
|
| 436 |
+
} catch (e) {
|
| 437 |
+
// 忽略不可操作的 input (比如被禁用的)
|
| 438 |
+
logger.debug('浏览器', `跳过不可用的文件输入框: ${e.message}`);
|
| 439 |
+
}
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
if (!uploaded) {
|
| 443 |
+
throw new Error('所有文件控件均无法接受输入');
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
// 如果提供了自定义的上传确认函数,使用它
|
| 447 |
+
if (options.uploadValidator && typeof options.uploadValidator === 'function') {
|
| 448 |
+
const expectedUploads = filePaths.length;
|
| 449 |
+
let validatedCount = 0;
|
| 450 |
+
|
| 451 |
+
const uploadPromise = new Promise((resolve) => {
|
| 452 |
+
const timeout = setTimeout(() => {
|
| 453 |
+
cleanup();
|
| 454 |
+
logger.warn('浏览器', `图片上传等待超时 (已确认: ${validatedCount}/${expectedUploads})`);
|
| 455 |
+
resolve();
|
| 456 |
+
}, 60000); // 60s 超时
|
| 457 |
+
|
| 458 |
+
const onResponse = (response) => {
|
| 459 |
+
if (options.uploadValidator(response)) {
|
| 460 |
+
validatedCount++;
|
| 461 |
+
logger.info('浏览器', `图片上传进度: ${validatedCount}/${expectedUploads}`);
|
| 462 |
+
if (validatedCount >= expectedUploads) {
|
| 463 |
+
cleanup();
|
| 464 |
+
resolve();
|
| 465 |
+
}
|
| 466 |
+
}
|
| 467 |
+
};
|
| 468 |
+
|
| 469 |
+
const cleanup = () => {
|
| 470 |
+
clearTimeout(timeout);
|
| 471 |
+
page.off('response', onResponse);
|
| 472 |
+
};
|
| 473 |
+
|
| 474 |
+
page.on('response', onResponse);
|
| 475 |
+
});
|
| 476 |
+
|
| 477 |
+
logger.info('浏览器', `已提交图片, 正在等待上传确认...`);
|
| 478 |
+
await uploadPromise;
|
| 479 |
+
logger.info('浏览器', `所有图片上传完成`);
|
| 480 |
+
} else {
|
| 481 |
+
// 默认行为: 等待上传预览出现
|
| 482 |
+
logger.info('浏览器', `已提交图片, 等待预览生成...`);
|
| 483 |
+
await sleep(2000, 4000);
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
} catch (e) {
|
| 487 |
+
logger.error('浏览器', `上传失败: ${e.message}`);
|
| 488 |
+
throw e;
|
| 489 |
+
}
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
/**
|
| 493 |
+
* 通过 filechooser 事件上传文件 (适用于无 DOM input 元素的场景,如 Firefox)
|
| 494 |
+
* @param {import('playwright-core').Page} page - Playwright 页面对象
|
| 495 |
+
* @param {string|import('playwright-core').ElementHandle|import('playwright-core').Locator} triggerTarget - 触发文件选择的按钮
|
| 496 |
+
* @param {string[]} filePaths - 文件路径数组
|
| 497 |
+
* @param {Object} [options] - 可选配置
|
| 498 |
+
* @param {Function} [options.uploadValidator] - 自定义上传确认回调函数, 接收 response 参数,返回 true 表示该响应代表一次成功上传
|
| 499 |
+
* @param {number} [options.timeout=60000] - 上传超时时间 (毫秒)
|
| 500 |
+
* @returns {Promise<void>}
|
| 501 |
+
*/
|
| 502 |
+
export async function uploadFilesViaChooser(page, triggerTarget, filePaths, options = {}) {
|
| 503 |
+
if (!filePaths || filePaths.length === 0) return;
|
| 504 |
+
|
| 505 |
+
const timeout = options.timeout || 60000;
|
| 506 |
+
const expectedUploads = filePaths.length;
|
| 507 |
+
let uploadedCount = 0;
|
| 508 |
+
|
| 509 |
+
logger.info('浏览器', `正在处理 ${filePaths.length} 张图片 (filechooser 模式)...`);
|
| 510 |
+
|
| 511 |
+
// 设置上传确认监听
|
| 512 |
+
const uploadPromise = new Promise((resolve) => {
|
| 513 |
+
if (!options.uploadValidator) {
|
| 514 |
+
// 无验证器,直接 resolve
|
| 515 |
+
resolve();
|
| 516 |
+
return;
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
const timeoutId = setTimeout(() => {
|
| 520 |
+
cleanup();
|
| 521 |
+
logger.warn('浏览器', `图片上传等待超时 (已确认: ${uploadedCount}/${expectedUploads})`);
|
| 522 |
+
resolve();
|
| 523 |
+
}, timeout);
|
| 524 |
+
|
| 525 |
+
const onResponse = (response) => {
|
| 526 |
+
if (options.uploadValidator(response)) {
|
| 527 |
+
uploadedCount++;
|
| 528 |
+
logger.info('浏览器', `图片上传进度: ${uploadedCount}/${expectedUploads}`);
|
| 529 |
+
if (uploadedCount >= expectedUploads) {
|
| 530 |
+
cleanup();
|
| 531 |
+
resolve();
|
| 532 |
+
}
|
| 533 |
+
}
|
| 534 |
+
};
|
| 535 |
+
|
| 536 |
+
const cleanup = () => {
|
| 537 |
+
clearTimeout(timeoutId);
|
| 538 |
+
page.off('response', onResponse);
|
| 539 |
+
};
|
| 540 |
+
|
| 541 |
+
page.on('response', onResponse);
|
| 542 |
+
});
|
| 543 |
+
|
| 544 |
+
// 设置等待 filechooser 事件(在点击之前)
|
| 545 |
+
const fileChooserPromise = page.waitForEvent('filechooser');
|
| 546 |
+
|
| 547 |
+
// 点击触发按钮
|
| 548 |
+
await safeClick(page, triggerTarget, { bias: 'button' });
|
| 549 |
+
|
| 550 |
+
// 等待 filechooser 事件并设置文件
|
| 551 |
+
const fileChooser = await fileChooserPromise;
|
| 552 |
+
await fileChooser.setFiles(filePaths);
|
| 553 |
+
logger.debug('浏览器', '已通过 filechooser 提交文件');
|
| 554 |
+
|
| 555 |
+
// 等待上传完成(如果有验证器)
|
| 556 |
+
if (options.uploadValidator) {
|
| 557 |
+
await uploadPromise;
|
| 558 |
+
logger.info('浏览器', '所有图片上传完成');
|
| 559 |
+
}
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
/**
|
| 563 |
+
* 检查页面是否有效
|
| 564 |
+
* @param {import('playwright-core').Page} page
|
| 565 |
+
* @returns {boolean}
|
| 566 |
+
*/
|
| 567 |
+
export function isPageValid(page) {
|
| 568 |
+
try {
|
| 569 |
+
return page && !page.isClosed();
|
| 570 |
+
} catch {
|
| 571 |
+
return false;
|
| 572 |
+
}
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
/**
|
| 576 |
+
* 创建页面关闭/崩溃监听Promise
|
| 577 |
+
* @param {import('playwright-core').Page} page
|
| 578 |
+
* @returns {{promise: Promise, cleanup: Function}}
|
| 579 |
+
*/
|
| 580 |
+
export function createPageCloseWatcher(page) {
|
| 581 |
+
let closeHandler, crashHandler;
|
| 582 |
+
|
| 583 |
+
const promise = new Promise((_, reject) => {
|
| 584 |
+
closeHandler = () => reject(new Error('PAGE_CLOSED'));
|
| 585 |
+
crashHandler = () => reject(new Error('PAGE_CRASHED'));
|
| 586 |
+
|
| 587 |
+
page.once('close', closeHandler);
|
| 588 |
+
page.once('crash', crashHandler);
|
| 589 |
+
});
|
| 590 |
+
|
| 591 |
+
const cleanup = () => {
|
| 592 |
+
if (closeHandler) page.off('close', closeHandler);
|
| 593 |
+
if (crashHandler) page.off('crash', crashHandler);
|
| 594 |
+
};
|
| 595 |
+
|
| 596 |
+
return { promise, cleanup };
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
/**
|
| 600 |
+
* 获取当前页面的所有 Cookies (实时从浏览器获取)
|
| 601 |
+
* @param {import('playwright-core').Page} page - Playwright 页面实例
|
| 602 |
+
* @returns {Promise<object[]>} Cookies 数组 (JSON 格式)
|
| 603 |
+
*/
|
| 604 |
+
export async function getCookies(page) {
|
| 605 |
+
const context = page.context();
|
| 606 |
+
return await context.cookies();
|
| 607 |
+
}
|
src/backend/index.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview 后端适配器入口
|
| 3 |
+
* @description 基于 Pool 架构统一管理多浏览器实例,提供统一的对外接口。
|
| 4 |
+
*
|
| 5 |
+
* 对外统一能力:
|
| 6 |
+
* - `initBrowser(cfg)` → 初始化 Pool
|
| 7 |
+
* - `generate(ctx, prompt, imagePaths, modelId, meta)`
|
| 8 |
+
* - `getModels()` / `getImagePolicy(modelKey)` / `getModelType(modelKey)`
|
| 9 |
+
* - `getCookies(workerName, domain)` - 获取指定 Worker 的 Cookies
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
import fs from 'fs';
|
| 13 |
+
import path from 'path';
|
| 14 |
+
import { loadConfig } from '../config/index.js';
|
| 15 |
+
import { PoolManager } from './pool/index.js';
|
| 16 |
+
import { logger } from '../utils/logger.js';
|
| 17 |
+
|
| 18 |
+
// --- 集中管理的路径常量 ---
|
| 19 |
+
const TEMP_DIR = path.join(process.cwd(), 'data', 'temp');
|
| 20 |
+
|
| 21 |
+
// 确保临时目录存在
|
| 22 |
+
if (!fs.existsSync(TEMP_DIR)) {
|
| 23 |
+
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// 全局 PoolManager 实例
|
| 27 |
+
let poolManager = null;
|
| 28 |
+
|
| 29 |
+
/**
|
| 30 |
+
* 获取后端接口
|
| 31 |
+
* @returns {object} 后端统一接口
|
| 32 |
+
*/
|
| 33 |
+
export function getBackend() {
|
| 34 |
+
const config = loadConfig();
|
| 35 |
+
|
| 36 |
+
// 将临时目录路径注入 config 对象
|
| 37 |
+
config.paths = {
|
| 38 |
+
tempDir: TEMP_DIR
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
return {
|
| 42 |
+
name: 'pool',
|
| 43 |
+
config,
|
| 44 |
+
TEMP_DIR,
|
| 45 |
+
|
| 46 |
+
/**
|
| 47 |
+
* 初始化 Pool
|
| 48 |
+
* @param {object} cfg - 配置对象
|
| 49 |
+
* @returns {Promise<{poolManager: PoolManager, config: object}>}
|
| 50 |
+
*/
|
| 51 |
+
initBrowser: async (cfg) => {
|
| 52 |
+
if (poolManager && poolManager.initialized) {
|
| 53 |
+
return { poolManager, config: cfg };
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
poolManager = new PoolManager(cfg);
|
| 57 |
+
await poolManager.initAll();
|
| 58 |
+
|
| 59 |
+
return { poolManager, config: cfg };
|
| 60 |
+
},
|
| 61 |
+
|
| 62 |
+
/**
|
| 63 |
+
* 生成图片
|
| 64 |
+
* @param {object} ctx - 浏览器上下文 (来自 initBrowser 返回)
|
| 65 |
+
* @param {string} prompt - 提示词
|
| 66 |
+
* @param {string[]} paths - 图片路径
|
| 67 |
+
* @param {string} modelId - 模型 ID
|
| 68 |
+
* @param {object} meta - 元信息
|
| 69 |
+
*/
|
| 70 |
+
generate: async (ctx, prompt, paths, modelId, meta) => {
|
| 71 |
+
if (!poolManager) {
|
| 72 |
+
return { error: 'Pool 未初始化' };
|
| 73 |
+
}
|
| 74 |
+
return await poolManager.generate(ctx, prompt, paths, modelId, meta);
|
| 75 |
+
},
|
| 76 |
+
|
| 77 |
+
/**
|
| 78 |
+
* 获取模型列表
|
| 79 |
+
* @returns {object}
|
| 80 |
+
*/
|
| 81 |
+
getModels: () => {
|
| 82 |
+
if (!poolManager) {
|
| 83 |
+
return { object: 'list', data: [] };
|
| 84 |
+
}
|
| 85 |
+
return poolManager.getModels();
|
| 86 |
+
},
|
| 87 |
+
|
| 88 |
+
/**
|
| 89 |
+
* 获取图片策略
|
| 90 |
+
* @param {string} modelKey - 模型 key
|
| 91 |
+
* @returns {string}
|
| 92 |
+
*/
|
| 93 |
+
getImagePolicy: (modelKey) => {
|
| 94 |
+
if (!poolManager) {
|
| 95 |
+
return 'optional';
|
| 96 |
+
}
|
| 97 |
+
return poolManager.getImagePolicy(modelKey);
|
| 98 |
+
},
|
| 99 |
+
|
| 100 |
+
/**
|
| 101 |
+
* 获取模型类型
|
| 102 |
+
* @param {string} modelKey - 模型 key
|
| 103 |
+
* @returns {string} 'text' | 'image'
|
| 104 |
+
*/
|
| 105 |
+
getModelType: (modelKey) => {
|
| 106 |
+
if (!poolManager) {
|
| 107 |
+
return 'image';
|
| 108 |
+
}
|
| 109 |
+
return poolManager.getModelType(modelKey);
|
| 110 |
+
},
|
| 111 |
+
|
| 112 |
+
/**
|
| 113 |
+
* 获取 Cookies
|
| 114 |
+
* @param {string} [workerName] - Worker 名称
|
| 115 |
+
* @param {string} [domain] - 域名
|
| 116 |
+
* @returns {Promise<{worker: string, cookies: object[]}>}
|
| 117 |
+
*/
|
| 118 |
+
getCookies: async (workerName, domain) => {
|
| 119 |
+
if (!poolManager) {
|
| 120 |
+
throw new Error('Pool 未初始化');
|
| 121 |
+
}
|
| 122 |
+
return await poolManager.getCookies(workerName, domain);
|
| 123 |
+
},
|
| 124 |
+
|
| 125 |
+
/**
|
| 126 |
+
* 触发监控导航(空闲时)
|
| 127 |
+
*/
|
| 128 |
+
navigateToMonitor: async () => {
|
| 129 |
+
if (poolManager) {
|
| 130 |
+
await poolManager.navigateToMonitor();
|
| 131 |
+
}
|
| 132 |
+
},
|
| 133 |
+
|
| 134 |
+
/**
|
| 135 |
+
* 获取 PoolManager 实例
|
| 136 |
+
* @returns {PoolManager|null}
|
| 137 |
+
*/
|
| 138 |
+
getPoolManager: () => poolManager
|
| 139 |
+
};
|
| 140 |
+
}
|
src/backend/pool/PoolManager.js
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview PoolManager 类
|
| 3 |
+
* @description 管理 Worker 池,负责初始化、任务分发和故障转移
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import { logger } from '../../utils/logger.js';
|
| 7 |
+
import { registry } from '../registry.js';
|
| 8 |
+
import { createStrategySelector } from '../strategies/index.js';
|
| 9 |
+
import { executeWithFailover } from '../strategies/failover.js';
|
| 10 |
+
import { normalizeError } from '../utils/error.js';
|
| 11 |
+
import { Worker } from './Worker.js';
|
| 12 |
+
|
| 13 |
+
/**
|
| 14 |
+
* PoolManager 类 - 管理 Worker 池
|
| 15 |
+
*/
|
| 16 |
+
export class PoolManager {
|
| 17 |
+
/**
|
| 18 |
+
* @param {object} config - 全局配置
|
| 19 |
+
*/
|
| 20 |
+
constructor(config) {
|
| 21 |
+
this.config = config;
|
| 22 |
+
this.workers = [];
|
| 23 |
+
this.strategy = config.backend.pool.strategy || 'least_busy';
|
| 24 |
+
this.strategySelector = createStrategySelector(this.strategy);
|
| 25 |
+
this.initialized = false;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* 初始化所有 Worker
|
| 30 |
+
*/
|
| 31 |
+
async initAll() {
|
| 32 |
+
if (this.initialized) return;
|
| 33 |
+
|
| 34 |
+
// 先加载所有适配器
|
| 35 |
+
await registry.loadAll();
|
| 36 |
+
|
| 37 |
+
// 注入适配器配置(用于模型过滤)
|
| 38 |
+
const adapterConfig = this.config.backend?.adapter || {};
|
| 39 |
+
registry.setAdapterConfig(adapterConfig);
|
| 40 |
+
|
| 41 |
+
// 解析登录模式参数
|
| 42 |
+
let loginWorkerName = null;
|
| 43 |
+
const loginArg = process.argv.find(arg => arg.startsWith('-login'));
|
| 44 |
+
const isLoginMode = !!loginArg;
|
| 45 |
+
if (loginArg && loginArg.includes('=')) {
|
| 46 |
+
loginWorkerName = loginArg.split('=')[1];
|
| 47 |
+
logger.info('工作池', `登录模式: 仅初始化 Worker "${loginWorkerName}"`);
|
| 48 |
+
} else if (isLoginMode) {
|
| 49 |
+
loginWorkerName = this.config.backend.pool.workers[0]?.name || null;
|
| 50 |
+
logger.info('工作池', `登录模式: 仅初始化第一个 Worker "${loginWorkerName}"`);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const workerConfigs = this.config.backend.pool.workers;
|
| 54 |
+
|
| 55 |
+
if (isLoginMode) {
|
| 56 |
+
logger.info('工作池', `登录模式: 从 ${workerConfigs.length} 个 Worker 中筛选...`);
|
| 57 |
+
} else {
|
| 58 |
+
logger.info('工作池', `正在初始化 ${workerConfigs.length} 个 Worker...`);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// 过滤并创建 Worker 实例
|
| 62 |
+
const validWorkers = [];
|
| 63 |
+
for (const workerConfig of workerConfigs) {
|
| 64 |
+
if (isLoginMode && workerConfig.name !== loginWorkerName) {
|
| 65 |
+
logger.debug('工作池', `[${workerConfig.name}] 跳过 (不匹配登录目标)`);
|
| 66 |
+
continue;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
if (workerConfig.type !== 'merge' && !registry.hasAdapter(workerConfig.type)) {
|
| 70 |
+
logger.error('工作池', `Worker [${workerConfig.name}] 的类型 "${workerConfig.type}" 无对应适配器,跳过`);
|
| 71 |
+
continue;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
if (workerConfig.type === 'merge') {
|
| 75 |
+
const invalidTypes = (workerConfig.mergeTypes || []).filter(t => !registry.hasAdapter(t));
|
| 76 |
+
if (invalidTypes.length > 0) {
|
| 77 |
+
logger.error('工作池', `Worker [${workerConfig.name}] 的 mergeTypes 包含无效类型: ${invalidTypes.join(', ')}`);
|
| 78 |
+
continue;
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
validWorkers.push(new Worker(this.config, workerConfig));
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
if (isLoginMode && validWorkers.length === 0) {
|
| 86 |
+
const availableNames = workerConfigs.map(w => w.name).join(', ');
|
| 87 |
+
throw new Error(`登录模式未找到 Worker "${loginWorkerName}"。可用的 Worker: ${availableNames}`);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// 按 userDataDir 分组
|
| 91 |
+
const browserMap = new Map();
|
| 92 |
+
|
| 93 |
+
for (const worker of validWorkers) {
|
| 94 |
+
try {
|
| 95 |
+
const existing = browserMap.get(worker.userDataDir);
|
| 96 |
+
|
| 97 |
+
if (existing) {
|
| 98 |
+
const workerProxy = JSON.stringify(worker.proxyConfig || null);
|
| 99 |
+
const existingProxy = JSON.stringify(existing.proxyConfig || null);
|
| 100 |
+
if (workerProxy !== existingProxy) {
|
| 101 |
+
logger.warn('工作池', `[${worker.name}] 代理配置与 [${existing.firstWorkerName}] 不一致,将使用后者的配置`);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
logger.debug('工作池', `[${worker.name}] 将与其他 Worker 共享浏览器 (${worker.userDataDir})`);
|
| 105 |
+
await worker.init(existing.browser);
|
| 106 |
+
} else {
|
| 107 |
+
await worker.init();
|
| 108 |
+
browserMap.set(worker.userDataDir, {
|
| 109 |
+
browser: worker.browser,
|
| 110 |
+
proxyConfig: worker.proxyConfig,
|
| 111 |
+
firstWorkerName: worker.name
|
| 112 |
+
});
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
this.workers.push(worker);
|
| 116 |
+
} catch (e) {
|
| 117 |
+
logger.error('工作池', `[${worker.name}] 初始化失败,跳过该 Worker`, { error: e.message });
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
if (this.workers.length === 0) {
|
| 122 |
+
throw new Error('所有 Worker 初始化都失败了,无法启动���务');
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
this.initialized = true;
|
| 126 |
+
logger.info('工作池', `工作池初始化完成,共 ${this.workers.length} 个 Worker 就绪 (${browserMap.size} 个浏览器实例)`);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
/**
|
| 130 |
+
* 根据模型选择 Worker
|
| 131 |
+
*/
|
| 132 |
+
selectWorker(modelId) {
|
| 133 |
+
const candidates = this.workers.filter(w => w.supports(modelId));
|
| 134 |
+
|
| 135 |
+
if (candidates.length === 0) {
|
| 136 |
+
throw new Error(`没有 Worker 支持模型: ${modelId}`);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
if (candidates.length === 1) {
|
| 140 |
+
return candidates[0];
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
switch (this.strategy) {
|
| 144 |
+
case 'round_robin': {
|
| 145 |
+
const idx = this.roundRobinIndex % candidates.length;
|
| 146 |
+
this.roundRobinIndex++;
|
| 147 |
+
return candidates[idx];
|
| 148 |
+
}
|
| 149 |
+
case 'random': {
|
| 150 |
+
const idx = Math.floor(Math.random() * candidates.length);
|
| 151 |
+
return candidates[idx];
|
| 152 |
+
}
|
| 153 |
+
case 'least_busy':
|
| 154 |
+
default: {
|
| 155 |
+
return candidates.reduce((min, w) => w.busyCount < min.busyCount ? w : min, candidates[0]);
|
| 156 |
+
}
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
/**
|
| 161 |
+
* 分发生图任务(支持故障转移)
|
| 162 |
+
*/
|
| 163 |
+
async generate(ctx, prompt, paths, modelId, meta) {
|
| 164 |
+
const failoverConfig = this.config.backend?.pool?.failover || {};
|
| 165 |
+
const failoverEnabled = failoverConfig.enabled !== false;
|
| 166 |
+
const maxRetries = failoverConfig.maxRetries || 2;
|
| 167 |
+
|
| 168 |
+
let candidates = this.workers.filter(w => w.supports(modelId));
|
| 169 |
+
|
| 170 |
+
if (candidates.length === 0) {
|
| 171 |
+
return { error: `没有 Worker 支持模型: ${modelId}` };
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
// 如果请求包含图片,优先选择 imagePolicy 为 optional 的 Worker
|
| 175 |
+
const hasImages = paths && paths.length > 0;
|
| 176 |
+
if (hasImages && candidates.length > 1) {
|
| 177 |
+
const optionalCandidates = candidates.filter(w => {
|
| 178 |
+
const policy = w.getImagePolicy(modelId);
|
| 179 |
+
return policy === 'optional' || policy === 'required';
|
| 180 |
+
});
|
| 181 |
+
|
| 182 |
+
if (optionalCandidates.length > 0) {
|
| 183 |
+
logger.debug('工作池', `请求包含图片,优先选择支持图片的 Worker (${optionalCandidates.length}/${candidates.length} 个)`);
|
| 184 |
+
candidates = optionalCandidates;
|
| 185 |
+
} else {
|
| 186 |
+
logger.warn('工作池', `请求包含图片,但没有 Worker 的 imagePolicy 为 optional`);
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
const sortedCandidates = this.strategySelector.sort(candidates);
|
| 191 |
+
|
| 192 |
+
if (!failoverEnabled) {
|
| 193 |
+
const worker = sortedCandidates[0];
|
| 194 |
+
logger.debug('工作池', `任务分发至: ${worker.name} (busy: ${worker.busyCount})`);
|
| 195 |
+
return await this._safeExecuteWorker(worker, ctx, prompt, paths, modelId, meta);
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
return await executeWithFailover(
|
| 199 |
+
sortedCandidates,
|
| 200 |
+
async (worker) => {
|
| 201 |
+
logger.debug('工作池', `任务分发至: ${worker.name} (busy: ${worker.busyCount})`);
|
| 202 |
+
return await this._safeExecuteWorker(worker, ctx, prompt, paths, modelId, meta);
|
| 203 |
+
},
|
| 204 |
+
{
|
| 205 |
+
maxRetries,
|
| 206 |
+
meta,
|
| 207 |
+
onRetry: (worker, error) => {
|
| 208 |
+
logger.warn('工作池', `[${worker.name}] 失败,尝试下一个 Worker...`, { error, ...meta });
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
);
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
/**
|
| 215 |
+
* 安全执行 Worker(带错误边界)
|
| 216 |
+
* @private
|
| 217 |
+
*/
|
| 218 |
+
async _safeExecuteWorker(worker, ctx, prompt, paths, modelId, meta) {
|
| 219 |
+
try {
|
| 220 |
+
return await worker.generate(ctx, prompt, paths, modelId, meta);
|
| 221 |
+
} catch (err) {
|
| 222 |
+
logger.error('工作池', `[${worker.name}] 执行异常`, { error: err.message, ...meta });
|
| 223 |
+
return normalizeError(err.message || '执行异常');
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
/**
|
| 228 |
+
* 获取所有模型列表
|
| 229 |
+
*/
|
| 230 |
+
getModels() {
|
| 231 |
+
const allModels = [];
|
| 232 |
+
const seenIds = new Set();
|
| 233 |
+
|
| 234 |
+
for (const worker of this.workers) {
|
| 235 |
+
const models = worker.getModels();
|
| 236 |
+
for (const m of models) {
|
| 237 |
+
if (!seenIds.has(m.id)) {
|
| 238 |
+
seenIds.add(m.id);
|
| 239 |
+
allModels.push(m);
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
return { object: 'list', data: allModels };
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
/**
|
| 248 |
+
* 获取图片策略(宽松策略:只要有一个 Worker 支持 optional 就返回 optional)
|
| 249 |
+
*/
|
| 250 |
+
getImagePolicy(modelKey) {
|
| 251 |
+
const policies = new Set();
|
| 252 |
+
|
| 253 |
+
for (const worker of this.workers) {
|
| 254 |
+
if (worker.supports(modelKey)) {
|
| 255 |
+
policies.add(worker.getImagePolicy(modelKey));
|
| 256 |
+
}
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
// 宽松策略:只要有一个 optional 就返回 optional
|
| 260 |
+
if (policies.has('optional')) return 'optional';
|
| 261 |
+
if (policies.has('required')) return 'required';
|
| 262 |
+
if (policies.has('forbidden')) return 'forbidden';
|
| 263 |
+
return 'optional';
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
/**
|
| 267 |
+
* 获取模型类型
|
| 268 |
+
*/
|
| 269 |
+
getModelType(modelKey) {
|
| 270 |
+
for (const worker of this.workers) {
|
| 271 |
+
if (worker.supports(modelKey)) {
|
| 272 |
+
return worker.getModelType(modelKey);
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
return 'image';
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
/**
|
| 279 |
+
* 获取指定实例的 Cookies
|
| 280 |
+
*/
|
| 281 |
+
async getCookies(instanceName, domain) {
|
| 282 |
+
let worker;
|
| 283 |
+
if (instanceName) {
|
| 284 |
+
worker = this.workers.find(w => w.instanceName === instanceName);
|
| 285 |
+
if (!worker) {
|
| 286 |
+
throw new Error(`浏览器实例不存在: ${instanceName}`);
|
| 287 |
+
}
|
| 288 |
+
} else {
|
| 289 |
+
worker = this.workers[0];
|
| 290 |
+
if (!worker) {
|
| 291 |
+
throw new Error('工作池中没有可用的 Worker');
|
| 292 |
+
}
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
const cookies = await worker.getCookies(domain);
|
| 296 |
+
return { instance: worker.instanceName, cookies };
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
/**
|
| 300 |
+
* 触发所有 merge Worker 的监控导航
|
| 301 |
+
*/
|
| 302 |
+
async navigateToMonitor() {
|
| 303 |
+
for (const worker of this.workers) {
|
| 304 |
+
if (worker.type === 'merge' && worker.busyCount === 0) {
|
| 305 |
+
await worker.navigateToMonitor();
|
| 306 |
+
}
|
| 307 |
+
}
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
/**
|
| 311 |
+
* 获取第一个 Worker 的 page
|
| 312 |
+
*/
|
| 313 |
+
getFirstPage() {
|
| 314 |
+
return this.workers[0]?.page || null;
|
| 315 |
+
}
|
| 316 |
+
}
|
src/backend/pool/Worker.js
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @fileoverview Worker 类
|
| 3 |
+
* @description 封装单个浏览器实例,提供模型匹配和任务执行能力
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import fs from 'fs';
|
| 7 |
+
import { logger } from '../../utils/logger.js';
|
| 8 |
+
import { initBrowserBase, createCursor } from '../engine/launcher.js';
|
| 9 |
+
import { registry } from '../registry.js';
|
| 10 |
+
import { tryGotoWithCheck } from '../utils/page.js';
|
| 11 |
+
|
| 12 |
+
/**
|
| 13 |
+
* Worker 类 - 封装单个浏览器实例
|
| 14 |
+
*/
|
| 15 |
+
export class Worker {
|
| 16 |
+
/**
|
| 17 |
+
* @param {object} globalConfig - 全局配置
|
| 18 |
+
* @param {object} workerConfig - Worker 配置
|
| 19 |
+
*/
|
| 20 |
+
constructor(globalConfig, workerConfig) {
|
| 21 |
+
this.name = workerConfig.name;
|
| 22 |
+
this.type = workerConfig.type;
|
| 23 |
+
this.instanceName = workerConfig.instanceName || null;
|
| 24 |
+
this.userDataDir = workerConfig.userDataDir;
|
| 25 |
+
this.proxyConfig = workerConfig.resolvedProxy;
|
| 26 |
+
this.globalConfig = globalConfig;
|
| 27 |
+
this.workerConfig = workerConfig;
|
| 28 |
+
|
| 29 |
+
// Merge 模式专属
|
| 30 |
+
this.mergeTypes = workerConfig.mergeTypes || [];
|
| 31 |
+
this.mergeMonitor = workerConfig.mergeMonitor || null;
|
| 32 |
+
|
| 33 |
+
// 运行时状态
|
| 34 |
+
this.browser = null;
|
| 35 |
+
this.page = null;
|
| 36 |
+
this.busyCount = 0;
|
| 37 |
+
this.initialized = false;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
/**
|
| 41 |
+
* 初始化浏览器实例
|
| 42 |
+
* @param {object} [sharedBrowser] - 可选,共享的浏览器实例
|
| 43 |
+
*/
|
| 44 |
+
async init(sharedBrowser = null) {
|
| 45 |
+
if (this.initialized) return;
|
| 46 |
+
|
| 47 |
+
// 确保用户数据目录存在
|
| 48 |
+
if (!fs.existsSync(this.userDataDir)) {
|
| 49 |
+
fs.mkdirSync(this.userDataDir, { recursive: true });
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// 获取目标 URL
|
| 53 |
+
let targetUrl = 'about:blank';
|
| 54 |
+
if (this.type === 'merge') {
|
| 55 |
+
const firstType = this.mergeTypes[0];
|
| 56 |
+
targetUrl = registry.getTargetUrl(firstType, this.globalConfig, this.workerConfig) || 'about:blank';
|
| 57 |
+
} else {
|
| 58 |
+
targetUrl = registry.getTargetUrl(this.type, this.globalConfig, this.workerConfig) || 'about:blank';
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// 登录模式下不注册导航处理器,避免自动登录干预用户操作
|
| 62 |
+
const isLoginMode = process.argv.some(arg => arg.startsWith('-login'));
|
| 63 |
+
let navigationHandler = null;
|
| 64 |
+
|
| 65 |
+
if (!isLoginMode) {
|
| 66 |
+
// 收集导航处理器
|
| 67 |
+
const handlers = [];
|
| 68 |
+
const typesToHandle = this.type === 'merge' ? this.mergeTypes : [this.type];
|
| 69 |
+
for (const type of typesToHandle) {
|
| 70 |
+
const typeHandlers = registry.getNavigationHandlers(type);
|
| 71 |
+
handlers.push(...typeHandlers);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
navigationHandler = handlers.length > 0
|
| 75 |
+
? async (page) => {
|
| 76 |
+
for (const handler of handlers) {
|
| 77 |
+
try { await handler(page); } catch (e) { /* ignore */ }
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
: null;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
logger.info('工作池', `[${this.name}] 正在初始化浏览器...`);
|
| 84 |
+
if (this.proxyConfig) {
|
| 85 |
+
logger.debug('工作池', `[${this.name}] 使用代理: ${this.proxyConfig.type}://${this.proxyConfig.host}:${this.proxyConfig.port}`);
|
| 86 |
+
} else {
|
| 87 |
+
logger.debug('工作池', `[${this.name}] 直连模式(无代理)`);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
if (sharedBrowser) {
|
| 91 |
+
await this._initWithSharedBrowser(sharedBrowser, targetUrl, navigationHandler);
|
| 92 |
+
} else {
|
| 93 |
+
await this._initNewBrowser(targetUrl, navigationHandler);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
this.initialized = true;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/**
|
| 100 |
+
* 使用共享浏览器初始化
|
| 101 |
+
* @private
|
| 102 |
+
*/
|
| 103 |
+
async _initWithSharedBrowser(sharedBrowser, targetUrl, navigationHandler) {
|
| 104 |
+
logger.info('工作池', `[${this.name}] 复用已有浏览器,创建新标签页...`);
|
| 105 |
+
this.browser = sharedBrowser;
|
| 106 |
+
this.page = await sharedBrowser.newPage();
|
| 107 |
+
this.page.authState = { isHandlingAuth: false };
|
| 108 |
+
this.page.cursor = createCursor(this.page);
|
| 109 |
+
|
| 110 |
+
await this._navigateToTarget(targetUrl);
|
| 111 |
+
|
| 112 |
+
if (navigationHandler) {
|
| 113 |
+
this.page.on('framenavigated', async () => {
|
| 114 |
+
try { await navigationHandler(this.page); } catch (e) { /* ignore */ }
|
| 115 |
+
});
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
logger.info('工作池', `[${this.name}] 初始化完成`);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
/**
|
| 122 |
+
* 启动新浏览器初始化
|
| 123 |
+
* @private
|
| 124 |
+
*/
|
| 125 |
+
async _initNewBrowser(targetUrl, navigationHandler) {
|
| 126 |
+
const base = await initBrowserBase(this.globalConfig, {
|
| 127 |
+
userDataDir: this.userDataDir,
|
| 128 |
+
instanceName: this.instanceName,
|
| 129 |
+
proxyConfig: this.proxyConfig
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
this.browser = base.context;
|
| 133 |
+
this.page = base.page;
|
| 134 |
+
this.page.authState = { isHandlingAuth: false };
|
| 135 |
+
this.page.cursor = createCursor(this.page);
|
| 136 |
+
|
| 137 |
+
if (navigationHandler) {
|
| 138 |
+
this.page.on('framenavigated', async () => {
|
| 139 |
+
try { await navigationHandler(this.page); } catch (e) { /* ignore */ }
|
| 140 |
+
});
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
logger.info('工作池', `[${this.name}] 正在连接目标页面...`);
|
| 144 |
+
await this._navigateToTarget(targetUrl);
|
| 145 |
+
|
| 146 |
+
// 登录模式:注册浏览器关闭事件(不阻塞)
|
| 147 |
+
const isLoginMode = process.argv.some(arg => arg.startsWith('-login'));
|
| 148 |
+
if (isLoginMode) {
|
| 149 |
+
logger.info('工作池', `[${this.name}] 登录模式已就绪,请在浏览器中完成登录`);
|
| 150 |
+
this.browser.on('close', () => {
|
| 151 |
+
logger.info('工作池', `[${this.name}] 浏览器已关闭,登录模式结束`);
|
| 152 |
+
process.exit(0);
|
| 153 |
+
});
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
logger.info('工作池', `[${this.name}] 初始化完成`);
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
/**
|
| 160 |
+
* 导航到目标 URL
|
| 161 |
+
* @private
|
| 162 |
+
*/
|
| 163 |
+
async _navigateToTarget(targetUrl) {
|
| 164 |
+
if (this.type === 'merge') {
|
| 165 |
+
let gotoSuccess = false;
|
| 166 |
+
for (const type of this.mergeTypes) {
|
| 167 |
+
const url = registry.getTargetUrl(type, this.globalConfig, this.workerConfig);
|
| 168 |
+
if (!url) continue;
|
| 169 |
+
const gotoResult = await tryGotoWithCheck(this.page, url, { timeout: 30000 });
|
| 170 |
+
if (!gotoResult.error) {
|
| 171 |
+
gotoSuccess = true;
|
| 172 |
+
logger.debug('工作池', `[${this.name}] 使用 ${type} 适配器初始化成功`);
|
| 173 |
+
break;
|
| 174 |
+
}
|
| 175 |
+
logger.warn('工作池', `[${this.name}] ${type} 网站不可用,尝试下一个...`, { error: gotoResult.error });
|
| 176 |
+
}
|
| 177 |
+
if (!gotoSuccess) {
|
| 178 |
+
logger.warn('工作池', `[${this.name}] 所有适配器网站当前不可用,但 Worker 仍将初始化(请求时可能会失败)`);
|
| 179 |
+
}
|
| 180 |
+
} else {
|
| 181 |
+
const gotoResult = await tryGotoWithCheck(this.page, targetUrl, { timeout: 60000 });
|
| 182 |
+
if (gotoResult.error) {
|
| 183 |
+
logger.warn('工作池', `[${this.name}] 目标网站当前不可用: ${gotoResult.error},但 Worker 仍将初始化`);
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
/**
|
| 189 |
+
* 检查是否支持指定模型
|
| 190 |
+
*/
|
| 191 |
+
supports(modelId) {
|
| 192 |
+
if (this.type === 'merge') {
|
| 193 |
+
// 检查任一适配器是否支持该模型
|
| 194 |
+
for (const type of this.mergeTypes) {
|
| 195 |
+
if (registry.supportsModel(type, modelId)) return true;
|
| 196 |
+
}
|
| 197 |
+
// 支持 type/model 格式
|
| 198 |
+
if (modelId.includes('/')) {
|
| 199 |
+
const [specifiedType, actualModel] = modelId.split('/', 2);
|
| 200 |
+
if (this.mergeTypes.includes(specifiedType)) {
|
| 201 |
+
return registry.supportsModel(specifiedType, actualModel);
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
return false;
|
| 205 |
+
} else {
|
| 206 |
+
// 支持 type/model 格式
|
| 207 |
+
if (modelId.includes('/')) {
|
| 208 |
+
const [specifiedType, actualModel] = modelId.split('/', 2);
|
| 209 |
+
if (specifiedType === this.type) {
|
| 210 |
+
return registry.supportsModel(this.type, actualModel);
|
| 211 |
+
}
|
| 212 |
+
return false;
|
| 213 |
+
}
|
| 214 |
+
return registry.supportsModel(this.type, modelId);
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
/**
|
| 219 |
+
* 确定模型对应的适配器类型(内部辅助方法)
|
| 220 |
+
* @private
|
| 221 |
+
*/
|
| 222 |
+
_getAdapterType(modelKey) {
|
| 223 |
+
if (this.type === 'merge') {
|
| 224 |
+
if (modelKey.includes('/')) {
|
| 225 |
+
const [specifiedType] = modelKey.split('/', 2);
|
| 226 |
+
return this.mergeTypes.includes(specifiedType) ? specifiedType : this.mergeTypes[0];
|
| 227 |
+
}
|
| 228 |
+
// 找到第一个支持该模型的适配器
|
| 229 |
+
for (const type of this.mergeTypes) {
|
| 230 |
+
if (registry.supportsModel(type, modelKey)) return type;
|
| 231 |
+
}
|
| 232 |
+
return this.mergeTypes[0];
|
| 233 |
+
}
|
| 234 |
+
return this.type;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
/**
|
| 238 |
+
* 生成图片
|
| 239 |
+
*/
|
| 240 |
+
async generate(ctx, prompt, paths, modelId, meta) {
|
| 241 |
+
const failoverConfig = this.globalConfig.backend?.pool?.failover || {};
|
| 242 |
+
const failoverEnabled = failoverConfig.enabled !== false;
|
| 243 |
+
|
| 244 |
+
if (this.type === 'merge' && failoverEnabled) {
|
| 245 |
+
return this._generateWithFailover(ctx, prompt, paths, modelId, meta, failoverConfig);
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
// 验证是否支持该模型
|
| 249 |
+
if (!this.supports(modelId)) {
|
| 250 |
+
return { error: `Worker [${this.name}] 不支持模型: ${modelId}` };
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
// 确定适配器类型
|
| 254 |
+
const type = this._getAdapterType(modelId);
|
| 255 |
+
|
| 256 |
+
// 处理 type/model 格式,提取实际 modelId
|
| 257 |
+
let actualModelId = modelId;
|
| 258 |
+
if (modelId.includes('/')) {
|
| 259 |
+
const parts = modelId.split('/', 2);
|
| 260 |
+
actualModelId = parts[1];
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
// 传递原始 modelId 给适配器,由适配器自己解析
|
| 264 |
+
return this._executeAdapter(ctx, type, actualModelId, prompt, paths, meta);
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
/**
|
| 268 |
+
* Merge 模式下的故障转移生成
|
| 269 |
+
* @private
|
| 270 |
+
*/
|
| 271 |
+
async _generateWithFailover(ctx, prompt, paths, modelId, meta, failoverConfig = {}) {
|
| 272 |
+
const maxRetries = failoverConfig.maxRetries || 2;
|
| 273 |
+
const candidateTypes = this._getCandidateTypes(modelId);
|
| 274 |
+
|
| 275 |
+
if (candidateTypes.length === 0) {
|
| 276 |
+
return { error: `Worker [${this.name}] 不支持模型: ${modelId}` };
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
const maxAttempts = maxRetries === 0 ? candidateTypes.length : Math.min(maxRetries + 1, candidateTypes.length);
|
| 280 |
+
let lastError = null;
|
| 281 |
+
|
| 282 |
+
for (let i = 0; i < maxAttempts; i++) {
|
| 283 |
+
const { type, modelId: actualModelId } = candidateTypes[i];
|
| 284 |
+
const result = await this._executeAdapter(ctx, type, actualModelId, prompt, paths, meta);
|
| 285 |
+
|
| 286 |
+
if (!result.error) {
|
| 287 |
+
return result;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
lastError = result.error;
|
| 291 |
+
if (i < maxAttempts - 1) {
|
| 292 |
+
logger.warn('工作池', `[${this.name}] ${type} 失败,尝试下一个适配器...`, { error: lastError, ...meta });
|
| 293 |
+
}
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
return { error: `所有支持该模型的适配器都无法使用: ${lastError}` };
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
/**
|
| 300 |
+
* 获取支持指定模型的候选适配器类型列表
|
| 301 |
+
* @private
|
| 302 |
+
*/
|
| 303 |
+
_getCandidateTypes(modelKey) {
|
| 304 |
+
const candidates = [];
|
| 305 |
+
|
| 306 |
+
if (modelKey.includes('/')) {
|
| 307 |
+
const [specifiedType, actualModel] = modelKey.split('/', 2);
|
| 308 |
+
if (this.mergeTypes.includes(specifiedType) && registry.supportsModel(specifiedType, actualModel)) {
|
| 309 |
+
candidates.push({ type: specifiedType, modelId: actualModel });
|
| 310 |
+
}
|
| 311 |
+
return candidates;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
// 收集所有支持该模型的适配器
|
| 315 |
+
for (const type of this.mergeTypes) {
|
| 316 |
+
if (registry.supportsModel(type, modelKey)) {
|
| 317 |
+
candidates.push({ type, modelId: modelKey });
|
| 318 |
+
}
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
return candidates;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
/**
|
| 325 |
+
* 执行单个适配器
|
| 326 |
+
* @private
|
| 327 |
+
*/
|
| 328 |
+
async _executeAdapter(ctx, type, modelId, prompt, paths, meta) {
|
| 329 |
+
const adapter = registry.getAdapter(type);
|
| 330 |
+
if (!adapter) {
|
| 331 |
+
return { error: `适配器不存在: ${type}` };
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
logger.info('工作池', `[${this.name}] 执行任务 -> ${type}/${modelId}`, meta);
|
| 335 |
+
|
| 336 |
+
const subContext = {
|
| 337 |
+
...ctx,
|
| 338 |
+
page: this.page,
|
| 339 |
+
config: this.globalConfig,
|
| 340 |
+
proxyConfig: this.proxyConfig,
|
| 341 |
+
userDataDir: this.userDataDir
|
| 342 |
+
};
|
| 343 |
+
|
| 344 |
+
this.busyCount++;
|
| 345 |
+
try {
|
| 346 |
+
// 传递原始 modelId,由适配器自己解析
|
| 347 |
+
return await adapter.generate(subContext, prompt, paths, modelId, meta);
|
| 348 |
+
} finally {
|
| 349 |
+
this.busyCount--;
|
| 350 |
+
}
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
/**
|
| 354 |
+
* 获取支持的模型列表
|
| 355 |
+
*/
|
| 356 |
+
getModels() {
|
| 357 |
+
if (this.type === 'merge') {
|
| 358 |
+
const allModels = [];
|
| 359 |
+
const seenIds = new Set();
|
| 360 |
+
|
| 361 |
+
for (const type of this.mergeTypes) {
|
| 362 |
+
const result = registry.getModelsForAdapter(type);
|
| 363 |
+
if (result?.data) {
|
| 364 |
+
for (const m of result.data) {
|
| 365 |
+
if (!seenIds.has(m.id)) {
|
| 366 |
+
seenIds.add(m.id);
|
| 367 |
+
allModels.push({ ...m, owned_by: 'internal_server' });
|
| 368 |
+
}
|
| 369 |
+
}
|
| 370 |
+
}
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
for (const type of this.mergeTypes) {
|
| 374 |
+
const result = registry.getModelsForAdapter(type);
|
| 375 |
+
if (result?.data) {
|
| 376 |
+
for (const m of result.data) {
|
| 377 |
+
allModels.push({
|
| 378 |
+
...m,
|
| 379 |
+
id: `${type}/${m.id}`,
|
| 380 |
+
owned_by: type
|
| 381 |
+
});
|
| 382 |
+
}
|
| 383 |
+
}
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
return allModels;
|
| 387 |
+
} else {
|
| 388 |
+
const result = registry.getModelsForAdapter(this.type);
|
| 389 |
+
const models = result?.data || [];
|
| 390 |
+
const allModels = [];
|
| 391 |
+
|
| 392 |
+
for (const m of models) {
|
| 393 |
+
allModels.push({ ...m, owned_by: 'internal_server' });
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
for (const m of models) {
|
| 397 |
+
allModels.push({
|
| 398 |
+
...m,
|
| 399 |
+
id: `${this.type}/${m.id}`,
|
| 400 |
+
owned_by: this.type
|
| 401 |
+
});
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
return allModels;
|
| 405 |
+
}
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
/**
|
| 409 |
+
* 获取图片策略(宽松策略:只要有一个适配器支持 optional 就返回 optional)
|
| 410 |
+
*/
|
| 411 |
+
getImagePolicy(modelKey) {
|
| 412 |
+
const policies = new Set();
|
| 413 |
+
|
| 414 |
+
if (this.type === 'merge') {
|
| 415 |
+
if (modelKey.includes('/')) {
|
| 416 |
+
const [specifiedType, actualModel] = modelKey.split('/', 2);
|
| 417 |
+
if (this.mergeTypes.includes(specifiedType)) {
|
| 418 |
+
return registry.getImagePolicy(specifiedType, actualModel);
|
| 419 |
+
}
|
| 420 |
+
}
|
| 421 |
+
// 收集所有支持该模型的适配器的 imagePolicy
|
| 422 |
+
for (const type of this.mergeTypes) {
|
| 423 |
+
if (registry.supportsModel(type, modelKey)) {
|
| 424 |
+
policies.add(registry.getImagePolicy(type, modelKey));
|
| 425 |
+
}
|
| 426 |
+
}
|
| 427 |
+
} else {
|
| 428 |
+
return registry.getImagePolicy(this.type, modelKey);
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
// 宽松策略:只要有一个 optional 就返回 optional
|
| 432 |
+
if (policies.has('optional')) return 'optional';
|
| 433 |
+
if (policies.has('required')) return 'required';
|
| 434 |
+
if (policies.has('forbidden')) return 'forbidden';
|
| 435 |
+
return 'optional';
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
/**
|
| 439 |
+
* 获取模型类型
|
| 440 |
+
*/
|
| 441 |
+
getModelType(modelKey) {
|
| 442 |
+
if (this.type === 'merge') {
|
| 443 |
+
if (modelKey.includes('/')) {
|
| 444 |
+
const [specifiedType, actualModel] = modelKey.split('/', 2);
|
| 445 |
+
if (this.mergeTypes.includes(specifiedType)) {
|
| 446 |
+
return registry.getModelType(specifiedType, actualModel);
|
| 447 |
+
}
|
| 448 |
+
}
|
| 449 |
+
for (const type of this.mergeTypes) {
|
| 450 |
+
if (registry.supportsModel(type, modelKey)) {
|
| 451 |
+
return registry.getModelType(type, modelKey);
|
| 452 |
+
}
|
| 453 |
+
}
|
| 454 |
+
return 'image';
|
| 455 |
+
} else {
|
| 456 |
+
return registry.getModelType(this.type, modelKey);
|
| 457 |
+
}
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
/**
|
| 461 |
+
* 导航到监控页面(空闲时)
|
| 462 |
+
*/
|
| 463 |
+
async navigateToMonitor() {
|
| 464 |
+
if (this.type !== 'merge' || !this.mergeMonitor) return;
|
| 465 |
+
if (!this.page || this.page.isClosed()) return;
|
| 466 |
+
|
| 467 |
+
const targetUrl = registry.getTargetUrl(this.mergeMonitor, this.globalConfig, this.workerConfig);
|
| 468 |
+
if (!targetUrl) return;
|
| 469 |
+
|
| 470 |
+
const currentUrl = this.page.url();
|
| 471 |
+
try {
|
| 472 |
+
if (currentUrl.includes(new URL(targetUrl).hostname)) return;
|
| 473 |
+
} catch (e) { return; }
|
| 474 |
+
|
| 475 |
+
logger.info('工作池', `[${this.name}] 空闲,跳转监控: ${this.mergeMonitor}`);
|
| 476 |
+
try {
|
| 477 |
+
await this.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
| 478 |
+
} catch (e) {
|
| 479 |
+
logger.warn('工作池', `[${this.name}] 监控跳转失败: ${e.message}`);
|
| 480 |
+
}
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
/**
|
| 484 |
+
* 获取 Cookies
|
| 485 |
+
*/
|
| 486 |
+
async getCookies(domain) {
|
| 487 |
+
if (!this.page) throw new Error(`Worker [${this.name}] 未初始化`);
|
| 488 |
+
const context = this.page.context();
|
| 489 |
+
if (domain) {
|
| 490 |
+
return await context.cookies(domain.startsWith('http') ? domain : `https://${domain}`);
|
| 491 |
+
}
|
| 492 |
+
return await context.cookies();
|
| 493 |
+
}
|
| 494 |
+
}
|