初次提交

This commit is contained in:
foxhui
2025-11-23 22:43:43 +08:00
Unverified
commit da94db9181
10 changed files with 2873 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
data/
client.js
+375
View File
@@ -0,0 +1,375 @@
# LMArenaImagenAutomator - 使用文档
## 📝 项目简介
LMArenaImagenAutomator 是一个基于 Puppeteer 的自动化图像生成工具,通过模拟人类操作与 LMArena 网站交互,提供图像生成服务。项目支持两种运行模式:
- **OpenAI 兼容模式**:提供标准的 OpenAI API 接口,便于集成到现有应用
- **Queue 队列模式**:使用 Server-Sent Events (SSE) 实时推送生成状态
### ✨ 主要特性
- 🎭 **拟人化操作**:使用贝塞尔曲线模拟真实鼠标移动轨迹
- 🤖 **智能输入**:模拟人类打字速度和错误纠正行为
- 🖼️ **多图支持**:最多支持同时上传 5 张参考图片
- 🔐 **安全认证**:基于 Bearer Token 的 API 鉴权
- 📊 **队列管理**:智能任务队列,防止请求过载
- 🌐 **代理支持**:支持 HTTP 和 SOCKS5 代理配置
---
## 🚀 快速开始
### 系统要求
- **Node.js**: 16.0 或更高版本
- **操作系统**: Windows、Linux 或 macOS
- **浏览器**: Google Chrome 或 Chromium (可选,Puppeteer 会自动下载)
### 安装步骤
1. **克隆项目**(如果从仓库获取)或解压项目文件
2. **安装依赖**
```bash
pnpm install
```
3. **生成 API 密钥**
```bash
npm run genkey
```
此命令会生成一个安全的随机密钥,请保存并配置到 `config.yaml` 中。
---
## ⚙️ 配置说明
### config.yaml 配置文件
配置文件位于项目根目录下的 `config.yaml`,包含以下主要配置项:
#### 服务器配置
```yaml
server:
# 运行模式: 'openai' (OpenAI 兼容) 或 'queue' (SSE 队列)
type: queue
# 监听端口
port: 3000
# API 鉴权密钥 (使用 npm run genkey 生成)
auth: sk-change-me-to-your-secure-key
```
#### 浏览器配置
```yaml
chrome:
# Chrome 可执行文件路径 (留空使用 Puppeteer 内置版本)
# Windows 示例: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
# Linux 示例: "/usr/bin/chromium"
path: ""
# 是否启用无头模式 (true = 后台运行,false = 显示浏览器)
headless: false
# 是否启用 GPU 加速 (无显卡服务器设为 false)
gpu: false
```
#### 代理配置
```yaml
chrome:
proxy:
# 是否启用代理
enable: false
# 代理类型: 'http' 或 'socks5'
type: http
# 代理服务器地址
host: 127.0.0.1
# 代理端口
port: 7890
# 代理认证 (可选)
# user: username
# passwd: password
```
### 重要配置建议
| 配置项 | 建议值 | 说明 |
|-------|--------|------|
| `server.type` | `queue` | 使用队列模式可获得实时状态反馈 |
| `server.auth` | 强密钥 | 务必修改默认值,使用 `npm run genkey` 生成 |
| `chrome.headless` | `false` / `true` | 建议保持非无头模式(true已映射为new模式) |
| `chrome.gpu` | `false` / `true` | 无显卡环境强烈建议关闭 |
---
## 📖 使用方法
### 【重要】务必进行的步骤
- 第一次启动程序时必须关闭无头模式启动(**Linux无界面请看文档结尾**)
- 等待网页加载完毕后登录账号(否则会在使用几次后弹出登录界面阻止操作)
- 点击输入框输入任意内容点发送,等待弹出服务条款和CloudFlare Turnstile验证码
- 点击验证码并同意条款后再次点击发送,此时可能会弹出reCAPTCHA验证码,若出现也将其通过
- 后续可改为无头模式运行,但建议使用非无头模式避免频繁触发人机验证码
### 方式一:使用 HTTP API
**启动服务器**
```bash
npm start
```
#### OpenAI 兼容模式
> [!WARNING]
> 由于模拟真实用户的浏览器操作,一次只能进行一个任务,剩下的将会放入队列中等待,为防止客户端超时影响体验,该模式如果已经有3个任务时后来的任务将会直接返回错误代码,推荐使用Queue队列模式,服务器会向客户端发送心跳包以确保连接存活。
**配置文件设置**
```yaml
server:
type: openai
port: 3000
auth: your-secret-key
```
**API 请求示例**
```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 '{
"messages": [
{
"type": "text",
"text": "generate a cat"
}
]
}'
```
**响应格式**
```json
{
"id": "chatcmpl-1732374740123",
"object": "chat.completion",
"created": 1732374740,
"model": "lmarena-image",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "![generated](data:image/jpeg;base64,/9j/4AAQ...)"
},
"finish_reason": "stop"
}]
}
```
#### Queue 队列模式(SSE)(推荐)
**配置文件设置**
```yaml
server:
type: queue
```
**请求端点**
```
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]"` | 流结束 |
**Node.js 示例代码**
```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({
messages: [{ role: "user", content: "a cute cat" }]
}));
req.end();
```
#### 带图片的请求
**支持格式**PNG、JPEG、GIF、WebP
**最大数量**5 张图片
**数据格式**Base64 编码
**请求示例**
```json
{
"messages": [{
"role": "user",
"content": [
{
"type": "text",
"text": "make it more colorful"
},
{
"type": "image_url",
"image_url": {
"url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAA..."
}
}
]
}]
}
```
### 方式二:使用CLI客户端脚本
**启动CLI工具**
```bash
npm test
```
根据指引填写图片路径和提示词即可
---
## 📁 项目结构
```
lmarena/
├── server.js # HTTP 服务器 (主入口)
├── config.yaml # 配置文件
├── package.json # 项目依赖
├── lib/
│ ├── lmarena.js # 核心生图逻辑 (Puppeteer 操作)
│ ├── config.js # 配置加载器
│ ├── genApiKey.js # API 密钥生成工具
│ └── test.js # 功能测试脚本
└── data/
├── chromeUserData/ # Chrome 用户数据 (自动创建)
└── temp/ # 临时图片存储
```
---
## 🔧 常见问题
### 浏览器启动失败
**问题**: `Error: Failed to launch the browser process`
**解决方案**:
- 确保已安装 Chrome 或 Chromium
- 检查 `config.yaml` 中的 `chrome.path` 是否正确
- 尝试删除 `data/chromeUserData` 目录后重新运行
### GPU 相关错误
**问题**: 无显卡服务器运行时出现 GPU 错误
**解决方案**:
- 该报错并不会影响程序运行,但是强烈建议在无显卡的设备上关闭GPU加速
```yaml
chrome:
gpu: false # 禁用 GPU 加速
```
### 请求被拒绝 (429 Too Many Requests)
**问题**: 并发请求过多
**解决方案**:
- 该问题仅存在于OpenAI兼容模式
- 当前限制:1 个并发 + 2 个排队 (总计 3 个)
- 修改 `server.js` 中的 `MAX_CONCURRENT` 和 `MAX_QUEUE_SIZE` (不建议,应为大多数客户端HTTP请求是有超时时间的)
- 等待当前任务完成后再提交新任务
### reCAPTCHA 验证失败
**问题**: 返回 `recaptcha validation failed`
**解决方案**:
- 这是 LMArena 的人机验证机制
- 建议:
- 降低请求频率
- 首次使用时手动完成一次验证 (关闭 headless 模式)
- 使用稳定和纯净的 IP 地址 (可使用 ping0.cc 查询IP地址纯净度)
### 图像生成超时
**问题**: 任务超过 120 秒未完成
**解决方案**:
- 检查网络连接是否稳定
- 某些复杂提示词可能需要更长时间
### Linux下关闭无头模式运行
**问题**: 在Linux多用户模式下无界面运行浏览器
**解决方案**:
方法一:X11转发(适用于后续无头模式运行)
- 推荐使用WindTerm开启右上角X-Server
- 在会话设置中的X11栏目中改为 “内部X11显示”
方法二:Xvfb+X11VNC(推荐)
- 使用xvfb创建虚拟显示器运行该程序,并且将虚拟显示器映射到VNC中便于后续管理(因为在后续使用中可能还会弹出reCAPTCHA验证码需要手动通过)
- 创建虚拟显示器并运行程序 (99为屏幕号,冲突可行更改)
```
xvfb-run --server-num=99 --server-args="-ac -screen 0 1280x720x16" npm start
```
- 将虚拟显示器映射至VNC
```
x11vnc -display :99 -localhost -nopw -once -noxdamage -ncache 10
```
- 后续可用RealVNC等程序通过5900端口连接(推荐使用SSH隧道转发不将VNC直接暴露在公网,然后VNC连接127.0.0.1
```
ssh -L 5900:127.0.0.1:5900 root@服务器IP
```
---
## 📊 配置建议
| 资源 | 最低配置 | 推荐配置 |
|------|---------|---------|
| CPU | 1核 | 2核及以上 |
| 内存 | 1GB | 2GB 及以上 |
参考:经测试可以在Oracle的1G1C免费机Debian环境下运行
## 📄 许可证
本项目仅供学习和研究使用,请遵守 LMArena.ai 的使用条款。
---
**感谢使用 LMArena 图像生成服务!** 🎉
+30
View File
@@ -0,0 +1,30 @@
server:
# 服务器模式: 'openai' (标准兼容) 或 'queue' (流式队列)
type: queue
# 监听端口
port: 3000
# 鉴权密钥 (Bearer Token),请使用 genapikey.js 生成
auth: sk-change-me-to-your-secure-key
chrome:
# 浏览器可执行文件路径 (留空则使用Puppeteer默认)
# Windows系统示例 "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
# Linux系统示例 "/usr/bin/chromium"
path: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
# 是否启用无头模式
headless: false
# 是否启用 GPU (无GPU设备运行请使用false)
gpu: false
# 代理设置
proxy:
# 是否启用代理
enable: false
# 代理类型: 'http' 或 'socks5'
type: http
# 代理服务器地址
host: 127.0.0.1
# 代理端口
port: 7890
# 代理认证 (可选)
# user: username
# passwd: password
+81
View File
@@ -0,0 +1,81 @@
import fs from 'fs';
import path from 'path';
import yaml from 'js-yaml';
import crypto from 'crypto';
const CONFIG_PATH = path.join(process.cwd(), 'config.yaml');
/**
* 生成随机 API Key
*/
function generateApiKey() {
return 'sk-' + crypto.randomBytes(24).toString('hex');
}
/**
* 默认配置模板
*/
function getDefaultConfig() {
return `# LMArena 配置文件
# 自动生成于 ${new Date().toLocaleString()}
server:
# 服务器模式: 'openai' (标准兼容) 或 'queue' (流式队列)
type: queue
# 监听端口
port: 3000
# 鉴权 Token (Bearer Token)
auth: ${generateApiKey()}
chrome:
# 浏览器可执行文件路径 (留空则使用Puppeteer默认)
# Windows系统示例 "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
# Linux系统示例 "/usr/bin/chromium"
# path: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
# 是否启用无头模式 (true: 后台运行, false: 显示界面)
headless: false
# 是否启用 GPU (无GPU设备运行请使用false)
gpu: false
# 代理设置
proxy:
# 是否启用代理
enable: false
# 代理类型: http 或 socks5
type: http
# 代理主机
host: 127.0.0.1
# 代理端口
port: 7890
# 代理认证 (可选)
# user: username
# passwd: password
`;
}
/**
* 加载配置,如果不存在则自动创建
*/
function loadConfig() {
try {
if (!fs.existsSync(CONFIG_PATH)) {
console.log('>>> [Config] 配置文件不存在,正在生成默认配置...');
const defaultConfig = getDefaultConfig();
fs.writeFileSync(CONFIG_PATH, defaultConfig, 'utf8');
console.log(`>>> [Config] 已生成默认配置文件: ${CONFIG_PATH}`);
console.log('>>> [Config] 请注意查看生成的随机 API Key');
}
const configFile = fs.readFileSync(CONFIG_PATH, 'utf8');
const config = yaml.load(configFile);
console.log('>>> [Config] 已加载 config.yaml');
return config;
} catch (e) {
console.error('>>> [Error] 无法加载或生成配置文件:', e.message);
process.exit(1);
}
}
export default loadConfig();
+18
View File
@@ -0,0 +1,18 @@
import crypto from 'crypto';
/**
* 生成随机 API Key
* 格式: sk- + 32位十六进制字符串
*/
function generateApiKey() {
const buffer = crypto.randomBytes(16);
const hex = buffer.toString('hex');
return `sk-${hex}`;
}
const key = generateApiKey();
console.log('\n=== API Key 生成器 ===');
console.log('您的新 API Key 是:');
console.log('\x1b[32m%s\x1b[0m', key); // 绿色高亮
console.log('\n请将其复制到 config.yaml 的 server.auth 字段中。');
console.log('======================\n');
+465
View File
@@ -0,0 +1,465 @@
import puppeteer from 'puppeteer';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// --- 配置常量 ---
const CHROME_PATH = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
const USER_DATA_DIR = path.join(process.cwd(), 'data', 'chromeUserData');
const TARGET_URL = 'https://lmarena.ai/c/new?mode=direct&chat-modality=image';
const TEMP_DIR = path.join(process.cwd(), 'data', 'temp');
// 确保临时目录存在
if (!fs.existsSync(TEMP_DIR)) {
fs.mkdirSync(TEMP_DIR, { recursive: true });
}
// --- 辅助工具 ---
/**
* 生成指定范围内的随机数
* @param {number} min 最小值
* @param {number} max 最大值
* @returns {number} 随机数
*/
const random = (min, max) => Math.random() * (max - min) + min;
/**
* 随机休眠一段时间
* @param {number} min 最小毫秒数
* @param {number} max 最大毫秒数
*/
const sleep = (min, max) => new Promise(r => setTimeout(r, Math.floor(random(min, max))));
/**
* 根据文件扩展名获取 MIME 类型
* @param {string} filePath 文件路径
* @returns {string} MIME 类型
*/
function getMimeType(filePath) {
const ext = path.extname(filePath).toLowerCase();
const map = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
return map[ext] || 'application/octet-stream';
}
// --- 核心拟人化算法 (贝塞尔曲线 + 物理模拟) ---
/**
* 三次贝塞尔曲线计算
*/
function cubicBezier(t, p0, p1, p2, p3) {
const k = 1 - t;
return k * k * k * p0 + 3 * k * k * t * p1 + 3 * k * t * t * p2 + t * t * t * p3;
}
/**
* 模拟人类鼠标移动轨迹
* @param {object} page Puppeteer 页面对象
* @param {number} startX 起始 X 坐标
* @param {number} startY 起始 Y 坐标
* @param {number} targetX 目标 X 坐标
* @param {number} targetY 目标 Y 坐标
*/
async function humanMove(page, startX, startY, targetX, targetY) {
const distance = Math.sqrt(Math.pow(targetX - startX, 2) + Math.pow(targetY - startY, 2));
const steps = Math.floor(Math.max(distance / 8, 15));
const offset = distance * 0.4;
// 生成两个随机控制点,使轨迹弯曲
const cp1X = startX + (targetX - startX) / 3 + random(-offset, offset);
const cp1Y = startY + (targetY - startY) / 3 + random(-offset, offset);
const cp2X = startX + 2 * (targetX - startX) / 3 + random(-offset, offset);
const cp2Y = startY + 2 * (targetY - startY) / 3 + random(-offset, offset);
for (let i = 0; i <= steps; i++) {
const t = i / steps;
// 缓动函数:起步快,结尾慢,模拟人类肌肉运动
const easeT = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
let x = cubicBezier(easeT, startX, cp1X, cp2X, targetX);
let y = cubicBezier(easeT, startY, cp1Y, cp2Y, targetY);
// 添加微小的随机抖动
if (i % 3 === 0) { x += random(-1, 1); y += random(-1, 1); }
await page.mouse.move(x, y);
}
}
/**
* 安全点击元素(包含拟人化移动和点击)
* @param {object} page Puppeteer 页面对象
* @param {string} selector CSS 选择器
*/
async function safeClick(page, selector) {
try {
const el = await page.$(selector);
if (!el) throw new Error(`未找到: ${selector}`);
const box = await el.boundingBox();
if (!box) throw new Error(`不可见: ${selector}`);
// 先稍微移动一下当前位置 (增加真实感)
await page.mouse.move(box.x - random(50, 100), box.y - random(50, 100), { steps: 2 });
// 目标点击位置在元素内部随机区域
const targetX = box.x + box.width * random(0.3, 0.7);
const targetY = box.y + box.height * random(0.3, 0.7);
// 移动鼠标到目标位置
await humanMove(page, box.x - 50, box.y - 50, targetX, targetY);
// 模拟点击过程:按下 -> 停顿 -> 抬起
await sleep(100, 300);
await page.mouse.down();
await sleep(60, 120);
await page.mouse.up();
} catch (err) {
throw err;
}
}
/**
* 模拟人类键盘输入
* @param {object} page Puppeteer 页面对象
* @param {string} selector 输入框选择器
* @param {string} text 要输入的文本
*/
async function humanType(page, selector, text) {
const el = await page.$(selector);
if (!el) throw new Error(`Element not found: ${selector}`);
// 智能输入策略
if (text.length < 50) {
// 短文本:保持拟人化逐字输入
for (let i = 0; i < text.length; i++) {
const char = text[i];
// 模拟错字 (5% 概率)
if (Math.random() < 0.05) {
await el.type('x', { delay: random(50, 150) });
await sleep(100, 300);
await page.keyboard.press('Backspace', { delay: random(50, 100) });
}
await el.type(char);
// 随机击键间隔
await sleep(30, 100);
}
} else {
// 长文本:假装打字 -> 停顿 -> 粘贴
const fakeCount = Math.floor(random(3, 8));
const fakeText = text.substring(0, fakeCount);
// 1. 假装打字几个字符
for (let i = 0; i < fakeText.length; i++) {
await el.type(fakeText[i], { delay: random(30, 100) });
}
// 2. 停顿思考 (0.5 - 1秒)
await sleep(500, 1000);
// 3. 全选删除 (模拟 Ctrl+A -> Backspace)
await page.keyboard.down('Control');
await page.keyboard.press('A');
await page.keyboard.up('Control');
await sleep(100, 300);
await page.keyboard.press('Backspace');
await sleep(100, 300);
// 4. 瞬间粘贴全部文本 (模拟 Ctrl+V)
await page.evaluate((sel, content) => {
const input = document.querySelector(sel);
input.focus();
document.execCommand('insertText', false, content);
}, selector, text);
}
}
/**
* 粘贴图片到输入框
* @param {object} page Puppeteer 页面对象
* @param {string} selector 输入框选择器
* @param {string[]} filePaths 图片文件路径数组
*/
async function pasteImages(page, selector, filePaths) {
if (!filePaths || filePaths.length === 0) return;
console.log(`>>> [粘贴] 上传 ${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);
if (filesData.length === 0) return;
// 点击输入框以获取焦点
await safeClick(page, selector);
await sleep(500, 800);
// 使用 Clipboard API 模拟粘贴事件
await page.evaluate(async (sel, files) => {
const target = 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 }));
}
target.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, clipboardData: dt }));
}, selector, filesData);
console.log('>>> [粘贴] 完成,等待缩略图...');
// 等待图片上传和缩略图生成
await sleep(2500, 4000);
}
/**
* 从响应文本中提取图片 URL
* @param {string} text 响应文本
* @returns {string|null} 图片 URL 或 null
*/
function extractImage(text) {
if (!text) return null;
const lines = text.split('\n');
for (const line of lines) {
if (line.startsWith('a2:')) {
try {
const data = JSON.parse(line.substring(3));
if (data?.[0]?.image) return data[0].image;
} catch (e) { }
}
}
return null;
}
/**
* 初始化浏览器
* @param {object} config 配置对象 (包含 chrome 配置)
* @returns {Promise<{browser: object, page: object, client: object, width: number, height: number}>}
*/
async function initBrowser(config) {
console.log('>>> [Browser] 开始初始化浏览器');
const chromeConfig = config?.chrome || {};
const width = Math.floor(random(900, 1100));
const height = Math.floor(random(500, 700));
// 1. 基础参数
const args = [
'--no-sandbox',
'--disable-setuid-sandbox',
`--window-size=${width},${height}`,
'--disable-blink-features=AutomationControlled',
'--disable-infobars',
'--test-type',
'--no-zygote',
'--disable-dev-shm-usage'
];
// 2. Headless 模式配置
let headlessMode = false;
if (chromeConfig.headless) {
headlessMode = 'new';
args.push('--disable-gl-drawing-for-tests');
console.log('>>> [Browser] Headless 模式: 启用');
} else {
console.log('>>> [Browser] Headless 模式: 禁用');
}
// 3. GPU 配置
if (chromeConfig.gpu === false) {
args.push(
'--disable-gpu',
'--use-gl=swiftshader',
'--disable-accelerated-2d-canvas'
);
console.log('>>> [Browser] GPU 加速: 禁用 (优化兼容性)');
} else {
console.log('>>> [Browser] GPU 加速: 启用');
}
// 4. 代理配置
if (chromeConfig.proxy && chromeConfig.proxy.enable) {
const { type, host, port } = chromeConfig.proxy;
const proxyUrl = type === 'socks5' ? `socks5://${host}:${port}` : `${host}:${port}`;
args.push(`--proxy-server=${proxyUrl}`);
// 禁用 QUIC (HTTP3) 以确保代理兼容性
args.push('--disable-quic');
console.log(`>>> [Browser] 代理配置: ${type}://${host}:${port}`);
}
const browser = await puppeteer.launch({
headless: headlessMode,
executablePath: chromeConfig.path || undefined,
userDataDir: USER_DATA_DIR,
defaultViewport: null,
ignoreDefaultArgs: ['--enable-automation'],
args: args
});
// 重用第一个标签页
const pages = await browser.pages();
const page = pages[0];
// 5. 代理认证
if (chromeConfig.proxy && chromeConfig.proxy.enable && chromeConfig.proxy.user) {
await page.authenticate({
username: chromeConfig.proxy.user,
password: chromeConfig.proxy.passwd
});
console.log('>>> [Browser] 代理认证: 已设置');
}
// 隐藏 WebDriver 特征
await page.evaluateOnNewDocument(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
});
// 创建 CDP 会话以监听网络请求
const client = await page.target().createCDPSession();
await client.send('Network.enable');
// --- [行为预热] 建立人机检测信任 ---
console.log('>>> [Browser] 正在连接 LMArena...');
await page.goto(TARGET_URL, { waitUntil: 'networkidle2' });
console.log('>>> [Warmup] 正在随机浏览页面以建立信任...');
// 计算屏幕中心点
const centerX = width / 2;
const centerY = height / 2;
// 第一次移动:从左上角移动到中心附近
await humanMove(page, 0, 0, centerX + random(-200, 200), centerY + random(-200, 200));
await sleep(500, 1000);
// 模拟滚动行为
try {
await page.mouse.wheel({ deltaY: random(100, 300) });
await sleep(800, 1500);
await page.mouse.wheel({ deltaY: -random(50, 100) });
} catch (e) { }
// 等待输入框出现
const textareaSelector = 'textarea';
await page.waitForSelector(textareaSelector, { timeout: 60000 });
// 移动鼠标到输入框
const box = await (await page.$(textareaSelector)).boundingBox();
if (box) {
await humanMove(page, centerX, centerY, box.x + box.width / 2, box.y + box.height / 2);
await sleep(500, 1000);
}
console.log('>>> [Browser] 浏览器初始化完成,系统就绪');
return { browser, page, client, width, height };
}
/**
* 执行生图任务
* @param {object} context 浏览器上下文 {page, client, width, height}
* @param {string} prompt 提示词
* @param {string[]} imgPaths 图片路径数组
* @returns {Promise<{image?: string, text?: string, error?: string}>}
*/
async function generateImage(context, prompt, imgPaths) {
const { page, client, width, height } = context;
const textareaSelector = 'textarea';
try {
// 1. 强制开启新会话 (通过URL跳转)
console.log('>>> [Task] 开启新会话...');
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' });
// 等待输入框出现
await page.waitForSelector(textareaSelector, { timeout: 30000 });
await sleep(1500, 2500); // 等页面稳一点
// 2. 粘贴图片
if (imgPaths && imgPaths.length > 0) {
await pasteImages(page, textareaSelector, imgPaths);
} else {
// 如果没有图片,也点击一下输入框获取焦点
await safeClick(page, textareaSelector);
}
// 3. 输入 Prompt
console.log('>>> [Input] 正在输入提示词...');
await humanType(page, textareaSelector, prompt);
await sleep(800, 1500);
// 4. 发送
const btnSelector = 'button[type="submit"]';
await safeClick(page, btnSelector);
console.log('>>> [Wait] 等待生成中...');
// 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;
// 检查是否包含 reCAPTCHA 错误
if (content.includes('recaptcha validation failed')) {
cleanup();
resolve({ error: 'recaptcha validation failed' });
return;
}
const img = extractImage(content);
if (img) {
console.log('>>> [Success] 生图成功');
cleanup();
resolve({ image: img });
} else {
console.log('>>> [Task] AI 返回文本回复:', 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);
});
// 任务结束,像人一样把鼠标移开,防止遮挡或误触
await humanMove(page, width / 2, height / 2, width - 100, height / 2);
return result;
} catch (err) {
console.error('>>> [Error] 生成任务失败:', err.message);
return { error: err.message };
}
}
export { initBrowser, generateImage, TEMP_DIR };
+66
View File
@@ -0,0 +1,66 @@
import readline from 'readline';
import config from './config.js';
import { initBrowser, generateImage } from './lmarena.js';
/**
* 创建命令行交互接口
*/
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
/**
* 封装 readline 为 Promise
* @param {string} query 提示问题
* @returns {Promise<string>} 用户输入
*/
const ask = (query) => new Promise((resolve) => rl.question(query, resolve));
async function main() {
console.log('>>> [CLI] LMArena CLI 测试工具');
console.log('>>> [CLI] 正在启动浏览器...');
let browserContext;
try {
// 传入配置对象
browserContext = await initBrowser(config);
console.log('>>> [CLI] 浏览器已就绪。');
} catch (err) {
console.error('>>> [Error] 浏览器启动失败:', err);
process.exit(1);
}
while (true) {
console.log('-----------------------------');
// 1. 获取图片路径
const imgInput = await ask('>>> [CLI] 请输入图片路径 (多张用逗号隔开,回车跳过): ');
const imagePaths = imgInput.trim()
? imgInput.split(',').map(p => p.trim()).filter(p => p)
: [];
// 2. 获取提示词
const prompt = await ask('>>> [CLI] 请输入提示词: ');
if (!prompt.trim()) {
console.log('>>> [Error] 提示词不能为空,请重试。');
continue;
}
console.log(`>>> [CLI] 开始任务: Prompt="${prompt}", Images=${imagePaths.length}`);
// 3. 调用生图逻辑
const result = await generateImage(browserContext, prompt, imagePaths);
// 4. 显示结果
if (result.error) {
console.error('>>> [Error]', result.error);
} else if (result.image) {
console.log('>>> [Success] 图片 URL:', result.image);
} else {
console.log('>>> [CLI] AI 使用文本回复:', result.text);
}
}
}
main();
+14
View File
@@ -0,0 +1,14 @@
{
"type": "module",
"scripts": {
"start": "node server.js",
"test": "node lib/test.js",
"genkey": "node lib/genApiKey.js"
},
"dependencies": {
"got-scraping": "^4.1.2",
"js-yaml": "^4.1.1",
"puppeteer": "^24.31.0",
"sharp": "^0.34.5"
}
}
+1505
View File
File diff suppressed because it is too large Load Diff
+316
View File
@@ -0,0 +1,316 @@
import http from 'http';
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
import { gotScraping } from 'got-scraping';
import config from './lib/config.js';
import { initBrowser, generateImage, TEMP_DIR } from './lib/lmarena.js';
const PORT = config.server.port || 3000;
const AUTH_TOKEN = config.server.auth;
const SERVER_MODE = config.server.type || 'openai'; // 'openai' 或 'queue'
// --- 全局状态 ---
let browserContext = null; // 浏览器上下文 {browser, page, client, width, height}
const queue = []; // 请求队列
let processingCount = 0; // 当前正在处理的任务数
const MAX_CONCURRENT = 1; // 同时处理的任务数 (Puppeteer 只能单线程操作)
const MAX_QUEUE_SIZE = 2; // 最大排队数 (总容量 = MAX_CONCURRENT + MAX_QUEUE_SIZE = 3)
/**
* 处理队列中的任务
*/
async function processQueue() {
// 如果正在处理的任务已满,或队列为空,则停止
if (processingCount >= MAX_CONCURRENT || queue.length === 0) return;
// 取出下一个任务
const task = queue.shift();
processingCount++;
// 如果是 Queue 模式,通知客户端状态变更
if (SERVER_MODE === 'queue' && task.sse) {
task.sse.send('status', { status: 'processing' });
}
try {
console.log(`>>> [Queue] 开始处理任务。剩余排队: ${queue.length}`);
// 确保浏览器已初始化
if (!browserContext) {
browserContext = await initBrowser(config);
}
const { req, res, prompt, imagePaths } = task;
// 调用核心生图逻辑
const result = await generateImage(browserContext, prompt, imagePaths);
// 清理临时图片
for (const p of imagePaths) {
try { fs.unlinkSync(p); } catch (e) { }
}
// 处理结果
let finalContent = '';
let queueResult = {};
if (result.error) {
// 特殊错误处理:reCAPTCHA
if (result.error === 'recaptcha validation failed') {
if (SERVER_MODE === 'openai') {
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 {
console.log('>>> [Download] 正在下载生成结果...');
const response = await gotScraping({
url: result.image,
responseType: 'buffer',
http2: true,
headerGeneratorOptions: {
browsers: [{ name: 'chrome', minVersion: 110 }],
devices: ['desktop'],
locales: ['en-US'],
operatingSystems: ['windows'],
}
});
const imgBuffer = response.body;
// 检测图片格式并转 Base64
const metadata = await sharp(imgBuffer).metadata();
const mimeType = metadata.format === 'png' ? 'image/png' : 'image/jpeg';
const base64 = imgBuffer.toString('base64');
finalContent = `![generated](data:${mimeType};base64,${base64})`;
queueResult = { status: 'completed', image: base64, msg: '' };
console.log('>>> [Response] 图片已转换为 Base64');
} catch (e) {
console.error('>>> [Error] 图片下载失败:', e.message);
finalContent = `[图片下载失败] ${result.image}`;
queueResult = { status: 'error', image: null, msg: `Download failed: ${e.message}` };
}
} else {
finalContent = result.text || '生成失败';
queueResult = { status: 'completed', image: null, msg: result.text };
}
// 发送响应
if (SERVER_MODE === 'openai') {
const response = {
id: 'chatcmpl-' + Date.now(),
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: 'lmarena-image',
choices: [{
index: 0,
message: {
role: 'assistant',
content: finalContent
},
finish_reason: 'stop'
}]
};
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) {
console.error('>>> [Error] 任务处理失败:', err);
if (SERVER_MODE === 'openai') {
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 {
processingCount--;
// 递归处理下一个任务
processQueue();
}
}
/**
* 启动 HTTP 服务器
*/
async function startServer() {
// 预先启动浏览器
try {
browserContext = await initBrowser(config);
} catch (err) {
console.error('>>> [Error] 浏览器初始化失败:', err);
process.exit(1);
}
const server = http.createServer(async (req, res) => {
// --- 鉴权中间件 ---
const authHeader = req.headers['authorization'];
if (!authHeader || authHeader !== `Bearer ${AUTH_TOKEN}`) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
// --- 路由分发 ---
const isQueueMode = SERVER_MODE === 'queue';
const targetPath = isQueueMode ? '/v1/queue/join' : '/v1/chat/completions';
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);
}
const chunks = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', async () => {
try {
// --- 限流检查 (仅 OpenAI 模式) ---
if (!isQueueMode && processingCount + queue.length >= MAX_CONCURRENT + MAX_QUEUE_SIZE) {
console.warn('>>> [Server] 请求过多,已拒绝(限流)');
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;
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' })); }
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' })); }
return;
}
const lastMessage = userMessages[userMessages.length - 1];
let prompt = '';
const imagePaths = [];
let imageCount = 0;
// 解析内容 (拼接文本 + 处理图片)
if (Array.isArray(lastMessage.content)) {
for (const item of lastMessage.content) {
if (item.type === 'text') {
prompt += item.text + ' ';
} else if (item.type === 'image_url' && item.image_url && item.image_url.url) {
imageCount++;
if (imageCount > 5) {
return;
}
const url = item.image_url.url;
if (url.startsWith('data:image')) {
const matches = url.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/);
if (matches && matches.length === 3) {
const buffer = Buffer.from(matches[2], 'base64');
// 压缩图片
const processedBuffer = await sharp(buffer)
.jpeg({ quality: 90 })
.toBuffer();
const filename = `img_${Date.now()}_${Math.random().toString(36).substring(7)}.jpg`;
const filePath = path.join(TEMP_DIR, filename);
fs.writeFileSync(filePath, processedBuffer);
imagePaths.push(filePath);
}
}
}
}
} else {
prompt = lastMessage.content; // 回落保留
}
prompt = prompt.trim();
console.log(`>>> [Queue] 请求入队 - Prompt: ${prompt}, Images: ${imagePaths.length}`);
if (isQueueMode) {
sseHelper.send('status', { status: 'queued', position: queue.length + 1 });
}
// 将任务加入队列
queue.push({ req, res, prompt, imagePaths, sse: sseHelper });
// 触发队列处理
processQueue();
} catch (err) {
console.error('>>> [Error] 服务器处理失败:', err);
if (isQueueMode && sseHelper) {
sseHelper.send('error', { msg: err.message });
sseHelper.end();
} else if (!res.writableEnded) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
}
});
} else {
res.writeHead(404);
res.end();
}
});
server.listen(PORT, () => {
console.log(`>>> [Server] HTTP 服务器启动成功,监听端口 ${PORT}`);
console.log(`>>> [Server] 运行模式: ${SERVER_MODE === 'openai' ? 'OpenAI 兼容模式' : 'Queue 队列模式'}`);
if (SERVER_MODE === 'openai') {
console.log(`>>> [Server] 最大并发: ${MAX_CONCURRENT}, 最大排队: ${MAX_QUEUE_SIZE}`);
}
});
}
startServer();