mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
feat: 初步支持 Gemini Business 并重构和整理
This commit is contained in:
+46
-53
@@ -5,85 +5,78 @@ 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
|
||||
|
||||
### Added
|
||||
- **Gemini Enterprise Business 支持**
|
||||
- 新增对 Gemini Enterprise Business 的初步支持
|
||||
- 实现请求拦截机制,强制指定 `Nano Banana Pro` 模型
|
||||
|
||||
### Changed
|
||||
- **代码重构**
|
||||
- 重构代码结构,提升代码复用率并增强项目的可维护性
|
||||
- 优化日志输出系统,提高调试信息的可读性
|
||||
- **CLI 交互增强**
|
||||
- 更新 `lib/test.js` 测试工具,支持交互式选择模型和测试方式
|
||||
|
||||
## [1.2.1] - 2024-11-27
|
||||
|
||||
### Added
|
||||
- **登录模式**
|
||||
- 添加登录模式(-login),便于手动登录
|
||||
- 新增独立登录参数 (`-login`),便于用户在非自动化模式下完成手动登录
|
||||
|
||||
### Changed
|
||||
- **浏览器启动**
|
||||
- 采用自动程序与浏览器分离的模式,让程序连接远程调试端口,可能可以更好的减少特征
|
||||
|
||||
---
|
||||
- **浏览器进程解耦**
|
||||
- 调整架构为程序与浏览器分离模式:主程序现通过连接远程调试端口(Remote Debugging Port)控制浏览器,旨在降低自动化检测特征
|
||||
|
||||
## [1.2.0] - 2024-11-26
|
||||
|
||||
### Added
|
||||
- **浏览器特征伪装**
|
||||
- 在 Windows10 官方 Chrome 环境下已通过 [antibot](https://bot.sannysoft.com/) 和 [CreepJS](https://abrahamjuliot.github.io/creepjs/) 测试(无红色警示)(但在Linux下未完全通过,需要用户配合,详情和进一步伪装请查看文档常见问题部分)
|
||||
- 引入 `ghost-cursor` 优化鼠标移动轨迹伪装,不再重复造轮子
|
||||
- **浏览器指纹伪装增强**
|
||||
- 针对 Windows 10 原生 Chrome 环境优化指纹,已在 [antibot](https://bot.sannysoft.com/) 和 [CreepJS](https://abrahamjuliot.github.io/creepjs/) 测试中无红色高危警告
|
||||
- 集成 `ghost-cursor` 库,通过贝塞尔曲线算法生成拟人化鼠标轨迹,提升伪装效果
|
||||
- *注:Linux 环境下的指纹伪装暂未完全覆盖,建议参考文档中的常见问题进行手动调优*
|
||||
|
||||
### Changed
|
||||
- **指定模型**
|
||||
- 更改模型UUID的拦截器不再使用注入Fetch脚本和Puppeteer Interception,改用CDP拦截器,减少特征
|
||||
|
||||
- **浏览器特征伪装**
|
||||
- 优化浏览器启动参数,减少特征
|
||||
- 优化窗口大小计算方式
|
||||
|
||||
---
|
||||
- **底层拦截机制重构**
|
||||
- 弃用基于 Fetch 脚本注入和 Puppeteer Request Interception 的旧方案
|
||||
- 迁移至 CDP (Chrome DevTools Protocol) 拦截器处理模型 UUID 映射,显著降低被检测风险
|
||||
- **环境参数优化**
|
||||
- 优化浏览器启动参数配置与窗口尺寸计算逻辑,进一步减少特征暴露
|
||||
|
||||
## [1.1.1] - 2024-11-25
|
||||
|
||||
### Fixed
|
||||
- **指定模型**
|
||||
- 修复因错误的UUID映射导致的 gemini-3-pro-image-preview 模型触发HTTP500的 BUG
|
||||
|
||||
---
|
||||
- **模型映射修复**
|
||||
- 修复因 UUID 映射错误导致 `gemini-3-pro-image-preview` 模型请求返回 HTTP 500 的异常
|
||||
|
||||
## [1.1.0] - 2024-11-24
|
||||
|
||||
### Added
|
||||
- **模型选择功能**:新增 `model` 参数支持,允许用户指定使用的图像生成模型
|
||||
- 支持 23+ 种模型,包括 Seedream、Gemini、Imagen、DALL-E 等
|
||||
- 新增 `/v1/models` API 端点,用于查询可用模型列表
|
||||
- 模型映射配置文件 `lib/models.js`,便于维护和扩展
|
||||
- 在浏览器页面注入拦截脚本,动态修改请求体中的 `modelAId`
|
||||
|
||||
- **CLI 测试工具增强**:`lib/test.js` 新增交互式模型选择
|
||||
- 支持在命令行中输入模型名称
|
||||
- 回车跳过则使用默认模型
|
||||
|
||||
- **API 接口更新**:
|
||||
- OpenAI 兼容模式 (`/v1/chat/completions`) 现在支持 `model` 参数
|
||||
- Queue 队列模式 (`/v1/queue/join`) 现在支持 `model` 参数
|
||||
- 未指定 `model` 时,使用 LMArena 网页默认模型
|
||||
|
||||
---
|
||||
- **多模型支持体系**
|
||||
- 新增 `model` 参数,支持指定 Seedream, Gemini, Imagen, DALL-E 等 23+ 种图像生成模型
|
||||
- 新增 `/v1/models` 端点,提供可用模型列表查询功能
|
||||
- 引入 `lib/models.js` 配置文件,实现模型映射的集中管理与扩展
|
||||
- 实现动态 payload 注入,在浏览器上下文中实时修改 `modelAId`
|
||||
- **API 兼容性更新**
|
||||
- OpenAI 兼容接口 (`/v1/chat/completions`) 及队列接口 (`/v1/queue/join`) 均已适配 `model` 参数
|
||||
- *注:若未指定模型,系统将默认调用网页端的缺省模型*
|
||||
|
||||
## [1.0.1] - 2024-11-23
|
||||
|
||||
### Fixed
|
||||
- **浏览器代理**
|
||||
- 修复需要鉴权的Socks5代理无法连接
|
||||
|
||||
---
|
||||
- **代理鉴权修复**
|
||||
- 修复了带身份验证的 SOCKS5 代理无法建立连接的问题。
|
||||
|
||||
## [1.0.0] - 2024-11-23
|
||||
|
||||
### Added
|
||||
- **初始版本发布**
|
||||
- 基于 Puppeteer 的自动化图像生成功能
|
||||
- 支持两种运行模式:
|
||||
- OpenAI 兼容模式
|
||||
- Queue 队列模式(SSE)
|
||||
- 拟人化操作特性:
|
||||
- 贝塞尔曲线鼠标移动
|
||||
- 智能键盘输入模拟
|
||||
- 随机延迟和抖动
|
||||
- 多图上传支持(最多 5 张)
|
||||
- Bearer Token 认证
|
||||
- 代理支持(HTTP 和 SOCKS5)
|
||||
- CLI 测试工具
|
||||
- 完整的配置文件系统
|
||||
- 发布基于 Puppeteer 的自动化图像生成核心功能。
|
||||
- 提供双运行模式:OpenAI API 兼容模式与 Queue 队列模式 (SSE)。
|
||||
- 拟人化交互:内置贝塞尔曲线鼠标移动、智能键盘输入模拟及随机抖动延迟算法。
|
||||
- **功能特性**:
|
||||
- 支持单次最多上传 5 张图片。
|
||||
- 支持 Bearer Token 标准认证。
|
||||
- 完整支持 HTTP 及 SOCKS5 代理协议。
|
||||
- 附带 CLI 测试工具及可配置化系统架构。
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# 日志等级: debug | info | warn | error
|
||||
logLevel: info
|
||||
|
||||
server:
|
||||
# 服务器模式: openai (标准兼容) | queue (流式队列)
|
||||
type: openai
|
||||
# 监听端口
|
||||
port: 3000
|
||||
# 鉴权 Token (Bearer Token) (可使用 npm run genkey 生成)
|
||||
auth: sk-change-me-to-your-secure-key
|
||||
|
||||
backend:
|
||||
# 选择后端: lmarena (竞技场) | gemini_biz (Gemini Enterprise Business)
|
||||
type: lmarena
|
||||
|
||||
# Gemini Business 设置
|
||||
geminiBiz:
|
||||
# 入口链接
|
||||
# 示例: "https://business.gemini.google/home/cid/8888a888-b6e0-88be-86e1-888cf3ee8cf4?csesidx=1666666666"
|
||||
entryUrl: ""
|
||||
|
||||
queue:
|
||||
# 最大排队数
|
||||
# 仅对OpenAI模式做出限制,非必要不建议更改
|
||||
# 因常见客户端都有超时保护,队列大于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"
|
||||
|
||||
# 是否启用无头模式
|
||||
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
|
||||
@@ -0,0 +1,374 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { initBrowserBase } from '../browser/launcher.js';
|
||||
import {
|
||||
random,
|
||||
sleep,
|
||||
getRealViewport,
|
||||
clamp,
|
||||
queryDeep,
|
||||
safeClick,
|
||||
humanType,
|
||||
pasteImages
|
||||
} from '../browser/utils.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
// --- 配置常量 ---
|
||||
const USER_DATA_DIR = path.join(process.cwd(), 'data', 'chromeUserDataGeminiBiz');
|
||||
const TEMP_DIR = path.join(process.cwd(), 'data', 'temp');
|
||||
|
||||
// 确保临时目录存在
|
||||
if (!fs.existsSync(TEMP_DIR)) {
|
||||
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找 Shadow DOM 中的输入框
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @returns {Promise<ElementHandle|null>}
|
||||
*/
|
||||
async function findInput(page) {
|
||||
return await page.evaluateHandle(() => {
|
||||
function queryDeep(root, selector) {
|
||||
let found = root.querySelector(selector);
|
||||
if (found) return found;
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, null, false);
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode;
|
||||
if (node.shadowRoot) {
|
||||
found = queryDeep(node.shadowRoot, selector);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const editor = queryDeep(document.body, 'ucs-prosemirror-editor');
|
||||
if (!editor) return null;
|
||||
return queryDeep(editor.shadowRoot, '.ProseMirror');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化浏览器
|
||||
* @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.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}>}
|
||||
*/
|
||||
async function initBrowser(config) {
|
||||
// 从配置读取 Gemini Biz entry URL
|
||||
const backendCfg = config.backend || {};
|
||||
const geminiCfg = backendCfg.geminiBiz || {};
|
||||
const targetUrl = geminiCfg.entryUrl;
|
||||
|
||||
if (!targetUrl) {
|
||||
throw new Error('GeminiBiz backend missing entry URL: backend.geminiBiz.entryUrl');
|
||||
}
|
||||
|
||||
// Gemini Biz 特定的输入框验证
|
||||
const waitInputValidator = async (page) => {
|
||||
let inputHandle = null;
|
||||
let retries = 0;
|
||||
const maxRetries = 20;
|
||||
|
||||
logger.info('适配器', '正在寻找输入框 (如果您需要登录,请使用登录模式)...');
|
||||
|
||||
while (retries < maxRetries) {
|
||||
try {
|
||||
inputHandle = await findInput(page);
|
||||
if (inputHandle && inputHandle.asElement()) {
|
||||
logger.info('适配器', '已找到输入框');
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.message.includes('Execution context was destroyed')) {
|
||||
logger.info('适配器', '页面跳转中,继续等待...');
|
||||
}
|
||||
}
|
||||
await sleep(1000, 1500);
|
||||
retries++;
|
||||
if (retries % 10 === 0) logger.info('适配器', `仍在寻找输入框... (${retries}/${maxRetries})`);
|
||||
}
|
||||
|
||||
if (!inputHandle || !inputHandle.asElement()) {
|
||||
logger.error('适配器', '等待超时,未找到输入框');
|
||||
}
|
||||
|
||||
if (inputHandle && inputHandle.asElement()) {
|
||||
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 });
|
||||
}
|
||||
await sleep(500, 1000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return await initBrowserBase(config, {
|
||||
userDataDir: USER_DATA_DIR,
|
||||
targetUrl,
|
||||
productName: 'Gemini Enterprise Business',
|
||||
reuseExistingTab: false,
|
||||
waitInputValidator
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成图片
|
||||
* @param {object} context - 浏览器上下文 { page, client, config }
|
||||
* @param {string} prompt - 提示词
|
||||
* @param {string[]} imgPaths - 参考图片路径数组
|
||||
* @param {string} modelId - 模型 ID (目前未使用,固定为 gemini-3-pro-preview)
|
||||
* @returns {Promise<{image?: string, error?: string}>} 生成结果
|
||||
*/
|
||||
async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
const { page, client } = context;
|
||||
let fetchPausedHandler = null;
|
||||
|
||||
try {
|
||||
// 获取配置 (通过闭包或全局)
|
||||
// 这里需要从 context 或其他方式获取 config
|
||||
const { loadConfig } = await import('../config.js');
|
||||
const config = loadConfig();
|
||||
const targetUrl = config.backend?.geminiBiz?.entryUrl;
|
||||
|
||||
if (!targetUrl) {
|
||||
throw new Error('GeminiBiz backend missing entry URL');
|
||||
}
|
||||
|
||||
// 开启新对话
|
||||
await page.goto(targetUrl, { waitUntil: 'networkidle2' });
|
||||
|
||||
// 1. 查找输入框
|
||||
logger.debug('适配器', '正在寻找输入框...', meta);
|
||||
|
||||
let inputHandle = await findInput(page);
|
||||
let retries = 0;
|
||||
while ((!inputHandle || !inputHandle.asElement()) && retries < 15) {
|
||||
await sleep(1000, 1500);
|
||||
inputHandle = await findInput(page);
|
||||
retries++;
|
||||
}
|
||||
|
||||
if (!inputHandle || !inputHandle.asElement()) {
|
||||
throw new Error('未找到输入框 (.ProseMirror)');
|
||||
}
|
||||
|
||||
// 2. 粘贴图片 (使用自定义验证器)
|
||||
if (imgPaths && imgPaths.length > 0) {
|
||||
const expectedUploads = imgPaths.length;
|
||||
let uploadedCount = 0;
|
||||
let metadataCount = 0;
|
||||
|
||||
await pasteImages(page, inputHandle, imgPaths, {
|
||||
uploadValidator: (response) => {
|
||||
const url = response.url();
|
||||
if (response.status() === 200) {
|
||||
if (url.includes('global/widgetAddContextFile')) {
|
||||
uploadedCount++;
|
||||
logger.debug('适配器', `图片上传进度 (Add): ${uploadedCount}/${expectedUploads}`, meta);
|
||||
return false; // 未完成,继续等待
|
||||
} else if (url.includes('global/widgetListSessionFileMetadata')) {
|
||||
metadataCount++;
|
||||
logger.info('适配器', `图片上传进度: ${metadataCount}/${expectedUploads}`, meta);
|
||||
|
||||
// 两个检查都满足才算完成
|
||||
if (uploadedCount >= expectedUploads && metadataCount >= expectedUploads) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
await sleep(1000, 2000); // 额外缓冲
|
||||
}
|
||||
|
||||
// 3. 输入文字
|
||||
logger.info('适配器', '正在输入提示词...', meta);
|
||||
await humanType(page, inputHandle, prompt);
|
||||
await sleep(1000, 2000);
|
||||
|
||||
// 4. 设置拦截器
|
||||
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) { }
|
||||
}
|
||||
|
||||
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: {} };
|
||||
|
||||
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);
|
||||
|
||||
// 5. 点击发送
|
||||
logger.debug('适配器', '点击发送...', meta);
|
||||
const sendBtnHandle = await page.evaluateHandle(() => {
|
||||
function queryDeep(root, selector) {
|
||||
let found = root.querySelector(selector);
|
||||
if (found) return found;
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, null, false);
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode;
|
||||
if (node.shadowRoot) {
|
||||
found = queryDeep(node.shadowRoot, selector);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// 精准匹配发送按钮
|
||||
return queryDeep(document.body, 'md-icon-button.send-button.submit, button[aria-label="提交"], button[aria-label="Send"], .send-button');
|
||||
});
|
||||
|
||||
if (sendBtnHandle && sendBtnHandle.asElement()) {
|
||||
await safeClick(page, sendBtnHandle);
|
||||
} else {
|
||||
logger.warn('适配器', '未找到发送按钮,尝试回车提交', meta);
|
||||
await inputHandle.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
logger.info('适配器', '等待生成结果中...', meta);
|
||||
|
||||
// 6. 等待结果
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const requestMethods = new Map(); // Store request methods by requestId
|
||||
|
||||
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(() => { });
|
||||
}
|
||||
};
|
||||
|
||||
let targetRequestId = null;
|
||||
|
||||
const onRequest = (e) => {
|
||||
requestMethods.set(e.requestId, e.request.method);
|
||||
};
|
||||
|
||||
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 (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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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('适配器', '生图成功', meta);
|
||||
cleanup();
|
||||
resolve({ image: dataUri });
|
||||
} catch (err) {
|
||||
logger.error('适配器', '生图失败 (提取图片失败)', { ...meta, error: err.message });
|
||||
cleanup();
|
||||
resolve({ error: err.message });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
client.on('Network.requestWillBeSent', onRequest);
|
||||
client.on('Network.responseReceived', onRes);
|
||||
client.on('Network.loadingFinished', onLoad);
|
||||
|
||||
// 超时保护 (180秒)
|
||||
setTimeout(() => {
|
||||
cleanup();
|
||||
resolve({ error: 'Timeout' });
|
||||
}, 180000);
|
||||
});
|
||||
|
||||
// 任务结束,移开鼠标
|
||||
if (page.cursor) {
|
||||
const currentVp = await getRealViewport(page);
|
||||
const relativeX = currentVp.safeWidth * random(0.85, 0.95);
|
||||
const relativeY = currentVp.height * random(0.3, 0.7);
|
||||
const finalX = clamp(relativeX, 0, currentVp.safeWidth);
|
||||
const finalY = clamp(relativeY, 0, currentVp.safeHeight);
|
||||
await page.cursor.moveTo({ x: finalX, y: finalY });
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} 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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { initBrowser, generateImage, TEMP_DIR };
|
||||
@@ -0,0 +1,27 @@
|
||||
import { loadConfig } from '../config.js';
|
||||
import * as lmarenaBackend from './lmarena.js';
|
||||
import * as geminiBackend from './gemini_biz.js';
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
let activeBackend;
|
||||
|
||||
if (config.backend?.type === 'gemini_biz') {
|
||||
activeBackend = {
|
||||
name: 'gemini_biz',
|
||||
initBrowser: (cfg) => geminiBackend.initBrowser(cfg),
|
||||
generateImage: (ctx, prompt, paths, model, meta) => geminiBackend.generateImage(ctx, prompt, paths, model, meta),
|
||||
TEMP_DIR: geminiBackend.TEMP_DIR
|
||||
};
|
||||
} else {
|
||||
activeBackend = {
|
||||
name: 'lmarena',
|
||||
initBrowser: (cfg) => lmarenaBackend.initBrowser(cfg),
|
||||
generateImage: (ctx, prompt, paths, model, meta) => lmarenaBackend.generateImage(ctx, prompt, paths, model, meta),
|
||||
TEMP_DIR: lmarenaBackend.TEMP_DIR
|
||||
};
|
||||
}
|
||||
|
||||
export function getBackend() {
|
||||
return { config, ...activeBackend };
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { gotScraping } from 'got-scraping';
|
||||
import { initBrowserBase } from '../browser/launcher.js';
|
||||
import {
|
||||
random,
|
||||
sleep,
|
||||
getRealViewport,
|
||||
clamp,
|
||||
safeClick,
|
||||
humanType,
|
||||
pasteImages
|
||||
} from '../browser/utils.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
// --- 配置常量 ---
|
||||
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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 从响应文本中提取图片 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}>}
|
||||
*/
|
||||
async function initBrowser(config) {
|
||||
// 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 });
|
||||
}
|
||||
await sleep(500, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
return await initBrowserBase(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}>}
|
||||
*/
|
||||
async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
const { page, client } = 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); // 等页面稳一点
|
||||
|
||||
// 2. 粘贴图片
|
||||
if (imgPaths && imgPaths.length > 0) {
|
||||
await pasteImages(page, textareaSelector, imgPaths);
|
||||
// 如果没有图片,也点击一下输入框获取焦点
|
||||
await safeClick(page, textareaSelector);
|
||||
}
|
||||
|
||||
// 3. 输入 Prompt
|
||||
logger.info('适配器', '正在输入提示词...', meta);
|
||||
await humanType(page, textareaSelector, prompt);
|
||||
await sleep(800, 1500);
|
||||
|
||||
// 注入 CDP 拦截器
|
||||
if (modelId) {
|
||||
// 1. 启用 Fetch 域拦截,仅拦截特定 URL
|
||||
await client.send('Fetch.enable', {
|
||||
patterns: [{
|
||||
urlPattern: '*nextjs-api/stream*',
|
||||
requestStage: 'Request'
|
||||
}]
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 4. 发送
|
||||
logger.debug('适配器', '点击发送...', meta);
|
||||
const btnSelector = 'button[type="submit"]';
|
||||
await safeClick(page, btnSelector);
|
||||
|
||||
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;
|
||||
|
||||
// 检查是否包含 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 });
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} 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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { initBrowser, generateImage, TEMP_DIR };
|
||||
@@ -0,0 +1,94 @@
|
||||
// LMArena 完整模型映射 (模型名 -> UUID)
|
||||
export const LMARENA_MODEL_MAPPING = {
|
||||
"gemini-3-pro-image-preview": "019aa208-5c19-7162-ae3b-0a9ddbb1e16a",
|
||||
"seedream-4-high-res-fal": "32974d8d-333c-4d2e-abf3-f258c0ac1310",
|
||||
"hunyuan-image-3.0": "7766a45c-1b6b-4fb8-9823-2557291e1ddd",
|
||||
"gemini-2.5-flash-image-preview": "0199ef2a-583f-7088-b704-b75fd169401d",
|
||||
"imagen-4.0-ultra-generate-preview-06-06": "f8aec69d-e077-4ed1-99be-d34f48559bbf",
|
||||
"imagen-4.0-generate-preview-06-06": "2ec9f1a6-126f-4c65-a102-15ac401dcea4",
|
||||
"wan2.5-t2i-preview": "019a5050-2875-78ed-ae3a-d9a51a438685",
|
||||
"gpt-image-1": "6e855f13-55d7-4127-8656-9168a9f4dcc0",
|
||||
"gpt-image-mini": "0199c238-f8ee-7f7d-afc1-7e28fcfd21cf",
|
||||
"mai-image-1": "1b407d5c-1806-477c-90a5-e5c5a114f3bc",
|
||||
"seedream-3": "d8771262-8248-4372-90d5-eb41910db034",
|
||||
"qwen-image-prompt-extend": "9fe82ee1-c84f-417f-b0e7-cab4ae4cf3f3",
|
||||
"flux-1-kontext-pro": "28a8f330-3554-448c-9f32-2c0a08ec6477",
|
||||
"imagen-3.0-generate-002": "51ad1d79-61e2-414c-99e3-faeb64bb6b1b",
|
||||
"ideogram-v3-quality": "73378be5-cdba-49e7-b3d0-027949871aa6",
|
||||
"photon": "e7c9fa2d-6f5d-40eb-8305-0980b11c7cab",
|
||||
"lucid-origin": "5a3b3520-c87d-481f-953c-1364687b6e8f",
|
||||
"recraft-v3": "b88d5814-1d20-49cc-9eb6-e362f5851661",
|
||||
"gemini-2.0-flash-preview-image-generation": "69bbf7d4-9f44-447e-a868-abc4f7a31810",
|
||||
"dall-e-3": "bb97bc68-131c-4ea4-a59e-03a6252de0d2",
|
||||
"flux-1-kontext-dev": "eb90ae46-a73a-4f27-be8b-40f090592c9a",
|
||||
"imagen-4.0-fast-generate-001": "f44fd4f8-af30-480f-8ce2-80b2bdfea55e",
|
||||
"hunyuan-image-2.1": "a9a26426-5377-4efa-bef9-de71e29ad943"
|
||||
};
|
||||
|
||||
// GeminiBiz 支持的模型列表 (仅需验证模型 ID,不需要 UUID)
|
||||
export const GEMINI_BIZ_SUPPORTED_MODELS = [
|
||||
"gemini-3-pro-image-preview"
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取后端对应的模型映射或列表
|
||||
* @param {string} backendName - 后端名称 ('lmarena' 或 'gemini_biz')
|
||||
* @returns {Object|Array} LMArena 返回映射对象,GeminiBiz 返回支持的模型数组
|
||||
* @private
|
||||
*/
|
||||
function getMapForBackend(backendName) {
|
||||
if (backendName === 'gemini_biz') {
|
||||
return GEMINI_BIZ_SUPPORTED_MODELS;
|
||||
}
|
||||
return LMARENA_MODEL_MAPPING;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定后端的模型列表 (OpenAI格式)
|
||||
* @param {string} backendName - 后端名称
|
||||
* @returns {Object} OpenAI 格式的模型列表
|
||||
*/
|
||||
export function getModelsForBackend(backendName) {
|
||||
const map = getMapForBackend(backendName);
|
||||
|
||||
let modelIds;
|
||||
if (backendName === 'gemini_biz') {
|
||||
// GeminiBiz: 直接使用支持的模型列表
|
||||
modelIds = map;
|
||||
} else {
|
||||
// LMArena: 从映射对象中提取键
|
||||
modelIds = Object.keys(map);
|
||||
}
|
||||
|
||||
return {
|
||||
object: 'list',
|
||||
data: modelIds.map(id => ({
|
||||
id,
|
||||
object: 'model',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: backendName === 'gemini_biz' ? 'gemini_biz' : 'lmarena'
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析模型 ID
|
||||
* @param {string} backendName - 后端名称
|
||||
* @param {string} modelKey - 请求的模型键
|
||||
* @returns {string|null} LMArena 返回 UUID,GeminiBiz 返回模型 ID (验证通过) 或 null
|
||||
*/
|
||||
export function resolveModelId(backendName, modelKey) {
|
||||
if (backendName === 'gemini_biz') {
|
||||
// GeminiBiz: 只验证模型是否在支持列表中
|
||||
return GEMINI_BIZ_SUPPORTED_MODELS.includes(modelKey) ? modelKey : null;
|
||||
}
|
||||
|
||||
// LMArena: 返回 UUID
|
||||
return LMARENA_MODEL_MAPPING[modelKey] || null;
|
||||
}
|
||||
|
||||
// 保留旧的导出以兼容 (如果有其他地方还在使用)
|
||||
export const MODEL_MAPPING = LMARENA_MODEL_MAPPING;
|
||||
export function getModels() {
|
||||
return getModelsForBackend('lmarena');
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
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 { getRealViewport, clamp, random, sleep } from './utils.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
// 配置 Stealth 插件
|
||||
const stealth = StealthPlugin();
|
||||
stealth.enabledEvasions.delete('iframe.contentWindow');
|
||||
puppeteer.use(stealth);
|
||||
|
||||
// 全局状态跟踪
|
||||
let globalChromeProcess = null;
|
||||
let globalBrowser = null;
|
||||
let globalProxyUrl = null;
|
||||
|
||||
/**
|
||||
* 清理浏览器资源和进程
|
||||
* 实现三级退出机制: Puppeteer close -> SIGTERM -> SIGKILL
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function cleanup() {
|
||||
|
||||
// Level 1: 通过 Puppeteer 协议优雅关闭,释放锁并保存 Profile
|
||||
if (globalBrowser) {
|
||||
try {
|
||||
logger.debug('浏览器', '正在断开远程调试连接...');
|
||||
await globalBrowser.close();
|
||||
globalBrowser = null;
|
||||
logger.debug('浏览器', '已断开远程调试连接');
|
||||
} catch (e) {
|
||||
logger.warn('浏览器', `断开远程调试连接失败 (可能已断开): ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Level 2 & 3: 处理残留进程
|
||||
if (globalChromeProcess && !globalChromeProcess.killed) {
|
||||
logger.info('浏览器', '正在终止浏览器进程...');
|
||||
try {
|
||||
// Level 2: 发送 SIGTERM (软杀)
|
||||
globalChromeProcess.kill('SIGTERM');
|
||||
|
||||
// 等待进程退出
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 2000) {
|
||||
try {
|
||||
process.kill(globalChromeProcess.pid, 0);
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
} catch (e) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
|
||||
// Level 3: 强制查杀 (SIGKILL)
|
||||
try {
|
||||
process.kill(globalChromeProcess.pid, 0);
|
||||
logger.debug('浏览器', '浏览器进程无响应,执行强制终止 (SIGKILL)...');
|
||||
process.kill(-globalChromeProcess.pid, 'SIGKILL');
|
||||
} catch (e) { }
|
||||
|
||||
globalChromeProcess = 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 防止重复注册
|
||||
let signalHandlersRegistered = false;
|
||||
|
||||
/**
|
||||
* 注册进程退出信号处理
|
||||
* @private
|
||||
*/
|
||||
function registerCleanupHandlers() {
|
||||
if (signalHandlersRegistered) return;
|
||||
|
||||
process.on('exit', () => {
|
||||
if (globalChromeProcess) globalChromeProcess.kill();
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
await cleanup();
|
||||
process.exit();
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await cleanup();
|
||||
process.exit();
|
||||
});
|
||||
|
||||
signalHandlersRegistered = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化浏览器 (统一启动逻辑)
|
||||
* @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} options - 启动选项
|
||||
* @param {string} options.userDataDir - 用户数据目录路径
|
||||
* @param {string} options.targetUrl - 目标 URL
|
||||
* @param {string} options.productName - 产品名称(用于日志)
|
||||
* @param {boolean} [options.reuseExistingTab=false] - 是否复用已有特定域名的 tab
|
||||
* @param {Function} [options.waitInputValidator] - 自定义输入框等待验证函数
|
||||
* @returns {Promise<{browser: object, page: object, client: object}>}
|
||||
*/
|
||||
export async function initBrowserBase(config, options) {
|
||||
const {
|
||||
userDataDir,
|
||||
targetUrl,
|
||||
productName,
|
||||
reuseExistingTab = false,
|
||||
waitInputValidator = null
|
||||
} = options;
|
||||
|
||||
// 检测登录模式
|
||||
const isLoginMode = process.argv.includes('-login');
|
||||
const ENABLE_AUTOMATION_MODE = !isLoginMode;
|
||||
|
||||
logger.info('浏览器', `开始初始化浏览器 (${productName})`);
|
||||
logger.info('浏览器', `自动化模式: ${ENABLE_AUTOMATION_MODE ? '开启' : '关闭'}`);
|
||||
if (isLoginMode) {
|
||||
logger.warn('浏览器', '当前为登录模式,请手动完成登录后关闭登录模式以继续自动化程序!');
|
||||
}
|
||||
|
||||
const chromeConfig = config?.chrome || {};
|
||||
const remoteDebuggingPort = 9222;
|
||||
|
||||
// Chrome 启动参数
|
||||
const args = [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--disable-dev-shm-usage',
|
||||
`--user-data-dir=${userDataDir}`,
|
||||
'--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)');
|
||||
} else {
|
||||
if (isLoginMode && chromeConfig.headless) {
|
||||
logger.warn('浏览器', '登录模式下强制禁用 Headless 模式。');
|
||||
}
|
||||
// 有头模式:最大化窗口以适配屏幕
|
||||
args.push('--start-maximized');
|
||||
logger.info('浏览器', 'Headless 模式: 禁用 (最大化窗口)');
|
||||
}
|
||||
|
||||
// GPU 配置
|
||||
if (chromeConfig.gpu === false) {
|
||||
args.push(
|
||||
'--disable-gpu',
|
||||
'--use-gl=swiftshader',
|
||||
'--disable-accelerated-2d-canvas',
|
||||
'--animation-duration-scale=0',
|
||||
'--disable-smooth-scrolling'
|
||||
);
|
||||
logger.info('浏览器', 'GPU 加速: 禁用');
|
||||
} else {
|
||||
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 chromePath = chromeConfig.path;
|
||||
|
||||
// --- 模式分支 ---
|
||||
|
||||
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('浏览器', '远程调试已连接');
|
||||
|
||||
// 注册清理处理器
|
||||
registerCleanupHandlers();
|
||||
|
||||
browser.on('disconnected', async () => {
|
||||
logger.warn('浏览器', '浏览器已断开连接');
|
||||
await cleanup();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// 获取或创建页面
|
||||
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('浏览器', '已创建新标签页');
|
||||
} else {
|
||||
logger.warn('浏览器', '检测到已有目标网站标签页,程序将复用标签页');
|
||||
}
|
||||
} else {
|
||||
// 总是新建标签页
|
||||
page = await browser.newPage();
|
||||
logger.debug('浏览器', '已创建新标签页');
|
||||
}
|
||||
|
||||
// 初始化 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' });
|
||||
} else {
|
||||
logger.info('浏览器', `页面已在 ${productName},跳过跳转`);
|
||||
}
|
||||
|
||||
logger.info('浏览器', '正在随机浏览页面以建立信任...');
|
||||
|
||||
// 计算屏幕中心点 (动态获取视口大小)
|
||||
const vp = await getRealViewport(page);
|
||||
|
||||
// 计算动态中心点
|
||||
const centerX = vp.width / 2;
|
||||
const centerY = vp.height / 2;
|
||||
|
||||
// 第一次移动:从左上角移动到中心附近
|
||||
if (page.cursor) {
|
||||
// 使用 clamp 确保随机偏移后仍在屏幕内
|
||||
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);
|
||||
|
||||
// 模拟滚动行为
|
||||
try {
|
||||
await page.mouse.wheel({ deltaY: random(100, 300) });
|
||||
await sleep(800, 1500);
|
||||
await page.mouse.wheel({ deltaY: -random(50, 100) });
|
||||
} catch (e) { }
|
||||
|
||||
// 如果提供了自定义输入框验证函数,使用它
|
||||
if (waitInputValidator && typeof waitInputValidator === 'function') {
|
||||
await waitInputValidator(page);
|
||||
}
|
||||
|
||||
logger.info('浏览器', '浏览器初始化完成,系统就绪');
|
||||
logger.warn('浏览器', '当任务运行时请勿随意调节窗口大小,以免鼠标轨迹错位!');
|
||||
|
||||
return { browser, page, client };
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/**
|
||||
* 生成指定范围内的随机数
|
||||
* @param {number} min 最小值
|
||||
* @param {number} max 最大值
|
||||
* @returns {number} 随机数
|
||||
*/
|
||||
export function random(min, max) {
|
||||
return Math.random() * (max - min) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* 随机休眠一段时间
|
||||
* @param {number} min 最小毫秒数
|
||||
* @param {number} max 最大毫秒数
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function sleep(min, max) {
|
||||
return new Promise(r => setTimeout(r, Math.floor(random(min, max))));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件扩展名获取 MIME 类型
|
||||
* @param {string} filePath 文件路径
|
||||
* @returns {string} MIME 类型
|
||||
*/
|
||||
export 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';
|
||||
}
|
||||
|
||||
/**
|
||||
* [Security Enhanced] 无痕获取当前页面实时视口
|
||||
* 使用纯净的匿名函数执行,不污染 Global Scope,不留指纹
|
||||
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
|
||||
* @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 缓冲,防止鼠标移到滚动条上或贴边触发浏览器原生手势
|
||||
safeWidth: w - 20,
|
||||
safeHeight: h
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
// Fallback: 如果上下文丢失,返回安全保守值
|
||||
return { width: 1280, height: 720, safeWidth: 1260, safeHeight: 720 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [Safety] 坐标钳位函数
|
||||
* 强制将坐标限制在合法视口范围内,杜绝 "Node is not visible" 报错
|
||||
* @param {number} value - 原始坐标值
|
||||
* @param {number} min - 最小值
|
||||
* @param {number} max - 最大值
|
||||
* @returns {number} 修正后的坐标值
|
||||
*/
|
||||
export function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度查找 Shadow DOM 中的元素
|
||||
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
|
||||
* @param {string} selector - CSS 选择器
|
||||
* @param {import('puppeteer').ElementHandle} [rootHandle=null] - 可选的根节点句柄
|
||||
* @returns {Promise<import('puppeteer').ElementHandle|null>} 找到的元素句柄或 null
|
||||
*/
|
||||
export async function queryDeep(page, selector, rootHandle = null) {
|
||||
return await page.evaluateHandle((sel, root) => {
|
||||
function find(node, s) {
|
||||
if (!node) return null;
|
||||
if (node instanceof Element && node.matches(s)) return node;
|
||||
let found = node.querySelector(s);
|
||||
if (found) return found;
|
||||
if (node.shadowRoot) {
|
||||
found = find(node.shadowRoot, s);
|
||||
if (found) return found;
|
||||
}
|
||||
const walker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT, null, false);
|
||||
while (walker.nextNode()) {
|
||||
const child = walker.currentNode;
|
||||
if (child.shadowRoot) {
|
||||
found = find(child.shadowRoot, s);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return find(root || document.body, sel);
|
||||
}, selector, rootHandle);
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全点击元素(包含拟人化移动和点击)
|
||||
* 支持 CSS selector 和 ElementHandle 两种输入
|
||||
* @param {import('puppeteer').Page} page - Puppeteer 页面对象
|
||||
* @param {string|import('puppeteer').ElementHandle} target - CSS 选择器或元素句柄
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function safeClick(page, target) {
|
||||
try {
|
||||
let el;
|
||||
|
||||
// 判断是 selector 还是 ElementHandle
|
||||
if (typeof target === 'string') {
|
||||
el = await page.$(target);
|
||||
if (!el) throw new Error(`未找到: ${target}`);
|
||||
} else {
|
||||
el = target;
|
||||
if (!el || !el.asElement()) throw new Error(`Element handle invalid`);
|
||||
}
|
||||
|
||||
// 使用 ghost-cursor 点击
|
||||
if (page.cursor) {
|
||||
await page.cursor.click(el);
|
||||
return;
|
||||
}
|
||||
|
||||
// 降级逻辑
|
||||
await el.click();
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟人类键盘输入
|
||||
* 支持 CSS selector 和 ElementHandle 两种输入
|
||||
* @param {import('puppeteer').Page} page - Puppeteer 页面对象
|
||||
* @param {string|import('puppeteer').ElementHandle} target - CSS 选择器或元素句柄
|
||||
* @param {string} text - 要输入的文本
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function humanType(page, target, text) {
|
||||
let el;
|
||||
|
||||
// 判断是 selector 还是 ElementHandle
|
||||
if (typeof target === 'string') {
|
||||
el = await page.$(target);
|
||||
if (!el) throw new Error(`Element not found: ${target}`);
|
||||
} else {
|
||||
el = target;
|
||||
if (!el) throw new Error(`Element handle invalid`);
|
||||
}
|
||||
|
||||
await el.focus();
|
||||
|
||||
// 智能输入策略
|
||||
if (text.length < 50) {
|
||||
// 短文本:保持拟人化逐字输入
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
// 模拟错字 (5% 概率)
|
||||
if (Math.random() < 0.05) {
|
||||
await page.keyboard.type('x', { delay: random(50, 150) });
|
||||
await sleep(100, 300);
|
||||
await page.keyboard.press('Backspace', { delay: random(50, 100) });
|
||||
}
|
||||
await page.keyboard.type(char, { delay: random(30, 100) });
|
||||
// 随机击键间隔
|
||||
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 page.keyboard.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)
|
||||
if (typeof target === 'string') {
|
||||
// 对于 selector,使用 querySelector
|
||||
await page.evaluate((sel, content) => {
|
||||
const input = document.querySelector(sel);
|
||||
input.focus();
|
||||
document.execCommand('insertText', false, content);
|
||||
}, target, text);
|
||||
} else {
|
||||
// 对于 ElementHandle,直接使用
|
||||
await page.evaluate((el, content) => {
|
||||
el.focus();
|
||||
document.execCommand('insertText', false, content);
|
||||
}, el, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 粘贴图片到输入框
|
||||
* 支持 CSS selector 和 ElementHandle 两种输入
|
||||
* @param {import('puppeteer').Page} page - Puppeteer 页面对象
|
||||
* @param {string|import('puppeteer').ElementHandle} target - CSS 选择器或元素句柄
|
||||
* @param {string[]} filePaths - 图片文件路径数组
|
||||
* @param {Object} [options] - 可选配置
|
||||
* @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);
|
||||
|
||||
if (filesData.length === 0) return;
|
||||
|
||||
// 点击输入框以获取焦点
|
||||
await safeClick(page, target);
|
||||
await sleep(500, 800);
|
||||
|
||||
// 如果提供了自定义的上传确认函数,使用它
|
||||
if (options.uploadValidator && typeof options.uploadValidator === 'function') {
|
||||
const expectedUploads = filesData.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);
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
+91
-24
@@ -1,39 +1,54 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import yaml from 'js-yaml';
|
||||
import crypto from 'crypto';
|
||||
import { generateApiKey } from './security/apiKey.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
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()}
|
||||
return `# 自动生成于 ${new Date().toLocaleString()}
|
||||
|
||||
# 日志等级: debug | info | warn | error
|
||||
logLevel: info
|
||||
|
||||
server:
|
||||
# 服务器模式: 'openai' (标准兼容) 或 'queue' (流式队列)
|
||||
# 服务器模式: openai (标准兼容) | queue (流式队列)
|
||||
type: queue
|
||||
# 监听端口
|
||||
port: 3000
|
||||
# 鉴权 Token (Bearer Token)
|
||||
# 鉴权 Token (Bearer Token) (可使用 npm run genkey 生成)
|
||||
auth: ${generateApiKey()}
|
||||
|
||||
backend:
|
||||
# 选择后端: lmarena (竞技场) | gemini_biz (Gemini Enterprise Business)
|
||||
type: lmarena
|
||||
|
||||
# Gemini Business 设置
|
||||
geminiBiz:
|
||||
# 入口链接
|
||||
# 示例: "https://business.gemini.google/home/cid/8888a888-b6e0-88be-86e1-888cf3ee8cf4?csesidx=1666666666"
|
||||
entryUrl: ""
|
||||
|
||||
queue:
|
||||
# 最大排队数
|
||||
# 仅对OpenAI模式做出限制,非必要不建议更改
|
||||
# 因常见客户端都有超时保护,队列大于2是一定会触发超时保护的
|
||||
maxQueueSize: 2
|
||||
# 图片数量上限
|
||||
# 网页最多支持10个附件,如果设置大于10则直接丢弃超出10的图片
|
||||
imageLimit: 5
|
||||
|
||||
chrome:
|
||||
# 浏览器可执行文件路径 (留空则使用Puppeteer默认)
|
||||
# Windows系统示例 "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
|
||||
# Linux系统示例 "/usr/bin/chromium"
|
||||
# path: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
|
||||
# Windows系统示例 "C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe"
|
||||
# Linux系统示例 "/usr/bin/google-chrome"
|
||||
# path: ""
|
||||
|
||||
# 是否启用无头模式 (true: 后台运行, false: 显示界面)
|
||||
# 是否启用无头模式
|
||||
headless: false
|
||||
|
||||
# 是否启用 GPU (无GPU设备运行请使用false)
|
||||
@@ -52,30 +67,82 @@ chrome:
|
||||
# 代理认证 (可选)
|
||||
# user: username
|
||||
# passwd: password
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载配置,如果不存在则自动创建
|
||||
* 加载配置,如果不存在则自动创建
|
||||
* @returns {object} 配置对象
|
||||
*/
|
||||
function loadConfig() {
|
||||
export function loadConfig() {
|
||||
try {
|
||||
if (!fs.existsSync(CONFIG_PATH)) {
|
||||
console.log('>>> [Config] 配置文件不存在,正在生成默认配置...');
|
||||
logger.warn('配置器', '配置文件不存在,正在生成默认配置...');
|
||||
const defaultConfig = getDefaultConfig();
|
||||
fs.writeFileSync(CONFIG_PATH, defaultConfig, 'utf8');
|
||||
console.log(`>>> [Config] 已生成默认配置文件: ${CONFIG_PATH}`);
|
||||
console.log('>>> [Config] 请注意查看生成的随机 API Key');
|
||||
logger.info('配置器', `已生成默认配置文件: ${CONFIG_PATH}`);
|
||||
logger.warn('配置器', '请注意查看生成的随机 API Key');
|
||||
}
|
||||
|
||||
const configFile = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
const config = yaml.load(configFile);
|
||||
console.log('>>> [Config] 已加载 config.yaml');
|
||||
|
||||
// 基础配置校验
|
||||
if (!config.server || !config.server.port) {
|
||||
throw new Error('配置文件缺少必需字段: server.port');
|
||||
}
|
||||
if (!config.server.auth) {
|
||||
throw new Error('配置文件缺少必需字段: server.auth');
|
||||
}
|
||||
|
||||
// 设置队列配置默认值
|
||||
if (!config.queue) {
|
||||
config.queue = {
|
||||
maxConcurrent: 1,
|
||||
maxQueueSize: 2,
|
||||
imageLimit: 5
|
||||
};
|
||||
} else {
|
||||
// 强制 maxConcurrent 为 1
|
||||
config.queue.maxConcurrent = 1;
|
||||
if (config.queue.maxQueueSize === undefined) config.queue.maxQueueSize = 2;
|
||||
if (config.queue.imageLimit === undefined) config.queue.imageLimit = 5;
|
||||
}
|
||||
|
||||
// 设置 backend 配置默认值
|
||||
if (!config.backend) {
|
||||
config.backend = {
|
||||
type: 'lmarena',
|
||||
geminiBiz: { entryUrl: '' }
|
||||
};
|
||||
}
|
||||
|
||||
// 校验 GeminiBiz 配置
|
||||
if (config.backend.type === 'gemini_biz') {
|
||||
if (!config.backend.geminiBiz || !config.backend.geminiBiz.entryUrl) {
|
||||
throw new Error('backend.type = gemini_biz requires backend.geminiBiz.entryUrl');
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('配置器', '已加载 config.yaml');
|
||||
logger.debug('配置器', `服务器模式: ${config.server.type || 'queue'}`);
|
||||
logger.debug('配置器', `后端类型: ${config.backend.type}`);
|
||||
if (config.backend.type === 'gemini_biz') {
|
||||
logger.debug('配置器', `GeminiBiz 入口: ${config.backend.geminiBiz.entryUrl}`);
|
||||
}
|
||||
|
||||
// 设置日志级别
|
||||
if (config.logLevel) {
|
||||
logger.setLevel(config.logLevel);
|
||||
}
|
||||
|
||||
return config;
|
||||
} catch (e) {
|
||||
console.error('>>> [Error] 无法加载或生成配置文件:', e.message);
|
||||
logger.error('配置器', '无法加载或生成配置文件', { error: e.message });
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export default loadConfig();
|
||||
// 默认导出为函数
|
||||
export default loadConfig;
|
||||
|
||||
+4
-17
@@ -1,18 +1,5 @@
|
||||
import crypto from 'crypto';
|
||||
import { generateApiKey } from './security/apiKey.js';
|
||||
|
||||
/**
|
||||
* 生成随机 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');
|
||||
console.log('>>> [GenAPIKey] 生成新的 API Key:');
|
||||
console.log(generateApiKey());
|
||||
console.log('\n>>> 请将此 Key 复制到 config.yaml 文件的 server.auth 字段中。');
|
||||
|
||||
-761
@@ -1,761 +0,0 @@
|
||||
import puppeteer from 'puppeteer-extra';
|
||||
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||
import { createCursor } from 'ghost-cursor';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { anonymizeProxy, closeAnonymizedProxy } from 'proxy-chain';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
|
||||
const stealth = StealthPlugin();
|
||||
//stealth.enabledEvasions.delete('user-agent-override');
|
||||
stealth.enabledEvasions.delete('iframe.contentWindow');
|
||||
puppeteer.use(stealth);
|
||||
|
||||
// --- 配置常量 ---
|
||||
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');
|
||||
|
||||
// --- 自动化开关 ---
|
||||
const isLoginMode = process.argv.includes('-login');
|
||||
const ENABLE_AUTOMATION_MODE = !isLoginMode;
|
||||
|
||||
if (isLoginMode) {
|
||||
console.log('>>> [Mode] 检测到登录模式 (-login),自动化已禁用。请手动完成登录。');
|
||||
}
|
||||
|
||||
// 全局状态跟踪
|
||||
let globalChromeProcess = null;
|
||||
let globalBrowser = null;
|
||||
let globalProxyUrl = null;
|
||||
|
||||
// 资源清理与进程退出处理
|
||||
async function cleanup() {
|
||||
console.log('>>> [System] 正在清理资源...');
|
||||
|
||||
// Level 1: 通过 Puppeteer 协议优雅关闭,释放锁并保存 Profile
|
||||
if (globalBrowser) {
|
||||
try {
|
||||
console.log('>>> [System] 正在关闭 Puppeteer 连接...');
|
||||
await globalBrowser.close();
|
||||
globalBrowser = null;
|
||||
} catch (e) {
|
||||
console.warn('>>> [Warn] Puppeteer 关闭失败 (可能已断开):', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Level 2 & 3: 处理残留进程
|
||||
if (globalChromeProcess && !globalChromeProcess.killed) {
|
||||
console.log('>>> [System] 正在终止 Chrome 进程...');
|
||||
try {
|
||||
// Level 2: 发送 SIGTERM (软杀)
|
||||
globalChromeProcess.kill('SIGTERM');
|
||||
|
||||
// 等待进程退出
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 2000) {
|
||||
try {
|
||||
process.kill(globalChromeProcess.pid, 0);
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
} catch (e) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
|
||||
// Level 3: 强制查杀 (SIGKILL)
|
||||
try {
|
||||
process.kill(globalChromeProcess.pid, 0);
|
||||
console.log('>>> [System] 进程无响应,执行强制查杀 (SIGKILL)...');
|
||||
process.kill(-globalChromeProcess.pid, 'SIGKILL');
|
||||
} catch (e) { }
|
||||
|
||||
globalChromeProcess = null;
|
||||
console.log('>>> [System] Chrome 进程已终止。');
|
||||
}
|
||||
|
||||
// 清理代理
|
||||
if (globalProxyUrl) {
|
||||
try {
|
||||
await closeAnonymizedProxy(globalProxyUrl, true);
|
||||
console.log('>>> [System] 代理桥接已关闭。');
|
||||
} catch (e) {
|
||||
console.error('>>> [Error] 关闭代理桥接失败:', e);
|
||||
}
|
||||
globalProxyUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 注册进程退出信号
|
||||
process.on('exit', () => {
|
||||
if (globalChromeProcess) globalChromeProcess.kill();
|
||||
});
|
||||
process.on('SIGINT', async () => {
|
||||
await cleanup();
|
||||
process.exit();
|
||||
});
|
||||
process.on('SIGTERM', async () => {
|
||||
await cleanup();
|
||||
process.exit();
|
||||
});
|
||||
|
||||
// 确保临时目录存在
|
||||
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';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* [Security Enhanced] 无痕获取当前页面实时视口
|
||||
* 使用纯净的匿名函数执行,不污染 Global Scope,不留指纹
|
||||
*/
|
||||
async function getRealViewport(page) {
|
||||
try {
|
||||
return await page.evaluate(() => {
|
||||
// 仅读取标准属性,不进行任何写入操作
|
||||
const w = window.innerWidth;
|
||||
const h = window.innerHeight;
|
||||
return {
|
||||
width: w,
|
||||
height: h,
|
||||
// 预留 20px 缓冲,防止鼠标移到滚动条上或贴边触发浏览器原生手势
|
||||
safeWidth: w - 20,
|
||||
safeHeight: h
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
// Fallback: 如果上下文丢失,返回安全保守值
|
||||
return { width: 1280, height: 720, safeWidth: 1260, safeHeight: 720 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [Safety] 坐标钳位函数
|
||||
* 强制将坐标限制在合法视口范围内,杜绝 "Node is not visible" 报错
|
||||
*/
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全点击元素(包含拟人化移动和点击)
|
||||
* @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}`);
|
||||
|
||||
// 使用 ghost-cursor 点击
|
||||
if (page.cursor) {
|
||||
await page.cursor.click(el);
|
||||
return;
|
||||
}
|
||||
|
||||
// 降级逻辑
|
||||
await el.click();
|
||||
} 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}>}
|
||||
*/
|
||||
async function initBrowser(config) {
|
||||
console.log(`>>> [Browser] 开始初始化浏览器 (LMArena - 分离模式) | 自动化模式: ${ENABLE_AUTOMATION_MODE ? '开启' : '关闭'}`);
|
||||
|
||||
const chromeConfig = config?.chrome || {};
|
||||
const remoteDebuggingPort = 9222;
|
||||
|
||||
// Chrome 启动参数
|
||||
const args = [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--disable-dev-shm-usage',
|
||||
`--user-data-dir=${USER_DATA_DIR}`,
|
||||
'--no-first-run'
|
||||
];
|
||||
|
||||
// Headless 模式配置
|
||||
let headlessMode = false;
|
||||
if (chromeConfig.headless && !isLoginMode) {
|
||||
headlessMode = 'new';
|
||||
// 无头模式锁死分辨率
|
||||
args.push('--headless=new');
|
||||
args.push('--window-size=1280,690');
|
||||
console.log('>>> [Browser] Headless 模式: 启用 (1280x690)');
|
||||
} else {
|
||||
if (isLoginMode && chromeConfig.headless) {
|
||||
console.log('>>> [Mode] 登录模式下强制禁用 Headless 模式。');
|
||||
}
|
||||
// 有头模式:最大化窗口以适配屏幕
|
||||
args.push('--start-maximized');
|
||||
console.log('>>> [Browser] Headless 模式: 禁用 (最大化窗口)');
|
||||
}
|
||||
|
||||
// GPU 配置
|
||||
if (chromeConfig.gpu === false) {
|
||||
args.push(
|
||||
'--disable-gpu',
|
||||
'--use-gl=swiftshader',
|
||||
'--disable-accelerated-2d-canvas',
|
||||
'--animation-duration-scale=0',
|
||||
'--disable-smooth-scrolling',
|
||||
'--animation-duration-scale=0'
|
||||
);
|
||||
console.log('>>> [Browser] GPU 加速: 禁用');
|
||||
} else {
|
||||
console.log('>>> [Browser] 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}`;
|
||||
console.log(`>>> [Browser] 检测到 SOCKS5 认证代理,正在创建本地桥接...`);
|
||||
// 创建本地中间代理 (无认证 -> 有认证)
|
||||
proxyUrlForChrome = await anonymizeProxy(upstreamUrl);
|
||||
globalProxyUrl = proxyUrlForChrome; // 记录全局代理
|
||||
console.log(`>>> [Browser] 本地桥接已建立: ${proxyUrlForChrome} -> ${host}:${port}`);
|
||||
|
||||
args.push(`--proxy-server=${proxyUrlForChrome}`);
|
||||
args.push('--disable-quic');
|
||||
} catch (e) {
|
||||
console.error('>>> [Error] 代理桥接创建失败:', e);
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
// 常规 HTTP 代理或无认证 SOCKS5
|
||||
const proxyUrl = type === 'socks5' ? `socks5://${host}:${port}` : `${host}:${port}`;
|
||||
args.push(`--proxy-server=${proxyUrl}`);
|
||||
args.push('--disable-quic');
|
||||
console.log(`>>> [Browser] 代理配置: ${type}://${host}:${port}`);
|
||||
}
|
||||
}
|
||||
|
||||
const chromePath = chromeConfig.path;
|
||||
|
||||
// --- 模式分支 ---
|
||||
|
||||
if (!ENABLE_AUTOMATION_MODE) {
|
||||
// 仅启动浏览器
|
||||
console.log(`>>> [Browser] 正在以手动模式启动 Chrome (无远程调试)...`);
|
||||
console.log(`>>> [Browser] 启动路径: ${chromePath}`);
|
||||
|
||||
// 在手动模式下自动打开目标页面
|
||||
args.push(TARGET_URL);
|
||||
|
||||
const chromeProcess = spawn(chromePath, args, {
|
||||
detached: false,
|
||||
stdio: 'ignore'
|
||||
});
|
||||
globalChromeProcess = chromeProcess;
|
||||
|
||||
console.log('>>> [Success] Chrome 已启动。脚本将持续运行直到浏览器关闭...');
|
||||
|
||||
await new Promise((resolve) => {
|
||||
chromeProcess.on('close', async (code) => {
|
||||
console.log(`>>> [Browser] Chrome 已关闭 (退出码: ${code})`);
|
||||
await cleanup();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
console.log('>>> [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) {
|
||||
console.log('>>> [Browser] 检测到已运行的 Chrome 实例,准备连接...');
|
||||
browserWSEndpoint = data.webSocketDebuggerUrl;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('>>> [Browser] 未检测到运行中的 Chrome,正在启动新实例...');
|
||||
}
|
||||
|
||||
if (!browserWSEndpoint) {
|
||||
const automationArgs = [...args, `--remote-debugging-port=${remoteDebuggingPort}`];
|
||||
console.log(`>>> [Browser] 启动 Chrome (自动化模式): ${chromePath}`);
|
||||
|
||||
const chromeProcess = spawn(chromePath, automationArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
});
|
||||
chromeProcess.unref();
|
||||
globalChromeProcess = chromeProcess;
|
||||
|
||||
console.log('>>> [Browser] Chrome 已启动,等待调试端口就绪...');
|
||||
|
||||
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;
|
||||
console.log('>>> [Browser] Chrome 调试接口已就绪。');
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
if (!browserWSEndpoint) {
|
||||
throw new Error('无法连接到 Chrome 远程调试端口,请检查 Chrome 是否成功启动。');
|
||||
}
|
||||
}
|
||||
|
||||
// 连接 Puppeteer
|
||||
const browser = await puppeteer.connect({
|
||||
browserWSEndpoint: browserWSEndpoint,
|
||||
defaultViewport: null
|
||||
});
|
||||
|
||||
globalBrowser = browser; // [新增] 保存实例引用供 cleanup 使用
|
||||
|
||||
console.log('>>> [Browser] Puppeteer 已连接到 Chrome 实例。');
|
||||
|
||||
browser.on('disconnected', async () => {
|
||||
console.log('>>> [Browser] 浏览器已断开连接 (可能已被关闭)。');
|
||||
await cleanup();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// 获取页面
|
||||
const pages = await browser.pages();
|
||||
let page = pages.find(p => p.url().includes('lmarena.ai'));
|
||||
if (!page) {
|
||||
page = await browser.newPage();
|
||||
} else {
|
||||
console.log('>>> [Browser] 复用已有标签页。');
|
||||
}
|
||||
|
||||
// 初始化 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
|
||||
});
|
||||
console.log('>>> [Browser] 代理认证: 已设置 (HTTP Basic Auth)');
|
||||
}
|
||||
|
||||
// 创建 CDP 会话
|
||||
const client = await page.target().createCDPSession();
|
||||
await client.send('Network.enable');
|
||||
|
||||
// 注册清理钩子
|
||||
if (proxyUrlForChrome) {
|
||||
console.log('>>> [Warn] 使用了本地代理桥接。请保持此脚本运行,否则 Chrome 将失去代理连接。');
|
||||
}
|
||||
|
||||
// --- 行为预热建立人机检测信任 ---
|
||||
if (!page.url().includes('lmarena.ai')) {
|
||||
console.log('>>> [Browser] 正在连接 LMArena...');
|
||||
await page.goto(TARGET_URL, { waitUntil: 'networkidle2' });
|
||||
} else {
|
||||
console.log('>>> [Browser] 页面已在 LMArena,跳过跳转。');
|
||||
}
|
||||
|
||||
console.log('>>> [Warmup] 正在随机浏览页面以建立信任...');
|
||||
|
||||
// 计算屏幕中心点 (动态获取视口大小)
|
||||
const vp = await getRealViewport(page);
|
||||
|
||||
// 计算动态中心点
|
||||
const centerX = vp.width / 2;
|
||||
const centerY = vp.height / 2;
|
||||
|
||||
// 第一次移动:从左上角移动到中心附近
|
||||
if (page.cursor) {
|
||||
// 使用 clamp 确保随机偏移后仍在屏幕内
|
||||
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);
|
||||
|
||||
// 模拟滚动行为
|
||||
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) {
|
||||
if (page.cursor) {
|
||||
await page.cursor.moveTo({ x: box.x + box.width / 2, y: box.y + box.height / 2 });
|
||||
}
|
||||
await sleep(500, 1000);
|
||||
}
|
||||
|
||||
console.log('>>> [Browser] 浏览器初始化完成,系统就绪');
|
||||
console.log('>>> [Browser] 当程序有任务运行时请勿随意调节窗口大小,以免鼠标轨迹错位!');
|
||||
|
||||
return { browser, page, client };
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行生图任务
|
||||
* @param {object} context 浏览器上下文 {page, client}
|
||||
* @param {string} prompt 提示词
|
||||
* @param {string[]} imgPaths 图片路径数组
|
||||
* @param {string|null} modelId 模型 UUID (可选)
|
||||
* @returns {Promise<{image?: string, text?: string, error?: string}>}
|
||||
*/
|
||||
async function generateImage(context, prompt, imgPaths, modelId) {
|
||||
const { page, client } = context;
|
||||
const textareaSelector = 'textarea';
|
||||
let fetchPausedHandler = null;
|
||||
|
||||
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);
|
||||
// 如果没有图片,也点击一下输入框获取焦点
|
||||
await safeClick(page, textareaSelector);
|
||||
}
|
||||
|
||||
// 3. 输入 Prompt
|
||||
console.log('>>> [Input] 正在输入提示词...');
|
||||
await humanType(page, textareaSelector, prompt);
|
||||
await sleep(800, 1500);
|
||||
|
||||
// 注入 CDP Fetch 拦截器
|
||||
if (modelId) {
|
||||
// 1. 启用 Fetch 域拦截,仅拦截特定 URL
|
||||
await client.send('Fetch.enable', {
|
||||
patterns: [{
|
||||
urlPattern: '*nextjs-api/stream*',
|
||||
requestStage: 'Request'
|
||||
}]
|
||||
});
|
||||
|
||||
// 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) {
|
||||
console.log(`>>> [CDP] 正在拦截请求。原始 modelAId: ${data.modelAId}`);
|
||||
|
||||
// 修改 modelAId
|
||||
data.modelAId = modelId;
|
||||
|
||||
// 重新序列化并转为 Base64 (Fetch.continueRequest 需要 base64)
|
||||
const newBody = JSON.stringify(data);
|
||||
const newBodyBase64 = Buffer.from(newBody).toString('base64');
|
||||
|
||||
console.log(`>>> [CDP] 请求已修改。新 modelAId: ${data.modelAId}`);
|
||||
|
||||
await client.send('Fetch.continueRequest', {
|
||||
requestId,
|
||||
postData: newBodyBase64
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('>>> [CDP] 拦截处理出错:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不匹配或出错,直接放行
|
||||
try {
|
||||
await client.send('Fetch.continueRequest', { requestId });
|
||||
} catch (e) { }
|
||||
};
|
||||
|
||||
// 3. 监听拦截事件
|
||||
client.on('Fetch.requestPaused', fetchPausedHandler);
|
||||
console.log(`>>> [Test] 已启用 CDP Fetch 拦截,目标模型: ${modelId}`);
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
// 任务结束,基于当前窗口比例智能移开鼠标
|
||||
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 });
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (err) {
|
||||
console.error('>>> [Error] 生成任务失败:', err.message);
|
||||
return { error: err.message };
|
||||
} finally {
|
||||
if (fetchPausedHandler) {
|
||||
client.off('Fetch.requestPaused', fetchPausedHandler);
|
||||
try {
|
||||
await client.send('Fetch.disable');
|
||||
} catch (e) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { initBrowser, generateImage, TEMP_DIR };
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import process from 'process';
|
||||
|
||||
const LEVELS = ['debug', 'info', 'warn', 'error'];
|
||||
|
||||
// ANSI 颜色代码
|
||||
const COLORS = {
|
||||
reset: '\x1b[0m',
|
||||
red: '\x1b[31m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
white: '\x1b[37m'
|
||||
};
|
||||
|
||||
// 根据日志级别获取颜色
|
||||
function getColor(level) {
|
||||
switch (level.toLowerCase()) {
|
||||
case 'error':
|
||||
return COLORS.red;
|
||||
case 'warn':
|
||||
return COLORS.yellow;
|
||||
case 'info':
|
||||
return COLORS.white;
|
||||
case 'debug':
|
||||
return COLORS.blue;
|
||||
default:
|
||||
return COLORS.reset;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(date = new Date()) {
|
||||
const pad = (n, len = 2) => n.toString().padStart(len, '0');
|
||||
const yyyy = date.getFullYear();
|
||||
const MM = pad(date.getMonth() + 1);
|
||||
const dd = pad(date.getDate());
|
||||
const HH = pad(date.getHours());
|
||||
const mm = pad(date.getMinutes());
|
||||
const ss = pad(date.getSeconds());
|
||||
const SSS = pad(date.getMilliseconds(), 3);
|
||||
return `${yyyy}-${MM}-${dd} ${HH}:${mm}:${ss}.${SSS}`;
|
||||
}
|
||||
|
||||
let currentLogLevel = (process.env.LOG_LEVEL || 'info').toLowerCase();
|
||||
|
||||
export function setLogLevel(level) {
|
||||
if (level && LEVELS.includes(level.toLowerCase())) {
|
||||
currentLogLevel = level.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
function shouldLog(level) {
|
||||
const targetLevel = level.toLowerCase();
|
||||
const envIndex = LEVELS.indexOf(currentLogLevel);
|
||||
const targetIndex = LEVELS.indexOf(targetLevel);
|
||||
|
||||
// If env level is invalid, default to info (index 1)
|
||||
const effectiveEnvIndex = envIndex === -1 ? 1 : envIndex;
|
||||
|
||||
return targetIndex >= effectiveEnvIndex;
|
||||
}
|
||||
|
||||
export function log(level, mod, msg, meta = {}) {
|
||||
if (!shouldLog(level)) return;
|
||||
|
||||
const ts = formatTime();
|
||||
const levelTag = level.toUpperCase();
|
||||
const base = `${ts} [${levelTag}] [${mod}] ${msg}`;
|
||||
|
||||
const metaStr = Object.keys(meta).length
|
||||
? ' | ' + Object.entries(meta).map(([k, v]) => {
|
||||
if (v instanceof Error) {
|
||||
return `${k}=${v.message}`;
|
||||
}
|
||||
if (typeof v === 'object' && v !== null) {
|
||||
try {
|
||||
return `${k}=${JSON.stringify(v)}`;
|
||||
} catch (e) {
|
||||
return `${k}=[Circular]`;
|
||||
}
|
||||
}
|
||||
return `${k}=${v}`;
|
||||
}).join(' ')
|
||||
: '';
|
||||
|
||||
const line = base + metaStr;
|
||||
const color = getColor(level);
|
||||
const coloredLine = `${color}${line}${COLORS.reset}`;
|
||||
|
||||
if (level === 'error') {
|
||||
console.error(coloredLine);
|
||||
} else if (level === 'warn') {
|
||||
console.warn(coloredLine);
|
||||
} else {
|
||||
console.log(coloredLine);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug: (mod, msg, meta) => log('debug', mod, msg, meta),
|
||||
info: (mod, msg, meta) => log('info', mod, msg, meta),
|
||||
warn: (mod, msg, meta) => log('warn', mod, msg, meta),
|
||||
error: (mod, msg, meta) => log('error', mod, msg, meta),
|
||||
setLevel: setLogLevel
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
export const MODEL_MAPPING = {
|
||||
"gemini-3-pro-image-preview": "019aa208-5c19-7162-ae3b-0a9ddbb1e16a",
|
||||
"seedream-4-high-res-fal": "32974d8d-333c-4d2e-abf3-f258c0ac1310",
|
||||
"hunyuan-image-3.0": "7766a45c-1b6b-4fb8-9823-2557291e1ddd",
|
||||
"gemini-2.5-flash-image-preview": "0199ef2a-583f-7088-b704-b75fd169401d",
|
||||
"imagen-4.0-ultra-generate-preview-06-06": "f8aec69d-e077-4ed1-99be-d34f48559bbf",
|
||||
"imagen-4.0-generate-preview-06-06": "2ec9f1a6-126f-4c65-a102-15ac401dcea4",
|
||||
"wan2.5-t2i-preview": "019a5050-2875-78ed-ae3a-d9a51a438685",
|
||||
"gpt-image-1": "6e855f13-55d7-4127-8656-9168a9f4dcc0",
|
||||
"gpt-image-mini": "0199c238-f8ee-7f7d-afc1-7e28fcfd21cf",
|
||||
"mai-image-1": "1b407d5c-1806-477c-90a5-e5c5a114f3bc",
|
||||
"seedream-3": "d8771262-8248-4372-90d5-eb41910db034",
|
||||
"qwen-image-prompt-extend": "9fe82ee1-c84f-417f-b0e7-cab4ae4cf3f3",
|
||||
"flux-1-kontext-pro": "28a8f330-3554-448c-9f32-2c0a08ec6477",
|
||||
"imagen-3.0-generate-002": "51ad1d79-61e2-414c-99e3-faeb64bb6b1b",
|
||||
"ideogram-v3-quality": "73378be5-cdba-49e7-b3d0-027949871aa6",
|
||||
"photon": "e7c9fa2d-6f5d-40eb-8305-0980b11c7cab",
|
||||
"lucid-origin": "5a3b3520-c87d-481f-953c-1364687b6e8f",
|
||||
"recraft-v3": "b88d5814-1d20-49cc-9eb6-e362f5851661",
|
||||
"gemini-2.0-flash-preview-image-generation": "69bbf7d4-9f44-447e-a868-abc4f7a31810",
|
||||
"dall-e-3": "bb97bc68-131c-4ea4-a59e-03a6252de0d2",
|
||||
"flux-1-kontext-dev": "eb90ae46-a73a-4f27-be8b-40f090592c9a",
|
||||
"imagen-4.0-fast-generate-001": "f44fd4f8-af30-480f-8ce2-80b2bdfea55e",
|
||||
"hunyuan-image-2.1": "a9a26426-5377-4efa-bef9-de71e29ad943"
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取模型列表
|
||||
*/
|
||||
export function getModels() {
|
||||
return {
|
||||
object: "list",
|
||||
data: Object.keys(MODEL_MAPPING).map(id => ({
|
||||
id: id,
|
||||
object: "model",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: "lmarena"
|
||||
}))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* 生成随机 API Key
|
||||
* 格式: sk-{48位十六进制字符}
|
||||
* @returns {string} API Key
|
||||
*/
|
||||
export function generateApiKey() {
|
||||
return 'sk-' + crypto.randomBytes(24).toString('hex');
|
||||
}
|
||||
+352
-78
@@ -1,83 +1,357 @@
|
||||
import readline from 'readline';
|
||||
import config from './config.js';
|
||||
import { initBrowser, generateImage } from './lmarena.js';
|
||||
import { MODEL_MAPPING } from './models.js';
|
||||
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})`);
|
||||
|
||||
/**
|
||||
* 创建命令行交互接口
|
||||
* 选择测试模式
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
// 3. 获取模型 ID
|
||||
const modelInput = await ask('>>> [CLI] 请输入模型 ID (回车跳过使用默认): ');
|
||||
const modelName = modelInput.trim();
|
||||
let modelId = null;
|
||||
|
||||
if (modelName) {
|
||||
if (MODEL_MAPPING[modelName]) {
|
||||
modelId = MODEL_MAPPING[modelName];
|
||||
console.log(`>>> [CLI] 使用模型: ${modelName} (${modelId})`);
|
||||
} else {
|
||||
console.log(`>>> [Warn] 未找到模型 "${modelName}",将尝试直接使用默认模型。`);
|
||||
}
|
||||
} else {
|
||||
console.log('>>> [CLI] 未指定模型,使用默认值。');
|
||||
}
|
||||
|
||||
console.log(`>>> [CLI] 开始任务: Prompt="${prompt}", Images=${imagePaths.length}`);
|
||||
|
||||
// 4. 调用生图逻辑
|
||||
const result = await generateImage(browserContext, prompt, imagePaths, modelId);
|
||||
|
||||
// 5. 显示结果
|
||||
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);
|
||||
}
|
||||
}
|
||||
async function selectTestMode() {
|
||||
const mode = await select({
|
||||
message: '选择测试模式',
|
||||
choices: [
|
||||
{ name: 'HTTP 服务器测试(需先启动服务器)', value: 'http' },
|
||||
{ name: '直接调用适配器', value: 'direct' }
|
||||
]
|
||||
});
|
||||
return mode;
|
||||
}
|
||||
|
||||
main();
|
||||
/**
|
||||
* 选择模型
|
||||
*/
|
||||
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);
|
||||
}
|
||||
})();
|
||||
@@ -11,6 +11,7 @@
|
||||
"genkey": "node lib/genApiKey.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inquirer/prompts": "^8.0.1",
|
||||
"ghost-cursor": "^1.4.1",
|
||||
"got-scraping": "^4.1.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
|
||||
Generated
+343
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@inquirer/prompts':
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.1(@types/node@24.10.1)
|
||||
ghost-cursor:
|
||||
specifier: ^1.4.1
|
||||
version: 1.4.1
|
||||
@@ -199,6 +202,140 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@inquirer/ansi@2.0.1':
|
||||
resolution: {integrity: sha512-QAZUk6BBncv/XmSEZTscd8qazzjV3E0leUMrEPjxCd51QBgCKmprUGLex5DTsNtURm7LMzv+CLcd6S86xvBfYg==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
|
||||
'@inquirer/checkbox@5.0.1':
|
||||
resolution: {integrity: sha512-5VPFBK8jKdsjMK3DTFOlbR0+Kkd4q0AWB7VhWQn6ppv44dr3b7PU8wSJQTC5oA0f/aGW7v/ZozQJAY9zx6PKig==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/confirm@6.0.1':
|
||||
resolution: {integrity: sha512-wD+pM7IxLn1TdcQN12Q6wcFe5VpyCuh/I2sSmqO5KjWH2R4v+GkUToHb+PsDGobOe1MtAlXMwGNkZUPc2+L6NA==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/core@11.0.1':
|
||||
resolution: {integrity: sha512-Tpf49h50e4KYffVUCXzkx4gWMafUi3aDQDwfVAAGBNnVcXiwJIj4m2bKlZ7Kgyf6wjt1eyXH1wDGXcAokm4Ssw==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/editor@5.0.1':
|
||||
resolution: {integrity: sha512-zDKobHI7Ry++4noiV9Z5VfYgSVpPZoMApviIuGwLOMciQaP+dGzCO+1fcwI441riklRiZg4yURWyEoX0Zy2zZw==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/expand@5.0.1':
|
||||
resolution: {integrity: sha512-TBrTpAB6uZNnGQHtSEkbvJZIQ3dXZOrwqQSO9uUbwct3G2LitwBCE5YZj98MbQ5nzihzs5pRjY1K9RRLH4WgoA==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/external-editor@2.0.1':
|
||||
resolution: {integrity: sha512-BPYWJXCAK9w6R+pb2s3WyxUz9ts9SP/LDOUwA9fu7LeuyYgojz83i0DSRwezu736BgMwz14G63Xwj70hSzHohQ==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/figures@2.0.1':
|
||||
resolution: {integrity: sha512-KtMxyjLCuDFqAWHmCY9qMtsZ09HnjMsm8H3OvpSIpfhHdfw3/AiGWHNrfRwbyvHPtOJpumm8wGn5fkhtvkWRsg==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
|
||||
'@inquirer/input@5.0.1':
|
||||
resolution: {integrity: sha512-cEhEUohCpE2BCuLKtFFZGp4Ief05SEcqeAOq9NxzN5ThOQP8Rl5N/Nt9VEDORK1bRb2Sk/zoOyQYfysPQwyQtA==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/number@4.0.1':
|
||||
resolution: {integrity: sha512-4//zgBGHe8Q/FfCoUXZUrUHyK/q5dyqiwsePz3oSSPSmw1Ijo35ZkjaftnxroygcUlLYfXqm+0q08lnB5hd49A==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/password@5.0.1':
|
||||
resolution: {integrity: sha512-UJudHpd7Ia30Q+x+ctYqI9Nh6SyEkaBscpa7J6Ts38oc1CNSws0I1hJEdxbQBlxQd65z5GEJPM4EtNf6tzfWaQ==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/prompts@8.0.1':
|
||||
resolution: {integrity: sha512-MURRu/cyvLm9vchDDaVZ9u4p+ADnY0Mz3LQr0KTgihrrvuKZlqcWwlBC4lkOMvd0KKX4Wz7Ww9+uA7qEpQaqjg==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/rawlist@5.0.1':
|
||||
resolution: {integrity: sha512-vVfVHKUgH6rZmMlyd0jOuGZo0Fw1jfcOqZF96lMwlgavx7g0x7MICe316bV01EEoI+c68vMdbkTTawuw3O+Fgw==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/search@4.0.1':
|
||||
resolution: {integrity: sha512-XwiaK5xBvr31STX6Ji8iS3HCRysBXfL/jUbTzufdWTS6LTGtvDQA50oVETt1BJgjKyQBp9vt0VU6AmU/AnOaGA==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/select@5.0.1':
|
||||
resolution: {integrity: sha512-gPByrgYoezGyKMq5KjV7Tuy1JU2ArIy6/sI8sprw0OpXope3VGQwP5FK1KD4eFFqEhKu470Dwe6/AyDPmGRA0Q==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/type@4.0.1':
|
||||
resolution: {integrity: sha512-odO8YwoQAw/eVu/PSPsDDVPmqO77r/Mq7zcoF5VduVqIu2wSRWUgmYb5K9WH1no0SjLnOe8MDKtDL++z6mfo2g==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@keyv/serialize@1.1.1':
|
||||
resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
|
||||
|
||||
@@ -255,10 +392,18 @@ packages:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-regex@6.2.2:
|
||||
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-styles@6.2.3:
|
||||
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
@@ -364,11 +509,18 @@ packages:
|
||||
caniuse-lite@1.0.30001756:
|
||||
resolution: {integrity: sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==}
|
||||
|
||||
chardet@2.1.1:
|
||||
resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==}
|
||||
|
||||
chromium-bidi@11.0.0:
|
||||
resolution: {integrity: sha512-cM3DI+OOb89T3wO8cpPSro80Q9eKYJ7hGVXoGS3GkDPxnYSqiv+6xwpIf6XERyJ9Tdsl09hmNmY94BkgZdVekw==}
|
||||
peerDependencies:
|
||||
devtools-protocol: '*'
|
||||
|
||||
cli-width@4.1.0:
|
||||
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
cliui@8.0.1:
|
||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -439,6 +591,9 @@ packages:
|
||||
electron-to-chromium@1.5.259:
|
||||
resolution: {integrity: sha512-I+oLXgpEJzD6Cwuwt1gYjxsDmu/S/Kd41mmLA3O+/uH2pFRO/DvOjUyGozL8j3KeLV6WyZ7ssPwELMsXCcsJAQ==}
|
||||
|
||||
emoji-regex@10.6.0:
|
||||
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
||||
|
||||
emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
@@ -518,6 +673,10 @@ packages:
|
||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
|
||||
get-east-asian-width@1.4.0:
|
||||
resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
get-stream@5.2.0:
|
||||
resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -567,6 +726,10 @@ packages:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
iconv-lite@0.7.0:
|
||||
resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -680,6 +843,10 @@ packages:
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
mute-stream@3.0.0:
|
||||
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
|
||||
engines: {node: ^20.17.0 || >=22.9.0}
|
||||
|
||||
netmask@2.0.2:
|
||||
resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
@@ -850,6 +1017,9 @@ packages:
|
||||
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||
hasBin: true
|
||||
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
semver@7.7.3:
|
||||
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -863,6 +1033,10 @@ packages:
|
||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
|
||||
signal-exit@4.1.0:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
smart-buffer@4.2.0:
|
||||
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
|
||||
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
|
||||
@@ -886,10 +1060,18 @@ packages:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
string-width@7.2.0:
|
||||
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strip-ansi@7.1.2:
|
||||
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
tar-fs@3.1.1:
|
||||
resolution: {integrity: sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==}
|
||||
|
||||
@@ -937,6 +1119,10 @@ packages:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
wrap-ansi@9.0.2:
|
||||
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
@@ -1081,6 +1267,125 @@ snapshots:
|
||||
'@img/sharp-win32-x64@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@inquirer/ansi@2.0.1': {}
|
||||
|
||||
'@inquirer/checkbox@5.0.1(@types/node@24.10.1)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 2.0.1
|
||||
'@inquirer/core': 11.0.1(@types/node@24.10.1)
|
||||
'@inquirer/figures': 2.0.1
|
||||
'@inquirer/type': 4.0.1(@types/node@24.10.1)
|
||||
optionalDependencies:
|
||||
'@types/node': 24.10.1
|
||||
|
||||
'@inquirer/confirm@6.0.1(@types/node@24.10.1)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.0.1(@types/node@24.10.1)
|
||||
'@inquirer/type': 4.0.1(@types/node@24.10.1)
|
||||
optionalDependencies:
|
||||
'@types/node': 24.10.1
|
||||
|
||||
'@inquirer/core@11.0.1(@types/node@24.10.1)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 2.0.1
|
||||
'@inquirer/figures': 2.0.1
|
||||
'@inquirer/type': 4.0.1(@types/node@24.10.1)
|
||||
cli-width: 4.1.0
|
||||
mute-stream: 3.0.0
|
||||
signal-exit: 4.1.0
|
||||
wrap-ansi: 9.0.2
|
||||
optionalDependencies:
|
||||
'@types/node': 24.10.1
|
||||
|
||||
'@inquirer/editor@5.0.1(@types/node@24.10.1)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.0.1(@types/node@24.10.1)
|
||||
'@inquirer/external-editor': 2.0.1(@types/node@24.10.1)
|
||||
'@inquirer/type': 4.0.1(@types/node@24.10.1)
|
||||
optionalDependencies:
|
||||
'@types/node': 24.10.1
|
||||
|
||||
'@inquirer/expand@5.0.1(@types/node@24.10.1)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.0.1(@types/node@24.10.1)
|
||||
'@inquirer/type': 4.0.1(@types/node@24.10.1)
|
||||
optionalDependencies:
|
||||
'@types/node': 24.10.1
|
||||
|
||||
'@inquirer/external-editor@2.0.1(@types/node@24.10.1)':
|
||||
dependencies:
|
||||
chardet: 2.1.1
|
||||
iconv-lite: 0.7.0
|
||||
optionalDependencies:
|
||||
'@types/node': 24.10.1
|
||||
|
||||
'@inquirer/figures@2.0.1': {}
|
||||
|
||||
'@inquirer/input@5.0.1(@types/node@24.10.1)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.0.1(@types/node@24.10.1)
|
||||
'@inquirer/type': 4.0.1(@types/node@24.10.1)
|
||||
optionalDependencies:
|
||||
'@types/node': 24.10.1
|
||||
|
||||
'@inquirer/number@4.0.1(@types/node@24.10.1)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.0.1(@types/node@24.10.1)
|
||||
'@inquirer/type': 4.0.1(@types/node@24.10.1)
|
||||
optionalDependencies:
|
||||
'@types/node': 24.10.1
|
||||
|
||||
'@inquirer/password@5.0.1(@types/node@24.10.1)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 2.0.1
|
||||
'@inquirer/core': 11.0.1(@types/node@24.10.1)
|
||||
'@inquirer/type': 4.0.1(@types/node@24.10.1)
|
||||
optionalDependencies:
|
||||
'@types/node': 24.10.1
|
||||
|
||||
'@inquirer/prompts@8.0.1(@types/node@24.10.1)':
|
||||
dependencies:
|
||||
'@inquirer/checkbox': 5.0.1(@types/node@24.10.1)
|
||||
'@inquirer/confirm': 6.0.1(@types/node@24.10.1)
|
||||
'@inquirer/editor': 5.0.1(@types/node@24.10.1)
|
||||
'@inquirer/expand': 5.0.1(@types/node@24.10.1)
|
||||
'@inquirer/input': 5.0.1(@types/node@24.10.1)
|
||||
'@inquirer/number': 4.0.1(@types/node@24.10.1)
|
||||
'@inquirer/password': 5.0.1(@types/node@24.10.1)
|
||||
'@inquirer/rawlist': 5.0.1(@types/node@24.10.1)
|
||||
'@inquirer/search': 4.0.1(@types/node@24.10.1)
|
||||
'@inquirer/select': 5.0.1(@types/node@24.10.1)
|
||||
optionalDependencies:
|
||||
'@types/node': 24.10.1
|
||||
|
||||
'@inquirer/rawlist@5.0.1(@types/node@24.10.1)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.0.1(@types/node@24.10.1)
|
||||
'@inquirer/type': 4.0.1(@types/node@24.10.1)
|
||||
optionalDependencies:
|
||||
'@types/node': 24.10.1
|
||||
|
||||
'@inquirer/search@4.0.1(@types/node@24.10.1)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.0.1(@types/node@24.10.1)
|
||||
'@inquirer/figures': 2.0.1
|
||||
'@inquirer/type': 4.0.1(@types/node@24.10.1)
|
||||
optionalDependencies:
|
||||
'@types/node': 24.10.1
|
||||
|
||||
'@inquirer/select@5.0.1(@types/node@24.10.1)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 2.0.1
|
||||
'@inquirer/core': 11.0.1(@types/node@24.10.1)
|
||||
'@inquirer/figures': 2.0.1
|
||||
'@inquirer/type': 4.0.1(@types/node@24.10.1)
|
||||
optionalDependencies:
|
||||
'@types/node': 24.10.1
|
||||
|
||||
'@inquirer/type@4.0.1(@types/node@24.10.1)':
|
||||
optionalDependencies:
|
||||
'@types/node': 24.10.1
|
||||
|
||||
'@keyv/serialize@1.1.1': {}
|
||||
|
||||
'@puppeteer/browsers@2.10.13':
|
||||
@@ -1134,10 +1439,14 @@ snapshots:
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
||||
ansi-regex@6.2.2: {}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
ansi-styles@6.2.3: {}
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
arr-union@3.1.0: {}
|
||||
@@ -1228,12 +1537,16 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001756: {}
|
||||
|
||||
chardet@2.1.1: {}
|
||||
|
||||
chromium-bidi@11.0.0(devtools-protocol@0.0.1521046):
|
||||
dependencies:
|
||||
devtools-protocol: 0.0.1521046
|
||||
mitt: 3.0.1
|
||||
zod: 3.25.76
|
||||
|
||||
cli-width@4.1.0: {}
|
||||
|
||||
cliui@8.0.1:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
@@ -1295,6 +1608,8 @@ snapshots:
|
||||
|
||||
electron-to-chromium@1.5.259: {}
|
||||
|
||||
emoji-regex@10.6.0: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
end-of-stream@1.4.5:
|
||||
@@ -1370,6 +1685,8 @@ snapshots:
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
get-east-asian-width@1.4.0: {}
|
||||
|
||||
get-stream@5.2.0:
|
||||
dependencies:
|
||||
pump: 3.0.3
|
||||
@@ -1459,6 +1776,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
iconv-lite@0.7.0:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
import-fresh@3.3.1:
|
||||
dependencies:
|
||||
parent-module: 1.0.1
|
||||
@@ -1550,6 +1871,8 @@ snapshots:
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
mute-stream@3.0.0: {}
|
||||
|
||||
netmask@2.0.2: {}
|
||||
|
||||
node-releases@2.0.27: {}
|
||||
@@ -1748,6 +2071,8 @@ snapshots:
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
semver@7.7.3: {}
|
||||
|
||||
shallow-clone@0.1.2:
|
||||
@@ -1788,6 +2113,8 @@ snapshots:
|
||||
'@img/sharp-win32-ia32': 0.34.5
|
||||
'@img/sharp-win32-x64': 0.34.5
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
smart-buffer@4.2.0: {}
|
||||
|
||||
socks-proxy-agent@8.0.5:
|
||||
@@ -1821,10 +2148,20 @@ snapshots:
|
||||
is-fullwidth-code-point: 3.0.0
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
string-width@7.2.0:
|
||||
dependencies:
|
||||
emoji-regex: 10.6.0
|
||||
get-east-asian-width: 1.4.0
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
|
||||
strip-ansi@7.1.2:
|
||||
dependencies:
|
||||
ansi-regex: 6.2.2
|
||||
|
||||
tar-fs@3.1.1:
|
||||
dependencies:
|
||||
pump: 3.0.3
|
||||
@@ -1881,6 +2218,12 @@ snapshots:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
wrap-ansi@9.0.2:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
string-width: 7.2.0
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
ws@8.18.3: {}
|
||||
|
||||
@@ -2,10 +2,14 @@ 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';
|
||||
import { MODEL_MAPPING, getModels } from './lib/models.js';
|
||||
import { getBackend } from './lib/backend/index.js';
|
||||
import { getModelsForBackend, resolveModelId } from './lib/backend/models.js';
|
||||
import { logger } from './lib/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;
|
||||
@@ -15,37 +19,37 @@ 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)
|
||||
const MAX_CONCURRENT = config.queue?.maxConcurrent || 1; // 从配置读取
|
||||
const MAX_QUEUE_SIZE = config.queue?.maxQueueSize || 2; // 从配置读取
|
||||
const IMAGE_LIMIT = config.queue?.imageLimit || 5; // 图片数量上限
|
||||
|
||||
/**
|
||||
* 处理队列中的任务
|
||||
*/
|
||||
async function processQueue() {
|
||||
// 如果正在处理的任务已满,或队列为空,则停止
|
||||
// 如果正在处理的任务已满,或队列为空,则停止
|
||||
if (processingCount >= MAX_CONCURRENT || queue.length === 0) return;
|
||||
|
||||
// 取出下一个任务
|
||||
const task = queue.shift();
|
||||
processingCount++;
|
||||
|
||||
// 如果是 Queue 模式,通知客户端状态变更
|
||||
// 如果是 Queue 模式,通知客户端状态变更
|
||||
if (SERVER_MODE === 'queue' && task.sse) {
|
||||
task.sse.send('status', { status: 'processing' });
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`>>> [Queue] 开始处理任务。剩余排队: ${queue.length}`);
|
||||
const { req, res, prompt, imagePaths, modelId, modelName, id, sse } = task;
|
||||
logger.info('服务器', '[队列] 开始处理任务', { id, remaining: queue.length });
|
||||
|
||||
// 确保浏览器已初始化
|
||||
if (!browserContext) {
|
||||
browserContext = await initBrowser(config);
|
||||
}
|
||||
|
||||
const { req, res, prompt, imagePaths, modelId } = task;
|
||||
|
||||
// 调用核心生图逻辑
|
||||
const result = await generateImage(browserContext, prompt, imagePaths, modelId);
|
||||
const result = await generateImage(browserContext, prompt, imagePaths, modelId, { id });
|
||||
|
||||
// 清理临时图片
|
||||
for (const p of imagePaths) {
|
||||
@@ -57,7 +61,7 @@ async function processQueue() {
|
||||
let queueResult = {};
|
||||
|
||||
if (result.error) {
|
||||
// 特殊错误处理:reCAPTCHA
|
||||
// 特殊错误处理:reCAPTCHA
|
||||
if (result.error === 'recaptcha validation failed') {
|
||||
if (SERVER_MODE === 'openai') {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
@@ -73,32 +77,20 @@ async function processQueue() {
|
||||
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;
|
||||
// result.image 已经是 "data:image/png;base64,..." 格式
|
||||
// 提取纯 Base64 部分用于 b64_json
|
||||
const base64Data = result.image.split(',')[1];
|
||||
|
||||
// 检测图片格式并转 Base64
|
||||
const metadata = await sharp(imgBuffer).metadata();
|
||||
const mimeType = metadata.format === 'png' ? 'image/png' : 'image/jpeg';
|
||||
const base64 = imgBuffer.toString('base64');
|
||||
// 构造 Markdown 图片展示 (Data URI)
|
||||
finalContent = ``;
|
||||
|
||||
finalContent = ``;
|
||||
queueResult = { status: 'completed', image: base64, msg: '' };
|
||||
console.log('>>> [Response] 图片已转换为 Base64');
|
||||
queueResult = { status: 'completed', image: base64Data, msg: '' };
|
||||
queueResult = { status: 'completed', image: base64Data, msg: '' };
|
||||
logger.info('服务器', '图片已准备就绪 (Base64)', { id });
|
||||
} catch (e) {
|
||||
console.error('>>> [Error] 图片下载失败:', e.message);
|
||||
finalContent = `[图片下载失败] ${result.image}`;
|
||||
queueResult = { status: 'error', image: null, msg: `Download failed: ${e.message}` };
|
||||
logger.error('服务器', '图片处理失败', { id, error: e.message });
|
||||
finalContent = `[图片处理失败] ${e.message}`;
|
||||
queueResult = { status: 'error', image: null, msg: `Processing failed: ${e.message}` };
|
||||
}
|
||||
} else {
|
||||
finalContent = result.text || '生成失败';
|
||||
@@ -111,7 +103,7 @@ async function processQueue() {
|
||||
id: 'chatcmpl-' + Date.now(),
|
||||
object: 'chat.completion',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: 'lmarena-image',
|
||||
model: modelName || 'default-model',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: {
|
||||
@@ -131,7 +123,7 @@ async function processQueue() {
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('>>> [Error] 任务处理失败:', err);
|
||||
logger.error('服务器', '任务处理失败', { id: task.id, error: err.message });
|
||||
if (SERVER_MODE === 'openai') {
|
||||
if (!task.res.writableEnded) {
|
||||
task.res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
@@ -157,11 +149,14 @@ async function startServer() {
|
||||
try {
|
||||
browserContext = await initBrowser(config);
|
||||
} catch (err) {
|
||||
console.error('>>> [Error] 浏览器初始化失败:', err);
|
||||
logger.error('服务器', '浏览器初始化失败', { error: err.message });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
// 为每个请求生成唯一 ID
|
||||
const id = crypto.randomUUID().slice(0, 8);
|
||||
|
||||
// --- 鉴权中间件 ---
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader || authHeader !== `Bearer ${AUTH_TOKEN}`) {
|
||||
@@ -176,8 +171,9 @@ async function startServer() {
|
||||
|
||||
// 1. 模型列表接口 (OpenAI & Queue 模式通用)
|
||||
if (req.method === 'GET' && req.url === '/v1/models') {
|
||||
const models = getModelsForBackend(name);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(getModels()));
|
||||
res.end(JSON.stringify(models));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -213,9 +209,9 @@ async function startServer() {
|
||||
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] 请求过多,已拒绝(限流)');
|
||||
logger.warn('服务器', '请求过多,已拒绝 (最大队列限制)', { id });
|
||||
if (isQueueMode) {
|
||||
sseHelper.send('error', { msg: 'Too Many Requests' });
|
||||
sseHelper.end();
|
||||
@@ -256,8 +252,25 @@ async function startServer() {
|
||||
prompt += item.text + ' ';
|
||||
} else if (item.type === 'image_url' && item.image_url && item.image_url.url) {
|
||||
imageCount++;
|
||||
if (imageCount > 5) {
|
||||
return;
|
||||
|
||||
// 逻辑:
|
||||
// 1. 如果配置限制 <= 10 (浏览器硬限制), 则严格执行, 超过报错
|
||||
// 2. 如果配置限制 > 10, 则视为用户想"尽力而为", 自动截断到 10 张, 忽略多余的
|
||||
|
||||
if (IMAGE_LIMIT <= 10) {
|
||||
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 })); }
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// IMAGE_LIMIT > 10
|
||||
if (imageCount > 10) {
|
||||
// 超过浏览器硬限制, 忽略该图片
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const url = item.image_url.url;
|
||||
@@ -287,34 +300,34 @@ async function startServer() {
|
||||
// 解析模型参数
|
||||
let modelId = null;
|
||||
if (data.model) {
|
||||
if (MODEL_MAPPING[data.model]) {
|
||||
modelId = MODEL_MAPPING[data.model];
|
||||
console.log(`>>> [Server] 触发模型: ${data.model}, UUID: ${modelId}`);
|
||||
modelId = resolveModelId(name, data.model);
|
||||
if (modelId) {
|
||||
logger.info('服务器', `触发模型: ${data.model} (${modelId})`, { id });
|
||||
} else {
|
||||
const errorMsg = `Invalid model: ${data.model}`;
|
||||
console.warn(`>>> [Server] ${errorMsg}`);
|
||||
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 })); }
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.log('>>> [Server] 未指定模型,使用网页默认值');
|
||||
logger.info('服务器', '未指定模型,使用网页默认', { id });
|
||||
}
|
||||
|
||||
console.log(`>>> [Queue] 请求入队 - Prompt: ${prompt}, Images: ${imagePaths.length}`);
|
||||
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 });
|
||||
queue.push({ req, res, prompt, imagePaths, sse: sseHelper, modelId, modelName: data.model || null, id });
|
||||
|
||||
// 触发队列处理
|
||||
processQueue();
|
||||
|
||||
} catch (err) {
|
||||
console.error('>>> [Error] 服务器处理失败:', err);
|
||||
logger.error('服务器', '服务器处理失败', { id, error: err.message });
|
||||
if (isQueueMode && sseHelper) {
|
||||
sseHelper.send('error', { msg: err.message });
|
||||
sseHelper.end();
|
||||
@@ -331,11 +344,9 @@ async function startServer() {
|
||||
});
|
||||
|
||||
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}`);
|
||||
}
|
||||
logger.info('服务器', `HTTP 服务器启动成功,监听端口 ${PORT}`);
|
||||
logger.info('服务器', `运行模式: ${SERVER_MODE === 'openai' ? 'OpenAI 兼容模式' : 'Queue 队列模式'}`);
|
||||
logger.info('服务器', `最大队列: ${MAX_QUEUE_SIZE},最大图片数量: ${IMAGE_LIMIT}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user