mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
feat: 将项目迁移到Playwright+Camoufox方案
This commit is contained in:
+2
-1
@@ -1,4 +1,5 @@
|
||||
node_modules/
|
||||
data/
|
||||
test/
|
||||
config.yaml
|
||||
config.yaml
|
||||
camoufox/
|
||||
+24
-7
@@ -5,7 +5,24 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.3.0] - 2024-11-28
|
||||
## [2.0.0] - 2025-12-06
|
||||
|
||||
### Added
|
||||
- **支持新网站**
|
||||
- 支持对 Nano Banana Free 网站的支持
|
||||
|
||||
### Changed
|
||||
- **代码重构**
|
||||
- 将项目核心迁移为 Playwright + Camoufox 的方案增强反检测
|
||||
- 迁移前的 Puppeteer 版本已在分支中保留,但不影响使用的情况下不会再进行更新和修复!
|
||||
|
||||
## [1.3.1] - 2025-12-05
|
||||
|
||||
### Added
|
||||
- **同步竞技场模型UUID**
|
||||
- 新增 gemini-3-pro-image-preview-2k 模型的支持
|
||||
|
||||
## [1.3.0] - 2025-11-28
|
||||
|
||||
### Added
|
||||
- **Gemini Enterprise Business 支持**
|
||||
@@ -19,7 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **CLI 交互增强**
|
||||
- 更新 `lib/test.js` 测试工具,支持交互式选择模型和测试方式
|
||||
|
||||
## [1.2.1] - 2024-11-27
|
||||
## [1.2.1] - 2025-11-27
|
||||
|
||||
### Added
|
||||
- **登录模式**
|
||||
@@ -29,7 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **浏览器进程解耦**
|
||||
- 调整架构为程序与浏览器分离模式:主程序现通过连接远程调试端口(Remote Debugging Port)控制浏览器,旨在降低自动化检测特征
|
||||
|
||||
## [1.2.0] - 2024-11-26
|
||||
## [1.2.0] - 2025-11-26
|
||||
|
||||
### Added
|
||||
- **浏览器指纹伪装增强**
|
||||
@@ -44,13 +61,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **环境参数优化**
|
||||
- 优化浏览器启动参数配置与窗口尺寸计算逻辑,进一步减少特征暴露
|
||||
|
||||
## [1.1.1] - 2024-11-25
|
||||
## [1.1.1] - 2025-11-25
|
||||
|
||||
### Fixed
|
||||
- **模型映射修复**
|
||||
- 修复因 UUID 映射错误导致 `gemini-3-pro-image-preview` 模型请求返回 HTTP 500 的异常
|
||||
|
||||
## [1.1.0] - 2024-11-24
|
||||
## [1.1.0] - 2025-11-24
|
||||
|
||||
### Added
|
||||
- **多模型支持体系**
|
||||
@@ -62,13 +79,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- OpenAI 兼容接口 (`/v1/chat/completions`) 及队列接口 (`/v1/queue/join`) 均已适配 `model` 参数
|
||||
- *注:若未指定模型,系统将默认调用网页端的缺省模型*
|
||||
|
||||
## [1.0.1] - 2024-11-23
|
||||
## [1.0.1] - 2025-11-23
|
||||
|
||||
### Fixed
|
||||
- **代理鉴权修复**
|
||||
- 修复了带身份验证的 SOCKS5 代理无法建立连接的问题。
|
||||
|
||||
## [1.0.0] - 2024-11-23
|
||||
## [1.0.0] - 2025-11-23
|
||||
|
||||
### Added
|
||||
- **初始版本发布**
|
||||
|
||||
@@ -2,22 +2,24 @@
|
||||
|
||||
## 📝 项目简介
|
||||
|
||||
LMArenaImagenAutomator 是一个基于 Puppeteer 的自动化图像生成工具,通过模拟人类操作与 LMArena、Gemini Enterprise 网站交互,提供图像生成服务。(未来可能支持更多支持免费生图的网站)
|
||||
LMArenaImagenAutomator 是一个基于 Playwright + Camoufox 的自动化图像生成工具,通过模拟人类操作与 LMArena、Gemini 等网站交互提供图像生成服务到OpenAI格式的接口。(未来可能支持更多支持免费生图的网站)
|
||||
|
||||
项目支持两种运行模式:
|
||||
- **OpenAI 兼容模式**:提供标准的 OpenAI API 接口,便于集成到现有应用
|
||||
- **Queue 队列模式**:使用 Server-Sent Events (SSE) 实时推送生成状态
|
||||
当前支持的网站:
|
||||
- LMArena
|
||||
- Gemini Enterprise Business
|
||||
- Nano Banana Free
|
||||
|
||||
### ✨ 主要特性
|
||||
|
||||
- 💁♂️ **拟人操作**:模拟人类鼠标移动轨迹和抖动
|
||||
- 💁♂️ **拟人操作**:模拟人类鼠标移动轨迹和抖动行为
|
||||
- 🤖 **智能输入**:模拟人类打字速度和错误纠正行为
|
||||
- 🖼️ **多图支持**:最多支持同时上传 10 张参考图片
|
||||
- 📊 **队列管理**:智能任务队列,防止请求过载
|
||||
- 📊 **队列管理**:支持任务队列,防止请求过载或超时
|
||||
- 🌐 **代理支持**:支持 HTTP 和 SOCKS5 代理配置
|
||||
- 🎭 **特征伪装**:尽量伪装成真人操作的浏览器
|
||||
- 🎭 **特征伪装**:尽量伪装成非自动程序控制的浏览器
|
||||
- 🔗 **流式保活**:复用标准接口的流式模式发送心跳包
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
@@ -25,35 +27,47 @@ LMArenaImagenAutomator 是一个基于 Puppeteer 的自动化图像生成工具
|
||||
|
||||
### 系统要求
|
||||
|
||||
- **Node.js**: 16.0 或更高版本
|
||||
- **操作系统**: Windows、Linux 或 macOS
|
||||
- **浏览器**: Google Chrome (**推荐**) 或 Chromium
|
||||
- **Node.js**: 20.0.0 或更高版本 (ABI 115+)
|
||||
- **操作系统**: Windows、Linux 或 MacOS
|
||||
- **浏览器**: Camoufox (经过反检测处理的FireFox浏览器)
|
||||
> **开发环境参考**
|
||||
> - Node.js v22.20.0 ( Windows 10 )
|
||||
> - Node.js v20.19.0 ( Debian 12 )
|
||||
|
||||
### 安装步骤
|
||||
|
||||
1. **克隆项目** 或下载解压项目文件
|
||||
|
||||
2. **安装依赖**
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
3. **生成配置文件**
|
||||
首次运行会自动生成配置文件,也可以手动复制模板
|
||||
2. **配置文件**
|
||||
复制配置文件模板进行配置文件的修改
|
||||
```
|
||||
cp config.example.yaml config.yaml
|
||||
```
|
||||
|
||||
3. **安装依赖**
|
||||
```bash
|
||||
# 安装 NPM 基本依赖
|
||||
pnpm install
|
||||
|
||||
# 安装所需额外依赖
|
||||
# 自动下载安装所需的浏览器和NPM预编译文件
|
||||
# 若网络无法连接 GitHub,请提前在配置文件中设置可用代理
|
||||
npm run init
|
||||
```
|
||||
|
||||
4. **启动程序**
|
||||
```bash
|
||||
# 标准模式
|
||||
npm start
|
||||
|
||||
# 登录模式 (用于手动登录,该模式会自动禁用自动程序和无头模式)
|
||||
# 如 Google 账号登录时出现浏览器不安全的提示可切换该模式登录后再使用默认模式启动
|
||||
# 登录模式 (用于手动登录,该模式会自动禁用自动化和无头模式)
|
||||
npm start -- -login
|
||||
```
|
||||
|
||||
# 测试模式
|
||||
npm test (-- -login)
|
||||
5. **测试程序 (可选)**
|
||||
用于快速测试服务器接口
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
---
|
||||
|
||||
@@ -63,7 +77,7 @@ LMArenaImagenAutomator 是一个基于 Puppeteer 的自动化图像生成工具
|
||||
|
||||
#### 1. 启动与登录
|
||||
- **关闭无头模式**:首次启动务必关闭无头模式。推荐使用 **登录模式**。(Linux 命令行用户请参阅文档结尾)
|
||||
- **手动登录**:网页加载完毕后,请手动完成账号登录,避免后续流程中断。
|
||||
- **手动登录**:网页加载完毕后,请手动完成账号登录。
|
||||
|
||||
#### 2. 验证流程
|
||||
- **触发验证**:在输入框输入任意内容并发送,触发服务条款及 CloudFlare Turnstile 验证。
|
||||
@@ -71,15 +85,15 @@ LMArenaImagenAutomator 是一个基于 Puppeteer 的自动化图像生成工具
|
||||
|
||||
#### 3. 运行建议
|
||||
- **模式选择**:完成上述初始化后,可切换至无头模式运行。
|
||||
- **最佳实践**:为降低风控概率,**强烈建议**保持非无头模式(有界面)运行。
|
||||
- **最佳实践**:为降低风控概率,**强烈建议**保持非无头模式运行。
|
||||
|
||||
|
||||
### 接口使用说明
|
||||
|
||||
#### 1. OpenAI 兼容模式
|
||||
#### 1. OpenAI 兼容接口
|
||||
|
||||
> [!WARNING]
|
||||
> 由于模拟真实浏览器操作,每次只能处理一个任务,其余任务将进入队列等待。为避免客户端超时影响体验,若当前任务数已达3个,后续请求将直接返回错误。因此,强烈推荐使用队列模式(`queue`),该模式下服务器会向客户端发送心跳包以确保连接持续活跃。
|
||||
> 由于模拟真实浏览器操作,每次只能处理一个任务,其余任务将进入队列等待。为避免客户端超时影响体验,若当前任务数已达3个,后续请求将直接返回错误。因此,强烈推荐开启流式保活,该模式下服务器会向客户端发送保活注释或者心跳包以确保连接持续活跃。
|
||||
|
||||
**请求端点**
|
||||
```
|
||||
@@ -89,7 +103,7 @@ POST http://127.0.0.1:3000/v1/chat/completions
|
||||
<details>
|
||||
<summary>📄 查看API请求示例</summary>
|
||||
|
||||
**请求示例**
|
||||
**请求示例(非流式)**
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3000/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -98,20 +112,25 @@ curl -X POST http://127.0.0.1:3000/v1/chat/completions \
|
||||
"model": "gemini-3-pro-image-preview",
|
||||
"messages": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "generate a cat"
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "generate a cat"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
**响应格式**
|
||||
**响应格式(非流式)**
|
||||
```json
|
||||
{
|
||||
"id": "chatcmpl-1732374740123",
|
||||
"object": "chat.completion",
|
||||
"created": 1732374740,
|
||||
"model": "lmarena-image",
|
||||
"model": "gemini-3-pro-image-preview",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
@@ -122,77 +141,50 @@ curl -X POST http://127.0.0.1:3000/v1/chat/completions \
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**请求示例(流式 - 推荐)**
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3000/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your-secret-key" \
|
||||
-d '{
|
||||
"model": "gemini-3-pro-image-preview",
|
||||
"stream": true,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "generate a cat"
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
**响应格式(流式)**
|
||||
```
|
||||
data: {"id":"chatcmpl-1732374740123","object":"chat.completion.chunk","created":1732374740,"model":"gemini-3-pro-image-preview","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
|
||||
|
||||
: keep-alive
|
||||
: keep-alive
|
||||
|
||||
data: {"id":"chatcmpl-1732374740123","object":"chat.completion.chunk","created":1732374740,"model":"gemini-3-pro-image-preview","choices":[{"index":0,"delta":{"content":""},"finish_reason":"stop"}]}
|
||||
|
||||
data: [DONE]
|
||||
```
|
||||
</details>
|
||||
|
||||
> **关于 `model` 参数**:
|
||||
> - **必填**:必须填写支持的模型名称,否则将使用 LMArena 网页默认模型
|
||||
> - **必填**:必须填写支持的模型名称,否则将使用目标网站的默认模型
|
||||
> - **查看可用模型**:
|
||||
> - 方式 1:访问 `/v1/models` 接口查询
|
||||
> - 方式 2:直接查看 `lib/backend/models.js` 文件
|
||||
> - **示例模型**:`gemini-3-pro-image-preview`、`seedream-4-high-res-fal`、`dall-e-3` 等
|
||||
|
||||
#### 2. Queue 队列模式 (SSE) (推荐)
|
||||
> **关于流式保活的心跳模式**:
|
||||
> - 主要分为两种方式,可自行在配置文档中根据所需切换
|
||||
> - comment模式: 发送 :keepalive 注释。不污染数据,绝大多数 SDK 支持,不会影响接口标准
|
||||
> - content模式: 在 choices[0].delta.content = "" 中发送空字符串(仅当你使用的客户端非常特殊,必须收到 data JSON 包才重置超时时使用)
|
||||
|
||||
**请求端点**
|
||||
```
|
||||
POST http://127.0.0.1:3000/v1/queue/join
|
||||
```
|
||||
|
||||
**SSE 事件类型**
|
||||
|
||||
| 事件类型 | 数据格式 | 说明 |
|
||||
|---------|---------|------|
|
||||
| `status` | `{status: "queued", position: 1}` | 任务已入队 |
|
||||
| `status` | `{status: "processing"}` | 开始处理 |
|
||||
| `result` | `{status: "completed", image: "base64..."}` | 生成成功 |
|
||||
| `result` | `{status: "error", msg: "错误信息"}` | 生成失败 |
|
||||
| `heartbeat` | 时间戳 | 保持连接 |
|
||||
| `done` | `"[DONE]"` | 流结束 |
|
||||
|
||||
<details>
|
||||
<summary>📄 查看 Node.js 示例代码</summary>
|
||||
|
||||
```javascript
|
||||
import http from 'http';
|
||||
|
||||
const options = {
|
||||
hostname: '127.0.0.1',
|
||||
port: 3000,
|
||||
path: '/v1/queue/join',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer your-secret-key'
|
||||
}
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
res.on('data', (chunk) => {
|
||||
const lines = chunk.toString().split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
const event = line.substring(7).trim();
|
||||
console.log('事件类型:', event);
|
||||
} else if (line.startsWith('data: ')) {
|
||||
const data = JSON.parse(line.substring(6));
|
||||
console.log('数据:', data);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.write(JSON.stringify({
|
||||
model: "gemini-3-pro-image-preview",
|
||||
messages: [{ role: "user", content: "a cute cat" }]
|
||||
}));
|
||||
req.end();
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
> **提示**:Queue 模式同样支持 `model` 参数,用法与 OpenAI 兼容模式一致。
|
||||
|
||||
#### 3. 获取可用模型列表
|
||||
#### 2. 获取可用模型列表
|
||||
|
||||
**请求端点**
|
||||
```
|
||||
@@ -231,12 +223,7 @@ curl -X GET http://127.0.0.1:3000/v1/models \
|
||||
|
||||
</details>
|
||||
|
||||
> **说明**:
|
||||
> - 此接口在 **OpenAI 兼容模式** 和 **Queue 队列模式** 下均可用
|
||||
> - `created` 字段为当前请求时的时间戳
|
||||
> - 完整模型列表可在 `lib/backend/models.js` 文件中查看
|
||||
|
||||
#### 4. 带图片的请求说明
|
||||
#### 3. 带图片的请求说明
|
||||
|
||||
**支持格式**:PNG、JPEG、GIF、WebP
|
||||
**最大数量**:5 张图片
|
||||
@@ -273,38 +260,13 @@ curl -X GET http://127.0.0.1:3000/v1/models \
|
||||
|
||||
## 🔧 常见问题
|
||||
|
||||
<details>
|
||||
<summary>❌ 浏览器启动失败</summary>
|
||||
|
||||
**问题**: `Error: Failed to launch the browser process`
|
||||
|
||||
**解决方案**:
|
||||
- 确保已安装 Chrome 或 Chromium
|
||||
- 大陆地区设备可能因网络原因 Puppeteer 自动安装失败
|
||||
- 可尝试手动安装后填写 `chrome.path` (Linux 可使用 `which` 指令检索路径)
|
||||
- 检查 `config.yaml` 中的 `chrome.path` 是否正确
|
||||
- 尝试删除 `data` 目录后重新运行
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>❌ GPU 相关错误</summary>
|
||||
|
||||
**问题**: 无显卡服务器运行时出现 GPU 错误
|
||||
|
||||
**解决方案**:
|
||||
- 该报错并不会影响程序运行,但是强烈建议在无显卡的设备上关闭GPU加速
|
||||
- 修改 `config.yaml` 中的`chrome.gpu`为false
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>❌ 请求被拒绝 (429 Too Many Requests)</summary>
|
||||
|
||||
**问题**: 并发请求过多
|
||||
|
||||
**解决方案**:
|
||||
- 该问题仅存在于OpenAI兼容模式
|
||||
- 该问题仅存在未开启流式保活时出现
|
||||
- 队列限制:1 个并发 + 2 个排队 (总计 3 个)
|
||||
- 修改 `config.yaml` 中的`queue.maxQueueSize` (不建议)
|
||||
- 等待当前任务完成后再提交新任务
|
||||
@@ -331,6 +293,7 @@ curl -X GET http://127.0.0.1:3000/v1/models \
|
||||
**问题**: 任务超过 120 秒未完成
|
||||
|
||||
**解决方案**:
|
||||
- 启用流式保活确保客户端不会主动断开连接
|
||||
- 检查网络连接是否稳定
|
||||
- 某些复杂提示词可能需要更长时间
|
||||
|
||||
@@ -352,7 +315,7 @@ curl -X GET http://127.0.0.1:3000/v1/models \
|
||||
|
||||
1. **启动虚拟显示器并运行程序** (屏幕号 99 可按需修改):
|
||||
```bash
|
||||
xvfb-run --server-num=99 --server-args="-ac -screen 0 1280x720x24" npm start
|
||||
xvfb-run --server-num=99 --server-args="-ac -screen 0 1920x1080x24" npm start
|
||||
```
|
||||
|
||||
2. **将虚拟显示器映射至 VNC**:
|
||||
@@ -369,62 +332,6 @@ curl -X GET http://127.0.0.1:3000/v1/models \
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🎭 【浏览器特征伪装】</summary>
|
||||
|
||||
**问题**: 如何优化浏览器特征伪装,减少验证码弹出频率?
|
||||
|
||||
> **欢迎了解相关内容的前辈基于改进建议**
|
||||
|
||||
**浏览器指纹伪装状态**:
|
||||
|
||||
- **Windows 10 (官方 Chrome)**:
|
||||
- 针对 Windows 10 原生 Chrome 环境优化指纹,已在 [antibot](https://bot.sannysoft.com/) 和 [CreepJS](https://abrahamjuliot.github.io/creepjs/) 测试中无红色高危警告
|
||||
- **Linux 环境**:
|
||||
- ⚠️ 未完全通过 CreepJS 测试,但实际使用中影响较小,检测严格程度可能低于测试工具。
|
||||
|
||||
**进一步优化建议**:
|
||||
|
||||
完成后,可有效缓解验证码的弹出频率。
|
||||
|
||||
**1. 使用官方 Chrome(推荐)**
|
||||
|
||||
不推荐使用 Chromium,因为它缺少 MP4/H.264 解码器等插件,且被大量爬虫使用,会成为明显特征。
|
||||
```bash
|
||||
# 从 Google 官方下载 Chrome DEB安装包
|
||||
# 大陆服务器可手动下载安装包 https://www.google.com/chrome/?platform=linux
|
||||
curl -LO https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||
apt install ./google-chrome-stable_current_amd64.deb -y
|
||||
```
|
||||
|
||||
**配置方式**:
|
||||
|
||||
修改 `config.yaml`,可使用`which google-chrome`指令查询路径
|
||||
```yaml
|
||||
chrome:
|
||||
path: "/usr/bin/google-chrome"
|
||||
```
|
||||
|
||||
使用环境变量可跳过 Puppeteer 自动下载 Chromium
|
||||
```bash
|
||||
export PUPPETEER_SKIP_CHROMIUM_DOWNLOAD="true"
|
||||
```
|
||||
|
||||
**2. 优化字体指纹**
|
||||
|
||||
Linux 服务器通常只安装了极少量字体(甚至没有中文),这会增加指纹特征。
|
||||
|
||||
**安装常用字体**:
|
||||
```bash
|
||||
# 安装中文字体(必备,否则中文提示词将显示方框)
|
||||
sudo apt install fonts-wqy-zenhei fonts-wqy-microhei
|
||||
|
||||
# 安装微软核心字体(减少字体指纹差异)
|
||||
sudo apt install ttf-mscorefonts-installer
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 📊 设备配置
|
||||
@@ -433,9 +340,9 @@ sudo apt install ttf-mscorefonts-installer
|
||||
| CPU | 1核 | 2核及以上 |
|
||||
| 内存 | 1GB | 2GB 及以上 |
|
||||
|
||||
经测试,本项目可在以下环境中稳定运行:
|
||||
- Oracle 免费机:1C1G 配置,基于 Debian 12 系统。
|
||||
- 阿里云轻量应用服务器:2C2G 配置,基于 Debian 11 系统。
|
||||
经测试,本项目可在以下环境中运行:
|
||||
- Oracle 免费机(有些卡顿,勉强能用):1C1G 配置,基于 Debian 12 系统。
|
||||
- 阿里云轻量应用服务器(开发测试所用):2C2G 配置,基于 Debian 11 系统。
|
||||
|
||||
## 📄 许可证和免责声明
|
||||
|
||||
@@ -450,6 +357,8 @@ sudo apt install ttf-mscorefonts-installer
|
||||
|
||||
查看完整的版本历史和更新内容,请访问 [CHANGELOG.md](CHANGELOG.md)。
|
||||
|
||||
迁移前的 Puppeteer 版本已在分支中保留,但不影响使用的情况下不会再进行更新和修复!
|
||||
|
||||
---
|
||||
|
||||
**感谢 LMArena 提供图像生成服务!** 🎉
|
||||
**感谢 LMArena 、Gemini 等网站提供图像生成服务!** 🎉
|
||||
|
||||
+24
-12
@@ -2,42 +2,54 @@
|
||||
logLevel: info
|
||||
|
||||
server:
|
||||
# 服务器模式: openai (标准兼容) | queue (流式队列)
|
||||
type: openai
|
||||
# 监听端口
|
||||
port: 3000
|
||||
# 鉴权 Token (Bearer Token) (可使用 npm run genkey 生成)
|
||||
auth: sk-change-me-to-your-secure-key
|
||||
# 保活
|
||||
keepalive:
|
||||
# 是否启用流式保活
|
||||
# 使用OpenAI接口的标准流式接口格式,客户端请求需强制使用 stream: true
|
||||
enable: false
|
||||
|
||||
# 心跳模式
|
||||
# "comment": (推荐) 发送 :keepalive 注释。不污染数据,绝大多数 SDK 支持,不会影响接口标准
|
||||
# "content": (备用) 在 choices[0].delta.content = "" 中发送空字符串
|
||||
# 仅当你使用的客户端非常特殊,必须收到 data JSON 包才重置超时时使用
|
||||
mode: "comment"
|
||||
|
||||
backend:
|
||||
# 选择后端: lmarena (竞技场) | gemini_biz (Gemini Enterprise Business)
|
||||
# 适配器设置
|
||||
# - lmarena (LMArena)
|
||||
# - gemini_biz (Gemini Enterprise Business)
|
||||
# - nanobananafree_ai (Nano Banana Free)
|
||||
type: lmarena
|
||||
|
||||
# Gemini Business 设置
|
||||
geminiBiz:
|
||||
# 入口链接
|
||||
# 示例: "https://business.gemini.google/home/cid/8888a888-b6e0-88be-86e1-888cf3ee8cf4?csesidx=1666666666"
|
||||
# 示例: "https://business.gemini.google/home/cid/8888a888-b6e0-88be-86e1-888cf3ee8cf4"
|
||||
entryUrl: ""
|
||||
|
||||
queue:
|
||||
# 最大排队数
|
||||
# 仅对OpenAI模式做出限制,非必要不建议更改
|
||||
# 因常见客户端都有超时保护,队列大于2是一定会触发超时保护的
|
||||
# 仅对未开启流式保活模式时做出限制,非必要不建议更改
|
||||
# 因客户端可能有超时保护,队列大于2是一定会触发超时保护的
|
||||
maxQueueSize: 2
|
||||
# 图片数量上限
|
||||
# 网页最多支持10个附件,如果设置大于10则直接丢弃超出10的图片
|
||||
imageLimit: 5
|
||||
|
||||
chrome:
|
||||
# 浏览器可执行文件路径 (留空则使用Puppeteer默认)
|
||||
# Windows系统示例 "C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe"
|
||||
# Linux系统示例 "/usr/bin/google-chrome"
|
||||
# path: "C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe"
|
||||
browser:
|
||||
# 浏览器可执行文件路径 (留空则使用 Camoufox 默认下载路径)
|
||||
# Windows系统示例 "C:\\camoufox\\camoufox.exe"
|
||||
# Linux系统示例 "/opt/camoufox/camoufox"
|
||||
path: ""
|
||||
|
||||
# 是否启用无头模式
|
||||
headless: false
|
||||
|
||||
# 是否启用 GPU (无GPU设备运行请使用false)
|
||||
# 是否启用 GPU (Camoufox 已内置指纹伪装,无GPU设备运行请使用false)
|
||||
gpu: false
|
||||
|
||||
# 代理设置
|
||||
|
||||
+79
-128
@@ -9,12 +9,13 @@ import {
|
||||
queryDeep,
|
||||
safeClick,
|
||||
humanType,
|
||||
pasteImages
|
||||
pasteImages,
|
||||
getHumanClickPoint
|
||||
} from '../browser/utils.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
// --- 配置常量 ---
|
||||
const USER_DATA_DIR = path.join(process.cwd(), 'data', 'chromeUserDataGeminiBiz');
|
||||
const USER_DATA_DIR = path.join(process.cwd(), 'data', 'camoufoxUserData');
|
||||
const TEMP_DIR = path.join(process.cwd(), 'data', 'temp');
|
||||
|
||||
// 确保临时目录存在
|
||||
@@ -51,14 +52,14 @@ async function findInput(page) {
|
||||
/**
|
||||
* 初始化浏览器
|
||||
* @param {object} config - 配置对象
|
||||
* @param {object} [config.chrome] - Chrome 配置
|
||||
* @param {boolean} [config.chrome.headless] - 是否开启 Headless 模式
|
||||
* @param {string} [config.chrome.path] - Chrome 可执行文件路径
|
||||
* @param {object} [config.chrome.proxy] - 代理配置
|
||||
* @param {object} [config.browser] - Browser 配置
|
||||
* @param {boolean} [config.browser.headless] - 是否开启 Headless 模式
|
||||
* @param {string} [config.browser.path] - Browser 可执行文件路径
|
||||
* @param {object} [config.browser.proxy] - 代理配置
|
||||
* @param {object} [config.backend] - 后端配置
|
||||
* @param {object} [config.backend.geminiBiz] - Gemini Biz 配置
|
||||
* @param {string} config.backend.geminiBiz.entryUrl - Gemini entry URL (必需)
|
||||
* @returns {Promise<{browser: import('puppeteer').Browser, page: import('puppeteer').Page, client: import('puppeteer').CDPSession}>}
|
||||
* @returns {Promise<{browser: object, page: object, client: object}>}
|
||||
*/
|
||||
async function initBrowser(config) {
|
||||
// 从配置读取 Gemini Biz entry URL
|
||||
@@ -103,7 +104,8 @@ async function initBrowser(config) {
|
||||
const box = await inputHandle.boundingBox();
|
||||
if (box) {
|
||||
if (page.cursor) {
|
||||
await page.cursor.moveTo({ x: box.x + box.width / 2, y: box.y + box.height / 2 });
|
||||
const { x, y } = getHumanClickPoint(box, 'input');
|
||||
await page.cursor.moveTo({ x, y });
|
||||
}
|
||||
await sleep(500, 1000);
|
||||
}
|
||||
@@ -114,7 +116,6 @@ async function initBrowser(config) {
|
||||
userDataDir: USER_DATA_DIR,
|
||||
targetUrl,
|
||||
productName: 'Gemini Enterprise Business',
|
||||
reuseExistingTab: false,
|
||||
waitInputValidator
|
||||
});
|
||||
}
|
||||
@@ -128,13 +129,11 @@ async function initBrowser(config) {
|
||||
* @returns {Promise<{image?: string, error?: string}>} 生成结果
|
||||
*/
|
||||
async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
const { page, client } = context;
|
||||
let fetchPausedHandler = null;
|
||||
const { page } = context;
|
||||
|
||||
try {
|
||||
// 获取配置 (通过闭包或全局)
|
||||
// 这里需要从 context 或其他方式获取 config
|
||||
const { loadConfig } = await import('../config.js');
|
||||
const { loadConfig } = await import('../utils/config.js');
|
||||
const config = loadConfig();
|
||||
const targetUrl = config.backend?.geminiBiz?.entryUrl;
|
||||
|
||||
@@ -143,7 +142,7 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
}
|
||||
|
||||
// 开启新对话
|
||||
await page.goto(targetUrl, { waitUntil: 'networkidle2' });
|
||||
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// 1. 查找输入框
|
||||
logger.debug('适配器', '正在寻找输入框...', meta);
|
||||
@@ -195,55 +194,33 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
await humanType(page, inputHandle, prompt);
|
||||
await sleep(1000, 2000);
|
||||
|
||||
// 4. 设置拦截器
|
||||
// 4. 设置拦截器 (使用 Playwright Route)
|
||||
logger.debug('适配器', '已启用请求拦截', meta);
|
||||
await client.send('Fetch.enable', {
|
||||
patterns: [{
|
||||
urlPattern: '*global/widgetStreamAssist*',
|
||||
requestStage: 'Request'
|
||||
}]
|
||||
});
|
||||
|
||||
fetchPausedHandler = async (event) => {
|
||||
const { requestId, request } = event;
|
||||
if (request.method === 'POST' && request.postData) {
|
||||
try {
|
||||
let rawBody = request.postData;
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(rawBody);
|
||||
} catch (e) {
|
||||
try {
|
||||
rawBody = Buffer.from(rawBody, 'base64').toString('utf8');
|
||||
data = JSON.parse(rawBody);
|
||||
} catch (e2) { }
|
||||
}
|
||||
// 清理旧的 route
|
||||
await page.unroute('**/*').catch(() => { });
|
||||
|
||||
if (data) {
|
||||
logger.debug('适配器', '已拦截请求,正在修改...', meta);
|
||||
if (!data.streamAssistRequest) data.streamAssistRequest = {};
|
||||
if (!data.streamAssistRequest.assistGenerationConfig) data.streamAssistRequest.assistGenerationConfig = {};
|
||||
//data.streamAssistRequest.assistGenerationConfig.modelId = "gemini-3-pro-preview";
|
||||
data.streamAssistRequest.toolsSpec = { imageGenerationSpec: {} };
|
||||
await page.route(url => url.href.includes('global/widgetStreamAssist'), async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() !== 'POST') return route.continue();
|
||||
|
||||
const newBody = JSON.stringify(data);
|
||||
const newBodyBase64 = Buffer.from(newBody).toString('base64');
|
||||
logger.info('适配器', '已拦截请求,强制使用 Nano Banana Pro', meta);
|
||||
await client.send('Fetch.continueRequest', {
|
||||
requestId,
|
||||
postData: newBodyBase64
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('适配器', '请求拦截处理失败', { ...meta, error: e.message });
|
||||
}
|
||||
}
|
||||
try {
|
||||
await client.send('Fetch.continueRequest', { requestId });
|
||||
} catch (e) { }
|
||||
};
|
||||
client.on('Fetch.requestPaused', fetchPausedHandler);
|
||||
const postData = request.postDataJSON();
|
||||
if (postData) {
|
||||
logger.debug('适配器', '已拦截请求,正在修改...', meta);
|
||||
if (!postData.streamAssistRequest) postData.streamAssistRequest = {};
|
||||
if (!postData.streamAssistRequest.assistGenerationConfig) postData.streamAssistRequest.assistGenerationConfig = {};
|
||||
postData.streamAssistRequest.toolsSpec = { imageGenerationSpec: {} };
|
||||
|
||||
logger.info('适配器', '已拦截请求,强制使用 Nano Banana Pro', meta);
|
||||
await route.continue({ postData: JSON.stringify(postData) });
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('适配器', '请求拦截处理失败', { ...meta, error: e.message });
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
// 5. 点击发送
|
||||
logger.debug('适配器', '点击发送...', meta);
|
||||
@@ -266,7 +243,10 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
});
|
||||
|
||||
if (sendBtnHandle && sendBtnHandle.asElement()) {
|
||||
await safeClick(page, sendBtnHandle);
|
||||
// 确保按钮在可视区域
|
||||
await sendBtnHandle.asElement().scrollIntoViewIfNeeded();
|
||||
await sleep(300, 500);
|
||||
await safeClick(page, sendBtnHandle, { bias: 'button' });
|
||||
} else {
|
||||
logger.warn('适配器', '未找到发送按钮,尝试回车提交', meta);
|
||||
await inputHandle.focus();
|
||||
@@ -275,76 +255,51 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
|
||||
logger.info('适配器', '等待生成结果中...', meta);
|
||||
|
||||
// 6. 等待结果
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const requestMethods = new Map(); // Store request methods by requestId
|
||||
// 6. 等待结果 (使用 Playwright waitForResponse)
|
||||
// 我们需要等待两个响应:
|
||||
// 1. widgetStreamAssist (API 响应,检查是否成功)
|
||||
// 2. download/v1alpha/projects (图片下载请求)
|
||||
|
||||
const cleanup = () => {
|
||||
client.off('Network.requestWillBeSent', onRequest);
|
||||
client.off('Network.responseReceived', onRes);
|
||||
client.off('Network.loadingFinished', onLoad);
|
||||
if (fetchPausedHandler) {
|
||||
client.off('Fetch.requestPaused', fetchPausedHandler);
|
||||
client.send('Fetch.disable').catch(() => { });
|
||||
}
|
||||
};
|
||||
const apiResponsePromise = page.waitForResponse(response =>
|
||||
response.url().includes('global/widgetStreamAssist') &&
|
||||
response.request().method() === 'POST' &&
|
||||
(response.status() === 200 || response.status() >= 400),
|
||||
{ timeout: 120000 }
|
||||
).catch(e => e);
|
||||
|
||||
let targetRequestId = null;
|
||||
const imageDownloadPromise = page.waitForResponse(response =>
|
||||
response.url().includes('download/v1alpha/projects') &&
|
||||
response.request().method() === 'GET' &&
|
||||
response.status() === 200,
|
||||
{ timeout: 120000 }
|
||||
).catch(e => e);
|
||||
|
||||
const onRequest = (e) => {
|
||||
requestMethods.set(e.requestId, e.request.method);
|
||||
};
|
||||
// 等待 API 响应
|
||||
const apiResponse = await apiResponsePromise;
|
||||
|
||||
const onRes = (e) => {
|
||||
// 1. 监听生图接口错误 (如 429 Too Many Requests)
|
||||
if (e.response.url.includes('global/widgetStreamAssist')) {
|
||||
if (e.response.status !== 200) {
|
||||
logger.error('适配器', `请求返回错误状态码: ${e.response.status}`, meta);
|
||||
cleanup();
|
||||
resolve({ error: `API Error: ${e.response.status}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (apiResponse instanceof Error) {
|
||||
throw apiResponse;
|
||||
}
|
||||
|
||||
if (e.response.url.includes('download/v1alpha/projects')) {
|
||||
const method = requestMethods.get(e.requestId);
|
||||
if (method === 'GET') {
|
||||
logger.info('适配器', '捕获到图片下载亲求', meta);
|
||||
targetRequestId = e.requestId;
|
||||
} else {
|
||||
logger.debug('适配器', `忽略非 GET 请求: ${method} - ${e.response.url}`, meta);
|
||||
}
|
||||
}
|
||||
};
|
||||
if (apiResponse.status() !== 200) {
|
||||
logger.error('适配器', `请求返回错误状态码: ${apiResponse.status()}`, meta);
|
||||
return { error: `API Error: ${apiResponse.status()}` };
|
||||
}
|
||||
|
||||
const onLoad = async (e) => {
|
||||
if (e.requestId === targetRequestId) {
|
||||
try {
|
||||
const { body } = await client.send('Network.getResponseBody', { requestId: targetRequestId });
|
||||
// GeminiBiz 返回的 body 已经是不带前缀的 base64 字符串,直接使用
|
||||
const dataUri = `data:image/png;base64,${body}`;
|
||||
// 等待图片下载响应
|
||||
logger.info('适配器', 'API 请求成功,等待图片下载...', meta);
|
||||
const imageResponse = await imageDownloadPromise;
|
||||
|
||||
logger.info('适配器', '生图成功', meta);
|
||||
cleanup();
|
||||
resolve({ image: dataUri });
|
||||
} catch (err) {
|
||||
logger.error('适配器', '生图失败 (提取图片失败)', { ...meta, error: err.message });
|
||||
cleanup();
|
||||
resolve({ error: err.message });
|
||||
}
|
||||
}
|
||||
};
|
||||
if (imageResponse instanceof Error) {
|
||||
throw imageResponse;
|
||||
}
|
||||
|
||||
client.on('Network.requestWillBeSent', onRequest);
|
||||
client.on('Network.responseReceived', onRes);
|
||||
client.on('Network.loadingFinished', onLoad);
|
||||
logger.info('适配器', '捕获到图片下载请求', meta);
|
||||
// 响应体本身就是 base64 字符串,直接获取文本即可,不需要再次 base64 编码
|
||||
const base64 = await imageResponse.text();
|
||||
const dataUri = `data:image/png;base64,${base64}`;
|
||||
|
||||
// 超时保护 (180秒)
|
||||
setTimeout(() => {
|
||||
cleanup();
|
||||
resolve({ error: 'Timeout' });
|
||||
}, 180000);
|
||||
});
|
||||
logger.info('适配器', '生图成功', meta);
|
||||
|
||||
// 任务结束,移开鼠标
|
||||
if (page.cursor) {
|
||||
@@ -356,18 +311,14 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
await page.cursor.moveTo({ x: finalX, y: finalY });
|
||||
}
|
||||
|
||||
return result;
|
||||
return { image: dataUri };
|
||||
|
||||
} catch (err) {
|
||||
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
||||
return { error: err.message };
|
||||
} finally {
|
||||
if (fetchPausedHandler) {
|
||||
client.off('Fetch.requestPaused', fetchPausedHandler);
|
||||
try {
|
||||
await client.send('Fetch.disable');
|
||||
} catch (e) { }
|
||||
}
|
||||
// 清理拦截器
|
||||
await page.unroute('**/*').catch(() => { });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { loadConfig } from '../config.js';
|
||||
import { loadConfig } from '../utils/config.js';
|
||||
import * as lmarenaBackend from './lmarena.js';
|
||||
import * as geminiBackend from './gemini_biz.js';
|
||||
import * as nanobananafreeBackend from './nanobananafree_ai.js';
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
@@ -13,6 +14,13 @@ if (config.backend?.type === 'gemini_biz') {
|
||||
generateImage: (ctx, prompt, paths, model, meta) => geminiBackend.generateImage(ctx, prompt, paths, model, meta),
|
||||
TEMP_DIR: geminiBackend.TEMP_DIR
|
||||
};
|
||||
} else if (config.backend?.type === 'nanobananafree_ai') {
|
||||
activeBackend = {
|
||||
name: 'nanobananafree_ai',
|
||||
initBrowser: (cfg) => nanobananafreeBackend.initBrowser(cfg),
|
||||
generateImage: (ctx, prompt, paths, model, meta) => nanobananafreeBackend.generateImage(ctx, prompt, paths, model, meta),
|
||||
TEMP_DIR: nanobananafreeBackend.TEMP_DIR
|
||||
};
|
||||
} else {
|
||||
activeBackend = {
|
||||
name: 'lmarena',
|
||||
|
||||
+127
-181
@@ -9,12 +9,15 @@ import {
|
||||
clamp,
|
||||
safeClick,
|
||||
humanType,
|
||||
pasteImages
|
||||
pasteImages,
|
||||
getHumanClickPoint
|
||||
} from '../browser/utils.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { loadConfig } from '../utils/config.js';
|
||||
import { getProxyConfig, getHttpProxy } from '../utils/proxy.js';
|
||||
|
||||
// --- 配置常量 ---
|
||||
const USER_DATA_DIR = path.join(process.cwd(), 'data', 'chromeUserData');
|
||||
const USER_DATA_DIR = path.join(process.cwd(), 'data', 'camoufoxUserData');
|
||||
const TARGET_URL = 'https://lmarena.ai/c/new?mode=direct&chat-modality=image';
|
||||
const TEMP_DIR = path.join(process.cwd(), 'data', 'temp');
|
||||
|
||||
@@ -25,8 +28,8 @@ if (!fs.existsSync(TEMP_DIR)) {
|
||||
|
||||
/**
|
||||
* 从响应文本中提取图片 URL
|
||||
* @param {string} text 响应文本
|
||||
* @returns {string|null} 图片 URL 或 null
|
||||
* @param {string} text - 响应文本内容
|
||||
* @returns {string|null} 提取到的图片 URL,如果未找到则返回 null
|
||||
*/
|
||||
function extractImage(text) {
|
||||
if (!text) return null;
|
||||
@@ -43,21 +46,20 @@ function extractImage(text) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化浏览器
|
||||
* @param {object} config 配置对象 (包含 chrome 配置)
|
||||
* @returns {Promise<{browser: object, page: object, client: object}>}
|
||||
* 初始化浏览器会话
|
||||
* @param {object} config - 全局配置对象
|
||||
* @returns {Promise<{browser: object, page: object, client: object}>} 初始化后的浏览器上下文
|
||||
*/
|
||||
async function initBrowser(config) {
|
||||
// LMArena 特定的输入框验证
|
||||
// LMArena 特定的输入框验证逻辑
|
||||
const waitInputValidator = async (page) => {
|
||||
const textareaSelector = 'textarea';
|
||||
await page.waitForSelector(textareaSelector, { timeout: 60000 });
|
||||
|
||||
// 移动鼠标到输入框
|
||||
const box = await (await page.$(textareaSelector)).boundingBox();
|
||||
if (box) {
|
||||
if (page.cursor) {
|
||||
await page.cursor.moveTo({ x: box.x + box.width / 2, y: box.y + box.height / 2 });
|
||||
const { x, y } = getHumanClickPoint(box, 'input');
|
||||
await page.cursor.moveTo({ x, y });
|
||||
}
|
||||
await sleep(500, 1000);
|
||||
}
|
||||
@@ -67,219 +69,163 @@ async function initBrowser(config) {
|
||||
userDataDir: USER_DATA_DIR,
|
||||
targetUrl: TARGET_URL,
|
||||
productName: 'LMArena',
|
||||
reuseExistingTab: true,
|
||||
waitInputValidator
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行生图任务
|
||||
* @param {object} context 浏览器上下文 {page, client}
|
||||
* @param {string} prompt 提示词
|
||||
* @param {string[]} imgPaths 图片路径数组
|
||||
* @param {string|null} modelId 模型 UUID (可选)
|
||||
* @returns {Promise<{image?: string, text?: string, error?: string}>}
|
||||
* @param {object} context - 浏览器上下文 { page, client }
|
||||
* @param {string} prompt - 提示词
|
||||
* @param {string[]} imgPaths - 图片路径数组
|
||||
* @param {string} [modelId] - 指定的模型 ID (可选)
|
||||
* @param {object} [meta={}] - 日志元数据
|
||||
* @returns {Promise<{image?: string, text?: string, error?: string}>} 生成结果
|
||||
*/
|
||||
async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
const { page, client } = context;
|
||||
const { page } = context;
|
||||
const textareaSelector = 'textarea';
|
||||
let fetchPausedHandler = null;
|
||||
|
||||
try {
|
||||
// 1. 强制开启新会话 (通过URL跳转)
|
||||
logger.info('适配器', '开启新会话', meta);
|
||||
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// 等待输入框出现
|
||||
// 等待输入框加载
|
||||
await page.waitForSelector(textareaSelector, { timeout: 30000 });
|
||||
await sleep(1500, 2500); // 等页面稳一点
|
||||
await sleep(1500, 2500);
|
||||
|
||||
// 2. 粘贴图片
|
||||
// 1. 上传图片
|
||||
if (imgPaths && imgPaths.length > 0) {
|
||||
await pasteImages(page, textareaSelector, imgPaths);
|
||||
// 如果没有图片,也点击一下输入框获取焦点
|
||||
await safeClick(page, textareaSelector);
|
||||
// 确保焦点在输入框
|
||||
await safeClick(page, textareaSelector, { bias: 'input' });
|
||||
}
|
||||
|
||||
// 3. 输入 Prompt
|
||||
// 2. 输入提示词
|
||||
logger.info('适配器', '正在输入提示词...', meta);
|
||||
await humanType(page, textareaSelector, prompt);
|
||||
await sleep(800, 1500);
|
||||
|
||||
// 注入 CDP 拦截器
|
||||
// 3. 配置请求拦截 (用于修改模型 ID)
|
||||
await page.unroute('**/*').catch(() => { });
|
||||
|
||||
if (modelId) {
|
||||
// 1. 启用 Fetch 域拦截,仅拦截特定 URL
|
||||
await client.send('Fetch.enable', {
|
||||
patterns: [{
|
||||
urlPattern: '*nextjs-api/stream*',
|
||||
requestStage: 'Request'
|
||||
}]
|
||||
});
|
||||
logger.debug('适配器', `准备拦截请求`, meta);
|
||||
await page.route(url => url.href.includes('/nextjs-api/stream'), async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() !== 'POST') return route.continue();
|
||||
|
||||
// 2. 定义拦截处理函数
|
||||
fetchPausedHandler = async (event) => {
|
||||
const { requestId, request } = event;
|
||||
|
||||
if (request.method === 'POST' && request.postData) {
|
||||
try {
|
||||
// 尝试解码可能是 Base64 编码的postData
|
||||
let rawBody = request.postData;
|
||||
// 尝试解析 JSON
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(rawBody);
|
||||
} catch (e) {
|
||||
// 尝试 Base64 解码
|
||||
try {
|
||||
rawBody = Buffer.from(rawBody, 'base64').toString('utf8');
|
||||
data = JSON.parse(rawBody);
|
||||
} catch (e2) {
|
||||
// 无法解析,跳过
|
||||
}
|
||||
}
|
||||
|
||||
if (data && data.modelAId) {
|
||||
logger.debug('适配器', `已拦截请求,原始模型UUID: ${data.modelAId}`, meta);
|
||||
|
||||
// 修改 modelAId
|
||||
data.modelAId = modelId;
|
||||
|
||||
// 重新序列化并转为 Base64 (Fetch.continueRequest 需要 base64)
|
||||
const newBody = JSON.stringify(data);
|
||||
const newBodyBase64 = Buffer.from(newBody).toString('base64');
|
||||
logger.debug('适配器', `已拦截请求,修改模型UUID为: ${data.modelAId}`, meta);
|
||||
logger.info('适配器', '已拦截请求,修改为指定模型', meta);
|
||||
|
||||
await client.send('Fetch.continueRequest', {
|
||||
requestId,
|
||||
postData: newBodyBase64
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('适配器', '请求拦截处理出错', { ...meta, error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不匹配或出错,直接放行
|
||||
try {
|
||||
await client.send('Fetch.continueRequest', { requestId });
|
||||
} catch (e) { }
|
||||
};
|
||||
|
||||
// 3. 监听拦截事件
|
||||
client.on('Fetch.requestPaused', fetchPausedHandler);
|
||||
logger.debug('适配器', `已启用请求拦截`, meta);
|
||||
const postData = request.postDataJSON();
|
||||
if (postData && postData.modelAId) {
|
||||
logger.info('适配器', `已拦截请求并修改模型: ${postData.modelAId} -> ${modelId}`, meta);
|
||||
postData.modelAId = modelId;
|
||||
await route.continue({ postData: JSON.stringify(postData) });
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('适配器', '拦截处理异常', { ...meta, error: e.message });
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
}
|
||||
|
||||
// 4. 发送
|
||||
// 4. 建立响应监听器
|
||||
// 只要 URL 匹配且是 POST,无论状态码是 200 还是 429,都立即返回,防止超时死等
|
||||
const responsePromise = page.waitForResponse(response =>
|
||||
response.url().includes('/nextjs-api/stream') &&
|
||||
response.request().method() === 'POST' &&
|
||||
(response.status() === 200 || response.status() >= 400),
|
||||
{ timeout: 120000 }
|
||||
).catch(e => e);
|
||||
|
||||
logger.debug('适配器', '点击发送...', meta);
|
||||
const btnSelector = 'button[type="submit"]';
|
||||
await safeClick(page, btnSelector);
|
||||
await safeClick(page, btnSelector, { bias: 'button' });
|
||||
|
||||
logger.info('适配器', '等待生成结果中...', meta);
|
||||
logger.info('适配器', '等待生成结果...', meta);
|
||||
|
||||
// 5. 监听网络响应
|
||||
let targetRequestId = null;
|
||||
const result = await new Promise((resolve) => {
|
||||
const cleanup = () => {
|
||||
client.off('Network.responseReceived', onRes);
|
||||
client.off('Network.loadingFinished', onLoad);
|
||||
};
|
||||
const onRes = (e) => {
|
||||
// 监听流式响应接口
|
||||
if (e.response.url.includes('/nextjs-api/stream/')) targetRequestId = e.requestId;
|
||||
};
|
||||
const onLoad = async (e) => {
|
||||
if (e.requestId === targetRequestId) {
|
||||
try {
|
||||
const { body, base64Encoded } = await client.send('Network.getResponseBody', { requestId: targetRequestId });
|
||||
const content = base64Encoded ? Buffer.from(body, 'base64').toString('utf8') : body;
|
||||
// 5. 等待并处理响应
|
||||
const response = await responsePromise;
|
||||
|
||||
// 检查是否包含 reCAPTCHA 错误
|
||||
if (content.includes('recaptcha validation failed')) {
|
||||
cleanup();
|
||||
resolve({ error: 'recaptcha validation failed' });
|
||||
return;
|
||||
}
|
||||
|
||||
const img = extractImage(content);
|
||||
if (img) {
|
||||
logger.info('适配器', '已获取生图结果,正在下载图片...', meta);
|
||||
|
||||
// 下载图片并转换为 Base64
|
||||
try {
|
||||
const response = await gotScraping({
|
||||
url: img,
|
||||
responseType: 'buffer',
|
||||
http2: true,
|
||||
headerGeneratorOptions: {
|
||||
browsers: [{ name: 'chrome', minVersion: 110 }],
|
||||
devices: ['desktop'],
|
||||
locales: ['en-US'],
|
||||
operatingSystems: ['windows'],
|
||||
}
|
||||
});
|
||||
const base64 = response.body.toString('base64');
|
||||
const dataUri = `data:image/png;base64,${base64}`;
|
||||
logger.info('适配器', '生图成功', meta);
|
||||
|
||||
cleanup();
|
||||
resolve({ image: dataUri });
|
||||
} catch (e) {
|
||||
logger.error('适配器', '图片下载失败', { ...meta, error: e.message });
|
||||
cleanup();
|
||||
resolve({ error: `Image download failed: ${e.message}` });
|
||||
}
|
||||
} else {
|
||||
logger.info('适配器', 'AI 返回文本回复', { ...meta, preview: content.substring(0, 150) });
|
||||
cleanup();
|
||||
resolve({ text: content });
|
||||
}
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
resolve({ error: err.message });
|
||||
}
|
||||
}
|
||||
};
|
||||
client.on('Network.responseReceived', onRes);
|
||||
client.on('Network.loadingFinished', onLoad);
|
||||
|
||||
// 超时保护 (120秒)
|
||||
setTimeout(() => {
|
||||
cleanup();
|
||||
resolve({ error: 'Timeout' });
|
||||
}, 120000);
|
||||
});
|
||||
|
||||
// 任务结束,基于当前窗口比例智能移开鼠标
|
||||
if (page.cursor) {
|
||||
// 1. 再次获取最新窗口大小 (用户可能在生成过程中改变了窗口大小)
|
||||
const currentVp = await getRealViewport(page);
|
||||
|
||||
// 2. 计算相对坐标:停靠在屏幕右侧 85% ~ 95% 的位置
|
||||
const relativeX = currentVp.safeWidth * random(0.85, 0.95);
|
||||
const relativeY = currentVp.height * random(0.3, 0.7); // 高度居中随机
|
||||
|
||||
// 3. 再次检查
|
||||
const finalX = clamp(relativeX, 0, currentVp.safeWidth);
|
||||
const finalY = clamp(relativeY, 0, currentVp.safeHeight);
|
||||
await page.cursor.moveTo({ x: finalX, y: finalY });
|
||||
if (response instanceof Error) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
return result;
|
||||
// 检查状态码
|
||||
if (response.status() === 429) {
|
||||
logger.warn('适配器', '触发限流/人机验证', meta);
|
||||
return { error: 'Rate limit exceeded or CAPTCHA triggered (HTTP 429)' };
|
||||
}
|
||||
|
||||
if (response.status() !== 200) {
|
||||
logger.warn('适配器', `返回异常状态码: ${response.status()}`, meta);
|
||||
return { error: `Server error: HTTP ${response.status()}` };
|
||||
}
|
||||
|
||||
// 解析成功响应
|
||||
const content = await response.text();
|
||||
|
||||
// 检查业务错误
|
||||
if (content.includes('recaptcha validation failed')) {
|
||||
return { error: 'recaptcha validation failed' };
|
||||
}
|
||||
|
||||
const img = extractImage(content);
|
||||
if (img) {
|
||||
logger.info('适配器', '已获取生图结果,正在下载图片...', meta);
|
||||
try {
|
||||
// 获取代理配置
|
||||
const config = loadConfig();
|
||||
const proxyConfig = getProxyConfig(config);
|
||||
const proxyUrl = await getHttpProxy(proxyConfig);
|
||||
|
||||
const options = {
|
||||
url: img,
|
||||
responseType: 'buffer',
|
||||
http2: true,
|
||||
headerGeneratorOptions: {
|
||||
browsers: [{ name: 'firefox', minVersion: 100 }],
|
||||
devices: ['desktop'],
|
||||
locales: ['en-US'],
|
||||
operatingSystems: ['windows'],
|
||||
}
|
||||
};
|
||||
|
||||
if (proxyUrl) {
|
||||
options.proxyUrl = proxyUrl;
|
||||
}
|
||||
|
||||
const imgRes = await gotScraping(options);
|
||||
const base64 = imgRes.body.toString('base64');
|
||||
return { image: `data:image/png;base64,${base64}` };
|
||||
} catch (e) {
|
||||
return { error: `Image download failed: ${e.message}` };
|
||||
}
|
||||
} else {
|
||||
logger.info('适配器', 'AI 返回文本回复', { ...meta, preview: content.substring(0, 150) });
|
||||
return { text: content };
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
if (err.name === 'TimeoutError') return { error: 'Timeout: 生成响应超时' };
|
||||
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
||||
return { error: err.message };
|
||||
} finally {
|
||||
if (fetchPausedHandler) {
|
||||
client.off('Fetch.requestPaused', fetchPausedHandler);
|
||||
// 清理拦截器
|
||||
if (modelId) await page.unroute('**/*').catch(() => { });
|
||||
|
||||
// 任务结束,将鼠标移至安全区域
|
||||
if (page.cursor) {
|
||||
try {
|
||||
await client.send('Fetch.disable');
|
||||
const vp = await getRealViewport(page);
|
||||
await page.cursor.moveTo({
|
||||
x: clamp(vp.safeWidth * random(0.85, 0.95), 0, vp.safeWidth),
|
||||
y: clamp(vp.height * random(0.3, 0.7), 0, vp.safeHeight)
|
||||
});
|
||||
} catch (e) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { initBrowser, generateImage, TEMP_DIR };
|
||||
export { initBrowser, generateImage, TEMP_DIR };
|
||||
+14
-1
@@ -7,6 +7,10 @@ export const IMAGE_POLICY = {
|
||||
|
||||
// LMArena 后端模型配置
|
||||
export const LMARENA_MODELS = {
|
||||
"gemini-3-pro-image-preview-2k": {
|
||||
codeName: "019abc10-e78d-7932-b725-7f1563ed8a12",
|
||||
imagePolicy: IMAGE_POLICY.OPTIONAL
|
||||
},
|
||||
"gemini-3-pro-image-preview": {
|
||||
codeName: "019aa208-5c19-7162-ae3b-0a9ddbb1e16a",
|
||||
imagePolicy: IMAGE_POLICY.OPTIONAL
|
||||
@@ -128,9 +132,16 @@ export const GEMINI_BIZ_MODELS = {
|
||||
}
|
||||
};
|
||||
|
||||
// NanoBananaFree AI 后端模型配置
|
||||
export const NANOBANANAFREE_AI_MODELS = {
|
||||
"gemini-2.5-flash-image": {
|
||||
imagePolicy: IMAGE_POLICY.OPTIONAL
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取后端对应的模型配置表
|
||||
* @param {string} backendName - 后端名称 ('lmarena' 或 'gemini_biz')
|
||||
* @param {string} backendName - 后端名称 ('lmarena' 或 'gemini_biz' 或 'nanobananafree_ai')
|
||||
* @returns {Object} 模型配置对象
|
||||
* @private
|
||||
*/
|
||||
@@ -140,6 +151,8 @@ function getModelsConfigForBackend(backendName) {
|
||||
return LMARENA_MODELS;
|
||||
case 'gemini_biz':
|
||||
return GEMINI_BIZ_MODELS;
|
||||
case 'nanobananafree_ai':
|
||||
return NANOBANANAFREE_AI_MODELS;
|
||||
// 将来新增其它后端:
|
||||
// case 'foo_site':
|
||||
// return FOO_SITE_MODELS;
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { initBrowserBase } from '../browser/launcher.js';
|
||||
import {
|
||||
random,
|
||||
sleep,
|
||||
getRealViewport,
|
||||
clamp,
|
||||
safeClick,
|
||||
humanType,
|
||||
pasteImages,
|
||||
getHumanClickPoint
|
||||
} from '../browser/utils.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
// --- 配置常量 ---
|
||||
const USER_DATA_DIR = path.join(process.cwd(), 'data', 'camoufoxUserData');
|
||||
const TARGET_URL = 'https://nanobananafree.ai/';
|
||||
const TEMP_DIR = path.join(process.cwd(), 'data', 'temp');
|
||||
|
||||
// 确保临时目录存在
|
||||
if (!fs.existsSync(TEMP_DIR)) {
|
||||
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化浏览器
|
||||
* @param {object} config - 配置对象
|
||||
* @returns {Promise<{browser: object, page: object, client: object}>}
|
||||
*/
|
||||
async function initBrowser(config) {
|
||||
// NanoBananaFree AI 特定的输入框验证逻辑
|
||||
const waitInputValidator = async (page) => {
|
||||
const textareaSelector = 'textarea';
|
||||
await page.waitForSelector(textareaSelector, { timeout: 60000 });
|
||||
const box = await (await page.$(textareaSelector)).boundingBox();
|
||||
if (box) {
|
||||
if (page.cursor) {
|
||||
const { x, y } = getHumanClickPoint(box, 'input');
|
||||
await page.cursor.moveTo({ x, y });
|
||||
}
|
||||
await sleep(500, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
return await initBrowserBase(config, {
|
||||
userDataDir: USER_DATA_DIR,
|
||||
targetUrl: TARGET_URL,
|
||||
productName: 'NanoBananaFree AI',
|
||||
waitInputValidator
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行生图任务
|
||||
* @param {object} context - 浏览器上下文 { page, client }
|
||||
* @param {string} prompt - 提示词
|
||||
* @param {string[]} imgPaths - 图片路径数组 (仅取第一张)
|
||||
* @param {string} [modelId] - 指定的模型 ID (可选,目前未使用)
|
||||
* @param {object} [meta={}] - 日志元数据
|
||||
* @returns {Promise<{image?: string, text?: string, error?: string}>} 生成结果
|
||||
*/
|
||||
async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
const { page } = context;
|
||||
const textareaSelector = 'textarea';
|
||||
|
||||
try {
|
||||
logger.info('适配器', '开启新会话', meta);
|
||||
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// 等待输入框加载
|
||||
await page.waitForSelector(textareaSelector, { timeout: 30000 });
|
||||
await sleep(1500, 2500);
|
||||
|
||||
// 1. 上传图片 (仅取第一张,多余的丢弃)
|
||||
if (imgPaths && imgPaths.length > 0) {
|
||||
const singleImage = [imgPaths[0]]; // 只取第一张
|
||||
if (imgPaths.length > 1) {
|
||||
logger.warn('适配器', `此后端仅支持1张图片,已丢弃 ${imgPaths.length - 1} 张`, meta);
|
||||
}
|
||||
await pasteImages(page, textareaSelector, singleImage);
|
||||
// 确保焦点在输入框
|
||||
await safeClick(page, textareaSelector, { bias: 'input' });
|
||||
}
|
||||
|
||||
// 2. 输入提示词
|
||||
logger.info('适配器', '正在输入提示词...', meta);
|
||||
await humanType(page, textareaSelector, prompt);
|
||||
await sleep(800, 1500);
|
||||
|
||||
// 3. 建立响应监听器
|
||||
// 监听包含 v1/generateContent 路径的 POST 请求
|
||||
const responsePromise = page.waitForResponse(response =>
|
||||
response.url().includes('v1/generateContent') &&
|
||||
response.request().method() === 'POST' &&
|
||||
(response.status() === 200 || response.status() >= 400),
|
||||
{ timeout: 120000 }
|
||||
).catch(e => e);
|
||||
|
||||
// 4. 点击发送按钮 (匹配 class 包含 _sendButton_ 的 div)
|
||||
logger.debug('适配器', '点击发送...', meta);
|
||||
|
||||
// 使用更通用的选择器匹配发送按钮
|
||||
const sendBtnSelector = 'div[class*="_sendButton_"]';
|
||||
await page.waitForSelector(sendBtnSelector, { timeout: 10000 });
|
||||
await safeClick(page, sendBtnSelector, { bias: 'button' });
|
||||
|
||||
logger.info('适配器', '等待生成结果...', meta);
|
||||
|
||||
// 5. 等待并处理响应
|
||||
const response = await responsePromise;
|
||||
|
||||
if (response instanceof Error) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
// 检查状态码
|
||||
if (response.status() !== 200) {
|
||||
// 非200状态,尝试读取错误信息
|
||||
try {
|
||||
const body = await response.json();
|
||||
const errMessage = body?.errMessage || body?.error?.message || `HTTP ${response.status()}`;
|
||||
logger.warn('适配器', `请求返回错误: ${errMessage}`, meta);
|
||||
return { error: errMessage };
|
||||
} catch (e) {
|
||||
logger.warn('适配器', `返回异常状态码: ${response.status()}`, meta);
|
||||
return { error: `Server error: HTTP ${response.status()}` };
|
||||
}
|
||||
}
|
||||
|
||||
// 解析成功响应
|
||||
const body = await response.json();
|
||||
|
||||
// 尝试从响应中提取 base64 图片
|
||||
// 路径: data.candidates[0].content.parts[0].inlineData.data
|
||||
const inlineData = body?.data?.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
|
||||
|
||||
if (inlineData) {
|
||||
logger.info('适配器', '已获取生图结果', meta);
|
||||
// 返回带有 data URI 前缀的 base64 图片
|
||||
return { image: `data:image/png;base64,${inlineData}` };
|
||||
} else {
|
||||
// 没有找到图片数据,可能是文本回复或其他格式
|
||||
logger.info('适配器', 'AI 返回非图片响应', { ...meta, preview: JSON.stringify(body).substring(0, 150) });
|
||||
return { text: JSON.stringify(body) };
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
if (err.name === 'TimeoutError') return { error: 'Timeout: 生成响应超时' };
|
||||
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
||||
return { error: err.message };
|
||||
} finally {
|
||||
// 任务结束,将鼠标移至安全区域
|
||||
if (page.cursor) {
|
||||
try {
|
||||
const vp = await getRealViewport(page);
|
||||
await page.cursor.moveTo({
|
||||
x: clamp(vp.safeWidth * random(0.85, 0.95), 0, vp.safeWidth),
|
||||
y: clamp(vp.height * random(0.3, 0.7), 0, vp.safeHeight)
|
||||
});
|
||||
} catch (e) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { initBrowser, generateImage, TEMP_DIR };
|
||||
+191
-236
@@ -1,52 +1,48 @@
|
||||
import puppeteer from 'puppeteer-extra';
|
||||
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||
import { createCursor } from 'ghost-cursor';
|
||||
import { anonymizeProxy, closeAnonymizedProxy } from 'proxy-chain';
|
||||
import { spawn } from 'child_process';
|
||||
import { Camoufox } from 'camoufox-js';
|
||||
import { FingerprintGenerator } from 'fingerprint-generator';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { createCursor } from 'ghost-cursor-playwright-port';
|
||||
import { getRealViewport, clamp, random, sleep } from './utils.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
// 配置 Stealth 插件
|
||||
const stealth = StealthPlugin();
|
||||
stealth.enabledEvasions.delete('iframe.contentWindow');
|
||||
puppeteer.use(stealth);
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { getBrowserProxy, cleanupProxy } from '../utils/proxy.js';
|
||||
|
||||
// 全局状态跟踪
|
||||
let globalChromeProcess = null;
|
||||
let globalBrowser = null;
|
||||
let globalProxyUrl = null;
|
||||
let globalBrowserProcess = null;
|
||||
let globalContext = null; // 替代 globalBrowser
|
||||
|
||||
/**
|
||||
* 清理浏览器资源和进程
|
||||
* 实现三级退出机制: Puppeteer close -> SIGTERM -> SIGKILL
|
||||
* 实现三级退出机制: Playwright close -> SIGTERM -> SIGKILL
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function cleanup() {
|
||||
|
||||
// Level 1: 通过 Puppeteer 协议优雅关闭,释放锁并保存 Profile
|
||||
if (globalBrowser) {
|
||||
// Level 1: 通过 Playwright 协议优雅关闭 Context,保存 Profile
|
||||
if (globalContext) {
|
||||
try {
|
||||
logger.debug('浏览器', '正在断开远程调试连接...');
|
||||
await globalBrowser.close();
|
||||
globalBrowser = null;
|
||||
logger.debug('浏览器', '已断开远程调试连接');
|
||||
logger.debug('浏览器', '正在断开远程调试连接并保存 Profile...');
|
||||
await globalContext.close();
|
||||
globalContext = null;
|
||||
logger.debug('浏览器', '已关闭浏览器上下文');
|
||||
} catch (e) {
|
||||
logger.warn('浏览器', `断开远程调试连接失败 (可能已断开): ${e.message}`);
|
||||
logger.warn('浏览器', `关闭上下文失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Level 2 & 3: 处理残留进程
|
||||
if (globalChromeProcess && !globalChromeProcess.killed) {
|
||||
// Level 2 & 3: 处理残留进程 (主要用于登录模式)
|
||||
if (globalBrowserProcess && !globalBrowserProcess.killed) {
|
||||
logger.info('浏览器', '正在终止浏览器进程...');
|
||||
try {
|
||||
// Level 2: 发送 SIGTERM (软杀)
|
||||
globalChromeProcess.kill('SIGTERM');
|
||||
globalBrowserProcess.kill('SIGTERM');
|
||||
|
||||
// 等待进程退出
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 2000) {
|
||||
try {
|
||||
process.kill(globalChromeProcess.pid, 0);
|
||||
process.kill(globalBrowserProcess.pid, 0);
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
} catch (e) {
|
||||
break;
|
||||
@@ -56,26 +52,17 @@ export async function cleanup() {
|
||||
|
||||
// Level 3: 强制查杀 (SIGKILL)
|
||||
try {
|
||||
process.kill(globalChromeProcess.pid, 0);
|
||||
process.kill(globalBrowserProcess.pid, 0);
|
||||
logger.debug('浏览器', '浏览器进程无响应,执行强制终止 (SIGKILL)...');
|
||||
process.kill(-globalChromeProcess.pid, 'SIGKILL');
|
||||
process.kill(-globalBrowserProcess.pid, 'SIGKILL');
|
||||
} catch (e) { }
|
||||
|
||||
globalChromeProcess = null;
|
||||
logger.info('浏览器', '浏览器进程进程已终止');
|
||||
globalBrowserProcess = null;
|
||||
logger.info('浏览器', '浏览器进程已终止');
|
||||
}
|
||||
|
||||
// 清理代理
|
||||
if (globalProxyUrl) {
|
||||
try {
|
||||
logger.debug('浏览器', '正在关闭 Socks5 代理桥接');
|
||||
await closeAnonymizedProxy(globalProxyUrl, true);
|
||||
logger.debug('浏览器', '已关闭 Socks5 代理桥接');
|
||||
} catch (e) {
|
||||
logger.error('浏览器', '关闭 Socks5 代理桥接失败', { error: e.message });
|
||||
}
|
||||
globalProxyUrl = null;
|
||||
}
|
||||
await cleanupProxy();
|
||||
}
|
||||
|
||||
// 防止重复注册
|
||||
@@ -89,7 +76,7 @@ function registerCleanupHandlers() {
|
||||
if (signalHandlersRegistered) return;
|
||||
|
||||
process.on('exit', () => {
|
||||
if (globalChromeProcess) globalChromeProcess.kill();
|
||||
if (globalBrowserProcess) globalBrowserProcess.kill();
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
@@ -105,14 +92,83 @@ function registerCleanupHandlers() {
|
||||
signalHandlersRegistered = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前操作系统名称
|
||||
* 将 Node.js 的 platform 转换为 Camoufox/FingerprintGenerator 支持的格式
|
||||
*/
|
||||
function getCurrentOS() {
|
||||
const platform = os.platform();
|
||||
if (platform === 'win32') return 'windows';
|
||||
if (platform === 'darwin') return 'macos';
|
||||
// 其他情况默认为 linux
|
||||
return 'linux';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或生成持久化指纹
|
||||
* @param {string} filePath - JSON文件保存路径
|
||||
*/
|
||||
function getPersistentFingerprint(filePath) {
|
||||
// 确保 data 目录存在
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// 尝试读取现有指纹
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const savedData = JSON.parse(fileContent);
|
||||
|
||||
// 简单校验:确保读取的是一个对象
|
||||
if (savedData && typeof savedData === 'object') {
|
||||
logger.info('浏览器', '已加载本地持久化指纹');
|
||||
return savedData;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('浏览器', `读取指纹文件失败,将重新生成: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 生成新指纹
|
||||
const currentOS = getCurrentOS();
|
||||
logger.info('浏览器', `正在为系统 [${currentOS}] 生成新指纹...`);
|
||||
|
||||
// 为不同系统使用不同的配置策略
|
||||
const generatorOptions = {
|
||||
browsers: ['firefox'],
|
||||
operatingSystems: [currentOS],
|
||||
devices: ['desktop'],
|
||||
locales: ['en-US'],
|
||||
screen: {
|
||||
minWidth: 1280, maxWidth: 1920,
|
||||
minHeight: 720, maxHeight: 1080
|
||||
}
|
||||
};
|
||||
|
||||
const generator = new FingerprintGenerator(generatorOptions);
|
||||
|
||||
const result = generator.getFingerprint();
|
||||
|
||||
// 关键点:我们只需要 result.fingerprint 部分
|
||||
const fingerprintToSave = result.fingerprint;
|
||||
|
||||
// 保存到文件
|
||||
fs.writeFileSync(filePath, JSON.stringify(fingerprintToSave, null, 2));
|
||||
logger.info('浏览器', `新指纹已保存至: ${filePath}`);
|
||||
|
||||
return fingerprintToSave;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化浏览器 (统一启动逻辑)
|
||||
* @param {object} config - 配置对象
|
||||
* @param {object} [config.chrome] - Chrome 配置
|
||||
* @param {boolean} [config.chrome.headless] - 是否开启 Headless 模式
|
||||
* @param {string} [config.chrome.path] - Chrome 可执行文件路径
|
||||
* @param {boolean} [config.chrome.gpu] - 是否启用 GPU
|
||||
* @param {object} [config.chrome.proxy] - 代理配置
|
||||
* @param {object} [config.browser] - Browser 配置
|
||||
* @param {boolean} [config.browser.headless] - 是否开启 Headless 模式
|
||||
* @param {string} [config.browser.path] - Camoufox 可执行文件路径
|
||||
* @param {boolean} [config.browser.gpu] - 是否启用 GPU
|
||||
* @param {object} [config.browser.proxy] - 代理配置
|
||||
* @param {object} options - 启动选项
|
||||
* @param {string} options.userDataDir - 用户数据目录路径
|
||||
* @param {string} options.targetUrl - 目标 URL
|
||||
@@ -126,7 +182,6 @@ export async function initBrowserBase(config, options) {
|
||||
userDataDir,
|
||||
targetUrl,
|
||||
productName,
|
||||
reuseExistingTab = false,
|
||||
waitInputValidator = null
|
||||
} = options;
|
||||
|
||||
@@ -140,41 +195,48 @@ export async function initBrowserBase(config, options) {
|
||||
logger.warn('浏览器', '当前为登录模式,请手动完成登录后关闭登录模式以继续自动化程序!');
|
||||
}
|
||||
|
||||
const chromeConfig = config?.chrome || {};
|
||||
const remoteDebuggingPort = 9222;
|
||||
const browserConfig = config?.browser || {};
|
||||
|
||||
// Chrome 启动参数
|
||||
const args = [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--disable-dev-shm-usage',
|
||||
`--user-data-dir=${userDataDir}`,
|
||||
'--no-first-run'
|
||||
];
|
||||
// 获取指纹对象
|
||||
const fingerprintPath = path.join(process.cwd(), 'data', 'camoufoxFingerprints.json');
|
||||
const myFingerprint = getPersistentFingerprint(fingerprintPath);
|
||||
|
||||
// 构造 Camoufox 启动选项
|
||||
logger.info('浏览器', '正在启动 Camoufox 浏览器...');
|
||||
const currentOS = getCurrentOS();
|
||||
const camoufoxLaunchOptions = {
|
||||
// 基础选项 (snake_case)
|
||||
executable_path: browserConfig.path || undefined,
|
||||
headless: browserConfig.headless && !isLoginMode,
|
||||
user_data_dir: userDataDir,
|
||||
window: [1280, 720],
|
||||
ff_version: 135,
|
||||
fingerprint: myFingerprint,
|
||||
os: currentOS,
|
||||
i_know_what_im_doing: true,
|
||||
block_webrtc: true,
|
||||
exclude_addons: ['UBO'],
|
||||
geoip: false,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--no-first-run'
|
||||
]
|
||||
};
|
||||
|
||||
// Headless 模式配置
|
||||
if (chromeConfig.headless && !isLoginMode) {
|
||||
args.push('--headless=new');
|
||||
args.push('--window-size=1280,690');
|
||||
args.push('--headless=new');
|
||||
args.push('--window-size=1280,690');
|
||||
logger.info('浏览器', 'Headless 模式: 启用 (1280x690)');
|
||||
if (browserConfig.headless && !isLoginMode) {
|
||||
logger.info('浏览器', 'Headless 模式: 启用');
|
||||
} else {
|
||||
if (isLoginMode && chromeConfig.headless) {
|
||||
logger.warn('浏览器', '登录模式下强制禁用 Headless 模式。');
|
||||
}
|
||||
// 有头模式:最大化窗口以适配屏幕
|
||||
args.push('--start-maximized');
|
||||
logger.info('浏览器', 'Headless 模式: 禁用 (最大化窗口)');
|
||||
logger.info('浏览器', 'Headless 模式: 禁用');
|
||||
}
|
||||
|
||||
// GPU 配置
|
||||
if (chromeConfig.gpu === false) {
|
||||
args.push(
|
||||
// GPU 配置适配
|
||||
if (browserConfig.gpu === false) {
|
||||
camoufoxLaunchOptions.args.push(
|
||||
'--disable-gpu',
|
||||
'--use-gl=swiftshader',
|
||||
'--disable-accelerated-2d-canvas',
|
||||
'--animation-duration-scale=0',
|
||||
'--disable-smooth-scrolling'
|
||||
);
|
||||
@@ -183,190 +245,79 @@ export async function initBrowserBase(config, options) {
|
||||
logger.info('浏览器', 'GPU 加速: 启用');
|
||||
}
|
||||
|
||||
// 代理配置
|
||||
let proxyUrlForChrome = null;
|
||||
if (chromeConfig.proxy && chromeConfig.proxy.enable) {
|
||||
const { type, host, port, user, passwd } = chromeConfig.proxy;
|
||||
|
||||
// 特殊处理 SOCKS5 + Auth (Chrome 原生不支持)
|
||||
if (type === 'socks5' && user && passwd) {
|
||||
try {
|
||||
const upstreamUrl = `socks5://${user}:${passwd}@${host}:${port}`;
|
||||
logger.info('浏览器', '检测到需鉴权的 Socks5 代理,正在创建本地代理桥接...');
|
||||
// 创建本地中间代理 (无认证 -> 有认证)
|
||||
proxyUrlForChrome = await anonymizeProxy(upstreamUrl);
|
||||
globalProxyUrl = proxyUrlForChrome; // 记录全局代理
|
||||
logger.info('浏览器', `本地代理桥接已建立: ${proxyUrlForChrome} -> ${host}:${port}`);
|
||||
|
||||
args.push(`--proxy-server=${proxyUrlForChrome}`);
|
||||
args.push('--disable-quic');
|
||||
logger.warn('浏览器', '为增强代理兼容性,已禁用QUIC (HTTP/3)');
|
||||
logger.info('浏览器', `代理配置: ${type}://${host}:${port}`);
|
||||
} catch (e) {
|
||||
logger.error('浏览器', '本地代理桥接创建失败', { error: e.message });
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
// 常规 HTTP 代理或无认证 SOCKS5
|
||||
const proxyUrl = type === 'socks5' ? `socks5://${host}:${port}` : `${host}:${port}`;
|
||||
args.push(`--proxy-server=${proxyUrl}`);
|
||||
args.push('--disable-quic');
|
||||
logger.warn('浏览器', '为增强代理兼容性,已禁用QUIC (HTTP/3)');
|
||||
logger.info('浏览器', `代理配置: ${type}://${host}:${port}`);
|
||||
}
|
||||
// 代理配置适配
|
||||
const proxyObject = await getBrowserProxy(browserConfig.proxy);
|
||||
if (proxyObject) {
|
||||
camoufoxLaunchOptions.proxy = proxyObject;
|
||||
}
|
||||
|
||||
const chromePath = chromeConfig.path;
|
||||
// 启动 Camoufox
|
||||
const context = await Camoufox(camoufoxLaunchOptions);
|
||||
globalContext = context; // 存储全局 Context
|
||||
|
||||
// --- 模式分支 ---
|
||||
|
||||
if (!ENABLE_AUTOMATION_MODE) {
|
||||
// 仅启动浏览器
|
||||
logger.info('浏览器', '正在以登录模式启动浏览器...');
|
||||
logger.debug('浏览器', `启动路径: ${chromePath}`);
|
||||
|
||||
// 在手动模式下自动打开目标页面
|
||||
args.push(targetUrl);
|
||||
|
||||
const chromeProcess = spawn(chromePath, args, {
|
||||
detached: false,
|
||||
stdio: 'ignore'
|
||||
});
|
||||
globalChromeProcess = chromeProcess;
|
||||
|
||||
// 注册清理处理器
|
||||
registerCleanupHandlers();
|
||||
logger.info('浏览器', '浏览器已启动,脚本将持续运行直到浏览器关闭...');
|
||||
|
||||
await new Promise((resolve) => {
|
||||
chromeProcess.on('close', async (code) => {
|
||||
logger.warn('浏览器', `浏览器已被关闭 (退出码: ${code})`);
|
||||
await cleanup();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
logger.info('浏览器', '浏览器已被关闭,脚本退出');
|
||||
process.exit(0);
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- 自动化模式 ---
|
||||
|
||||
let browserWSEndpoint = null;
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${remoteDebuggingPort}/json/version`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data && data.webSocketDebuggerUrl) {
|
||||
logger.debug('浏览器', '检测到已运行的浏览器实例,正在连接...');
|
||||
browserWSEndpoint = data.webSocketDebuggerUrl;
|
||||
logger.info('浏览器', '已连接到已运行的浏览器实例,程序将复用实例');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.debug('浏览器', '未检测到运行中的浏览器实例,正在启动新实例...');
|
||||
}
|
||||
|
||||
if (!browserWSEndpoint) {
|
||||
const automationArgs = [...args, `--remote-debugging-port=${remoteDebuggingPort}`];
|
||||
logger.info('浏览器', '正在启动浏览器...');
|
||||
logger.debug('浏览器', `启动路径: ${chromePath}`);
|
||||
|
||||
const chromeProcess = spawn(chromePath, automationArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
});
|
||||
chromeProcess.unref();
|
||||
globalChromeProcess = chromeProcess;
|
||||
|
||||
logger.debug('浏览器', '浏览器已启动,等待调试端口就绪...');
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await sleep(1000, 1500);
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${remoteDebuggingPort}/json/version`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data && data.webSocketDebuggerUrl) {
|
||||
browserWSEndpoint = data.webSocketDebuggerUrl;
|
||||
logger.debug('浏览器', '浏览器调试接口已就绪');
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
if (!browserWSEndpoint) {
|
||||
throw new Error('无法连接到 Chrome 远程调试端口,请检查 Chrome 是否成功启动。');
|
||||
}
|
||||
}
|
||||
|
||||
// 连接 Puppeteer
|
||||
const browser = await puppeteer.connect({
|
||||
browserWSEndpoint: browserWSEndpoint,
|
||||
defaultViewport: null
|
||||
});
|
||||
|
||||
globalBrowser = browser; // 保存实例引用供 cleanup 使用
|
||||
|
||||
logger.info('浏览器', '远程调试已连接');
|
||||
logger.info('浏览器', 'Camoufox 浏览器已启动');
|
||||
|
||||
// 注册清理处理器
|
||||
registerCleanupHandlers();
|
||||
|
||||
browser.on('disconnected', async () => {
|
||||
logger.warn('浏览器', '浏览器已断开连接');
|
||||
// 注册断开连接事件
|
||||
context.on('close', async () => {
|
||||
logger.warn('浏览器', 'Camoufox 浏览器已断开连接');
|
||||
await cleanup();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// 获取或创建页面
|
||||
// 获取或创建 Page
|
||||
let page;
|
||||
if (reuseExistingTab) {
|
||||
// 复用已有标签页
|
||||
const pages = await browser.pages();
|
||||
const urlDomain = new URL(targetUrl).hostname;
|
||||
page = pages.find(p => p.url().includes(urlDomain));
|
||||
|
||||
if (!page) {
|
||||
page = await browser.newPage();
|
||||
logger.debug('浏览器', '已创建新标签页');
|
||||
const existingPages = context.pages(); // 获取启动时自动打开的页面
|
||||
if (!page) {
|
||||
if (existingPages.length > 0) {
|
||||
page = existingPages[0];
|
||||
logger.debug('浏览器', '复用浏览器启动时的默认标签页');
|
||||
} else {
|
||||
logger.warn('浏览器', '检测到已有目标网站标签页,程序将复用标签页');
|
||||
page = await context.newPage();
|
||||
logger.debug('浏览器', '浏览器没有标签,已创建新标签页');
|
||||
}
|
||||
} else {
|
||||
// 总是新建标签页
|
||||
page = await browser.newPage();
|
||||
logger.debug('浏览器', '已创建新标签页');
|
||||
}
|
||||
|
||||
// 强制刷新一下视口大小,防止复用默认窗口时尺寸不对
|
||||
if (camoufoxLaunchOptions.viewport) {
|
||||
await page.setViewportSize(camoufoxLaunchOptions.viewport);
|
||||
}
|
||||
|
||||
// 登录模式挂起逻辑
|
||||
if (isLoginMode) {
|
||||
// 尝试导航到目标页面方便用户登录
|
||||
try {
|
||||
logger.info('浏览器', `正在连接 ${productName}...`);
|
||||
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
|
||||
} catch (e) {
|
||||
logger.warn('浏览器', `打开页面失败: ${e.message}`);
|
||||
}
|
||||
|
||||
logger.info('浏览器', '请在弹出的浏览器窗口中手动完成登录操作');
|
||||
logger.info('浏览器', '完成后可直接关闭浏览器窗口或在终端结束程序');
|
||||
|
||||
await new Promise((resolve) => {
|
||||
context.on('close', () => {
|
||||
logger.info('浏览器', '检测到浏览器窗口关闭,程序即将退出');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
await cleanup();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 初始化 ghost-cursor
|
||||
page.cursor = createCursor(page);
|
||||
|
||||
// 代理认证 (仅当未使用 proxy-chain 桥接时)
|
||||
if (chromeConfig.proxy && chromeConfig.proxy.enable && chromeConfig.proxy.user && !proxyUrlForChrome) {
|
||||
await page.authenticate({
|
||||
username: chromeConfig.proxy.user,
|
||||
password: chromeConfig.proxy.passwd
|
||||
});
|
||||
logger.info('浏览器', '代理认证: 已激活 (HTTP Basic Auth)');
|
||||
}
|
||||
|
||||
// 创建 CDP 会话
|
||||
const client = await page.target().createCDPSession();
|
||||
await client.send('Network.enable');
|
||||
|
||||
// 注册清理钩子
|
||||
if (proxyUrlForChrome) {
|
||||
logger.warn('浏览器', '因使用了本地代理桥接,请保持此程序运行,否则浏览器将失去代理连接');
|
||||
}
|
||||
|
||||
// --- 行为预热建立人机检测信任 ---
|
||||
const urlDomain = new URL(targetUrl).hostname;
|
||||
if (!page.url().includes(urlDomain)) {
|
||||
logger.info('浏览器', `正在连接 ${productName}...`);
|
||||
await page.goto(targetUrl, { waitUntil: 'networkidle2' });
|
||||
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
|
||||
} else {
|
||||
logger.info('浏览器', `页面已在 ${productName},跳过跳转`);
|
||||
}
|
||||
@@ -386,7 +337,7 @@ export async function initBrowserBase(config, options) {
|
||||
const targetX = clamp(centerX + random(-200, 200), 10, vp.safeWidth);
|
||||
const targetY = clamp(centerY + random(-200, 200), 10, vp.safeHeight);
|
||||
|
||||
// 重置 cursor 内部状态 (可选,增加拟人化)
|
||||
// 移动鼠标 (增加拟人化)
|
||||
await page.cursor.moveTo({ x: targetX, y: targetY });
|
||||
}
|
||||
await sleep(500, 1000);
|
||||
@@ -406,5 +357,9 @@ export async function initBrowserBase(config, options) {
|
||||
logger.info('浏览器', '浏览器初始化完成,系统就绪');
|
||||
logger.warn('浏览器', '当任务运行时请勿随意调节窗口大小,以免鼠标轨迹错位!');
|
||||
|
||||
return { browser, page, client };
|
||||
// 返回对象 (兼容性处理)
|
||||
return {
|
||||
browser: context,
|
||||
page
|
||||
};
|
||||
}
|
||||
|
||||
+206
-125
@@ -1,11 +1,10 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { logger } from '../logger.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
/**
|
||||
* 生成指定范围内的随机数
|
||||
* @param {number} min 最小值
|
||||
* @param {number} max 最大值
|
||||
* @param {number} min - 最小值
|
||||
* @param {number} max - 最大值
|
||||
* @returns {number} 随机数
|
||||
*/
|
||||
export function random(min, max) {
|
||||
@@ -14,8 +13,8 @@ export function random(min, max) {
|
||||
|
||||
/**
|
||||
* 随机休眠一段时间
|
||||
* @param {number} min 最小毫秒数
|
||||
* @param {number} max 最大毫秒数
|
||||
* @param {number} min - 最小毫秒数
|
||||
* @param {number} max - 最大毫秒数
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function sleep(min, max) {
|
||||
@@ -24,7 +23,7 @@ export function sleep(min, max) {
|
||||
|
||||
/**
|
||||
* 根据文件扩展名获取 MIME 类型
|
||||
* @param {string} filePath 文件路径
|
||||
* @param {string} filePath - 文件路径
|
||||
* @returns {string} MIME 类型
|
||||
*/
|
||||
export function getMimeType(filePath) {
|
||||
@@ -40,34 +39,34 @@ export function getMimeType(filePath) {
|
||||
}
|
||||
|
||||
/**
|
||||
* [Security Enhanced] 无痕获取当前页面实时视口
|
||||
* 使用纯净的匿名函数执行,不污染 Global Scope,不留指纹
|
||||
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
|
||||
* 无痕获取当前页面实时视口
|
||||
* 使用纯净的匿名函数执行,不污染 Global Scope
|
||||
* @param {import('playwright-core').Page} page - Playwright 页面实例
|
||||
* @returns {Promise<{width: number, height: number, safeWidth: number, safeHeight: number}>} 视口尺寸及安全区域
|
||||
*/
|
||||
export async function getRealViewport(page) {
|
||||
try {
|
||||
return await page.evaluate(() => {
|
||||
// 仅读取标准属性,不进行任何写入操作
|
||||
// 仅读取标准属性,不进行任何写入操作
|
||||
const w = window.innerWidth;
|
||||
const h = window.innerHeight;
|
||||
return {
|
||||
width: w,
|
||||
height: h,
|
||||
// 预留 20px 缓冲,防止鼠标移到滚动条上或贴边触发浏览器原生手势
|
||||
// 预留 20px 缓冲,防止鼠标移到滚动条上或贴边触发浏览器原生手势
|
||||
safeWidth: w - 20,
|
||||
safeHeight: h
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
// Fallback: 如果上下文丢失,返回安全保守值
|
||||
// Fallback: 如果上下文丢失,返回安全保守值
|
||||
return { width: 1280, height: 720, safeWidth: 1260, safeHeight: 720 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [Safety] 坐标钳位函数
|
||||
* 强制将坐标限制在合法视口范围内,杜绝 "Node is not visible" 报错
|
||||
* 坐标钳位函数
|
||||
* 强制将坐标限制在合法视口范围内,防止 "Node is not visible" 报错
|
||||
* @param {number} value - 原始坐标值
|
||||
* @param {number} min - 最小值
|
||||
* @param {number} max - 最大值
|
||||
@@ -79,13 +78,14 @@ export function clamp(value, min, max) {
|
||||
|
||||
/**
|
||||
* 深度查找 Shadow DOM 中的元素
|
||||
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
|
||||
* @param {import('playwright-core').Page} page - Playwright 页面实例
|
||||
* @param {string} selector - CSS 选择器
|
||||
* @param {import('puppeteer').ElementHandle} [rootHandle=null] - 可选的根节点句柄
|
||||
* @returns {Promise<import('puppeteer').ElementHandle|null>} 找到的元素句柄或 null
|
||||
* @param {import('playwright-core').ElementHandle} [rootHandle=null] - 可选的根节点句柄
|
||||
* @returns {Promise<import('playwright-core').ElementHandle|null>} 找到的元素句柄或 null
|
||||
*/
|
||||
export async function queryDeep(page, selector, rootHandle = null) {
|
||||
return await page.evaluateHandle((sel, root) => {
|
||||
// Playwright evaluateHandle 只接受一个参数,包装成数组传递
|
||||
return await page.evaluateHandle(([sel, root]) => {
|
||||
function find(node, s) {
|
||||
if (!node) return null;
|
||||
if (node instanceof Element && node.matches(s)) return node;
|
||||
@@ -106,17 +106,39 @@ export async function queryDeep(page, selector, rootHandle = null) {
|
||||
return null;
|
||||
}
|
||||
return find(root || document.body, sel);
|
||||
}, selector, rootHandle);
|
||||
}, [selector, rootHandle]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全点击元素(包含拟人化移动和点击)
|
||||
* 计算拟人化的随机点击坐标
|
||||
* @param {object} box - 元素边界框 {x, y, width, height}
|
||||
* @param {string} [type='random'] - 点击类型: 'input'(偏左) 或 'random'/'button'(随机)
|
||||
* @returns {{x: number, y: number}} 计算出的坐标
|
||||
*/
|
||||
export function getHumanClickPoint(box, type = 'random') {
|
||||
let x, y;
|
||||
if (type === 'input') {
|
||||
// 输入框: 偏左 (5% - 40% 宽度), 垂直居中附近 (20% - 80% 高度)
|
||||
x = box.x + box.width * random(0.05, 0.4);
|
||||
y = box.y + box.height * random(0.2, 0.8);
|
||||
} else {
|
||||
// 按钮/其他: 中心附近随机 (20% - 80% 宽度/高度)
|
||||
x = box.x + box.width * random(0.2, 0.8);
|
||||
y = box.y + box.height * random(0.2, 0.8);
|
||||
}
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全点击元素 (包含拟人化移动和点击)
|
||||
* 支持 CSS selector 和 ElementHandle 两种输入
|
||||
* @param {import('puppeteer').Page} page - Puppeteer 页面对象
|
||||
* @param {string|import('puppeteer').ElementHandle} target - CSS 选择器或元素句柄
|
||||
* @param {import('playwright-core').Page} page - Playwright 页面对象
|
||||
* @param {string|import('playwright-core').ElementHandle} target - CSS 选择器或元素句柄
|
||||
* @param {object} [options] - 点击选项
|
||||
* @param {string} [options.bias='random'] - 偏移偏好: 'input' 或 'random'
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function safeClick(page, target) {
|
||||
export async function safeClick(page, target, options = {}) {
|
||||
try {
|
||||
let el;
|
||||
|
||||
@@ -131,6 +153,14 @@ export async function safeClick(page, target) {
|
||||
|
||||
// 使用 ghost-cursor 点击
|
||||
if (page.cursor) {
|
||||
const box = await el.boundingBox();
|
||||
if (box) {
|
||||
const { x, y } = getHumanClickPoint(box, options.bias || 'random');
|
||||
await page.cursor.moveTo({ x, y });
|
||||
await page.mouse.click(x, y);
|
||||
return;
|
||||
}
|
||||
// 如果无法获取 box,降级到默认点击
|
||||
await page.cursor.click(el);
|
||||
return;
|
||||
}
|
||||
@@ -145,8 +175,8 @@ export async function safeClick(page, target) {
|
||||
/**
|
||||
* 模拟人类键盘输入
|
||||
* 支持 CSS selector 和 ElementHandle 两种输入
|
||||
* @param {import('puppeteer').Page} page - Puppeteer 页面对象
|
||||
* @param {string|import('puppeteer').ElementHandle} target - CSS 选择器或元素句柄
|
||||
* @param {import('playwright-core').Page} page - Playwright 页面对象
|
||||
* @param {string|import('playwright-core').ElementHandle} target - CSS 选择器或元素句柄
|
||||
* @param {string} text - 要输入的文本
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
@@ -166,9 +196,29 @@ export async function humanType(page, target, text) {
|
||||
|
||||
// 智能输入策略
|
||||
if (text.length < 50) {
|
||||
// 短文本:保持拟人化逐字输入
|
||||
// 短文本: 保持拟人化逐字输入
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const nextChar = text[i + 1];
|
||||
|
||||
// 处理换行符 (避免触发发送)
|
||||
if (char === '\r' && nextChar === '\n') {
|
||||
// Windows 换行符 (\r\n)
|
||||
await page.keyboard.down('Shift');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.up('Shift');
|
||||
i++; // 跳过 \n
|
||||
await sleep(30, 100);
|
||||
continue;
|
||||
} else if (char === '\n' || char === '\r') {
|
||||
// Unix/Mac 换行符 (\n 或 \r)
|
||||
await page.keyboard.down('Shift');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.up('Shift');
|
||||
await sleep(30, 100);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 模拟错字 (5% 概率)
|
||||
if (Math.random() < 0.05) {
|
||||
await page.keyboard.type('x', { delay: random(50, 150) });
|
||||
@@ -180,7 +230,7 @@ export async function humanType(page, target, text) {
|
||||
await sleep(30, 100);
|
||||
}
|
||||
} else {
|
||||
// 长文本:假装打字 -> 停顿 -> 粘贴
|
||||
// 长文本: 假装打字 -> 停顿 -> 粘贴
|
||||
const fakeCount = Math.floor(random(3, 8));
|
||||
const fakeText = text.substring(0, fakeCount);
|
||||
|
||||
@@ -189,10 +239,10 @@ export async function humanType(page, target, text) {
|
||||
await page.keyboard.type(fakeText[i], { delay: random(30, 100) });
|
||||
}
|
||||
|
||||
// 2. 停顿思考 (0.5 - 1秒)
|
||||
// 2. 停顿思考
|
||||
await sleep(500, 1000);
|
||||
|
||||
// 3. 全选删除 (模拟 Ctrl+A -> Backspace)
|
||||
// 3. 全选删除
|
||||
await page.keyboard.down('Control');
|
||||
await page.keyboard.press('A');
|
||||
await page.keyboard.up('Control');
|
||||
@@ -200,127 +250,158 @@ export async function humanType(page, target, text) {
|
||||
await page.keyboard.press('Backspace');
|
||||
await sleep(100, 300);
|
||||
|
||||
// 4. 瞬间粘贴全部文本 (模拟 Ctrl+V)
|
||||
// 4. 瞬间粘贴全部文本
|
||||
if (typeof target === 'string') {
|
||||
// 对于 selector,使用 querySelector
|
||||
await page.evaluate((sel, content) => {
|
||||
await page.evaluate(({ sel, content }) => {
|
||||
const input = document.querySelector(sel);
|
||||
input.focus();
|
||||
document.execCommand('insertText', false, content);
|
||||
}, target, text);
|
||||
}, { sel: target, content: text });
|
||||
} else {
|
||||
// 对于 ElementHandle,直接使用
|
||||
await page.evaluate((el, content) => {
|
||||
await page.evaluate(({ el, content }) => {
|
||||
el.focus();
|
||||
document.execCommand('insertText', false, content);
|
||||
}, el, text);
|
||||
}, { el: target, content: text });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 粘贴图片到输入框
|
||||
* 支持 CSS selector 和 ElementHandle 两种输入
|
||||
* @param {import('puppeteer').Page} page - Puppeteer 页面对象
|
||||
* @param {string|import('puppeteer').ElementHandle} target - CSS 选择器或元素句柄
|
||||
* 查找页面上所有的文件输入框 (包括 Shadow DOM)
|
||||
* @private
|
||||
* @param {import('playwright-core').Page} page - Playwright 页面对象
|
||||
* @returns {Promise<import('playwright-core').ElementHandle[]>} 文件输入框 ElementHandle 数组
|
||||
*/
|
||||
async function findAllFileInputs(page) {
|
||||
// 使用 Playwright 的 evaluateHandle 在浏览器上下文中深度遍历
|
||||
const inputsHandle = await page.evaluateHandle(() => {
|
||||
const inputs = [];
|
||||
|
||||
function traverse(root) {
|
||||
if (!root) return;
|
||||
|
||||
// 1. 检查当前节点下的 input
|
||||
const nodes = root.querySelectorAll('input[type="file"]');
|
||||
nodes.forEach(n => inputs.push(n));
|
||||
|
||||
// 2. 遍历 Shadow DOM
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, null, false);
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode;
|
||||
if (node.shadowRoot) {
|
||||
traverse(node.shadowRoot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(document.body);
|
||||
return inputs;
|
||||
});
|
||||
|
||||
const properties = await inputsHandle.getProperties();
|
||||
const handles = [];
|
||||
for (const prop of properties.values()) {
|
||||
const elementHandle = prop.asElement();
|
||||
if (elementHandle) handles.push(elementHandle);
|
||||
}
|
||||
return handles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一图片上传入口 (Camoufox/Playwright 专用稳定版)
|
||||
* 策略: 深度搜索原生 input[type="file"] -> setInputFiles
|
||||
* @param {import('playwright-core').Page} page - Playwright 页面对象
|
||||
* @param {string|import('playwright-core').ElementHandle} target - CSS 选择器或元素句柄 (用于聚焦)
|
||||
* @param {string[]} filePaths - 图片文件路径数组
|
||||
* @param {Object} [options] - 可选配置
|
||||
* @param {Function} [options.uploadValidator] - 自定义上传确认回调函数,接收 response 参数
|
||||
* @param {Function} [options.uploadValidator] - 自定义上传确认回调函数, 接收 response 参数
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function pasteImages(page, target, filePaths, options = {}) {
|
||||
if (!filePaths || filePaths.length === 0) return;
|
||||
logger.info('浏览器', `正在粘贴 ${filePaths.length} 张图片...`);
|
||||
// 读取图片文件并转换为 Base64
|
||||
const filesData = filePaths.map(p => {
|
||||
const clean = p.replace(/['"]/g, '').trim();
|
||||
if (!fs.existsSync(clean)) return null;
|
||||
return {
|
||||
base64: fs.readFileSync(clean).toString('base64'),
|
||||
mime: getMimeType(clean),
|
||||
filename: path.basename(clean)
|
||||
};
|
||||
}).filter(f => f);
|
||||
logger.info('浏览器', `正在处理 ${filePaths.length} 张图片...`);
|
||||
|
||||
if (filesData.length === 0) return;
|
||||
// 1. 拟人化: 先点击一下目标区域 (让后台看起来像是用户聚焦了输入框)
|
||||
await safeClick(page, target, { bias: 'input' });
|
||||
await sleep(500, 1000);
|
||||
|
||||
// 点击输入框以获取焦点
|
||||
await safeClick(page, target);
|
||||
await sleep(500, 800);
|
||||
try {
|
||||
logger.info('浏览器', '正在深度扫描文件上传控件...');
|
||||
const fileInputs = await findAllFileInputs(page);
|
||||
|
||||
// 如果提供了自定义的上传确认函数,使用它
|
||||
if (options.uploadValidator && typeof options.uploadValidator === 'function') {
|
||||
const expectedUploads = filesData.length;
|
||||
let validatedCount = 0;
|
||||
if (fileInputs.length === 0) {
|
||||
throw new Error('未找到任何 input[type="file"] 控件,无法上传');
|
||||
}
|
||||
|
||||
const uploadPromise = new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
logger.warn('浏览器', `图片上传等待超时 (已确认: ${validatedCount}/${expectedUploads})`);
|
||||
resolve();
|
||||
}, 60000); // 60s 超时
|
||||
logger.info('浏览器', `找到 ${fileInputs.length} 个文件输入框,尝试上传...`);
|
||||
|
||||
const onResponse = (response) => {
|
||||
if (options.uploadValidator(response)) {
|
||||
validatedCount++;
|
||||
logger.info('浏览器', `图片上传进度: ${validatedCount}/${expectedUploads}`);
|
||||
if (validatedCount >= expectedUploads) {
|
||||
cleanup();
|
||||
resolve();
|
||||
// LMArena 通常只有一个用于聊天的上传控件,或者我们尝试第一个可用的
|
||||
// 如果有多个,通常最后一个是当前对话框的,或者我们可以尝试全部 (比较暴力但有效)
|
||||
let uploaded = false;
|
||||
|
||||
for (const handle of fileInputs) {
|
||||
try {
|
||||
// 检查元素是否连接在 DOM 上
|
||||
const isConnected = await handle.evaluate(el => el.isConnected);
|
||||
if (!isConnected) continue;
|
||||
|
||||
// 使用 Playwright 原生上传 (绕过所有事件拦截)
|
||||
await handle.setInputFiles(filePaths);
|
||||
uploaded = true;
|
||||
logger.info('浏览器', '已通过原生控件注入文件');
|
||||
break; // 只要有一个成功就停止
|
||||
} catch (e) {
|
||||
// 忽略不可操作的 input (比如被禁用的)
|
||||
logger.debug('浏览器', `跳过不可用的文件输入框: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!uploaded) {
|
||||
throw new Error('所有文件控件均无法接受输入');
|
||||
}
|
||||
|
||||
// 如果提供了自定义的上传确认函数,使用它
|
||||
if (options.uploadValidator && typeof options.uploadValidator === 'function') {
|
||||
const expectedUploads = filePaths.length;
|
||||
let validatedCount = 0;
|
||||
|
||||
const uploadPromise = new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
logger.warn('浏览器', `图片上传等待超时 (已确认: ${validatedCount}/${expectedUploads})`);
|
||||
resolve();
|
||||
}, 60000); // 60s 超时
|
||||
|
||||
const onResponse = (response) => {
|
||||
if (options.uploadValidator(response)) {
|
||||
validatedCount++;
|
||||
logger.info('浏览器', `图片上传进度: ${validatedCount}/${expectedUploads}`);
|
||||
if (validatedCount >= expectedUploads) {
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
page.off('response', onResponse);
|
||||
};
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
page.off('response', onResponse);
|
||||
};
|
||||
|
||||
page.on('response', onResponse);
|
||||
});
|
||||
page.on('response', onResponse);
|
||||
});
|
||||
|
||||
// 执行粘贴
|
||||
await executePaste(page, target, filesData);
|
||||
logger.info('浏览器', `粘贴完成,正在等待图片上传确认...`);
|
||||
await uploadPromise;
|
||||
logger.info('浏览器', `所有图片上传完成`);
|
||||
} else {
|
||||
// 默认行为:简单粘贴并等待固定时间
|
||||
await executePaste(page, target, filesData);
|
||||
logger.info('浏览器', `粘贴完成,等待缩略图缓冲`);
|
||||
// 等待图片上传和缩略图生成
|
||||
await sleep(2500, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行粘贴操作的内部函数
|
||||
* @private
|
||||
*/
|
||||
async function executePaste(page, target, filesData) {
|
||||
// 统一处理 selector 和 ElementHandle
|
||||
if (typeof target === 'string') {
|
||||
await page.evaluate(async (sel, files) => {
|
||||
const element = document.querySelector(sel);
|
||||
const dt = new DataTransfer();
|
||||
for (const f of files) {
|
||||
const bin = atob(f.base64);
|
||||
const arr = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
||||
dt.items.add(new File([arr], f.filename, { type: f.mime }));
|
||||
}
|
||||
element.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, clipboardData: dt }));
|
||||
}, target, filesData);
|
||||
} else {
|
||||
await page.evaluate(async (el, files) => {
|
||||
const dt = new DataTransfer();
|
||||
for (const f of files) {
|
||||
const bin = atob(f.base64);
|
||||
const arr = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
||||
dt.items.add(new File([arr], f.filename, { type: f.mime }));
|
||||
}
|
||||
el.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, clipboardData: dt }));
|
||||
}, target, filesData);
|
||||
logger.info('浏览器', `文件已注入,正在等待上传确认...`);
|
||||
await uploadPromise;
|
||||
logger.info('浏览器', `所有图片上传完成`);
|
||||
} else {
|
||||
// 默认行为: 等待上传预览出现
|
||||
logger.info('浏览器', `文件已注入,等待预览生成...`);
|
||||
await sleep(2000, 4000);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
logger.error('浏览器', `上传失败: ${e.message}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { generateApiKey } from './security/apiKey.js';
|
||||
|
||||
console.log('>>> [GenAPIKey] 生成新的 API Key:');
|
||||
console.log(generateApiKey());
|
||||
console.log('\n>>> 请将此 Key 复制到 config.yaml 文件的 server.auth 字段中。');
|
||||
@@ -1,10 +0,0 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* 生成随机 API Key
|
||||
* 格式: sk-{48位十六进制字符}
|
||||
* @returns {string} API Key
|
||||
*/
|
||||
export function generateApiKey() {
|
||||
return 'sk-' + crypto.randomBytes(24).toString('hex');
|
||||
}
|
||||
-357
@@ -1,357 +0,0 @@
|
||||
import { getBackend } from './backend/index.js';
|
||||
import { getModelsForBackend, resolveModelId } from './backend/models.js';
|
||||
import { select, input } from '@inquirer/prompts';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import http from 'http';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
// 使用统一后端获取配置和函数
|
||||
const { config, name, initBrowser, generateImage, TEMP_DIR } = getBackend();
|
||||
|
||||
logger.info('CLI/Test', `测试工具启动 (后端适配器: ${name})`);
|
||||
|
||||
/**
|
||||
* 选择测试模式
|
||||
*/
|
||||
async function selectTestMode() {
|
||||
const mode = await select({
|
||||
message: '选择测试模式',
|
||||
choices: [
|
||||
{ name: 'HTTP 服务器测试(需先启动服务器)', value: 'http' },
|
||||
{ name: '直接调用适配器', value: 'direct' }
|
||||
]
|
||||
});
|
||||
return mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择模型
|
||||
*/
|
||||
async function selectModel() {
|
||||
const models = getModelsForBackend(name);
|
||||
const choices = [
|
||||
{ name: 'Skip(使用默认模型)', value: null },
|
||||
...models.data.map(m => ({ name: m.id, value: m.id }))
|
||||
];
|
||||
|
||||
const modelId = await select({
|
||||
message: '选择模型',
|
||||
choices,
|
||||
pageSize: 15
|
||||
});
|
||||
|
||||
return modelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入提示词
|
||||
*/
|
||||
async function promptForInput() {
|
||||
const prompt = await input({
|
||||
message: '输入提示词(回车使用默认)',
|
||||
default: 'A cute cat'
|
||||
});
|
||||
return prompt.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入图片路径
|
||||
*/
|
||||
async function promptForImages() {
|
||||
const imagesInput = await input({
|
||||
message: '输入图片路径(逗号分隔,回车跳过)',
|
||||
default: ''
|
||||
});
|
||||
|
||||
if (!imagesInput.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return imagesInput.split(',').map(p => p.trim()).filter(p => p);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 测试模式 - OpenAI 格式
|
||||
*/
|
||||
async function testViaHttpOpenAI(prompt, modelId, imagePaths) {
|
||||
const PORT = config.server.port || 3000;
|
||||
const AUTH_TOKEN = config.server.auth;
|
||||
|
||||
logger.info('CLI/Test', 'HTTP 测试 - OpenAI 模式');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// 构造请求体
|
||||
const messages = [];
|
||||
const lastMessage = { role: 'user', content: [] };
|
||||
|
||||
// 添加文本
|
||||
if (prompt) {
|
||||
lastMessage.content.push({ type: 'text', text: prompt });
|
||||
}
|
||||
|
||||
// 添加图片
|
||||
for (const imgPath of imagePaths) {
|
||||
if (fs.existsSync(imgPath)) {
|
||||
const buffer = fs.readFileSync(imgPath);
|
||||
const base64 = buffer.toString('base64');
|
||||
const ext = path.extname(imgPath).slice(1).toLowerCase();
|
||||
const mimeType = ext === 'jpg' ? 'jpeg' : ext;
|
||||
lastMessage.content.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: `data:image/${mimeType};base64,${base64}` }
|
||||
});
|
||||
} else {
|
||||
logger.warn('CLI/Test', `图片不存在,已跳过: ${imgPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
messages.push(lastMessage);
|
||||
|
||||
const body = {
|
||||
messages,
|
||||
...(modelId && { model: modelId })
|
||||
};
|
||||
|
||||
const bodyStr = JSON.stringify(body);
|
||||
|
||||
const options = {
|
||||
hostname: '127.0.0.1',
|
||||
port: PORT,
|
||||
path: '/v1/chat/completions',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(bodyStr),
|
||||
'Authorization': `Bearer ${AUTH_TOKEN}`
|
||||
}
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 200) {
|
||||
const response = JSON.parse(data);
|
||||
resolve(response);
|
||||
} else {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.write(bodyStr);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 测试模式 - Queue 格式
|
||||
*/
|
||||
async function testViaHttpQueue(prompt, modelId, imagePaths) {
|
||||
const PORT = config.server.port || 3000;
|
||||
const AUTH_TOKEN = config.server.auth;
|
||||
|
||||
logger.info('CLI/Test', 'HTTP 测试 - Queue 模式');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// 构造请求体
|
||||
const messages = [];
|
||||
const lastMessage = { role: 'user', content: [] };
|
||||
|
||||
if (prompt) {
|
||||
lastMessage.content.push({ type: 'text', text: prompt });
|
||||
}
|
||||
|
||||
for (const imgPath of imagePaths) {
|
||||
if (fs.existsSync(imgPath)) {
|
||||
const buffer = fs.readFileSync(imgPath);
|
||||
const base64 = buffer.toString('base64');
|
||||
const ext = path.extname(imgPath).slice(1).toLowerCase();
|
||||
const mimeType = ext === 'jpg' ? 'jpeg' : ext;
|
||||
lastMessage.content.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: `data:image/${mimeType};base64,${base64}` }
|
||||
});
|
||||
} else {
|
||||
logger.warn('CLI/Test', `图片不存在,已跳过: ${imgPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
messages.push(lastMessage);
|
||||
|
||||
const body = {
|
||||
messages,
|
||||
...(modelId && { model: modelId })
|
||||
};
|
||||
|
||||
const bodyStr = JSON.stringify(body);
|
||||
|
||||
const options = {
|
||||
hostname: '127.0.0.1',
|
||||
port: PORT,
|
||||
path: '/v1/queue/join',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(bodyStr),
|
||||
'Authorization': `Bearer ${AUTH_TOKEN}`
|
||||
}
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
let buffer = '';
|
||||
res.on('data', chunk => {
|
||||
buffer += chunk.toString();
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop(); // 保留未完成的行
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim() || !line.startsWith('data:')) continue;
|
||||
|
||||
const data = line.slice(5).trim();
|
||||
if (data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const event = JSON.parse(data);
|
||||
if (event.status === 'error') {
|
||||
reject(new Error(event.msg));
|
||||
} else if (event.status === 'completed') {
|
||||
resolve(event);
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
// SSE 结束
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.write(bodyStr);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接调用适配器测试
|
||||
*/
|
||||
async function testViaDirect(prompt, modelId, imagePaths) {
|
||||
logger.info('CLI/Test', '直接调用适配器测试');
|
||||
|
||||
// 初始化浏览器
|
||||
const context = await initBrowser(config);
|
||||
|
||||
// 解析模型 ID
|
||||
const resolvedModelId = modelId ? resolveModelId(name, modelId) : null;
|
||||
|
||||
// 执行生图
|
||||
const result = await generateImage(context, prompt, imagePaths, resolvedModelId);
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存图片
|
||||
*/
|
||||
function saveImage(base64Data) {
|
||||
const testSaveDir = path.join(TEMP_DIR, 'testSave');
|
||||
if (!fs.existsSync(testSaveDir)) {
|
||||
fs.mkdirSync(testSaveDir, { recursive: true });
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const savePath = path.join(testSaveDir, `test_${timestamp}.png`);
|
||||
|
||||
// 移除 Data URI 前缀(如果有)
|
||||
const cleanBase64 = base64Data.replace(/^data:image\/\w+;base64,/, '');
|
||||
fs.writeFileSync(savePath, Buffer.from(cleanBase64, 'base64'));
|
||||
|
||||
logger.info('CLI/Test', `图片已保存: ${savePath}`);
|
||||
return savePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主流程
|
||||
*/
|
||||
(async () => {
|
||||
try {
|
||||
// 1. 选择测试模式
|
||||
const testMode = await selectTestMode();
|
||||
logger.info('CLI/Test', `测试模式: ${testMode === 'http' ? 'HTTP 服务器' : '直接调用'}`);
|
||||
|
||||
// 2. 选择模型
|
||||
const modelId = await selectModel();
|
||||
if (modelId) {
|
||||
logger.info('CLI/Test', `选择模型: ${modelId}`);
|
||||
} else {
|
||||
logger.info('CLI/Test', '跳过模型选择,使用默认');
|
||||
}
|
||||
|
||||
// 3. 输入提示词
|
||||
const prompt = await promptForInput();
|
||||
logger.info('CLI/Test', `提示词: ${prompt}`);
|
||||
|
||||
// 4. 输入图片路径
|
||||
const imagePaths = await promptForImages();
|
||||
if (imagePaths.length > 0) {
|
||||
logger.info('CLI/Test', `参考图片: ${imagePaths.join(', ')}`);
|
||||
}
|
||||
|
||||
// 5. 执行测试
|
||||
let result;
|
||||
if (testMode === 'http') {
|
||||
const serverType = config.server.type || 'openai';
|
||||
if (serverType === 'queue') {
|
||||
result = await testViaHttpQueue(prompt, modelId, imagePaths);
|
||||
} else {
|
||||
result = await testViaHttpOpenAI(prompt, modelId, imagePaths);
|
||||
}
|
||||
|
||||
// 处理 HTTP 响应
|
||||
if (result.choices) {
|
||||
// OpenAI 格式
|
||||
const content = result.choices[0].message.content;
|
||||
logger.info('CLI/Test', `响应内容: ${content.slice(0, 100)}...`);
|
||||
|
||||
// 提取图片(如果有)
|
||||
const match = content.match(/!\[.*?\]\((data:image\/[^)]+)\)/);
|
||||
if (match) {
|
||||
saveImage(match[1]);
|
||||
} else {
|
||||
logger.info('CLI/Test', `文本回复: ${content}`);
|
||||
}
|
||||
} else if (result.image) {
|
||||
// Queue 格式
|
||||
saveImage(result.image);
|
||||
} else if (result.msg) {
|
||||
logger.info('CLI/Test', `文本回复: ${result.msg}`);
|
||||
}
|
||||
|
||||
} else {
|
||||
// 直接调用
|
||||
result = await testViaDirect(prompt, modelId, imagePaths);
|
||||
|
||||
if (result.image) {
|
||||
saveImage(result.image);
|
||||
} else if (result.text) {
|
||||
logger.info('CLI/Test', `文本回复: ${result.text}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('CLI/Test', '测试完成');
|
||||
process.exit(0);
|
||||
|
||||
} catch (err) {
|
||||
logger.error('CLI/Test', '测试失败', { error: err.message });
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
@@ -1,11 +1,20 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import yaml from 'js-yaml';
|
||||
import { generateApiKey } from './security/apiKey.js';
|
||||
import yaml from 'yaml';
|
||||
import crypto from 'crypto';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
const CONFIG_PATH = path.join(process.cwd(), 'config.yaml');
|
||||
|
||||
/**
|
||||
* 生成随机 API Key
|
||||
* 格式: sk-{48位十六进制字符}
|
||||
* @returns {string} API Key
|
||||
*/
|
||||
function generateApiKey() {
|
||||
return 'sk-' + crypto.randomBytes(24).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认配置模板
|
||||
*/
|
||||
@@ -16,49 +25,61 @@ function getDefaultConfig() {
|
||||
logLevel: info
|
||||
|
||||
server:
|
||||
# 服务器模式: openai (标准兼容) | queue (流式队列)
|
||||
type: openai
|
||||
# 监听端口
|
||||
port: 3000
|
||||
# 鉴权 Token (Bearer Token) (可使用 npm run genkey 生成)
|
||||
auth: ${generateApiKey()}
|
||||
# 保活
|
||||
keepalive:
|
||||
# 是否启用流式保活
|
||||
# 使用OpenAI接口的标准流式接口格式,客户端请求需强制使用 stream: true
|
||||
enable: false
|
||||
|
||||
# 心跳模式
|
||||
# "comment": (推荐) 发送 :keepalive 注释。不污染数据,绝大多数 SDK 支持,不会影响接口标准
|
||||
# "content": (备用) 在 choices[0].delta.content = "" 中发送空字符串
|
||||
# 仅当你使用的客户端非常特殊,必须收到 data JSON 包才重置超时时使用
|
||||
mode: "comment"
|
||||
|
||||
backend:
|
||||
# 选择后端: lmarena (竞技场) | gemini_biz (Gemini Enterprise Business)
|
||||
# 适配器设置
|
||||
# - lmarena (LMArena)
|
||||
# - gemini_biz (Gemini Enterprise Business)
|
||||
# - nanobananafree_ai (Nano Banana Free)
|
||||
type: lmarena
|
||||
|
||||
# Gemini Business 设置
|
||||
geminiBiz:
|
||||
# 入口链接
|
||||
# 示例: "https://business.gemini.google/home/cid/8888a888-b6e0-88be-86e1-888cf3ee8cf4?csesidx=1666666666"
|
||||
# 示例: "https://business.gemini.google/home/cid/8888a888-b6e0-88be-86e1-888cf3ee8cf4"
|
||||
entryUrl: ""
|
||||
|
||||
queue:
|
||||
# 最大排队数
|
||||
# 仅对OpenAI模式做出限制,非必要不建议更改
|
||||
# 因常见客户端都有超时保护,队列大于2是一定会触发超时保护的
|
||||
# 仅对未开启流式保活模式时做出限制,非必要不建议更改
|
||||
# 因客户端可能有超时保护,队列大于2是一定会触发超时保护的
|
||||
maxQueueSize: 2
|
||||
# 图片数量上限
|
||||
# 网页最多支持10个附件,如果设置大于10则直接丢弃超出10的图片
|
||||
imageLimit: 5
|
||||
|
||||
chrome:
|
||||
# 浏览器可执行文件路径 (留空则使用Puppeteer默认)
|
||||
# Windows系统示例 "C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe"
|
||||
# Linux系统示例 "/usr/bin/google-chrome"
|
||||
# path: ""
|
||||
browser:
|
||||
# 浏览器可执行文件路径 (留空则使用 Camoufox 默认下载路径)
|
||||
# Windows系统示例 "C:\\camoufox\\camoufox.exe"
|
||||
# Linux系统示例 "/opt/camoufox/camoufox"
|
||||
path: ""
|
||||
|
||||
# 是否启用无头模式
|
||||
headless: false
|
||||
|
||||
# 是否启用 GPU (无GPU设备运行请使用false)
|
||||
# 是否启用 GPU (Camoufox 已内置指纹伪装,无GPU设备运行请使用false)
|
||||
gpu: false
|
||||
|
||||
# 代理设置
|
||||
proxy:
|
||||
# 是否启用代理
|
||||
enable: false
|
||||
# 代理类型: http 或 socks5
|
||||
# 代理类型: http | socks5
|
||||
type: http
|
||||
# 代理主机
|
||||
host: 127.0.0.1
|
||||
@@ -86,7 +107,7 @@ export function loadConfig() {
|
||||
}
|
||||
|
||||
const configFile = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
const config = yaml.load(configFile);
|
||||
const config = yaml.parse(configFile);
|
||||
|
||||
// 基础配置校验
|
||||
if (!config.server || !config.server.port) {
|
||||
@@ -110,6 +131,22 @@ export function loadConfig() {
|
||||
if (config.queue.imageLimit === undefined) config.queue.imageLimit = 5;
|
||||
}
|
||||
|
||||
// 设置 keepalive 配置默认值
|
||||
if (!config.server.keepalive) {
|
||||
config.server.keepalive = {
|
||||
enable: true,
|
||||
mode: 'comment'
|
||||
};
|
||||
} else {
|
||||
if (config.server.keepalive.enable === undefined) config.server.keepalive.enable = true;
|
||||
if (config.server.keepalive.mode === undefined) config.server.keepalive.mode = 'comment';
|
||||
// 验证 mode 值
|
||||
if (!['comment', 'content'].includes(config.server.keepalive.mode)) {
|
||||
logger.warn('配置器', `无效的 keepalive.mode: ${config.server.keepalive.mode},使用默认值 comment`);
|
||||
config.server.keepalive.mode = 'comment';
|
||||
}
|
||||
}
|
||||
|
||||
// 设置 backend 配置默认值
|
||||
if (!config.backend) {
|
||||
config.backend = {
|
||||
@@ -126,8 +163,8 @@ export function loadConfig() {
|
||||
}
|
||||
|
||||
logger.debug('配置器', '已加载 config.yaml');
|
||||
logger.debug('配置器', `服务器模式: ${config.server.type || 'queue'}`);
|
||||
logger.debug('配置器', `后端类型: ${config.backend.type}`);
|
||||
logger.debug('配置器', '后端类型:', config.backend.type);
|
||||
logger.debug('配置器', '流式保活:', config.server.keepalive.enable ? '已启用' : '已禁用');
|
||||
if (config.backend.type === 'gemini_biz') {
|
||||
logger.debug('配置器', `GeminiBiz 入口: ${config.backend.geminiBiz.entryUrl}`);
|
||||
}
|
||||
@@ -146,3 +183,11 @@ export function loadConfig() {
|
||||
|
||||
// 默认导出为函数
|
||||
export default loadConfig;
|
||||
|
||||
// 生成 API Key
|
||||
if (process.argv.includes('-genkey')) {
|
||||
console.log('>>> [GenAPIKey] 生成新的 API Key:');
|
||||
console.log(generateApiKey());
|
||||
console.log('\n>>> 请将此 Key 复制到 config.yaml 文件的 server.auth 字段中。');
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
import fs from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { gotScraping } from 'got-scraping';
|
||||
import compressing from 'compressing';
|
||||
import yaml from 'yaml';
|
||||
import { logger } from './logger.js';
|
||||
import { getHttpProxy, getProxyConfig } from './proxy.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PROJECT_ROOT = path.join(__dirname, '..', '..');
|
||||
const TEMP_DIR = path.join(PROJECT_ROOT, 'data', 'temp');
|
||||
const CONFIG_PATH = path.join(PROJECT_ROOT, 'config.yaml');
|
||||
|
||||
// 确保临时目录存在
|
||||
if (!fs.existsSync(TEMP_DIR)) {
|
||||
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Node.js ABI 版本
|
||||
*/
|
||||
function getNodeABI() {
|
||||
return process.versions.modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取平台信息
|
||||
*/
|
||||
function getPlatformInfo() {
|
||||
const platform = os.platform();
|
||||
const arch = os.arch();
|
||||
const nodeVersion = process.version;
|
||||
const abi = getNodeABI();
|
||||
|
||||
return { platform, arch, nodeVersion, abi };
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证平台支持
|
||||
*/
|
||||
function validatePlatform(platform, arch) {
|
||||
const supported = {
|
||||
'win32': ['x64'],
|
||||
'darwin': ['x64', 'arm64'],
|
||||
'linux': ['x64', 'arm64']
|
||||
};
|
||||
|
||||
if (!supported[platform] || !supported[platform].includes(arch)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Node.js ABI 版本支持
|
||||
*/
|
||||
function validateABI(abi) {
|
||||
const supportedABIs = [115, 121, 123, 125, 127, 128, 130, 131, 132, 133, 135, 136, 137, 139, 140, 141];
|
||||
return supportedABIs.includes(parseInt(abi, 10));
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件(带进度,流式,支持重试)
|
||||
* @param {string} url - 下载地址
|
||||
* @param {string} destPath - 目标文件路径
|
||||
* @param {string|null} proxyUrl - 代理 URL
|
||||
* @param {number} maxRetries - 最大重试次数
|
||||
*/
|
||||
async function downloadFile(url, destPath, proxyUrl = null, maxRetries = 3) {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
if (attempt > 1) {
|
||||
logger.info('初始化', `第 ${attempt}/${maxRetries} 次尝试下载...`);
|
||||
// 删除之前失败的文件
|
||||
try {
|
||||
if (fs.existsSync(destPath)) {
|
||||
fs.unlinkSync(destPath);
|
||||
}
|
||||
} catch (e) { }
|
||||
} else {
|
||||
logger.info('初始化', `开始下载: ${url}`);
|
||||
}
|
||||
|
||||
await downloadFileOnce(url, destPath, proxyUrl);
|
||||
return destPath;
|
||||
} catch (error) {
|
||||
logger.error('初始化', `下载失败 (尝试 ${attempt}/${maxRetries}): ${error.message}`);
|
||||
|
||||
if (attempt === maxRetries) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 等待后重试(递增延迟)
|
||||
const delay = attempt * 2000;
|
||||
logger.info('初始化', `${delay / 1000} 秒后重试...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单次下载尝试(内部函数)
|
||||
*/
|
||||
async function downloadFileOnce(url, destPath, proxyUrl = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
http2: false,
|
||||
timeout: {
|
||||
request: 900000, // 总请求超时 15 分钟
|
||||
read: 180000 // 两次数据接收间隔超时 3 分钟
|
||||
},
|
||||
retry: {
|
||||
limit: 0
|
||||
},
|
||||
headerGeneratorOptions: {
|
||||
browsers: [{ name: 'firefox', minVersion: 100 }],
|
||||
devices: ['desktop'],
|
||||
locales: ['en-US'],
|
||||
operatingSystems: ['windows'],
|
||||
}
|
||||
};
|
||||
|
||||
if (proxyUrl) {
|
||||
options.proxyUrl = proxyUrl;
|
||||
}
|
||||
|
||||
const downloadStream = gotScraping.stream(url, options);
|
||||
const fileStream = fs.createWriteStream(destPath);
|
||||
|
||||
let downloadedSize = 0;
|
||||
let totalSize = 0;
|
||||
let lastLogTime = Date.now();
|
||||
|
||||
downloadStream.on('response', (response) => {
|
||||
totalSize = parseInt(response.headers['content-length'] || '0', 10);
|
||||
if (totalSize > 0) {
|
||||
logger.info('初始化', `文件大小: ${(totalSize / 1024 / 1024).toFixed(2)} MB`);
|
||||
}
|
||||
});
|
||||
|
||||
downloadStream.on('data', (chunk) => {
|
||||
downloadedSize += chunk.length;
|
||||
|
||||
// 每秒更新一次进度
|
||||
const now = Date.now();
|
||||
if (totalSize > 0 && now - lastLogTime > 1000) {
|
||||
const percent = ((downloadedSize / totalSize) * 100).toFixed(1);
|
||||
const downloadedMB = (downloadedSize / 1024 / 1024).toFixed(2);
|
||||
const totalMB = (totalSize / 1024 / 1024).toFixed(2);
|
||||
logger.info('初始化', `下载进度: ${percent}% (${downloadedMB}MB / ${totalMB}MB)`);
|
||||
lastLogTime = now;
|
||||
}
|
||||
});
|
||||
|
||||
downloadStream.on('error', (error) => {
|
||||
fileStream.close();
|
||||
try {
|
||||
fs.unlinkSync(destPath);
|
||||
} catch (e) { }
|
||||
reject(error);
|
||||
});
|
||||
|
||||
fileStream.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
fileStream.on('finish', () => {
|
||||
const finalSize = (downloadedSize / 1024 / 1024).toFixed(2);
|
||||
|
||||
// 验证下载完整性
|
||||
if (totalSize > 0 && downloadedSize !== totalSize) {
|
||||
const errorMsg = `下载不完整: 预期 ${(totalSize / 1024 / 1024).toFixed(2)} MB, 实际 ${finalSize} MB`;
|
||||
logger.error('初始化', errorMsg);
|
||||
|
||||
// 清理损坏的文件
|
||||
try {
|
||||
fs.unlinkSync(destPath);
|
||||
} catch (e) { }
|
||||
|
||||
reject(new Error(errorMsg));
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('初始化', `下载完成: ${finalSize} MB`);
|
||||
resolve(destPath);
|
||||
});
|
||||
|
||||
downloadStream.pipe(fileStream);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 better-sqlite3 下载 URL
|
||||
*/
|
||||
function getBetterSqlite3Url(platform, arch, abi) {
|
||||
const version = '12.5.0';
|
||||
const platformMap = {
|
||||
'win32': 'win32',
|
||||
'darwin': 'darwin',
|
||||
'linux': 'linux'
|
||||
};
|
||||
|
||||
const platformName = platformMap[platform];
|
||||
const archName = arch; // x64 或 arm64
|
||||
|
||||
return `https://github.com/WiseLibs/better-sqlite3/releases/download/v${version}/better-sqlite3-v${version}-node-v${abi}-${platformName}-${archName}.tar.gz`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载并安装 better-sqlite3
|
||||
*/
|
||||
async function installBetterSqlite3(platform, arch, abi, proxyUrl) {
|
||||
logger.info('初始化', '开始安装 better-sqlite3...');
|
||||
|
||||
const url = getBetterSqlite3Url(platform, arch, abi);
|
||||
const downloadPath = path.join(TEMP_DIR, 'better-sqlite3.tar.gz');
|
||||
|
||||
// 下载
|
||||
await downloadFile(url, downloadPath, proxyUrl);
|
||||
|
||||
// 解压 .tar.gz 文件
|
||||
logger.info('初始化', '正在解压 better-sqlite3...');
|
||||
await compressing.tgz.uncompress(downloadPath, TEMP_DIR);
|
||||
|
||||
// 查找 better_sqlite3.node
|
||||
const files = fs.readdirSync(TEMP_DIR, { recursive: true });
|
||||
const nodeFile = files.find(f => f.endsWith('better_sqlite3.node'));
|
||||
if (!nodeFile) {
|
||||
throw new Error('未找到 better_sqlite3.node 文件');
|
||||
}
|
||||
|
||||
// 复制到 node_modules
|
||||
const buildDir = path.join(PROJECT_ROOT, 'node_modules', 'better-sqlite3', 'build', 'Release');
|
||||
if (!fs.existsSync(buildDir)) {
|
||||
fs.mkdirSync(buildDir, { recursive: true });
|
||||
}
|
||||
|
||||
const sourcePath = path.join(TEMP_DIR, nodeFile);
|
||||
const destPath = path.join(buildDir, 'better_sqlite3.node');
|
||||
fs.copyFileSync(sourcePath, destPath);
|
||||
|
||||
logger.info('初始化', `better-sqlite3 安装成功: ${destPath}`);
|
||||
|
||||
// 清理
|
||||
fs.unlinkSync(downloadPath);
|
||||
// 清理解压后的所有文件
|
||||
files.forEach(f => {
|
||||
const filePath = path.join(TEMP_DIR, f);
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.isDirectory()) {
|
||||
fs.rmSync(filePath, { recursive: true, force: true });
|
||||
} else {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 Camoufox 下载 URL
|
||||
*/
|
||||
function getCamoufoxUrl(platform, arch) {
|
||||
const version = '135.0.1-beta.24';
|
||||
const platformMap = {
|
||||
'win32': 'win',
|
||||
'darwin': 'mac',
|
||||
'linux': 'lin'
|
||||
};
|
||||
|
||||
const archMap = {
|
||||
'x64': 'x86_64',
|
||||
'arm64': 'arm64'
|
||||
};
|
||||
|
||||
const platformName = platformMap[platform];
|
||||
const archName = archMap[arch];
|
||||
|
||||
return `https://github.com/daijro/camoufox/releases/download/v${version}/camoufox-${version}-${platformName}.${archName}.zip`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载并安装 Camoufox
|
||||
*/
|
||||
async function installCamoufox(platform, arch, proxyUrl) {
|
||||
logger.info('初始化', '开始安装 Camoufox 浏览器...');
|
||||
|
||||
const url = getCamoufoxUrl(platform, arch);
|
||||
const downloadPath = path.join(TEMP_DIR, 'camoufox.zip');
|
||||
|
||||
// 下载
|
||||
await downloadFile(url, downloadPath, proxyUrl);
|
||||
|
||||
// 解压 .zip 文件到 camoufox 目录
|
||||
logger.info('初始化', '正在解压 Camoufox...');
|
||||
const camoufoxDir = path.join(PROJECT_ROOT, 'camoufox');
|
||||
if (!fs.existsSync(camoufoxDir)) {
|
||||
fs.mkdirSync(camoufoxDir, { recursive: true });
|
||||
}
|
||||
|
||||
await compressing.zip.uncompress(downloadPath, camoufoxDir);
|
||||
|
||||
logger.info('初始化', `Camoufox 安装成功: ${camoufoxDir}`);
|
||||
|
||||
// 更新 config.yaml
|
||||
updateConfigPath(platform, camoufoxDir);
|
||||
|
||||
// 创建 version.json
|
||||
const versionJsonPath = path.join(camoufoxDir, 'version.json');
|
||||
const versionData = {
|
||||
version: "135.0",
|
||||
release: "beta.24"
|
||||
};
|
||||
fs.writeFileSync(versionJsonPath, JSON.stringify(versionData, null, 2), 'utf8');
|
||||
logger.info('初始化', `已生成 version.json: ${versionJsonPath}`);
|
||||
|
||||
// 清理
|
||||
fs.unlinkSync(downloadPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 config.yaml 中的 browser.path
|
||||
*/
|
||||
function updateConfigPath(platform, camoufoxDir) {
|
||||
try {
|
||||
const configContent = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
|
||||
// 解析为文档对象 (CST)
|
||||
const doc = yaml.parseDocument(configContent);
|
||||
|
||||
// 构造绝对路径
|
||||
let browserPath;
|
||||
if (platform === 'win32') {
|
||||
browserPath = path.join(camoufoxDir, 'camoufox.exe');
|
||||
} else if (platform === 'darwin') {
|
||||
browserPath = path.join(camoufoxDir, 'Camoufox.app', 'Contents', 'MacOS', 'camoufox');
|
||||
} else {
|
||||
browserPath = path.join(camoufoxDir, 'camoufox');
|
||||
}
|
||||
|
||||
// 规范化路径分隔符
|
||||
browserPath = browserPath.replace(/\\/g, '/');
|
||||
|
||||
// 安全地更新路径,如果节点不存在则创建
|
||||
if (!doc.has('browser')) {
|
||||
doc.set('browser', { path: browserPath });
|
||||
} else {
|
||||
const browserNode = doc.get('browser');
|
||||
if (browserNode && typeof browserNode.set === 'function') {
|
||||
browserNode.set('path', browserPath);
|
||||
} else {
|
||||
// 如果 browser 不是对象(理论上不应该发生),强制覆盖
|
||||
doc.set('browser', { path: browserPath });
|
||||
}
|
||||
}
|
||||
|
||||
// 转回字符串,保留注释
|
||||
const updatedYaml = doc.toString();
|
||||
fs.writeFileSync(CONFIG_PATH, updatedYaml, 'utf8');
|
||||
|
||||
logger.info('初始化', `已更新配置文件 browser.path: ${browserPath}`);
|
||||
} catch (e) {
|
||||
logger.error('初始化', '更新配置文件失败', { error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主流程
|
||||
*/
|
||||
(async () => {
|
||||
try {
|
||||
logger.info('初始化', '========================================');
|
||||
logger.info('初始化', '依赖初始化脚本启动');
|
||||
logger.info('初始化', '========================================');
|
||||
|
||||
// 显示系统信息
|
||||
const { platform, arch, nodeVersion, abi } = getPlatformInfo();
|
||||
logger.info('初始化', `操作系统: ${platform}`);
|
||||
logger.info('初始化', `芯片架构: ${arch}`);
|
||||
logger.info('初始化', `Node.js 版本: ${nodeVersion}`);
|
||||
logger.info('初始化', `Node.js ABI 版本: ${abi}`);
|
||||
|
||||
// 验证平台支持
|
||||
if (!validatePlatform(platform, arch)) {
|
||||
logger.error('初始化', '不支持的平台!');
|
||||
logger.error('初始化', `因该项目使用了 Camoufox 浏览器,没有您设备可用的预编译版本`);
|
||||
logger.error('初始化', `支持的平台: Windows x64, macOS x64/arm64, Linux x64/arm64`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.info('初始化', '平台支持检查通过');
|
||||
|
||||
// 验证 ABI 版本支持
|
||||
if (!validateABI(abi)) {
|
||||
logger.error('初始化', '不支持的 Node.js ABI 版本!');
|
||||
logger.error('初始化', `当前 ABI 版本: ${abi}`);
|
||||
logger.error('初始化', `支持的 ABI 版本: 115, 121, 123, 125, 127, 128, 130, 131, 132, 133, 135, 136, 137, 139, 140, 141`);
|
||||
logger.error('初始化', `建议使用 Node.js 20.10.0 或更高版本`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.info('初始化', 'ABI 版本检查通过');
|
||||
|
||||
// 读取并转换代理配置
|
||||
let proxyUrl = null;
|
||||
try {
|
||||
const configContent = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
const config = yaml.parse(configContent);
|
||||
const proxyConfig = getProxyConfig(config);
|
||||
if (proxyConfig) {
|
||||
proxyUrl = await getHttpProxy(proxyConfig);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('初始化', '无法读取配置文件或转换代理,不使用代理');
|
||||
}
|
||||
|
||||
// 安装 better-sqlite3
|
||||
await installBetterSqlite3(platform, arch, abi, proxyUrl);
|
||||
|
||||
// 安装 Camoufox
|
||||
await installCamoufox(platform, arch, proxyUrl);
|
||||
|
||||
// 修复 Camoufox 环境 (Linux)
|
||||
fixCamoufoxEnv();
|
||||
|
||||
logger.info('初始化', '========================================');
|
||||
logger.info('初始化', '所有依赖安装完成!');
|
||||
logger.info('初始化', '========================================');
|
||||
process.exit(0);
|
||||
|
||||
} catch (err) {
|
||||
logger.error('初始化', '初始化失败', { error: err.message });
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
||||
/**
|
||||
* 自动修复 Linux 下 Camoufox 的路径依赖
|
||||
* 目的:建立软链接,欺骗 camoufox-js 以为浏览器安装在默认目录,从而防止自动下载
|
||||
*/
|
||||
function fixCamoufoxEnv() {
|
||||
// 1. 仅在 Linux 下执行
|
||||
if (os.platform() !== 'linux') return;
|
||||
|
||||
logger.info('初始化', '正在检查 Camoufox 环境配置...');
|
||||
|
||||
// --- 路径配置 ---
|
||||
// 假设浏览器存放在项目根目录下的 camoufox 文件夹中
|
||||
// 依赖 init.js 中已定义的 PROJECT_ROOT 变量
|
||||
const customBrowserDir = path.join(PROJECT_ROOT, 'camoufox');
|
||||
|
||||
// 官方默认缓存路径: ~/.cache/camoufox
|
||||
const defaultCacheDir = path.join(os.homedir(), '.cache');
|
||||
const defaultLinkPath = path.join(defaultCacheDir, 'camoufox');
|
||||
|
||||
// 2. 预检查:确保源文件存在
|
||||
if (!fs.existsSync(customBrowserDir)) {
|
||||
logger.warn('初始化', `未找到自定义浏览器目录: ${customBrowserDir}`);
|
||||
logger.warn('初始化', `请确保已将浏览器解压至项目根目录的 camoufox 文件夹`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 检查并修复软链接
|
||||
if (fs.existsSync(defaultLinkPath)) {
|
||||
const stats = fs.lstatSync(defaultLinkPath);
|
||||
if (stats.isSymbolicLink()) {
|
||||
const currentTarget = fs.readlinkSync(defaultLinkPath);
|
||||
if (currentTarget === customBrowserDir) {
|
||||
logger.info('初始化', 'Camoufox 路径映射已就绪');
|
||||
return;
|
||||
}
|
||||
logger.info('初始化', '路径映射不一致,正在更新...');
|
||||
fs.unlinkSync(defaultLinkPath);
|
||||
} else {
|
||||
// 备份旧的实体文件夹
|
||||
logger.warn('初始化', `默认路径被占用,正在备份...`);
|
||||
fs.renameSync(defaultLinkPath, `${defaultLinkPath}_backup_${Date.now()}`);
|
||||
}
|
||||
} else {
|
||||
if (!fs.existsSync(defaultCacheDir)) {
|
||||
fs.mkdirSync(defaultCacheDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 创建软链接
|
||||
try {
|
||||
// 使用 shell 命令创建软链接,比 fs.symlinkSync 在某些 Linux 环境下更可靠
|
||||
execSync(`ln -sf "${customBrowserDir}" "${defaultLinkPath}"`);
|
||||
|
||||
// 验证链接是否有效
|
||||
if (fs.existsSync(defaultLinkPath)) {
|
||||
const linkTarget = fs.readlinkSync(defaultLinkPath);
|
||||
logger.info('初始化', `成功创建路径映射: ${defaultLinkPath} -> ${linkTarget}`);
|
||||
} else {
|
||||
logger.warn('初始化', '软链接创建后无法访问,可能存在权限问题');
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('初始化', `创建软链接失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { anonymizeProxy, closeAnonymizedProxy } from 'proxy-chain';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
// 全局代理状态追踪
|
||||
const proxyState = {
|
||||
anonymizedProxyUrl: null, // 转换后的 HTTP 代理地址
|
||||
originalProxyUrl: null // 原始代理地址
|
||||
};
|
||||
|
||||
/**
|
||||
* 构建代理 URL
|
||||
* @param {object} proxyConfig - 代理配置对象
|
||||
* @param {string} proxyConfig.type - 代理类型 ('http' 或 'socks5')
|
||||
* @param {string} proxyConfig.host - 代理主机地址
|
||||
* @param {number} proxyConfig.port - 代理端口
|
||||
* @param {string} [proxyConfig.user] - 可选的用户名
|
||||
* @param {string} [proxyConfig.passwd] - 可选的密码
|
||||
* @returns {string} - 代理 URL
|
||||
*/
|
||||
export function buildProxyUrl(proxyConfig) {
|
||||
const { type, host, port, user, passwd } = proxyConfig;
|
||||
|
||||
// 构建带认证的代理 URL
|
||||
if (user && passwd) {
|
||||
return `${type}://${user}:${passwd}@${host}:${port}`;
|
||||
}
|
||||
|
||||
// 构建不带认证的代理 URL
|
||||
if (type === 'socks5') {
|
||||
return `socks5://${host}:${port}`;
|
||||
}
|
||||
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将代理转换为 HTTP 代理
|
||||
* - HTTP 代理:直接返回
|
||||
* - SOCKS5 代理:使用 proxy-chain 转换为本地 HTTP 代理
|
||||
* @param {object} proxyConfig - 代理配置对象
|
||||
* @returns {Promise<string|null>} - 转换后的 HTTP 代理 URL,如果无需代理则返回 null
|
||||
*/
|
||||
export async function getHttpProxy(proxyConfig) {
|
||||
if (!proxyConfig || !proxyConfig.enable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { type, host, port } = proxyConfig;
|
||||
const originalUrl = buildProxyUrl(proxyConfig);
|
||||
|
||||
// 如果是 HTTP 代理,直接返回
|
||||
if (type === 'http') {
|
||||
logger.debug('代理器', `使用 HTTP 代理: ${host}:${port}`);
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
// 如果是 SOCKS5 代理,需要转换为 HTTP 代理
|
||||
if (type === 'socks5') {
|
||||
try {
|
||||
logger.info('代理器', `检测到 SOCKS5 代理,正在转换为 HTTP 代理: ${host}:${port}`);
|
||||
const httpProxyUrl = await anonymizeProxy(originalUrl);
|
||||
|
||||
// 保存状态用于后续清理
|
||||
proxyState.anonymizedProxyUrl = httpProxyUrl;
|
||||
proxyState.originalProxyUrl = originalUrl;
|
||||
|
||||
logger.info('代理器', `SOCKS5 代理已转换为 HTTP 代理: ${httpProxyUrl}`);
|
||||
return httpProxyUrl;
|
||||
} catch (error) {
|
||||
logger.error('代理器', `SOCKS5 代理转换失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn('代理器', `不支持的代理类型: ${type}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用于浏览器的代理配置
|
||||
* 返回 Playwright 可以使用的代理对象
|
||||
* @param {object} proxyConfig - 代理配置对象
|
||||
* @returns {Promise<object|null>} - Playwright 代理配置对象
|
||||
*/
|
||||
export async function getBrowserProxy(proxyConfig) {
|
||||
if (!proxyConfig || !proxyConfig.enable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { type, host, port, user, passwd } = proxyConfig;
|
||||
|
||||
// 对于 SOCKS5 + 认证,需要转换为 HTTP 代理
|
||||
if (type === 'socks5' && user && passwd) {
|
||||
try {
|
||||
const originalUrl = buildProxyUrl(proxyConfig);
|
||||
logger.info('代理器', `检测到需鉴权的 SOCKS5 代理,正在创建本地代理桥接: ${host}:${port}`);
|
||||
|
||||
const httpProxyUrl = await anonymizeProxy(originalUrl);
|
||||
|
||||
// 保存状态用于后续清理
|
||||
proxyState.anonymizedProxyUrl = httpProxyUrl;
|
||||
proxyState.originalProxyUrl = originalUrl;
|
||||
|
||||
logger.info('代理器', `本地代理桥接已建立: ${httpProxyUrl} -> ${host}:${port}`);
|
||||
|
||||
return {
|
||||
server: httpProxyUrl
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('代理器', `本地代理桥接创建失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 对于其他情况(HTTP 代理、不带认证的 SOCKS5)
|
||||
const proxyUrl = type === 'socks5' ? `socks5://${host}:${port}` : `${host}:${port}`;
|
||||
|
||||
const proxyObject = {
|
||||
server: proxyUrl
|
||||
};
|
||||
|
||||
// 如果有认证信息,添加到代理对象
|
||||
if (user && passwd) {
|
||||
proxyObject.username = user;
|
||||
proxyObject.password = passwd;
|
||||
}
|
||||
|
||||
logger.info('代理器', `代理配置: ${type}://${host}:${port}`);
|
||||
return proxyObject;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理代理资源
|
||||
* 关闭由 proxy-chain 创建的本地代理服务器
|
||||
*/
|
||||
export async function cleanupProxy() {
|
||||
if (proxyState.anonymizedProxyUrl) {
|
||||
try {
|
||||
logger.debug('代理器', '正在关闭本地代理桥接...');
|
||||
await closeAnonymizedProxy(proxyState.anonymizedProxyUrl, true);
|
||||
logger.debug('代理器', '本地代理桥接已关闭');
|
||||
|
||||
// 清理状态
|
||||
proxyState.anonymizedProxyUrl = null;
|
||||
proxyState.originalProxyUrl = null;
|
||||
} catch (error) {
|
||||
logger.error('代理器', `关闭本地代理桥接失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从配置文件读取代理配置
|
||||
* @param {object} config - 配置对象
|
||||
* @returns {object|null} - 代理配置对象或 null
|
||||
*/
|
||||
export function getProxyConfig(config) {
|
||||
if (config?.browser?.proxy?.enable) {
|
||||
return config.browser.proxy;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { getBackend } from '../backend/index.js';
|
||||
import { getModelsForBackend, resolveModelId } from '../backend/models.js';
|
||||
import { select, input } from '@inquirer/prompts';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import http from 'http';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
// 使用统一后端获取配置和函数
|
||||
const { config, name, TEMP_DIR } = getBackend();
|
||||
|
||||
logger.info('CLI/Test', `测试工具启动 (后端适配器: ${name})`);
|
||||
|
||||
/**
|
||||
* 选择模型
|
||||
*/
|
||||
async function selectModel() {
|
||||
const models = getModelsForBackend(name);
|
||||
const choices = [
|
||||
{ name: 'Skip(使用默认模型)', value: null },
|
||||
...models.data.map(m => ({ name: m.id, value: m.id }))
|
||||
];
|
||||
|
||||
const modelId = await select({
|
||||
message: '选择模型',
|
||||
choices,
|
||||
pageSize: 15
|
||||
});
|
||||
|
||||
return modelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入提示词
|
||||
*/
|
||||
async function promptForInput() {
|
||||
const prompt = await input({
|
||||
message: '输入提示词 (必填)',
|
||||
validate: (val) => val.trim().length > 0 || '提示词不能为空'
|
||||
});
|
||||
return prompt.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入图片路径
|
||||
*/
|
||||
async function promptForImages() {
|
||||
const imagePaths = [];
|
||||
while (true) {
|
||||
const imgPath = await input({
|
||||
message: `输入参考图片路径 (留空跳过,已添加 ${imagePaths.length} 张)`,
|
||||
});
|
||||
|
||||
if (!imgPath.trim()) break;
|
||||
|
||||
const cleanPath = imgPath.trim().replace(/^["']|["']$/g, '');
|
||||
if (fs.existsSync(cleanPath)) {
|
||||
imagePaths.push(cleanPath);
|
||||
} else {
|
||||
logger.warn('CLI/Test', `图片不存在: ${cleanPath}`);
|
||||
}
|
||||
}
|
||||
return imagePaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 测试模式 - OpenAI 格式
|
||||
*/
|
||||
async function testViaHttpOpenAI(prompt, modelId, imagePaths) {
|
||||
const PORT = config.server.port || 3000;
|
||||
const AUTH_TOKEN = config.server.auth;
|
||||
const KEEPALIVE_ENABLED = config.server.keepalive?.enable ?? true;
|
||||
|
||||
logger.info('CLI/Test', 'HTTP 测试 - OpenAI 模式');
|
||||
if (KEEPALIVE_ENABLED) {
|
||||
logger.info('CLI/Test', '流式保活已启用,将使用 stream=true');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// 构造请求体
|
||||
const messages = [];
|
||||
const lastMessage = { role: 'user', content: [] };
|
||||
|
||||
if (prompt) {
|
||||
lastMessage.content.push({ type: 'text', text: prompt });
|
||||
}
|
||||
|
||||
for (const imgPath of imagePaths) {
|
||||
if (fs.existsSync(imgPath)) {
|
||||
const buffer = fs.readFileSync(imgPath);
|
||||
const base64 = buffer.toString('base64');
|
||||
const ext = path.extname(imgPath).slice(1).toLowerCase();
|
||||
const mimeType = ext === 'jpg' ? 'jpeg' : ext;
|
||||
lastMessage.content.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: `data:image/${mimeType};base64,${base64}` }
|
||||
});
|
||||
} else {
|
||||
logger.warn('CLI/Test', `图片不存在,已跳过: ${imgPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
messages.push(lastMessage);
|
||||
|
||||
const body = {
|
||||
messages,
|
||||
stream: KEEPALIVE_ENABLED, // 如果启用 keepalive,必须使用 stream
|
||||
...(modelId && { model: modelId })
|
||||
};
|
||||
|
||||
const bodyStr = JSON.stringify(body);
|
||||
|
||||
const options = {
|
||||
hostname: '127.0.0.1',
|
||||
port: PORT,
|
||||
path: '/v1/chat/completions',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(bodyStr),
|
||||
'Authorization': `Bearer ${AUTH_TOKEN}`
|
||||
}
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
if (KEEPALIVE_ENABLED) {
|
||||
// 流式响应
|
||||
let buffer = '';
|
||||
let contentReceived = '';
|
||||
|
||||
res.on('data', chunk => {
|
||||
buffer += chunk.toString();
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop(); // 保留未完成的行
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
// 跳过心跳注释
|
||||
if (line.startsWith(':')) continue;
|
||||
|
||||
if (line.startsWith('data:')) {
|
||||
const data = line.slice(5).trim();
|
||||
if (data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const chunk = JSON.parse(data);
|
||||
if (chunk.choices && chunk.choices[0].delta && chunk.choices[0].delta.content) {
|
||||
contentReceived += chunk.choices[0].delta.content;
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 200) {
|
||||
resolve({ choices: [{ message: { content: contentReceived } }] });
|
||||
} else {
|
||||
reject(new Error(`HTTP ${res.statusCode}`));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 非流式响应
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 200) {
|
||||
const response = JSON.parse(data);
|
||||
resolve(response);
|
||||
} else {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.write(bodyStr);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存图片
|
||||
*/
|
||||
function saveImage(base64Data) {
|
||||
const testSaveDir = path.join(TEMP_DIR, 'testSave');
|
||||
if (!fs.existsSync(testSaveDir)) {
|
||||
fs.mkdirSync(testSaveDir, { recursive: true });
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const savePath = path.join(testSaveDir, `test_${timestamp}.png`);
|
||||
|
||||
// 移除 Data URI 前缀(如果有)
|
||||
const cleanBase64 = base64Data.replace(/^data:image\/\w+;base64,/, '');
|
||||
fs.writeFileSync(savePath, Buffer.from(cleanBase64, 'base64'));
|
||||
|
||||
logger.info('CLI/Test', `图片已保存: ${savePath}`);
|
||||
return savePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主流程
|
||||
*/
|
||||
(async () => {
|
||||
try {
|
||||
logger.info('CLI/Test', '=== HTTP 服务器测试 ===');
|
||||
logger.info('CLI/Test', '请确保服务器已启动 (npm start)');
|
||||
|
||||
// 1. 选择模型
|
||||
const modelId = await selectModel();
|
||||
if (modelId) {
|
||||
logger.info('CLI/Test', `选择模型: ${modelId}`);
|
||||
} else {
|
||||
logger.info('CLI/Test', '跳过模型选择,使用默认');
|
||||
}
|
||||
|
||||
// 2. 输入提示词
|
||||
const prompt = await promptForInput();
|
||||
logger.info('CLI/Test', `提示词: ${prompt}`);
|
||||
|
||||
// 3. 输入图片路径
|
||||
const imagePaths = await promptForImages();
|
||||
if (imagePaths.length > 0) {
|
||||
logger.info('CLI/Test', `参考图片: ${imagePaths.join(', ')}`);
|
||||
}
|
||||
|
||||
// 4. 执行测试
|
||||
logger.info('CLI/Test', '正在发送请求...');
|
||||
const result = await testViaHttpOpenAI(prompt, modelId, imagePaths);
|
||||
|
||||
// 5. 处理响应
|
||||
if (result.choices) {
|
||||
const content = result.choices[0].message.content;
|
||||
logger.info('CLI/Test', `响应内容: ${content.slice(0, 100)}...`);
|
||||
|
||||
// 提取图片(如果有)
|
||||
const match = content.match(/!\[.*?\]\((data:image\/[^)]+)\)/);
|
||||
if (match) {
|
||||
saveImage(match[1]);
|
||||
} else {
|
||||
logger.info('CLI/Test', `文本回复: ${content}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('CLI/Test', '测试完成');
|
||||
process.exit(0);
|
||||
|
||||
} catch (err) {
|
||||
logger.error('CLI/Test', '测试失败', { error: err.message });
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
+13
-10
@@ -1,24 +1,27 @@
|
||||
{
|
||||
"name": "lmarena-imagen-automator",
|
||||
"version": "1.0.0",
|
||||
"description": "基于 Puppeteer 的 LMArena 自动化图像生成工具",
|
||||
"version": "2.0.0",
|
||||
"description": "基于 Playwright + Camoufox 的自动化图像生成工具",
|
||||
"license": "MIT",
|
||||
"author": "foxhui",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "node lib/test.js",
|
||||
"genkey": "node lib/genApiKey.js"
|
||||
"test": "node lib/utils/test.js",
|
||||
"genkey": "node lib/utils/config.js -genkey",
|
||||
"init": "node lib/utils/init.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inquirer/prompts": "^8.0.1",
|
||||
"ghost-cursor": "^1.4.1",
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"camoufox-js": "^0.8.3",
|
||||
"compressing": "^2.0.0",
|
||||
"fingerprint-generator": "^2.1.78",
|
||||
"ghost-cursor-playwright-port": "^1.4.3",
|
||||
"got-scraping": "^4.1.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"playwright-core": "^1.57.0",
|
||||
"proxy-chain": "^2.6.0",
|
||||
"puppeteer": "^24.31.0",
|
||||
"puppeteer-extra": "^3.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"sharp": "^0.34.5"
|
||||
"sharp": "^0.34.5",
|
||||
"yaml": "^2.8.2"
|
||||
}
|
||||
}
|
||||
Generated
+812
-930
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
ignoredBuiltDependencies:
|
||||
- better-sqlite3
|
||||
@@ -4,16 +4,16 @@ import path from 'path';
|
||||
import sharp from 'sharp';
|
||||
import { getBackend } from './lib/backend/index.js';
|
||||
import { getModelsForBackend, resolveModelId, getImagePolicy, IMAGE_POLICY } from './lib/backend/models.js';
|
||||
import { logger } from './lib/logger.js';
|
||||
import { logger } from './lib/utils/logger.js';
|
||||
import crypto from 'crypto';
|
||||
|
||||
// 使用统一后端获取配置和函数
|
||||
const { config, name, initBrowser, generateImage, TEMP_DIR } = getBackend();
|
||||
|
||||
|
||||
const PORT = config.server.port || 3000;
|
||||
const AUTH_TOKEN = config.server.auth;
|
||||
const SERVER_MODE = config.server.type || 'openai'; // 'openai' 或 'queue'
|
||||
const KEEPALIVE_ENABLED = config.server.keepalive?.enable ?? true;
|
||||
const KEEPALIVE_MODE = config.server.keepalive?.mode || 'comment';
|
||||
|
||||
// --- 全局状态 ---
|
||||
let browserContext = null; // 浏览器上下文 {browser, page, client, width, height}
|
||||
@@ -34,15 +34,39 @@ async function processQueue() {
|
||||
const task = queue.shift();
|
||||
processingCount++;
|
||||
|
||||
// 如果是 Queue 模式,通知客户端状态变更
|
||||
if (SERVER_MODE === 'queue' && task.sse) {
|
||||
task.sse.send('status', { status: 'processing' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { req, res, prompt, imagePaths, modelId, modelName, id, sse } = task;
|
||||
const { req, res, prompt, imagePaths, modelId, modelName, id, isStreaming } = task;
|
||||
logger.info('服务器', '[队列] 开始处理任务', { id, remaining: queue.length });
|
||||
|
||||
// 如果是流式,启动心跳
|
||||
let heartbeatInterval = null;
|
||||
if (isStreaming) {
|
||||
heartbeatInterval = setInterval(() => {
|
||||
if (res.writableEnded) {
|
||||
clearInterval(heartbeatInterval);
|
||||
return;
|
||||
}
|
||||
// 发送心跳包
|
||||
if (KEEPALIVE_MODE === 'comment') {
|
||||
res.write(`:keepalive\n\n`);
|
||||
} else {
|
||||
// content 模式:发送空 delta
|
||||
const chunk = {
|
||||
id: 'chatcmpl-' + Date.now(),
|
||||
object: 'chat.completion.chunk',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: modelName || 'default-model',
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content: '' },
|
||||
finish_reason: null
|
||||
}]
|
||||
};
|
||||
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 确保浏览器已初始化
|
||||
if (!browserContext) {
|
||||
browserContext = await initBrowser(config);
|
||||
@@ -51,54 +75,59 @@ async function processQueue() {
|
||||
// 调用核心生图逻辑
|
||||
const result = await generateImage(browserContext, prompt, imagePaths, modelId, { id });
|
||||
|
||||
// 清理临时图片
|
||||
for (const p of imagePaths) {
|
||||
try { fs.unlinkSync(p); } catch (e) { }
|
||||
}
|
||||
// 清除心跳
|
||||
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
||||
|
||||
// 处理结果
|
||||
let finalContent = '';
|
||||
let queueResult = {};
|
||||
|
||||
if (result.error) {
|
||||
// 特殊错误处理:reCAPTCHA
|
||||
if (result.error === 'recaptcha validation failed') {
|
||||
if (SERVER_MODE === 'openai') {
|
||||
if (isStreaming) {
|
||||
res.write(`data: ${JSON.stringify({ error: 'recaptcha validation failed' })}\n\n`);
|
||||
res.write(`data: [DONE]\n\n`);
|
||||
res.end();
|
||||
} else {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'recaptcha validation failed' }));
|
||||
} else {
|
||||
task.sse.send('result', { status: 'error', image: null, msg: 'recaptcha validation failed' });
|
||||
task.sse.send('done', '[DONE]');
|
||||
task.sse.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
finalContent = `[生成错误] ${result.error}`;
|
||||
queueResult = { status: 'error', image: null, msg: result.error };
|
||||
} else if (result.image) {
|
||||
try {
|
||||
// result.image 已经是 "data:image/png;base64,..." 格式
|
||||
// 提取纯 Base64 部分用于 b64_json
|
||||
const base64Data = result.image.split(',')[1];
|
||||
|
||||
// 构造 Markdown 图片展示 (Data URI)
|
||||
finalContent = ``;
|
||||
|
||||
queueResult = { status: 'completed', image: base64Data, msg: '' };
|
||||
queueResult = { status: 'completed', image: base64Data, msg: '' };
|
||||
logger.info('服务器', '图片已准备就绪 (Base64)', { id });
|
||||
} catch (e) {
|
||||
logger.error('服务器', '图片处理失败', { id, error: e.message });
|
||||
finalContent = `[图片处理失败] ${e.message}`;
|
||||
queueResult = { status: 'error', image: null, msg: `Processing failed: ${e.message}` };
|
||||
}
|
||||
} else {
|
||||
finalContent = result.text || '生成失败';
|
||||
queueResult = { status: 'completed', image: null, msg: result.text };
|
||||
}
|
||||
|
||||
// 发送响应
|
||||
if (SERVER_MODE === 'openai') {
|
||||
if (isStreaming) {
|
||||
// 流式响应
|
||||
const chunk = {
|
||||
id: 'chatcmpl-' + Date.now(),
|
||||
object: 'chat.completion.chunk',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: modelName || 'default-model',
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content: finalContent },
|
||||
finish_reason: 'stop'
|
||||
}]
|
||||
};
|
||||
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
||||
res.write(`data: [DONE]\n\n`);
|
||||
res.end();
|
||||
} else {
|
||||
// 非流式响应
|
||||
const response = {
|
||||
id: 'chatcmpl-' + Date.now(),
|
||||
object: 'chat.completion',
|
||||
@@ -115,26 +144,29 @@ async function processQueue() {
|
||||
};
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(response));
|
||||
} else {
|
||||
// Queue Mode
|
||||
task.sse.send('result', queueResult);
|
||||
task.sse.send('done', '[DONE]');
|
||||
task.sse.end();
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
logger.error('服务器', '任务处理失败', { id: task.id, error: err.message });
|
||||
if (SERVER_MODE === 'openai') {
|
||||
if (task.isStreaming) {
|
||||
if (!task.res.writableEnded) {
|
||||
task.res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`);
|
||||
task.res.write(`data: [DONE]\n\n`);
|
||||
task.res.end();
|
||||
}
|
||||
} else {
|
||||
if (!task.res.writableEnded) {
|
||||
task.res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
task.res.end(JSON.stringify({ error: err.message }));
|
||||
}
|
||||
} else {
|
||||
task.sse.send('result', { status: 'error', image: null, msg: err.message });
|
||||
task.sse.send('done', '[DONE]');
|
||||
task.sse.end();
|
||||
}
|
||||
} finally {
|
||||
// 无论成功失败,都尝试清理临时图片
|
||||
if (task && task.imagePaths) {
|
||||
for (const p of task.imagePaths) {
|
||||
try { fs.unlinkSync(p); } catch (e) { }
|
||||
}
|
||||
}
|
||||
processingCount--;
|
||||
// 递归处理下一个任务
|
||||
processQueue();
|
||||
@@ -166,10 +198,7 @@ async function startServer() {
|
||||
}
|
||||
|
||||
// --- 路由分发 ---
|
||||
const isQueueMode = SERVER_MODE === 'queue';
|
||||
const targetPath = isQueueMode ? '/v1/queue/join' : '/v1/chat/completions';
|
||||
|
||||
// 1. 模型列表接口 (OpenAI & Queue 模式通用)
|
||||
// 1. 模型列表接口
|
||||
if (req.method === 'GET' && req.url === '/v1/models') {
|
||||
const models = getModelsForBackend(name);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
@@ -177,66 +206,64 @@ async function startServer() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url.startsWith(targetPath)) {
|
||||
// --- SSE 设置 (仅 Queue 模式) ---
|
||||
let sseHelper = null;
|
||||
if (isQueueMode) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
});
|
||||
|
||||
sseHelper = {
|
||||
send: (event, data) => {
|
||||
res.write(`event: ${event}\n`);
|
||||
res.write(`data: ${typeof data === 'object' ? JSON.stringify(data) : data}\n\n`);
|
||||
},
|
||||
end: () => res.end()
|
||||
};
|
||||
|
||||
// 启动心跳
|
||||
const heartbeat = setInterval(() => {
|
||||
if (res.writableEnded) {
|
||||
clearInterval(heartbeat);
|
||||
return;
|
||||
}
|
||||
sseHelper.send('heartbeat', Date.now());
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 2. 聊天补全接口
|
||||
if (req.method === 'POST' && req.url.startsWith('/v1/chat/completions')) {
|
||||
const chunks = [];
|
||||
req.on('data', chunk => chunks.push(chunk));
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
// --- 限流检查 ---
|
||||
if (!isQueueMode && processingCount + queue.length >= MAX_CONCURRENT + MAX_QUEUE_SIZE) {
|
||||
logger.warn('服务器', '请求过多,已拒绝 (最大队列限制)', { id });
|
||||
if (isQueueMode) {
|
||||
sseHelper.send('error', { msg: 'Too Many Requests' });
|
||||
sseHelper.end();
|
||||
} else {
|
||||
res.writeHead(429, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Too Many Requests. Server is busy.' }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const body = Buffer.concat(chunks).toString();
|
||||
const data = JSON.parse(body);
|
||||
const messages = data.messages;
|
||||
const isStreaming = data.stream === true;
|
||||
|
||||
// Stream 参数验证
|
||||
if (KEEPALIVE_ENABLED && !isStreaming) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Stream mode is required when keepalive is enabled. Please set "stream": true in your request.' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// 限流检查(仅在未开启 keepalive 时限制)
|
||||
if (!KEEPALIVE_ENABLED && processingCount + queue.length >= MAX_CONCURRENT + MAX_QUEUE_SIZE) {
|
||||
logger.warn('服务器', '请求过多,已拒绝 (最大队列限制)', { id });
|
||||
res.writeHead(429, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Too Many Requests. Server is busy.' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果是流式,设置 SSE 响应头
|
||||
if (isStreaming) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
});
|
||||
}
|
||||
|
||||
if (!messages || messages.length === 0) {
|
||||
if (isQueueMode) { sseHelper.send('error', { msg: 'No messages' }); sseHelper.end(); }
|
||||
else { res.writeHead(400); res.end(JSON.stringify({ error: 'No messages' })); }
|
||||
if (isStreaming) {
|
||||
res.write(`data: ${JSON.stringify({ error: 'No messages' })}\n\n`);
|
||||
res.write(`data: [DONE]\n\n`);
|
||||
res.end();
|
||||
} else {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'No messages' }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 筛选用户消息
|
||||
const userMessages = messages.filter(m => m.role === 'user');
|
||||
if (userMessages.length === 0) {
|
||||
if (isQueueMode) { sseHelper.send('error', { msg: 'No user messages' }); sseHelper.end(); }
|
||||
else { res.writeHead(400); res.end(JSON.stringify({ error: 'No user messages' })); }
|
||||
if (isStreaming) {
|
||||
res.write(`data: ${JSON.stringify({ error: 'No user messages' })}\n\n`);
|
||||
res.write(`data: [DONE]\n\n`);
|
||||
res.end();
|
||||
} else {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'No user messages' }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const lastMessage = userMessages[userMessages.length - 1];
|
||||
@@ -261,8 +288,14 @@ async function startServer() {
|
||||
if (imageCount > IMAGE_LIMIT) {
|
||||
const errorMsg = `Too many images. Maximum ${IMAGE_LIMIT} images allowed.`;
|
||||
logger.warn('server', errorMsg, { id });
|
||||
if (isQueueMode) { sseHelper.send('error', { msg: errorMsg }); sseHelper.end(); }
|
||||
else { res.writeHead(400); res.end(JSON.stringify({ error: errorMsg })); }
|
||||
if (isStreaming) {
|
||||
res.write(`data: ${JSON.stringify({ error: errorMsg })}\n\n`);
|
||||
res.write(`data: [DONE]\n\n`);
|
||||
res.end();
|
||||
} else {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: errorMsg }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@@ -306,8 +339,14 @@ async function startServer() {
|
||||
} else {
|
||||
const errorMsg = `Invalid model for backend ${name}: ${data.model}`;
|
||||
logger.warn('服务器', errorMsg, { id });
|
||||
if (isQueueMode) { sseHelper.send('error', { msg: errorMsg }); sseHelper.end(); }
|
||||
else { res.writeHead(400); res.end(JSON.stringify({ error: errorMsg })); }
|
||||
if (isStreaming) {
|
||||
res.write(`data: ${JSON.stringify({ error: errorMsg })}\n\n`);
|
||||
res.write(`data: [DONE]\n\n`);
|
||||
res.end();
|
||||
} else {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: errorMsg }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@@ -321,38 +360,42 @@ async function startServer() {
|
||||
if (policy === IMAGE_POLICY.REQUIRED && !hasImage) {
|
||||
const errorMsg = `Model ${data.model} requires a reference image.`;
|
||||
logger.warn('服务器', errorMsg, { id });
|
||||
if (isQueueMode) { sseHelper.send('error', { msg: errorMsg }); sseHelper.end(); }
|
||||
else { res.writeHead(400); res.end(JSON.stringify({ error: errorMsg })); }
|
||||
if (isStreaming) {
|
||||
res.write(`data: ${JSON.stringify({ error: errorMsg })}\n\n`);
|
||||
res.write(`data: [DONE]\n\n`);
|
||||
res.end();
|
||||
} else {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: errorMsg }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (policy === IMAGE_POLICY.FORBIDDEN && hasImage) {
|
||||
const errorMsg = `Model ${data.model} does not accept images.`;
|
||||
logger.warn('服务器', errorMsg, { id });
|
||||
if (isQueueMode) { sseHelper.send('error', { msg: errorMsg }); sseHelper.end(); }
|
||||
else { res.writeHead(400); res.end(JSON.stringify({ error: errorMsg })); }
|
||||
if (isStreaming) {
|
||||
res.write(`data: ${JSON.stringify({ error: errorMsg })}\n\n`);
|
||||
res.write(`data: [DONE]\n\n`);
|
||||
res.end();
|
||||
} else {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: errorMsg }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('服务器', `[队列] 请求入队: ${prompt.slice(0, 10)}...`, { id, images: imagePaths.length });
|
||||
|
||||
|
||||
if (isQueueMode) {
|
||||
sseHelper.send('status', { status: 'queued', position: queue.length + 1 });
|
||||
}
|
||||
|
||||
// 将任务加入队列
|
||||
queue.push({ req, res, prompt, imagePaths, sse: sseHelper, modelId, modelName: data.model || null, id });
|
||||
queue.push({ req, res, prompt, imagePaths, modelId, modelName: data.model || null, id, isStreaming });
|
||||
|
||||
// 触发队列处理
|
||||
processQueue();
|
||||
|
||||
} catch (err) {
|
||||
logger.error('服务器', '服务器处理失败', { id, error: err.message });
|
||||
if (isQueueMode && sseHelper) {
|
||||
sseHelper.send('error', { msg: err.message });
|
||||
sseHelper.end();
|
||||
} else if (!res.writableEnded) {
|
||||
if (!res.writableEnded) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: err.message }));
|
||||
}
|
||||
@@ -366,7 +409,7 @@ async function startServer() {
|
||||
|
||||
server.listen(PORT, () => {
|
||||
logger.info('服务器', `HTTP 服务器启动成功,监听端口 ${PORT}`);
|
||||
logger.info('服务器', `运行模式: ${SERVER_MODE === 'openai' ? 'OpenAI 兼容模式' : 'Queue 队列模式'}`);
|
||||
logger.info('服务器', `流式保活: ${KEEPALIVE_ENABLED ? '已启用 (' + KEEPALIVE_MODE + ' 模式)' : '已禁用'}`);
|
||||
logger.info('服务器', `最大队列: ${MAX_QUEUE_SIZE},最大图片数量: ${IMAGE_LIMIT}`);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user