feat: 去除适配器冗余代码和历史遗留

This commit is contained in:
foxhui
2025-12-15 03:09:36 +08:00
Unverified
parent 71a1ba4889
commit 09806cda16
8 changed files with 163 additions and 197 deletions
+6 -6
View File
@@ -3,7 +3,7 @@
## 📝 项目简介
LMArenaImagenAutomator 是一个基于 Playwright + Camoufox 的自动化图像生成工具,支持多窗口并发与多账号管理(实现浏览器实例数据完全隔离),通过模拟人类操作与 LMArena、Gemini 等网站交互,提供兼容 OpenAI 格式的图像生成接口服务。
LMArenaImagenAutomator 是一个基于 Playwright + Camoufox 的自动化图像生成工具,支持多窗口并发与多账号管理(浏览器实例数据隔离),通过模拟人类操作与 LMArena、Gemini 等网站交互,提供兼容 OpenAI 格式的图像生成接口服务。
当前支持的网站:
- [LMArena](https://lmarena.ai/)
@@ -133,9 +133,9 @@ backend:
> [!WARNING]
> **并发限制与流式保活建议**
> 本项目通过模拟真实浏览器操作实现,**必须串行处理任务**,并发请求将进入队列。为防止排队过久导致客户端超时,当积压任务达到 3 个时将拒绝新请求。
> 本项目通过模拟真实浏览器操作实现,处理过程根据实际情况时间可能有所变化,当积压任务超过设置的数量时会直接拒绝非流式模式的请求。
>
> **💡 强烈建议开启流式模式**:服务器将发送保活心跳包,有效避免因排队等待造成的连接超时。
> **💡 强烈建议开启流式模式**:服务器将发送保活心跳包,可无限排队避免超时。
**请求端点**
```
@@ -218,7 +218,7 @@ data: [DONE]
| 参数 | 说明 |
| :--- | :--- |
| **model** | **必填**。指定使用的模型名称(如 `gemini-3-pro-image-preview`)。<br>可通过 `/v1/models` 接口或查看 `lib/backend/models.js` 获取完整列表。 |
| **model** | **必填**。指定使用的模型名称(如 `gemini-3-pro-image-preview`)。<br>可通过 `/v1/models` 接口获取支持的模型列表。 |
| **stream** | **推荐开启**。流式响应包含心跳保活机制,防止生成耗时过长导致连接超时。 |
> **💡 关于流式保活(Heartbeat**
@@ -323,7 +323,7 @@ curl -X GET http://127.0.0.1:3000/v1/cookies \
</details>
#### 4. 多模态请求 (图生图/图生文)
#### 4. 多模态请求 (图生图/文生图)
**功能说明**:支持在消息中附带图片进行对话或生成。
@@ -454,7 +454,7 @@ curl -X GET http://127.0.0.1:3000/v1/cookies \
| **CPU** | 1 核 | 2 核及以上 |
| **内存** | 1 GB | 2 GB 及以上 |
**实测环境表现**
**实测环境表现** (均为单浏览器实例)
- **Oracle 免费机** (1C1G, Debian 12):资源紧张,比较卡顿,仅供尝鲜或轻度使用。
- **阿里云轻量云** (2C2G, Debian 11):运行流畅稳定,为本项目开发测试基准环境。
+6 -9
View File
@@ -1,6 +1,5 @@
/**
* @fileoverview Gemini(消费者版)适配器
* @description 通过自动化方式驱动 Gemini 网页端生成图片,并将结果转换为统一的后端返回结构。
*/
import {
@@ -11,7 +10,8 @@ import {
import {
fillPrompt,
normalizePageError,
moveMouseAway
moveMouseAway,
waitForInput
} from '../utils.js';
import { logger } from '../../utils/logger.js';
@@ -38,7 +38,7 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' });
// 1. 等待输入框加载
await inputLocator.waitFor({ timeout: 30000 });
await waitForInput(page, inputLocator, { click: false });
await sleep(1500, 2500);
// 2. 上传图片 (使用 filechooser 事件,因为 Firefox 不会创建 DOM input 元素)
@@ -169,9 +169,8 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
* @param {import('playwright-core').Page} page
*/
async function waitInputValidator(page) {
await page.getByRole('textbox').waitFor({ timeout: 60000 });
await safeClick(page, page.getByRole('textbox'), { bias: 'input' });
await sleep(500, 1000);
const inputLocator = page.getByRole('textbox');
await waitForInput(page, inputLocator, { click: true });
}
/**
@@ -205,6 +204,4 @@ export const manifest = {
// 核心生图方法
generateImage
};
export { generateImage };
};
+16 -76
View File
@@ -1,6 +1,5 @@
/**
* @fileoverview Gemini Business 适配器
* @description 通过自动化方式驱动 Gemini Business 网页端生成图片,并将结果转换为统一的后端返回结构。
*/
import {
@@ -14,7 +13,12 @@ import {
normalizePageError,
normalizeHttpError,
waitApiResponse,
moveMouseAway
moveMouseAway,
waitForPageAuth,
lockPageAuth,
unlockPageAuth,
isPageAuthLocked,
waitForInput
} from '../utils.js';
import { logger } from '../../utils/logger.js';
@@ -27,23 +31,14 @@ const INPUT_SELECTOR = 'ucs-prosemirror-editor .ProseMirror';
* @param {string} targetUrl - 目标 URL,用于判断跳转完成
* @returns {Promise<boolean>} 是否处理了跳转
*/
let isHandlingAuth = false;
/** 等待登录处理完成 */
async function waitForAuthComplete() {
while (isHandlingAuth) {
await sleep(500, 1000);
}
}
async function handleAccountChooser(page) {
// 防止重复处理
if (isHandlingAuth) return false;
if (isPageAuthLocked(page)) return false;
try {
const currentUrl = page.url();
if (currentUrl.includes('auth.business.gemini.google/account-chooser')) {
isHandlingAuth = true;
lockPageAuth(page);
logger.info('适配器', '[登录器] 检测到账户选择页面,尝试自动确认...');
// 尝试查找提交按钮 (通常是标准的 button[type="submit"])
@@ -75,76 +70,23 @@ async function handleAccountChooser(page) {
// 额外缓冲时间,确保页面完全加载
await sleep(2000, 3000);
isHandlingAuth = false;
unlockPageAuth(page);
return true;
} else {
// 按钮还没加载出来,保持锁,等待下次检查
// 不要释放 isHandlingAuth,让全局监听器下次再试
logger.debug('适配器', '[登录器] 按钮尚未加载,等待中...');
await sleep(500, 1000);
isHandlingAuth = false; // 释放锁让下次尝试
unlockPageAuth(page); // 释放锁让下次尝试
return true; // 返回 true 表示"仍在处理中"
}
}
} catch (err) {
logger.warn('适配器', `[登录器] 处理账户选择页面失败: ${err.message}`);
isHandlingAuth = false;
unlockPageAuth(page);
}
return false;
}
/**
* 等待输入框出现,同时自动处理账户选择页面跳转
*
* @param {import('playwright-core').Page} page - 页面对象
* @param {object} [options={}] - 选项
* @param {number} [options.timeout=60000] - 超时时间(毫秒)
* @param {boolean} [options.click=true] - 是否点击输入框
* @returns {Promise<void>}
*/
async function waitForInputWithAccountChooser(page, options = {}) {
const { timeout = 60000, click = true } = options;
// 先检查一次当前页面 (全局监听器也会处理,但显式调用确保首次检查)
await handleAccountChooser(page);
// 轮询等待输入框
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
// 如果正在处理跳转,暂停检测输入框
if (isHandlingAuth) {
await sleep(500, 1000);
continue;
}
let inputHandle = null;
try {
inputHandle = await page.$(INPUT_SELECTOR);
} catch (e) {
// 忽略执行上下文销毁错误
if (e.message.includes('Execution context was destroyed')) {
inputHandle = null;
} else {
throw e;
}
}
if (inputHandle) break;
await sleep(1000, 1500);
}
// 最终确认输入框存在
await page.waitForSelector(INPUT_SELECTOR, { timeout: 5000 }).catch(() => {
throw new Error('未找到输入框 (.ProseMirror)');
});
if (click) {
await safeClick(page, INPUT_SELECTOR, { bias: 'input' });
await sleep(500, 1000);
}
}
/**
* 生成图片
@@ -166,17 +108,17 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
}
// 开启新对话 - 先等待可能正在进行的登录处理完成
await waitForAuthComplete();
await waitForPageAuth(page);
logger.info('适配器', '开启新会话', meta);
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
// 如果触发了账户选择跳转,等待全局处理器完成
await waitForAuthComplete();
await waitForPageAuth(page);
// 1. 等待输入框加载(使用公共函数处理账户选择)
// 1. 等待输入框加载
logger.debug('适配器', '正在寻找输入框...', meta);
await waitForInputWithAccountChooser(page, { click: false });
await waitForInput(page, INPUT_SELECTOR, { click: false });
await sleep(1500, 2500);
// 2. 上传图片 (uploadImages - 使用自定义验证器)
@@ -316,8 +258,6 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
}
}
export { generateImage };
/**
* 适配器 manifest
*/
@@ -343,7 +283,7 @@ export const manifest = {
// 输入框就绪校验
async waitInput(page, ctx) {
await waitForInputWithAccountChooser(page);
await waitForInput(page, INPUT_SELECTOR, { click: true });
},
// 导航处理器
+6 -13
View File
@@ -1,6 +1,5 @@
/**
* @fileoverview LMArena 适配器
* @description 通过自动化方式驱动 LMArena 网页端生成图片(或解析文本),并将结果转换为统一的后端返回结构。
*/
import {
@@ -15,7 +14,8 @@ import {
normalizePageError,
normalizeHttpError,
downloadImage,
moveMouseAway
moveMouseAway,
waitForInput
} from '../utils.js';
import { logger } from '../../utils/logger.js';
@@ -59,8 +59,8 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
logger.info('适配器', '开启新会话...', meta);
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' });
// 1. 等待输入框加载 (waitInput)
await page.waitForSelector(textareaSelector, { timeout: 30000 });
// 1. 等待输入框加载
await waitForInput(page, textareaSelector, { click: false });
await sleep(1500, 2500);
// 2. 上传图片 (uploadImages)
@@ -136,10 +136,7 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
const img = extractImage(content);
if (img) {
logger.info('适配器', '已获取结果,正在下载图片...', meta);
const result = await downloadImage(img, {
proxyConfig: context.proxyConfig,
userDataDir: context.userDataDir
});
const result = await downloadImage(img, context);
if (result.image) {
logger.info('适配器', '已下载图片,任务完成', meta);
}
@@ -171,9 +168,7 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
*/
async function waitInputValidator(page) {
const textareaSelector = 'textarea';
await page.waitForSelector(textareaSelector, { timeout: 60000 });
await safeClick(page, textareaSelector, { bias: 'input' });
await sleep(500, 1000);
await waitForInput(page, textareaSelector, { click: true });
}
/**
@@ -240,5 +235,3 @@ export const manifest = {
// 核心生图方法
generateImage
};
export { generateImage };
+5 -10
View File
@@ -1,9 +1,7 @@
/**
* @fileoverview NanoBananaFree AI 适配器
* @description 通过自动化方式驱动 nanobananafree.ai 网页端生成图片,并将结果转换为统一的后端返回结构。
*/
import {
sleep,
safeClick,
@@ -15,7 +13,8 @@ import {
waitApiResponse,
normalizePageError,
normalizeHttpError,
moveMouseAway
moveMouseAway,
waitForInput
} from '../utils.js';
import { logger } from '../../utils/logger.js';
@@ -40,8 +39,8 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
logger.info('适配器', '开启新会话', meta);
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' });
// 1. 等待输入框加载 (waitInput)
await page.waitForSelector(textareaSelector, { timeout: 30000 });
// 1. 等待输入框加载
await waitForInput(page, textareaSelector, { click: false });
await sleep(1500, 2500);
// 2. 上传图片 (uploadImages - 仅取第一张)
@@ -136,9 +135,7 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
*/
async function waitInputValidator(page) {
const textareaSelector = 'textarea';
await page.waitForSelector(textareaSelector, { timeout: 60000 });
await safeClick(page, textareaSelector, { bias: 'input' });
await sleep(500, 1000);
await waitForInput(page, textareaSelector, { click: true });
}
/**
@@ -173,5 +170,3 @@ export const manifest = {
// 核心生图方法
generateImage
};
export { generateImage };
+17 -77
View File
@@ -1,6 +1,5 @@
/**
* @fileoverview zAIzai.is)适配器
* @description 通过自动化方式驱动 zai.is 网页端生成图片,并将结果转换为统一的后端返回结构。
*/
import {
@@ -15,7 +14,12 @@ import {
normalizeHttpError,
waitApiResponse,
moveMouseAway,
downloadImage
downloadImage,
waitForPageAuth,
lockPageAuth,
unlockPageAuth,
isPageAuthLocked,
waitForInput
} from '../utils.js';
import { logger } from '../../utils/logger.js';
@@ -30,24 +34,15 @@ const TARGET_URL = 'https://zai.is/';
* @param {import('playwright-core').Page} page
* @returns {Promise<boolean>} 是否处理了登录
*/
let isHandlingAuth = false;
/** 等待登录处理完成 */
async function waitForAuthComplete() {
while (isHandlingAuth) {
await sleep(500, 1000);
}
}
async function handleDiscordAuth(page) {
// 防止重复处理
if (isHandlingAuth) return false;
if (isPageAuthLocked(page)) return false;
const currentUrl = page.url();
// 1. 检查是否在 zai.is/auth 页面
if (currentUrl.includes('zai.is/auth')) {
isHandlingAuth = true;
lockPageAuth(page);
logger.info('适配器', '[登录器] 检测到登录页面,正在处理 Discord 登录...');
try {
@@ -101,11 +96,11 @@ async function handleDiscordAuth(page) {
logger.info('适配器', '[登录器] Discord 登录完成');
await sleep(2000, 3000);
isHandlingAuth = false;
unlockPageAuth(page);
return true;
} catch (err) {
logger.warn('适配器', `[登录器] Discord 登录处理失败: ${err.message}`);
isHandlingAuth = false;
unlockPageAuth(page);
}
}
@@ -113,56 +108,6 @@ async function handleDiscordAuth(page) {
return false;
}
/**
* 等待输入框出现,同时自动处理 Discord 登录
* @param {import('playwright-core').Page} page
* @param {object} [options={}]
* @param {number} [options.timeout=60000]
* @param {boolean} [options.click=true]
*/
async function waitForInputWithAuth(page, options = {}) {
const { timeout = 60000, click = true } = options;
// 先检查一次当前页面 (全局监听器也会处理,但显式调用确保首次检查)
await handleDiscordAuth(page);
// 轮询等待输入框
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
// 如果正在处理登录,暂停检测输入框,避免冲突
if (isHandlingAuth) {
await sleep(500, 1000);
continue;
}
let inputHandle = null;
try {
inputHandle = await page.$(INPUT_SELECTOR);
} catch (e) {
// 忽略执行上下文销毁错误 (通常发生在页面刷新/跳转时)
if (e.message.includes('Execution context was destroyed')) {
inputHandle = null;
} else {
throw e;
}
}
if (inputHandle) break;
await sleep(1000, 1500);
}
// 最终确认输入框存在
await page.waitForSelector(INPUT_SELECTOR, { timeout: 5000 }).catch(() => {
throw new Error('未找到输入框 (.tiptap.ProseMirror)');
});
if (click) {
await safeClick(page, INPUT_SELECTOR, { bias: 'input' });
await sleep(500, 1000);
}
}
/**
* 生成图片
@@ -178,17 +123,17 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
try {
// 开启新对话 - 先等待可能正在进行的登录处理完成
await waitForAuthComplete();
await waitForPageAuth(page);
logger.info('适配器', '开启新会话', meta);
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' });
// 如果触发了登录跳转,等待全局处理器完成
await waitForAuthComplete();
await waitForPageAuth(page);
// 1. 等待输入框加载(使用公共函数处理登录)
// 1. 等待输入框加载
logger.debug('适配器', '正在寻找输入框...', meta);
await waitForInputWithAuth(page, { click: false });
await waitForInput(page, INPUT_SELECTOR, { click: false });
await sleep(1500, 2500);
// 2. 上传图片 (如果有多张图片,会一张一张上传,每次都是 v1/files POST 请求)
@@ -374,10 +319,7 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
logger.info('适配器', `已提取图片链接: ${imageUrl}`, meta);
// 下载图片
const downloadResult = await downloadImage(imageUrl, {
proxyConfig: context.proxyConfig,
userDataDir: context.userDataDir
});
const downloadResult = await downloadImage(imageUrl, context);
if (downloadResult.error) {
return downloadResult;
}
@@ -426,7 +368,7 @@ export const manifest = {
// 输入框就绪校验
async waitInput(page, ctx) {
await waitForInputWithAuth(page);
await waitForInput(page, INPUT_SELECTOR, { click: true });
},
// 导航处理器
@@ -434,6 +376,4 @@ export const manifest = {
// 核心生图方法
generateImage
};
export { generateImage };
};
+6
View File
@@ -107,6 +107,9 @@ class Worker {
// sharedBrowser 实际是 BrowserContextCamoufox 使用 launchPersistentContext
this.page = await sharedBrowser.newPage();
// 挂载页面级认证状态(供适配器使用,避免全局锁导致多 Worker 互相阻塞)
this.page.authState = { isHandlingAuth: false };
// 初始化 ghost-cursor
this.page.cursor = createCursor(this.page);
@@ -154,6 +157,9 @@ class Worker {
this.browser = base.context;
this.page = base.page;
// 挂载页面级认证状态(供适配器使用,避免全局锁导致多 Worker 互相阻塞)
this.page.authState = { isHandlingAuth: false };
// 初始化 ghost-cursor
this.page.cursor = createCursor(this.page);
+101 -6
View File
@@ -8,11 +8,104 @@
* - `waitApiResponse`:等待匹配的 API 响应(包含页面关闭/崩溃监听)
* - `normalizePageError`:将页面级异常归一化为可返回给服务器层的错误
* - `normalizeHttpError`:将 HTTP 响应错误(含限流/人机验证)归一化
* - `waitForPageAuth`/`lockPageAuth`...:页面认证锁机制,防止多任务并发冲突
* - `waitForInput`: 等待输入框就绪
*/
import { sleep, humanType, safeClick, isPageValid, createPageCloseWatcher, getRealViewport, clamp, random } from '../browser/utils.js';
import { logger } from '../utils/logger.js';
// ==========================================
// 页面认证锁工具函数
// ==========================================
/**
* 等待页面认证完成
* @param {import('playwright-core').Page} page - 页面对象
*/
export async function waitForPageAuth(page) {
while (page.authState?.isHandlingAuth) {
await sleep(500, 1000);
}
}
/**
* 设置页面认证锁(加锁)
* @param {import('playwright-core').Page} page - 页面对象
*/
export function lockPageAuth(page) {
if (page.authState) page.authState.isHandlingAuth = true;
}
/**
* 释放页面认证锁(解锁)
* @param {import('playwright-core').Page} page - 页面对象
*/
export function unlockPageAuth(page) {
if (page.authState) page.authState.isHandlingAuth = false;
}
/**
* 检查页面是否正在处理认证
* @param {import('playwright-core').Page} page - 页面对象
* @returns {boolean}
*/
export function isPageAuthLocked(page) {
return page.authState?.isHandlingAuth === true;
}
/**
* 等待输入框出现(自动等待认证完成)
*
* 使用轮询方式等待输入框出现,同时尊重页面认证锁。
* 当页面正在处理登录跳转时会自动暂停检测。
*
* @param {import('playwright-core').Page} page - 页面对象
* @param {string|import('playwright-core').Locator} selectorOrLocator - 输入框选择器或 Locator 对象
* @param {object} [options={}] - 选项
* @param {number} [options.timeout=60000] - 超时时间(毫秒)
* @param {boolean} [options.click=true] - 找到后是否点击输入框
* @returns {Promise<void>}
*/
export async function waitForInput(page, selectorOrLocator, options = {}) {
const { timeout = 60000, click = true } = options;
// 判断是选择器字符串还是 Locator 对象
const isLocator = typeof selectorOrLocator !== 'string';
const displayName = isLocator ? 'Locator' : selectorOrLocator;
const startTime = Date.now();
// 等待认证完成(如果正在处理登录跳转)
while (isPageAuthLocked(page)) {
if (Date.now() - startTime >= timeout) break;
await sleep(500, 1000);
}
// 计算剩余超时时间
const elapsed = Date.now() - startTime;
const remainingTimeout = Math.max(timeout - elapsed, 5000);
// 等待输入框出现 - 对字符串选择器使用 waitForSelector,对 Locator 使用 waitFor
if (isLocator) {
await selectorOrLocator.first().waitFor({ state: 'visible', timeout: remainingTimeout }).catch(() => {
throw new Error(`未找到输入框 (${displayName})`);
});
} else {
await page.waitForSelector(selectorOrLocator, { timeout: remainingTimeout }).catch(() => {
throw new Error(`未找到输入框 (${displayName})`);
});
}
if (click) {
const target = isLocator ? selectorOrLocator : selectorOrLocator;
await safeClick(page, target, { bias: 'input' });
await sleep(500, 1000);
}
}
// ==========================================
/**
* 任务完成后移开鼠标(拟人化行为)
*
@@ -199,19 +292,17 @@ export function normalizeHttpError(response, content = null) {
* 根据 camoufoxFingerprints.json 动态生成请求头,保持与浏览器指纹一致
*
* @param {string} url - 图片 URL
* @param {object} options - 下载选项
* @param {object} [options.proxyConfig] - Worker 级代理配置
* @param {string} [options.userDataDir] - 用户数据目录(用于读取对应的指纹文件)
* @param {object} context - 上下文对象,包含 proxyConfig 和 userDataDir
* @returns {Promise<{ image?: string, error?: string }>} 下载结果
*/
export async function downloadImage(url, options = {}) {
export async function downloadImage(url, context = {}) {
// 动态导入依赖
const { gotScraping } = await import('got-scraping');
const fs = await import('fs');
const path = await import('path');
const { getHttpProxy } = await import('../utils/proxy.js');
const { proxyConfig = null, userDataDir } = options;
const { proxyConfig = null, userDataDir } = context;
try {
// 读取指纹文件获取浏览器信息(优先使用 userDataDir 内的指纹)
@@ -270,7 +361,11 @@ export async function downloadImage(url, options = {}) {
const response = await gotScraping(options);
const base64 = response.body.toString('base64');
return { image: `data:image/png;base64,${base64}` };
// 根据响应 content-type 生成正确的 MIME 类型
const contentType = response.headers['content-type'] || 'image/png';
// 提取 MIME 类型 (去除可能的 charset 等附加信息)
const mimeType = contentType.split(';')[0].trim();
return { image: `data:${mimeType};base64,${base64}` };
} catch (e) {
return { error: `已获取结果,但图片下载时遇到错误: ${e.message}` };
}