diff --git a/CHANGELOG.md b/CHANGELOG.md index 656e47c..8b45a80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 测试工具 - - 完整的配置文件系统 \ No newline at end of file + - 发布基于 Puppeteer 的自动化图像生成核心功能。 + - 提供双运行模式:OpenAI API 兼容模式与 Queue 队列模式 (SSE)。 + - 拟人化交互:内置贝塞尔曲线鼠标移动、智能键盘输入模拟及随机抖动延迟算法。 + - **功能特性**: + - 支持单次最多上传 5 张图片。 + - 支持 Bearer Token 标准认证。 + - 完整支持 HTTP 及 SOCKS5 代理协议。 + - 附带 CLI 测试工具及可配置化系统架构。 diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..e7b1a38 --- /dev/null +++ b/config.example.yaml @@ -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 diff --git a/lib/backend/gemini_biz.js b/lib/backend/gemini_biz.js new file mode 100644 index 0000000..3408345 --- /dev/null +++ b/lib/backend/gemini_biz.js @@ -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} + */ +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 }; diff --git a/lib/backend/index.js b/lib/backend/index.js new file mode 100644 index 0000000..8c54ee4 --- /dev/null +++ b/lib/backend/index.js @@ -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 }; +} diff --git a/lib/backend/lmarena.js b/lib/backend/lmarena.js new file mode 100644 index 0000000..0f8bb32 --- /dev/null +++ b/lib/backend/lmarena.js @@ -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 }; diff --git a/lib/backend/models.js b/lib/backend/models.js new file mode 100644 index 0000000..6ae318f --- /dev/null +++ b/lib/backend/models.js @@ -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'); +} diff --git a/lib/browser/launcher.js b/lib/browser/launcher.js new file mode 100644 index 0000000..1cf7d08 --- /dev/null +++ b/lib/browser/launcher.js @@ -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} + */ +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 }; +} diff --git a/lib/browser/utils.js b/lib/browser/utils.js new file mode 100644 index 0000000..6978f79 --- /dev/null +++ b/lib/browser/utils.js @@ -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} + */ +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} 找到的元素句柄或 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} + */ +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} + */ +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} + */ +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); + } +} diff --git a/lib/config.js b/lib/config.js index 769881e..9abaa25 100644 --- a/lib/config.js +++ b/lib/config.js @@ -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; diff --git a/lib/genApiKey.js b/lib/genApiKey.js index 769e11b..e57e679 100644 --- a/lib/genApiKey.js +++ b/lib/genApiKey.js @@ -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 字段中。'); diff --git a/lib/lmarena.js b/lib/lmarena.js deleted file mode 100644 index e6ba022..0000000 --- a/lib/lmarena.js +++ /dev/null @@ -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 }; diff --git a/lib/logger.js b/lib/logger.js new file mode 100644 index 0000000..a51684d --- /dev/null +++ b/lib/logger.js @@ -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 +}; diff --git a/lib/models.js b/lib/models.js deleted file mode 100644 index f019215..0000000 --- a/lib/models.js +++ /dev/null @@ -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" - })) - }; -} diff --git a/lib/security/apiKey.js b/lib/security/apiKey.js new file mode 100644 index 0000000..30639dc --- /dev/null +++ b/lib/security/apiKey.js @@ -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'); +} diff --git a/lib/test.js b/lib/test.js index ae7eaa3..a062c9a 100644 --- a/lib/test.js +++ b/lib/test.js @@ -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} 用户输入 - */ -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(); \ No newline at end of file +/** + * 选择模型 + */ +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); + } +})(); \ No newline at end of file diff --git a/package.json b/package.json index 21c378e..a638ced 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5e90e2..9062d47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/server.js b/server.js index 72f023a..ba5cc80 100644 --- a/server.js +++ b/server.js @@ -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 = `![generated](${result.image})`; - finalContent = `![generated](data:${mimeType};base64,${base64})`; - 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}`); }); }