refactor: 重构服务器部分并整理目录和注释

This commit is contained in:
foxhui
2025-12-13 03:37:51 +08:00
parent 063a05a499
commit 5d8dc7c0a5
27 changed files with 367 additions and 378 deletions
+189
View File
@@ -0,0 +1,189 @@
/**
* @fileoverview Gemini(消费者版)适配器
* @description 通过自动化方式驱动 Gemini 网页端生成图片,并将结果转换为统一的后端返回结构。
*/
import { initBrowserBase } from '../../browser/launcher.js';
import {
sleep,
safeClick,
uploadFilesViaChooser
} from '../../browser/utils.js';
import {
fillPrompt,
normalizePageError,
moveMouseAway
} from '../utils.js';
import { logger } from '../../utils/logger.js';
// --- 配置常量 ---
const TARGET_URL = 'https://gemini.google.com/app?hl=en';
/**
* 初始化浏览器会话
* @param {object} config - 全局配置对象
* @returns {Promise<{browser: object, page: object, config: object}>}
*/
async function initBrowser(config) {
// 输入框验证逻辑
const waitInputValidator = async (page) => {
await page.getByRole('textbox').waitFor({ timeout: 60000 });
await safeClick(page, page.getByRole('textbox'), { bias: 'input' });
await sleep(500, 1000);
};
const base = await initBrowserBase(config, {
userDataDir: config.paths.userDataDir,
targetUrl: TARGET_URL,
productName: 'Gemini',
waitInputValidator
});
return { ...base, config };
}
/**
* 执行生图任务
* @param {object} context - 浏览器上下文 { page, config }
* @param {string} prompt - 提示词
* @param {string[]} imgPaths - 图片路径数组
* @param {string} [modelId] - 模型 ID (此适配器未使用)
* @param {object} [meta={}] - 日志元数据
* @returns {Promise<{image?: string, error?: string}>}
*/
async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
const { page } = context;
const inputLocator = page.getByRole('textbox');
const sendBtnLocator = page.getByRole('button', { name: 'Send message' });
try {
logger.info('适配器', '开启新会话...', meta);
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' });
// 1. 等待输入框加载
await inputLocator.waitFor({ timeout: 30000 });
await sleep(1500, 2500);
// 2. 上传图片 (使用 filechooser 事件,因为 Firefox 不会创建 DOM input 元素)
if (imgPaths && imgPaths.length > 0) {
// 点击加号按钮打开菜单
logger.debug('适配器', '点击加号按钮...', meta);
const uploadMenuBtn = page.getByRole('button', { name: 'Open upload file menu' });
await safeClick(page, uploadMenuBtn, { bias: 'button' });
await sleep(500, 1000);
// 使用公共函数上传文件
const uploadFilesBtn = page.getByRole('button', { name: /Upload files/ });
await uploadFilesViaChooser(page, uploadFilesBtn, imgPaths, {
uploadValidator: (response) => {
const url = response.url();
return response.status() === 200 &&
url.includes('google.com/upload/') &&
url.includes('upload_id=');
}
});
await sleep(1000, 2000);
}
// 3. 填写提示词
await safeClick(page, inputLocator, { bias: 'input' });
await fillPrompt(page, inputLocator, prompt, meta);
await sleep(500, 1000);
// 4. 点击 Tools 按钮启用图片生成
logger.debug('适配器', '点击 Tools 按钮...', meta);
const toolsBtn = page.getByRole('button', { name: 'Tools' });
await safeClick(page, toolsBtn, { bias: 'button' });
await sleep(500, 1000);
// 5. 点击 Create images 按钮
logger.debug('适配器', '点击 Create images 按钮...', meta);
const createImagesBtn = page.getByRole('button', { name: 'Create images' });
await safeClick(page, createImagesBtn, { bias: 'button' });
await sleep(500, 1000);
// 6. 设置响应监听 - 等待 StreamGenerate 成功后捕获图片
let imageData = null;
const imagePromise = new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error('等待图片响应超时 (120秒)'));
}, 120000);
let streamGenerateSuccess = false;
const onResponse = async (response) => {
const url = response.url();
// 先等待 StreamGenerate 成功
if (!streamGenerateSuccess &&
url.includes('assistant.lamda.BardFrontendService/StreamGenerate') &&
response.request().method() === 'POST' &&
response.status() === 200) {
streamGenerateSuccess = true;
logger.info('适配器', '生成请求成功,等待图片...', meta);
}
// StreamGenerate 成功后,捕获图片响应
if (streamGenerateSuccess &&
url.includes('googleusercontent.com/rd-gg-dl') &&
url.includes('=s1024-rj') &&
response.request().method() === 'GET' &&
response.status() === 200) {
try {
// 直接获取图片二进制数据
const buffer = await response.body();
const base64 = buffer.toString('base64');
// 根据 Content-Type 确定图片格式
const contentType = response.headers()['content-type'] || 'image/jpeg';
imageData = `data:${contentType};base64,${base64}`;
logger.info('适配器', '已捕获图片数据', meta);
cleanup();
resolve(imageData);
} catch (e) {
logger.warn('适配器', `捕获图片失败: ${e.message}`, meta);
}
}
};
const cleanup = () => {
clearTimeout(timeout);
page.off('response', onResponse);
};
page.on('response', onResponse);
});
// 7. 点击发送
logger.debug('适配器', '点击发送...', meta);
await safeClick(page, sendBtnLocator, { bias: 'button' });
logger.info('适配器', '等待生成结果...', meta);
// 7. 等待图片响应
const image = await imagePromise;
if (image) {
logger.info('适配器', '已获取图片,任务完成', meta);
return { image };
} else {
return { error: '未能获取图片' };
}
} catch (err) {
// 顶层错误处理
const pageError = normalizePageError(err, meta);
if (pageError) return pageError;
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
return { error: `生成任务失败: ${err.message}` };
} finally {
// 任务结束,将鼠标移至安全区域
await moveMouseAway(page);
}
}
export { initBrowser, generateImage };
+356
View File
@@ -0,0 +1,356 @@
/**
* @fileoverview Gemini Business 适配器
* @description 通过自动化方式驱动 Gemini Business 网页端生成图片,并将结果转换为统一的后端返回结构。
*/
import { initBrowserBase } from '../../browser/launcher.js';
import {
sleep,
safeClick,
pasteImages
} from '../../browser/utils.js';
import {
fillPrompt,
submit,
normalizePageError,
normalizeHttpError,
waitApiResponse,
moveMouseAway
} from '../utils.js';
import { logger } from '../../utils/logger.js';
// Gemini Biz 输入框选择器
const INPUT_SELECTOR = 'ucs-prosemirror-editor .ProseMirror';
/**
* 处理账户选择页面跳转
* @param {import('playwright-core').Page} page - Playwright 页面对象
* @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;
try {
const currentUrl = page.url();
if (currentUrl.includes('auth.business.gemini.google/account-chooser')) {
isHandlingAuth = true;
logger.info('适配器', '[登录器] 检测到账户选择页面,尝试自动确认...');
// 尝试查找提交按钮 (通常是标准的 button[type="submit"])
const submitBtn = await page.$('button[type="submit"]');
if (submitBtn) {
// 确保按钮在可视区域
await submitBtn.scrollIntoViewIfNeeded();
await sleep(300, 500);
// 使用 safeClick 模拟人类点击行为
logger.info('适配器', '[登录器] 正在点击确认按钮...');
await safeClick(page, submitBtn, { bias: 'button' });
// 点击后等待跳转回目标页面
logger.info('适配器', '[登录器] 等待跳转回目标页面...');
try {
await page.waitForFunction(() => {
const href = window.location.href;
return !href.includes('accounts.google.com') &&
!href.includes('auth.business.gemini.google') &&
href.includes('business.gemini.google');
}, { timeout: 60000, polling: 1000 });
logger.info('适配器', `[登录器] 已跳转回目标页面`);
} catch (timeoutErr) {
const finalUrl = page.url();
logger.warn('适配器', `[登录器] 等待跳转回目标页面超时,尝试继续... 当前URL: ${finalUrl}`);
}
// 额外缓冲时间,确保页面完全加载
await sleep(2000, 3000);
isHandlingAuth = false;
return true;
} else {
// 按钮还没加载出来,保持锁,等待下次检查
// 不要释放 isHandlingAuth,让全局监听器下次再试
logger.debug('适配器', '[登录器] 按钮尚未加载,等待中...');
await sleep(500, 1000);
isHandlingAuth = false; // 释放锁让下次尝试
return true; // 返回 true 表示"仍在处理中"
}
}
} catch (err) {
logger.warn('适配器', `[登录器] 处理账户选择页面失败: ${err.message}`);
isHandlingAuth = false;
}
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);
}
}
/**
* 初始化浏览器
* @param {object} config - 配置对象
* @param {object} [config.browser] - Browser 配置
* @param {boolean} [config.browser.headless] - 是否开启 Headless 模式
* @param {string} [config.browser.path] - Browser 可执行文件路径
* @param {object} [config.browser.proxy] - 代理配置
* @param {object} [config.backend] - 后端配置
* @param {object} [config.backend.geminiBiz] - Gemini Biz 配置
* @param {string} config.backend.geminiBiz.entryUrl - Gemini entry URL (必需)
* @returns {Promise<{browser: object, page: object, client: object}>}
*/
async function initBrowser(config) {
// 从配置读取 Gemini Biz entry URL
const backendCfg = config.backend || {};
const geminiCfg = backendCfg.geminiBiz || {};
const targetUrl = geminiCfg.entryUrl;
if (!targetUrl) {
logger.error('适配器', '未找到GeminiBiz的入口URL, 请在配置文件中配置后再启动', meta);
throw new Error('GeminiBiz backend missing entry URL: backend.geminiBiz.entryUrl');
}
// 输入框验证逻辑(使用公共函数)
const waitInputValidator = async (page) => {
await waitForInputWithAccountChooser(page);
};
const base = await initBrowserBase(config, {
userDataDir: config.paths.userDataDir,
targetUrl,
productName: 'Gemini Enterprise Business',
waitInputValidator,
navigationHandler: handleAccountChooser
});
return { ...base, config };
}
/**
* 生成图片
* @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, config } = context;
try {
const targetUrl = config.backend?.geminiBiz?.entryUrl;
if (!targetUrl) {
throw new Error('GeminiBiz backend missing entry URL');
}
// 开启新对话 - 先等待可能正在进行的登录处理完成
await waitForAuthComplete();
logger.info('适配器', '开启新会话', meta);
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
// 如果触发了账户选择跳转,等待全局处理器完成
await waitForAuthComplete();
// 1. 等待输入框加载(使用公共函数处理账户选择)
logger.debug('适配器', '正在寻找输入框...', meta);
await waitForInputWithAccountChooser(page, { click: false });
await sleep(1500, 2500);
// 2. 上传图片 (uploadImages - 使用自定义验证器)
if (imgPaths && imgPaths.length > 0) {
const expectedUploads = imgPaths.length;
let uploadedCount = 0;
let metadataCount = 0;
await pasteImages(page, INPUT_SELECTOR, 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. 填写提示词 (fillPrompt)
await safeClick(page, INPUT_SELECTOR, { bias: 'input' });
await fillPrompt(page, INPUT_SELECTOR, prompt, meta);
await sleep(500, 1000);
// 4. 设置拦截器
logger.debug('适配器', '已启用请求拦截', meta);
await page.unroute('**/*').catch(() => { });
await page.route(url => url.href.includes('global/widgetStreamAssist'), async (route) => {
const request = route.request();
if (request.method() !== 'POST') return route.continue();
try {
const postData = request.postDataJSON();
if (postData) {
logger.debug('适配器', '已拦截请求,正在修改...', meta);
if (!postData.streamAssistRequest) postData.streamAssistRequest = {};
if (!postData.streamAssistRequest.assistGenerationConfig) postData.streamAssistRequest.assistGenerationConfig = {};
postData.streamAssistRequest.toolsSpec = { imageGenerationSpec: {} };
logger.info('适配器', '已拦截请求,强制使用 Nano Banana Pro', meta);
await route.continue({ postData: JSON.stringify(postData) });
return;
}
} catch (e) {
logger.error('适配器', '请求拦截处理失败', { ...meta, error: e.message });
}
await route.continue();
});
// 5. 提交 (submit - 使用公共函数)
logger.debug('适配器', '点击发送...', meta);
await submit(page, {
btnSelector: 'md-icon-button.send-button.submit, button[aria-label="提交"], button[aria-label="Send"], .send-button',
inputTarget: INPUT_SELECTOR,
meta
});
logger.info('适配器', '等待生成结果中...', meta);
// 6. 等待 API 响应
let apiResponse;
try {
apiResponse = await waitApiResponse(page, {
urlMatch: 'global/widgetStreamAssist',
method: 'POST',
timeout: 120000,
meta
});
} catch (e) {
const pageError = normalizePageError(e, meta);
if (pageError) return pageError;
throw e;
}
// 检查 API 响应状态
const httpError = normalizeHttpError(apiResponse);
if (httpError) {
logger.error('适配器', `请求生成时返回错误: ${httpError.error}`, meta);
return { error: `请求生成时返回错误: ${httpError.error}` };
}
// 7. 等待图片下载响应
logger.info('适配器', '已获取结果,正在下载图片...', meta);
let imageResponse;
try {
imageResponse = await waitApiResponse(page, {
urlMatch: 'download/v1alpha/projects',
method: 'GET',
timeout: 120000,
meta
});
} catch (e) {
const pageError = normalizePageError(e, meta);
if (pageError) {
if (e.name === 'TimeoutError') {
return { error: '已获取结果, 但图片下载时超时 (120秒)' };
}
return pageError;
}
throw e;
}
const base64 = await imageResponse.text();
logger.info('适配器', '已下载图片,任务完成', meta);
const dataUri = `data:image/png;base64,${base64}`;
return { image: dataUri };
} catch (err) {
// 顶层错误处理
const pageError = normalizePageError(err, meta);
if (pageError) return pageError;
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
return { error: `生成任务失败: ${err.message}` };
} finally {
// 清理拦截器
await page.unroute('**/*').catch(() => { });
// 任务结束,将鼠标移至安全区域
await moveMouseAway(page);
}
}
export { initBrowser, generateImage, handleAccountChooser };
+188
View File
@@ -0,0 +1,188 @@
/**
* @fileoverview LMArena 适配器
* @description 通过自动化方式驱动 LMArena 网页端生成图片(或解析文本),并将结果转换为统一的后端返回结构。
*/
import { initBrowserBase } from '../../browser/launcher.js';
import {
sleep,
safeClick,
pasteImages
} from '../../browser/utils.js';
import {
fillPrompt,
submit,
waitApiResponse,
normalizePageError,
normalizeHttpError,
downloadImage,
moveMouseAway
} from '../utils.js';
import { logger } from '../../utils/logger.js';
// --- 配置常量 ---
const TARGET_URL = 'https://lmarena.ai/c/new?mode=direct&chat-modality=image';
/**
* 从响应文本中提取图片 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 - 全局配置对象
* @returns {Promise<{browser: object, page: object, client: object}>} 初始化后的浏览器上下文
*/
async function initBrowser(config) {
// 输入框验证逻辑
const waitInputValidator = async (page) => {
const textareaSelector = 'textarea';
await page.waitForSelector(textareaSelector, { timeout: 60000 });
await safeClick(page, textareaSelector, { bias: 'input' });
await sleep(500, 1000);
};
const base = await initBrowserBase(config, {
userDataDir: config.paths.userDataDir,
targetUrl: TARGET_URL,
productName: 'LMArena',
waitInputValidator
});
return { ...base, config };
}
/**
* 执行生图任务
* @param {object} context - 浏览器上下文 { page, client }
* @param {string} prompt - 提示词
* @param {string[]} imgPaths - 图片路径数组
* @param {string} [modelId] - 指定的模型 ID (可选)
* @param {object} [meta={}] - 日志元数据
* @returns {Promise<{image?: string, text?: string, error?: string}>} 生成结果
*/
async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
const { page, config } = context;
const textareaSelector = 'textarea';
try {
logger.info('适配器', '开启新会话...', meta);
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' });
// 1. 等待输入框加载 (waitInput)
await page.waitForSelector(textareaSelector, { timeout: 30000 });
await sleep(1500, 2500);
// 2. 上传图片 (uploadImages)
if (imgPaths && imgPaths.length > 0) {
await pasteImages(page, textareaSelector, imgPaths);
}
// 3. 填写提示词 (fillPrompt)
await safeClick(page, textareaSelector, { bias: 'input' });
await fillPrompt(page, textareaSelector, prompt, meta);
// 4. 配置请求拦截 (用于修改模型 ID)
await page.unroute('**/*').catch(() => { });
if (modelId) {
logger.debug('适配器', `准备拦截请求`, meta);
await page.route(url => url.href.includes('/nextjs-api/stream'), async (route) => {
const request = route.request();
if (request.method() !== 'POST') return route.continue();
try {
const postData = request.postDataJSON();
if (postData && postData.modelAId) {
logger.info('适配器', `已拦截请求并修改模型: ${postData.modelAId} -> ${modelId}`, meta);
postData.modelAId = modelId;
await route.continue({ postData: JSON.stringify(postData) });
return;
}
} catch (e) {
logger.error('适配器', '拦截处理异常', { ...meta, error: e.message });
}
await route.continue();
});
}
// 5. 提交表单 (submit)
logger.debug('适配器', '点击发送...', meta);
await submit(page, {
btnSelector: 'button[type="submit"]',
inputTarget: textareaSelector,
meta
});
logger.info('适配器', '等待生成结果...', meta);
// 6. 等待 API 响应 (waitApiResponse)
let response;
try {
response = await waitApiResponse(page, {
urlMatch: '/nextjs-api/stream',
method: 'POST',
timeout: 120000,
meta
});
} catch (e) {
// 使用公共错误处理
const pageError = normalizePageError(e, meta);
if (pageError) return pageError;
throw e;
}
// 7. 解析响应结果
const content = await response.text();
// 8. 检查 HTTP 错误 (normalizeHttpError)
const httpError = normalizeHttpError(response, content);
if (httpError) {
logger.error('适配器', `请求生成时返回错误: ${httpError.error}`, meta);
return { error: `请求生成时返回错误: ${httpError.error}` };
}
// 9. 提取图片 URL
const img = extractImage(content);
if (img) {
logger.info('适配器', '已获取结果,正在下载图片...', meta);
const result = await downloadImage(img, config);
if (result.image) {
logger.info('适配器', '已下载图片,任务完成', meta);
}
return result;
} else {
logger.warn('适配器', '未获得结果,响应中无图片数据', { ...meta, preview: content.substring(0, 150) });
return { text: `未获得结果,响应中无图片数据: ${content}` };
}
} catch (err) {
// 顶层错误处理
const pageError = normalizePageError(err, meta);
if (pageError) return pageError;
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
return { error: `生成任务失败: ${err.message}` };
} finally {
// 清理拦截器
if (modelId) await page.unroute('**/*').catch(() => { });
// 任务结束,将鼠标移至安全区域
await moveMouseAway(page);
}
}
export { initBrowser, generateImage };
+155
View File
@@ -0,0 +1,155 @@
/**
* @fileoverview NanoBananaFree AI 适配器
* @description 通过自动化方式驱动 nanobananafree.ai 网页端生成图片,并将结果转换为统一的后端返回结构。
*/
import { initBrowserBase } from '../../browser/launcher.js';
import {
sleep,
safeClick,
pasteImages
} from '../../browser/utils.js';
import {
fillPrompt,
submit,
waitApiResponse,
normalizePageError,
normalizeHttpError,
moveMouseAway
} from '../utils.js';
import { logger } from '../../utils/logger.js';
// --- 配置常量 ---
const TARGET_URL = 'https://nanobananafree.ai/';
/**
* 初始化浏览器
* @param {object} config - 配置对象
* @returns {Promise<{browser: object, page: object, client: object}>}
*/
async function initBrowser(config) {
// NanoBananaFree AI 特定的输入框验证逻辑
const waitInputValidator = async (page) => {
const textareaSelector = 'textarea';
await page.waitForSelector(textareaSelector, { timeout: 60000 });
await safeClick(page, textareaSelector, { bias: 'input' });
await sleep(500, 1000);
};
const base = await initBrowserBase(config, {
userDataDir: config.paths.userDataDir,
targetUrl: TARGET_URL,
productName: 'NanoBananaFree AI',
waitInputValidator
});
return { ...base, config };
}
/**
* 执行生图任务
* @param {object} context - 浏览器上下文 { page, client }
* @param {string} prompt - 提示词
* @param {string[]} imgPaths - 图片路径数组 (仅取第一张)
* @param {string} [modelId] - 指定的模型 ID (可选,目前未使用)
* @param {object} [meta={}] - 日志元数据
* @returns {Promise<{image?: string, text?: string, error?: string}>} 生成结果
*/
async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
const { page } = context;
const textareaSelector = 'textarea';
try {
logger.info('适配器', '开启新会话', meta);
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' });
// 1. 等待输入框加载 (waitInput)
await page.waitForSelector(textareaSelector, { timeout: 30000 });
await sleep(1500, 2500);
// 2. 上传图片 (uploadImages - 仅取第一张)
if (imgPaths && imgPaths.length > 0) {
const singleImage = [imgPaths[0]];
if (imgPaths.length > 1) {
logger.warn('适配器', `此后端仅支持1张图片, 已丢弃 ${imgPaths.length - 1}`, meta);
}
await pasteImages(page, textareaSelector, singleImage);
}
// 3. 填写提示词 (fillPrompt)
await safeClick(page, textareaSelector, { bias: 'input' });
await fillPrompt(page, textareaSelector, prompt, meta);
// 4. 提交表单 (submit)
logger.debug('适配器', '点击发送...', meta);
await submit(page, {
btnSelector: 'div[class*="_sendButton_"]',
inputTarget: textareaSelector,
meta
});
logger.info('适配器', '等待生成结果...', meta);
// 5. 等待 API 响应 (waitApiResponse)
let response;
try {
response = await waitApiResponse(page, {
urlMatch: 'v1/generateContent',
method: 'POST',
timeout: 120000,
meta
});
} catch (e) {
// 使用公共错误处理
const pageError = normalizePageError(e, meta);
if (pageError) return pageError;
throw e;
}
// 6. 解析响应结果
// 先尝试获取响应内容用于错误解析
let content = null;
try {
content = await response.text();
} catch (e) { }
// 检查 HTTP 错误
const httpError = normalizeHttpError(response, content);
if (httpError) {
logger.error('适配器', `请求生成时返回错误: ${httpError.error}`, meta);
return { error: `请求生成时返回错误: ${httpError.error}` };
}
// 解析成功响应(使用已读取的 content)
let body;
try {
body = JSON.parse(content);
} catch (e) {
logger.error('适配器', '解析响应JSON时出错', meta);
return { error: '解析响应JSON时出错' };
}
// 7. 提取 base64 图片
const inlineData = body?.data?.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
if (inlineData) {
logger.info('适配器', '已获取生结果, 且已获取图片数据', meta);
return { image: `data:image/png;base64,${inlineData}` };
} else {
logger.info('适配器', 'AI 返回非图片响应', { ...meta, preview: JSON.stringify(body).substring(0, 150) });
return { text: JSON.stringify(body) };
}
} catch (err) {
// 顶层错误处理
const pageError = normalizePageError(err, meta);
if (pageError) return pageError;
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
return { error: `生成任务失败: ${err.message}` };
} finally {
// 任务结束,将鼠标移至安全区域
await moveMouseAway(page);
}
}
export { initBrowser, generateImage };
+421
View File
@@ -0,0 +1,421 @@
/**
* @fileoverview zAIzai.is)适配器
* @description 通过自动化方式驱动 zai.is 网页端生成图片,并将结果转换为统一的后端返回结构。
*/
import { initBrowserBase } from '../../browser/launcher.js';
import {
sleep,
safeClick,
pasteImages
} from '../../browser/utils.js';
import {
fillPrompt,
submit,
normalizePageError,
normalizeHttpError,
waitApiResponse,
moveMouseAway,
downloadImage
} from '../utils.js';
import { logger } from '../../utils/logger.js';
// zai.is 输入框选择器
const INPUT_SELECTOR = '.tiptap.ProseMirror';
// 入口 URL
const TARGET_URL = 'https://zai.is/';
/**
* 处理 Discord OAuth2 登录流程
* @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;
const currentUrl = page.url();
// 1. 检查是否在 zai.is/auth 页面
if (currentUrl.includes('zai.is/auth')) {
isHandlingAuth = true;
logger.info('适配器', '[登录器] 检测到登录页面,正在处理 Discord 登录...');
try {
// 等待页面加载完成,点击唯一的 button 标签
await page.waitForSelector('button', { timeout: 30000 });
await sleep(1000, 1500);
await safeClick(page, 'button', { bias: 'button' });
logger.info('适配器', '[登录器] 已点击登录按钮,等待跳转到 Discord...');
// 2. 等待跳转到 Discord OAuth2 授权页面
await page.waitForURL(url => url.href.includes('discord.com/oauth2/authorize'), { timeout: 60000 });
logger.info('适配器', '[登录器] 已到达 Discord 授权页面');
await sleep(2000, 3000);
// 3. 使用鼠标滚轮滚动 main 元素,直到授权按钮可用
// 授权按钮选择器: data-align="stretch" 的 div 中的最后一个按钮 (授权按钮在右边)
const authorizeBtnSelector = 'div[data-align="stretch"] button:last-child';
for (let i = 0; i < 15; i++) {
const authorizeBtn = await page.$(authorizeBtnSelector);
if (authorizeBtn) {
const isDisabled = await authorizeBtn.evaluate(el => el.disabled).catch(() => true);
if (!isDisabled) {
logger.info('适配器', '[登录器] 授权按钮已可用,正在点击...');
await sleep(500, 1000);
await safeClick(page, authorizeBtn, { bias: 'button' });
break;
}
}
// 使用鼠标滚轮在 main 元素中滚动
const mainElement = await page.$('main');
if (mainElement) {
const box = await mainElement.boundingBox();
if (box) {
// 将鼠标移动到 main 元素中心并滚动
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, 200);
}
}
await sleep(800, 1200);
}
// 4. 等待跳转回 zai.is (不包含 auth 和 discord)
logger.info('适配器', '[登录器] 等待跳转回目标页面...');
await page.waitForURL(url => {
const href = url.href;
return href.includes('zai.is') &&
!href.includes('/auth') &&
!href.includes('discord.com');
}, { timeout: 60000 });
logger.info('适配器', '[登录器] Discord 登录完成');
await sleep(2000, 3000);
isHandlingAuth = false;
return true;
} catch (err) {
logger.warn('适配器', `[登录器] Discord 登录处理失败: ${err.message}`);
isHandlingAuth = false;
}
}
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);
}
}
/**
* 初始化浏览器
* @param {object} config - 配置对象
* @returns {Promise<{browser: object, page: object, client: object}>}
*/
async function initBrowser(config) {
// 输入框验证逻辑(使用公共函数)
const waitInputValidator = async (page) => {
await waitForInputWithAuth(page);
};
const base = await initBrowserBase(config, {
userDataDir: config.paths.userDataDir,
targetUrl: TARGET_URL,
productName: 'Zai.is',
waitInputValidator,
navigationHandler: handleDiscordAuth
});
return { ...base, config };
}
/**
* 生成图片
* @param {object} context - 浏览器上下文 { page, client, config }
* @param {string} prompt - 提示词
* @param {string[]} imgPaths - 参考图片路径数组
* @param {string} modelId - 模型 ID
* @param {object} meta - 日志元数据
* @returns {Promise<{image?: string, error?: string}>} 生成结果
*/
async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
const { page, config } = context;
try {
// 开启新对话 - 先等待可能正在进行的登录处理完成
await waitForAuthComplete();
logger.info('适配器', '开启新会话', meta);
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' });
// 如果触发了登录跳转,等待全局处理器完成
await waitForAuthComplete();
// 1. 等待输入框加载(使用公共函数处理登录)
logger.debug('适配器', '正在寻找输入框...', meta);
await waitForInputWithAuth(page, { click: false });
await sleep(1500, 2500);
// 2. 上传图片 (如果有多张图片,会一张一张上传,每次都是 v1/files POST 请求)
if (imgPaths && imgPaths.length > 0) {
const expectedUploads = imgPaths.length;
let uploadedCount = 0;
await pasteImages(page, INPUT_SELECTOR, imgPaths, {
uploadValidator: (response) => {
const url = response.url();
if (response.status() === 200 && url.includes('v1/files')) {
uploadedCount++;
logger.info('适配器', `图片上传进度: ${uploadedCount}/${expectedUploads}`, meta);
if (uploadedCount >= expectedUploads) {
return true;
}
}
return false;
}
});
await sleep(1000, 2000);
}
// 3. 填写提示词
await safeClick(page, INPUT_SELECTOR, { bias: 'input' });
await fillPrompt(page, INPUT_SELECTOR, prompt, meta);
await sleep(500, 1000);
// 4. 设置请求拦截器 - 修改 chat/completions 请求中的 model 参数
logger.debug('适配器', '已启用请求拦截', meta);
await page.unroute('**/*').catch(() => { });
await page.route(url => url.href.includes('chat/completions'), async (route) => {
const request = route.request();
if (request.method() !== 'POST') return route.continue();
try {
const postData = request.postDataJSON();
if (postData) {
logger.debug('适配器', '已拦截 chat/completions 请求,正在修改 model...', meta);
postData.model = modelId;
logger.info('适配器', `已拦截请求,使用模型: ${modelId}`, meta);
await route.continue({ postData: JSON.stringify(postData) });
return;
}
} catch (e) {
logger.error('适配器', '请求拦截处理失败', { ...meta, error: e.message });
}
await route.continue();
});
// 5. 提交
logger.debug('适配器', '点击发送...', meta);
await submit(page, {
btnSelector: 'button[type="submit"]',
inputTarget: INPUT_SELECTOR,
meta
});
logger.info('适配器', '等待生成结果中...', meta);
// 6. 等待 v1/chats/new 响应 (状态码 200 且响应体中有 id)
let chatsNewResponse;
try {
chatsNewResponse = await waitApiResponse(page, {
urlMatch: 'v1/chats/new',
method: 'POST',
timeout: 60000,
meta
});
} catch (e) {
const pageError = normalizePageError(e, meta);
if (pageError) return pageError;
throw e;
}
// 检查 chats/new 响应
const httpError = normalizeHttpError(chatsNewResponse);
if (httpError) {
logger.error('适配器', `创建对话失败: ${httpError.error}`, meta);
return { error: `创建对话失败: ${httpError.error}` };
}
try {
const chatsNewBody = await chatsNewResponse.json();
if (!chatsNewBody.id) {
logger.error('适配器', '创建对话响应中没有无 id', meta);
return { error: '创建对话响应中没有 id' };
}
logger.debug('适配器', `对话创建成功, id: ${chatsNewBody.id}`, meta);
} catch (e) {
logger.error('适配器', '解析 chats/new 响应失败', { ...meta, error: e.message });
return { error: '解析对话响应失败' };
}
// 7. 等待 chat/completions 响应 (状态码 200 且 status: true)
let completionsResponse;
try {
completionsResponse = await waitApiResponse(page, {
urlMatch: 'chat/completions',
method: 'POST',
timeout: 120000,
meta
});
} catch (e) {
const pageError = normalizePageError(e, meta);
if (pageError) return pageError;
throw e;
}
const completionsHttpError = normalizeHttpError(completionsResponse);
if (completionsHttpError) {
logger.error('适配器', `生成请求失败: ${completionsHttpError.error}`, meta);
return { error: `生成请求失败: ${completionsHttpError.error}` };
}
try {
const completionsBody = await completionsResponse.json();
if (!completionsBody.status) {
logger.error('适配器', '生成响应 status 不为 true', meta);
return { error: '生成失败,响应状态异常' };
}
logger.debug('适配器', '生成请求成功', meta);
} catch (e) {
logger.error('适配器', '解析 completions 响应失败', { ...meta, error: e.message });
return { error: '解析生成响应失败' };
}
// 8. 等待 chat/completed 响应,从中提取图片链接
logger.debug('适配器', '正在等待完成响应...', meta);
let completedResponse;
try {
completedResponse = await waitApiResponse(page, {
urlMatch: 'chat/completed',
method: 'POST',
timeout: 120000,
meta
});
} catch (e) {
const pageError = normalizePageError(e, meta);
if (pageError) {
if (e.name === 'TimeoutError') {
return { error: '等待完成响应超时 (120秒)' };
}
return pageError;
}
throw e;
}
// 解析 chat/completed 响应
let completedBody;
try {
completedBody = await completedResponse.json();
} catch (e) {
logger.error('适配器', '解析 chat/completed 响应失败', { ...meta, error: e.message });
return { error: '解析完成响应失败' };
}
// 在 messages 数组中查找匹配的消息 (id 与响应体的 id 相同)
const targetMessage = (completedBody.messages || []).find(msg => msg.id === completedBody.id);
if (!targetMessage) {
logger.error('适配器', `未找到匹配的消息`, meta);
return { error: '未找到匹配的消息' };
}
// 检查 content
const content = targetMessage.content;
if (!content || content.trim() === '') {
logger.warn('适配器', '回复内容为空可能触发违规/限流', meta);
return { error: '回复内容为空可能触发违规/限流' };
}
// 从 content 中提取图片链接 (格式: ![image](https://zai.is/media/xxx.jpg))
const imageUrlMatch = content.match(/!\[.*?\]\((https:\/\/zai\.is\/media\/[^)]+)\)/);
if (!imageUrlMatch || !imageUrlMatch[1]) {
logger.warn('适配器', '回复中未找到图片链接', meta);
return { error: '回复中未找到图片链接' };
}
const imageUrl = imageUrlMatch[1];
logger.info('适配器', `已提取图片链接: ${imageUrl}`, meta);
// 下载图片
const downloadResult = await downloadImage(imageUrl, config);
if (downloadResult.error) {
return downloadResult;
}
logger.info('适配器', '已下载图片,任务完成', meta);
return { image: downloadResult.image };
} catch (err) {
// 顶层错误处理
const pageError = normalizePageError(err, meta);
if (pageError) return pageError;
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
return { error: `生成任务失败: ${err.message}` };
} finally {
// 清理拦截器
await page.unroute('**/*').catch(() => { });
// 任务结束,将鼠标移至安全区域
await moveMouseAway(page);
}
}
export { initBrowser, generateImage, handleDiscordAuth };
+224
View File
@@ -0,0 +1,224 @@
/**
* @fileoverview 后端适配器入口
* @description 负责加载配置、准备运行目录(用户数据/临时目录),并根据配置返回“单后端”或“聚合后端”的统一接口。
*
* 对外统一能力:
* - `initBrowser(cfg)`
* - `generateImage(ctx, prompt, imagePaths, modelId, meta)`
* - `resolveModelId(modelKey)` / `getModels()` / `getImagePolicy(modelKey)`
*/
import fs from 'fs';
import path from 'path';
import { loadConfig } from '../utils/config.js';
import { initBrowserBase } from '../browser/launcher.js';
import * as modelsModule from './models.js';
import { logger } from '../utils/logger.js';
// 导入适配器
import * as lmarenaBackend from './adapter/lmarena.js';
import * as geminiBackend from './adapter/gemini_biz.js';
import * as geminiConsumerBackend from './adapter/gemini.js';
import * as nanobananafreeBackend from './adapter/nanobananafree_ai.js';
import * as zaiIsBackend from './adapter/zai_is.js';
// --- 集中管理的路径常量 ---
const USER_DATA_DIR = path.join(process.cwd(), 'data', 'camoufoxUserData');
const TEMP_DIR = path.join(process.cwd(), 'data', 'temp');
// 确保必要目录存在
if (!fs.existsSync(USER_DATA_DIR)) {
fs.mkdirSync(USER_DATA_DIR, { recursive: true });
}
if (!fs.existsSync(TEMP_DIR)) {
fs.mkdirSync(TEMP_DIR, { recursive: true });
}
// 适配器映射表
const ADAPTER_MAP = {
'gemini_biz': geminiBackend,
'gemini': geminiConsumerBackend,
'nanobananafree_ai': nanobananafreeBackend,
'zai_is': zaiIsBackend,
'lmarena': lmarenaBackend
};
// 2. 聚合后端模式实现(跨调用复用全局 Page)
const MergedBackend = {
name: 'merge',
_globalBrowser: null,
_globalPage: null,
initBrowser: async (cfg) => {
if (MergedBackend._globalPage && !MergedBackend._globalPage.isClosed()) {
return { browser: MergedBackend._globalBrowser, page: MergedBackend._globalPage, config: cfg };
}
const activeTypes = cfg.backend.merge.type || [];
const handlers = [];
// 收集导航处理器
for (const type of activeTypes) {
const adapter = ADAPTER_MAP[type];
if (type === 'gemini_biz' && adapter.handleAccountChooser) handlers.push(adapter.handleAccountChooser);
if (type === 'zai_is' && adapter.handleDiscordAuth) handlers.push(adapter.handleDiscordAuth);
}
// 聚合处理器
const aggregatedHandler = async (page) => {
for (const handler of handlers) {
try { await handler(page); } catch (e) { }
}
};
logger.info('适配器', `[后端聚合] 启动全局浏览器,聚合: ${activeTypes.join(', ')}`);
const base = await initBrowserBase(cfg, {
userDataDir: cfg.paths.userDataDir,
targetUrl: 'about:blank',
productName: '聚合模式',
navigationHandler: aggregatedHandler,
waitInputValidator: async () => { }
});
MergedBackend._globalBrowser = base.browser;
MergedBackend._globalPage = base.page;
return { ...base, config: cfg };
},
generateImage: async (ctx, prompt, paths, modelId, meta) => {
if (!modelId || !modelId.includes('|')) return { error: 'Invalid aggregated model ID' };
const [adapterType, realId] = modelId.split('|');
const adapter = ADAPTER_MAP[adapterType];
if (!adapter) return { error: `Adapter not found: ${adapterType}` };
logger.info('适配器', `[后端聚合] 路由至: ${adapterType}, Model: ${realId}`, meta);
// 构造子上下文:复用全局 Page,但传入当前 config(适配器会读取 config.backend.geminiBiz 等字段)
const subContext = {
...ctx,
page: MergedBackend._globalPage,
config: cfg
};
return adapter.generateImage(subContext, prompt, paths, realId, meta);
},
resolveModelId: (modelKey) => {
const types = config.backend.merge.type;
// 支持 backend/model 格式指定后端 (如 lmarena/seedream-4.5)
if (modelKey.includes('/')) {
const [specifiedType, actualModel] = modelKey.split('/', 2);
if (ADAPTER_MAP[specifiedType] && types.includes(specifiedType)) {
const realId = modelsModule.resolveModelId(specifiedType, actualModel);
if (realId) return `${specifiedType}|${realId}`;
}
return null; // 指定的后端不存在或不在聚合列表中
}
// 按优先级自动匹配
for (const type of types) {
const realId = modelsModule.resolveModelId(type, modelKey);
if (realId) return `${type}|${realId}`;
}
return null;
},
getModels: () => {
const types = config.backend.merge.type;
const allModels = [];
const seenIds = new Set();
// 1. 添加按优先级自动选择的模型 (去重)
for (const type of types) {
const result = modelsModule.getModelsForBackend(type);
if (result?.data) {
for (const m of result.data) {
if (!seenIds.has(m.id)) {
seenIds.add(m.id);
allModels.push({
...m,
owned_by: type // 标记优先匹配的后端
});
}
}
}
}
// 2. 添加带前缀的模型 (backend/model 格式,用于指定后端)
for (const type of types) {
const result = modelsModule.getModelsForBackend(type);
if (result?.data) {
for (const m of result.data) {
const prefixedId = `${type}/${m.id}`;
allModels.push({
...m,
id: prefixedId,
owned_by: type
});
}
}
}
return { object: 'list', data: allModels };
},
getImagePolicy: (modelKey) => {
const types = config.backend.merge.type;
// 支持 backend/model 格式
if (modelKey.includes('/')) {
const [specifiedType, actualModel] = modelKey.split('/', 2);
if (ADAPTER_MAP[specifiedType] && types.includes(specifiedType)) {
return modelsModule.getImagePolicy(specifiedType, actualModel);
}
return 'optional';
}
// 按优先级查找
for (const type of types) {
const realId = modelsModule.resolveModelId(type, modelKey);
if (realId) return modelsModule.getImagePolicy(type, modelKey);
}
return 'optional';
}
};
export function getBackend() {
const config = loadConfig();
// 将路径常量注入 config 对象
config.paths = {
userDataDir: USER_DATA_DIR,
tempDir: TEMP_DIR
};
// 单一后端模式实现(基于当前配置构建)
const SingleBackend = {
name: config.backend.type,
initBrowser: async (cfg) => {
const adapter = ADAPTER_MAP[cfg.backend.type] || lmarenaBackend;
return adapter.initBrowser(cfg);
},
generateImage: async (ctx, prompt, paths, modelId, meta) => {
const adapter = ADAPTER_MAP[config.backend.type] || lmarenaBackend;
return adapter.generateImage(ctx, prompt, paths, modelId, meta);
},
resolveModelId: (modelKey) => modelsModule.resolveModelId(config.backend.type, modelKey),
getModels: () => modelsModule.getModelsForBackend(config.backend.type),
getImagePolicy: (modelKey) => modelsModule.getImagePolicy(config.backend.type, modelKey)
};
const isMerge = config.backend.merge && config.backend.merge.enable;
const activeBackend = isMerge ? MergedBackend : SingleBackend;
logger.info('适配器', `后端模式: ${isMerge ? '聚合' : '独立'}`);
return { config, TEMP_DIR, ...activeBackend };
}
+261
View File
@@ -0,0 +1,261 @@
/**
* @fileoverview 模型与图片策略映射
* @description 维护各后端的模型列表/别名与图片输入策略,并提供统一的解析与查询方法供服务器层使用。
*/
/**
* 图片输入策略枚举
* - optional:可带可不带(默认)
* - required:必须有参考图
* - forbidden:禁止带图
*/
export const IMAGE_POLICY = {
OPTIONAL: 'optional', // 可带可不带(默认)
REQUIRED: 'required', // 必须有参考图
FORBIDDEN: 'forbidden' // 禁止带图
};
/** LMArena 后端模型配置(modelKey -> 上游 codeName + 图片策略) */
export const LMARENA_MODELS = {
"gemini-3-pro-image-preview-2k": {
codeName: "019abc10-e78d-7932-b725-7f1563ed8a12",
imagePolicy: IMAGE_POLICY.OPTIONAL
},
"gemini-3-pro-image-preview": {
codeName: "019aa208-5c19-7162-ae3b-0a9ddbb1e16a",
imagePolicy: IMAGE_POLICY.OPTIONAL
},
"flux-2-flex": {
codeName: "019abed6-d96e-7a2b-bf69-198c28bef281",
imagePolicy: IMAGE_POLICY.OPTIONAL
},
"gemini-2.5-flash-image-preview": {
codeName: "0199ef2a-583f-7088-b704-b75fd169401d",
imagePolicy: IMAGE_POLICY.OPTIONAL
},
"hunyuan-image-3.0": {
codeName: "7766a45c-1b6b-4fb8-9823-2557291e1ddd",
imagePolicy: IMAGE_POLICY.FORBIDDEN
},
"flux-2-pro": {
codeName: "019abcf4-5600-7a8b-864d-9b8ab7ab7328",
imagePolicy: IMAGE_POLICY.OPTIONAL
},
"seedream-4.5": {
codeName: "019abd43-b052-7eec-aa57-e895e45c9723",
imagePolicy: IMAGE_POLICY.OPTIONAL
},
"seedream-4-high-res-fal": {
codeName: "32974d8d-333c-4d2e-abf3-f258c0ac1310",
imagePolicy: IMAGE_POLICY.OPTIONAL
},
"wan2.5-t2i-preview": {
codeName: "019a5050-2875-78ed-ae3a-d9a51a438685",
imagePolicy: IMAGE_POLICY.FORBIDDEN
},
"gpt-image-1": {
codeName: "6e855f13-55d7-4127-8656-9168a9f4dcc0",
imagePolicy: IMAGE_POLICY.OPTIONAL
},
"gpt-image-mini": {
codeName: "0199c238-f8ee-7f7d-afc1-7e28fcfd21cf",
imagePolicy: IMAGE_POLICY.OPTIONAL
},
"mai-image-1": {
codeName: "1b407d5c-1806-477c-90a5-e5c5a114f3bc",
imagePolicy: IMAGE_POLICY.FORBIDDEN
},
"seedream-3": {
codeName: "d8771262-8248-4372-90d5-eb41910db034",
imagePolicy: IMAGE_POLICY.FORBIDDEN
},
"qwen-image-prompt-extend": {
codeName: "9fe82ee1-c84f-417f-b0e7-cab4ae4cf3f3",
imagePolicy: IMAGE_POLICY.FORBIDDEN
},
"flux-1-kontext-pro": {
codeName: "28a8f330-3554-448c-9f32-2c0a08ec6477",
imagePolicy: IMAGE_POLICY.OPTIONAL
},
"imagen-3.0-generate-002": {
codeName: "51ad1d79-61e2-414c-99e3-faeb64bb6b1b",
imagePolicy: IMAGE_POLICY.FORBIDDEN
},
"ideogram-v3-quality": {
codeName: "73378be5-cdba-49e7-b3d0-027949871aa6",
imagePolicy: IMAGE_POLICY.FORBIDDEN
},
"photon": {
codeName: "e7c9fa2d-6f5d-40eb-8305-0980b11c7cab",
imagePolicy: IMAGE_POLICY.FORBIDDEN
},
"recraft-v3": {
codeName: "b88d5814-1d20-49cc-9eb6-e362f5851661",
imagePolicy: IMAGE_POLICY.FORBIDDEN
},
"lucid-origin": {
codeName: "5a3b3520-c87d-481f-953c-1364687b6e8f",
imagePolicy: IMAGE_POLICY.FORBIDDEN
},
"gemini-2.0-flash-preview-image-generation": {
codeName: "69bbf7d4-9f44-447e-a868-abc4f7a31810",
imagePolicy: IMAGE_POLICY.OPTIONAL
},
"dall-e-3": {
codeName: "bb97bc68-131c-4ea4-a59e-03a6252de0d2",
imagePolicy: IMAGE_POLICY.FORBIDDEN
},
"flux-1-kontext-dev": {
codeName: "eb90ae46-a73a-4f27-be8b-40f090592c9a",
imagePolicy: IMAGE_POLICY.OPTIONAL
},
"vidu-q2-image": {
codeName: "019adb32-afa4-749e-9992-39653b52fe13",
imagePolicy: IMAGE_POLICY.OPTIONAL
},
"imagen-4.0-fast-generate-001": {
codeName: "f44fd4f8-af30-480f-8ce2-80b2bdfea55e",
imagePolicy: IMAGE_POLICY.FORBIDDEN
},
"imagen-4.0-ultra-generate-001": {
codeName: "019ae6da-6438-7077-9d2d-b311a35645f8",
imagePolicy: IMAGE_POLICY.FORBIDDEN
},
"flux-2-dev": {
codeName: "019ae6a0-4773-77d5-8ffb-cc35813e063c",
imagePolicy: IMAGE_POLICY.OPTIONAL
},
"imagen-4.0-generate-001": {
codeName: "019ae6da-6788-761a-8253-e0bb2bf2e3a9",
imagePolicy: IMAGE_POLICY.FORBIDDEN
},
"wan2.5-i2i-preview": {
codeName: "019aeb62-c6ea-788e-88f9-19b1b48325b5",
imagePolicy: IMAGE_POLICY.REQUIRED
},
"hunyuan-image-2.1": {
codeName: "a9a26426-5377-4efa-bef9-de71e29ad943",
imagePolicy: IMAGE_POLICY.FORBIDDEN
},
"qwen-image-edit": {
codeName: "995cf221-af30-466d-a809-8e0985f83649",
imagePolicy: IMAGE_POLICY.REQUIRED
},
"reve-v1": {
codeName: "0199e980-ba42-737b-9436-927b6e7ca73e",
imagePolicy: IMAGE_POLICY.REQUIRED
},
"reve-fast-edit": {
codeName: "019a5675-0a56-7835-abdd-1cb9e7870afa",
imagePolicy: IMAGE_POLICY.REQUIRED
}
};
/** Gemini Business 后端模型配置 */
export const GEMINI_BIZ_MODELS = {
"gemini-3-pro-image-preview": {
imagePolicy: IMAGE_POLICY.OPTIONAL
}
};
/** NanoBananaFree AI 后端模型配置 */
export const NANOBANANAFREE_AI_MODELS = {
"gemini-2.5-flash-image": {
imagePolicy: IMAGE_POLICY.OPTIONAL
}
};
/** zai.is 后端模型配置 */
export const ZAI_IS_MODELS = {
"gemini-3-pro-image-preview": {
imagePolicy: IMAGE_POLICY.OPTIONAL
},
"gemini-2.5-flash-image": {
imagePolicy: IMAGE_POLICY.OPTIONAL
}
};
// Gemini 后端模型配置
export const GEMINI_MODELS = {
"gemini-3-pro-image-preview": {
imagePolicy: IMAGE_POLICY.OPTIONAL
}
};
/**
* 获取后端对应的模型配置表
* @param {string} backendName - 后端名称 ('lmarena' 或 'gemini_biz' 或 'nanobananafree_ai')
* @returns {Object} 模型配置对象
* @private
*/
function getModelsConfigForBackend(backendName) {
switch (backendName) {
case 'lmarena':
return LMARENA_MODELS;
case 'gemini_biz':
return GEMINI_BIZ_MODELS;
case 'gemini':
return GEMINI_MODELS;
case 'nanobananafree_ai':
return NANOBANANAFREE_AI_MODELS;
case 'zai_is':
return ZAI_IS_MODELS;
default:
return {};
}
}
/**
* 获取指定后端的模型列表 (OpenAI格式)
* @param {string} backendName - 后端名称
* @returns {Object} OpenAI 格式的模型列表
*/
export function getModelsForBackend(backendName) {
const modelsConf = getModelsConfigForBackend(backendName);
const modelIds = Object.keys(modelsConf);
return {
object: 'list',
data: modelIds.map(id => ({
id,
object: 'model',
created: Math.floor(Date.now() / 1000),
owned_by: backendName,
// 向前端暴露图片策略
image_policy: modelsConf[id].imagePolicy || IMAGE_POLICY.OPTIONAL
}))
};
}
/**
* 解析模型 ID
* @param {string} backendName - 后端名称
* @param {string} modelKey - 请求的模型键
* @returns {string|null} 返回内部使用的 codeName,若模型无效则返回 null
*/
export function resolveModelId(backendName, modelKey) {
const modelsConf = getModelsConfigForBackend(backendName);
const model = modelsConf[modelKey];
if (!model) return null; // 未配置的模型 -> 无效
// 无 codeName 时,退回到模型 ID 本身
return model.codeName || modelKey;
}
/**
* 获取模型的图片策略
* @param {string} backendName - 后端名称
* @param {string} modelKey - 模型键
* @returns {string} 图片策略 ('optional' | 'required' | 'forbidden')
*/
export function getImagePolicy(backendName, modelKey) {
const modelsConf = getModelsConfigForBackend(backendName);
const model = modelsConf[modelKey];
if (!model || !model.imagePolicy) {
return IMAGE_POLICY.OPTIONAL;
}
return model.imagePolicy;
}
+271
View File
@@ -0,0 +1,271 @@
/**
* @fileoverview 后端适配器公共流程
* @description 提供各适配器复用的通用页面操作与错误归一化能力。
*
* 主要函数:
* - `fillPrompt`:拟人化输入提示词
* - `submit`:提交表单(点击按钮失败则回退为回车)
* - `waitApiResponse`:等待匹配的 API 响应(包含页面关闭/崩溃监听)
* - `normalizePageError`:将页面级异常归一化为可返回给服务器层的错误
* - `normalizeHttpError`:将 HTTP 响应错误(含限流/人机验证)归一化
*/
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 - Playwright 页面对象
*/
export async function moveMouseAway(page) {
if (!page.cursor) return;
try {
const vp = await getRealViewport(page);
await page.cursor.moveTo({
x: clamp(vp.safeWidth * random(0.85, 0.95), 0, vp.safeWidth),
y: clamp(vp.height * random(0.3, 0.7), 0, vp.safeHeight)
});
} catch (e) {
// 忽略鼠标移动失败
}
}
/**
* 填写提示词 (通用)
* @param {import('playwright-core').Page} page - Playwright 页面对象
* @param {string|import('playwright-core').ElementHandle} target - 输入目标(选择器或元素句柄)
* @param {string} prompt - 提示词内容
* @param {object} [meta={}] - 日志元数据
*/
export async function fillPrompt(page, target, prompt, meta = {}) {
logger.info('适配器', '正在输入提示词...', meta);
await humanType(page, target, prompt);
await sleep(800, 1500);
}
/**
* 提交表单 (带回退逻辑)
*
* 尝试点击指定按钮,失败时回退到按回车提交
*
* @param {import('playwright-core').Page} page - Playwright 页面对象
* @param {object} options - 提交选项
* @param {string} options.btnSelector - 按钮选择器
* @param {string|import('playwright-core').ElementHandle} [options.inputTarget] - 输入框(回退时使用)
* @param {object} [options.meta={}] - 日志元数据
* @returns {Promise<boolean>} 是否成功点击按钮(false 表示使用了回退)
*/
export async function submit(page, options = {}) {
const { btnSelector, inputTarget, meta = {} } = options;
try {
const btnHandle = await page.$(btnSelector);
if (btnHandle) {
// 确保按钮在可视区域
await btnHandle.scrollIntoViewIfNeeded().catch(() => { });
await sleep(200, 400);
await safeClick(page, btnHandle, { bias: 'button' });
return true;
}
} catch (e) {
// 选择器无效或其他错误,继续回退逻辑
}
// 回退:按回车提交
logger.warn('适配器', '未找到发送按钮,尝试回车提交', meta);
if (inputTarget) {
if (typeof inputTarget === 'string') {
await page.focus(inputTarget).catch(() => { });
} else {
await inputTarget.focus().catch(() => { });
}
}
await page.keyboard.press('Enter');
return false;
}
/**
* 等待 API 响应 (带页面关闭监听)
*
* 使用 Promise.race 同时监听:
* - API 响应
* - 页面关闭/崩溃事件
*
* @param {import('playwright-core').Page} page - Playwright 页面对象
* @param {object} options - 等待选项
* @param {string} options.urlMatch - URL 匹配字符串(包含关系)
* @param {string} [options.method='POST'] - HTTP 方法
* @param {number} [options.timeout=120000] - 超时时间(毫秒)
* @param {object} [options.meta={}] - 日志元数据
* @returns {Promise<import('playwright-core').Response>} 响应对象
* @throws {Error} 页面关闭/崩溃/超时时抛出错误
*/
export async function waitApiResponse(page, options = {}) {
const { urlMatch, method = 'POST', timeout = 120000, meta = {} } = options;
// 先检查页面状态
if (!isPageValid(page)) {
throw new Error('PAGE_INVALID');
}
const pageWatcher = createPageCloseWatcher(page);
try {
const responsePromise = page.waitForResponse(
response =>
response.url().includes(urlMatch) &&
response.request().method() === method &&
(response.status() === 200 || response.status() >= 400),
{ timeout }
);
return await Promise.race([responsePromise, pageWatcher.promise]);
} finally {
pageWatcher.cleanup();
}
}
/**
* 统一处理页面级错误
*
* 处理以下错误类型:
* - PAGE_CLOSED: 页面被关闭
* - PAGE_CRASHED: 页面崩溃
* - PAGE_INVALID: 页面状态无效
* - TimeoutError: 请求超时
*
* @param {Error} err - 原始错误
* @param {object} [meta={}] - 日志元数据
* @returns {{ error: string } | null} 标准化错误对象,未匹配返回 null
*/
export function normalizePageError(err, meta = {}) {
if (err.message === 'PAGE_CLOSED') {
logger.error('适配器', '页面已关闭', meta);
return { error: '页面已关闭,请勿在生图过程中刷新页面' };
}
if (err.message === 'PAGE_CRASHED') {
logger.error('适配器', '页面崩溃', meta);
return { error: '页面崩溃,请重试' };
}
if (err.message === 'PAGE_INVALID') {
logger.error('适配器', '页面状态无效', meta);
return { error: '页面状态无效,请重新初始化' };
}
if (err.name === 'TimeoutError') {
logger.error('适配器', '请求超时', meta);
return { error: '请求超时 (120秒), 请检查网络或稍后重试' };
}
return null; // 未匹配到已知错误类型
}
/**
* 统一处理 HTTP 响应错误
*
* 处理以下错误类型:
* - 429: 限流 / CAPTCHA
* - recaptcha validation failed: 人机验证失败
* - 4xx/5xx: 服务端错误
*
* @param {import('playwright-core').Response} response - HTTP 响应对象
* @param {string} [content=null] - 响应体内容(可选)
* @returns {{ error: string, code?: string } | null} 标准化错误对象,无错误返回 null
*/
export function normalizeHttpError(response, content = null) {
const status = response.status();
// 429 限流检查
if (status === 429 || content?.includes('Too Many Requests')) {
return { error: '触发限流/上游繁忙', code: '429' };
}
// reCAPTCHA 验证失败
if (content?.includes('recaptcha validation failed')) {
return { error: '触发人机验证', code: 'RECAPTCHA' };
}
// 其他客户端/服务端错误
if (status >= 400) {
return { error: `上游服务器错误,HTTP错误码: ${status}`, code: String(status) };
}
return null;
}
/**
* 下载图片并转换为 Base64
*
* 根据 camoufoxFingerprints.json 动态生成请求头,保持与浏览器指纹一致
*
* @param {string} url - 图片 URL
* @param {object} config - 配置对象(需包含 proxy 配置)
* @returns {Promise<{ image?: string, error?: string }>} 下载结果
*/
export async function downloadImage(url, config) {
// 动态导入依赖
const { gotScraping } = await import('got-scraping');
const fs = await import('fs');
const path = await import('path');
const { getProxyConfig, getHttpProxy } = await import('../utils/proxy.js');
try {
// 读取指纹文件获取浏览器信息
const fingerprintPath = path.join(process.cwd(), 'data', 'camoufoxFingerprints.json');
let browserName = 'firefox';
let browserMinVersion = 100;
let os = 'windows';
let locale = 'en-US';
if (fs.existsSync(fingerprintPath)) {
try {
const fingerprint = JSON.parse(fs.readFileSync(fingerprintPath, 'utf8'));
// 从指纹中提取信息
if (fingerprint.navigator?.userAgent) {
// 解析 User-Agent 获取浏览器版本
const versionMatch = fingerprint.navigator.userAgent.match(/Firefox\/(\d+)/i);
if (versionMatch) {
browserMinVersion = parseInt(versionMatch[1], 10);
}
}
if (fingerprint.navigator?.platform) {
const platform = fingerprint.navigator.platform.toLowerCase();
if (platform.includes('win')) os = 'windows';
else if (platform.includes('mac')) os = 'macos';
else if (platform.includes('linux')) os = 'linux';
}
if (fingerprint.navigator?.language) {
locale = fingerprint.navigator.language;
}
} catch (e) {
// 解析失败使用默认值
}
}
// 获取代理配置
const proxyConfig = getProxyConfig(config);
const proxyUrl = await getHttpProxy(proxyConfig);
const options = {
url,
responseType: 'buffer',
http2: true,
headerGeneratorOptions: {
browsers: [{ name: browserName, minVersion: browserMinVersion }],
devices: ['desktop'],
locales: [locale],
operatingSystems: [os],
}
};
if (proxyUrl) {
options.proxyUrl = proxyUrl;
}
const response = await gotScraping(options);
const base64 = response.body.toString('base64');
return { image: `data:image/png;base64,${base64}` };
} catch (e) {
return { error: `已获取结果,但图片下载时遇到错误: ${e.message}` };
}
}