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

This commit is contained in:
foxhui
2025-12-13 03:37:51 +08:00
Unverified
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}` };
}
}
+378
View File
@@ -0,0 +1,378 @@
/**
* @fileoverview 浏览器启动与生命周期管理
* @description 负责启动 CamoufoxPlaywright 内核)、注入指纹与代理、创建/复用页面,并在进程退出时做资源清理。
*
* 约定:
* - 登录模式会尽量保留 Profile(用户数据目录)
* - 清理采用三级退出:Playwright close -> SIGTERM -> SIGKILL
*/
import { Camoufox } from 'camoufox-js';
import { FingerprintGenerator } from 'fingerprint-generator';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { createCursor } from 'ghost-cursor-playwright-port';
import { getRealViewport, clamp, random, sleep } from './utils.js';
import { logger } from '../utils/logger.js';
import { getBrowserProxy, cleanupProxy } from '../utils/proxy.js';
// 全局状态:用于在登录模式下管理残留进程与复用上下文
let globalBrowserProcess = null;
let globalContext = null; // 替代 globalBrowser
/**
* 清理浏览器资源和进程
* 实现三级退出机制: Playwright close -> SIGTERM -> SIGKILL
* @returns {Promise<void>}
*/
export async function cleanup() {
// Level 1: 通过 Playwright 协议优雅关闭 Context,保存 Profile
if (globalContext) {
try {
logger.debug('浏览器', '正在断开远程调试连接并保存 Profile...');
await globalContext.close();
globalContext = null;
logger.debug('浏览器', '已关闭浏览器上下文');
} catch (e) {
logger.warn('浏览器', `关闭上下文失败: ${e.message}`);
}
}
// Level 2 & 3: 处理残留进程 (主要用于登录模式)
if (globalBrowserProcess && !globalBrowserProcess.killed) {
logger.info('浏览器', '正在终止浏览器进程...');
try {
// Level 2: 发送 SIGTERM (软杀)
globalBrowserProcess.kill('SIGTERM');
// 等待进程退出
const start = Date.now();
while (Date.now() - start < 2000) {
try {
process.kill(globalBrowserProcess.pid, 0);
await new Promise(r => setTimeout(r, 200));
} catch (e) {
break;
}
}
} catch (e) { }
// Level 3: 强制查杀 (SIGKILL)
try {
process.kill(globalBrowserProcess.pid, 0);
logger.debug('浏览器', '浏览器进程无响应,执行强制终止 (SIGKILL)...');
process.kill(-globalBrowserProcess.pid, 'SIGKILL');
} catch (e) { }
globalBrowserProcess = null;
logger.info('浏览器', '浏览器进程已终止');
}
// 清理代理
await cleanupProxy();
}
// 防止重复注册
let signalHandlersRegistered = false;
/**
* 注册进程退出信号处理
* @private
*/
function registerCleanupHandlers() {
if (signalHandlersRegistered) return;
process.on('exit', () => {
if (globalBrowserProcess) globalBrowserProcess.kill();
});
process.on('SIGINT', async () => {
await cleanup();
process.exit();
});
process.on('SIGTERM', async () => {
await cleanup();
process.exit();
});
signalHandlersRegistered = true;
}
/**
* 获取当前操作系统名称
* 将 Node.js 的 platform 转换为 Camoufox/FingerprintGenerator 支持的格式
*/
function getCurrentOS() {
const platform = os.platform();
if (platform === 'win32') return 'windows';
if (platform === 'darwin') return 'macos';
// 其他情况默认为 linux
return 'linux';
}
/**
* 获取或生成持久化指纹
* @param {string} filePath - JSON文件保存路径
*/
function getPersistentFingerprint(filePath) {
// 确保 data 目录存在
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// 尝试读取现有指纹
if (fs.existsSync(filePath)) {
try {
const fileContent = fs.readFileSync(filePath, 'utf8');
const savedData = JSON.parse(fileContent);
// 简单校验:确保读取的是一个对象
if (savedData && typeof savedData === 'object') {
logger.info('浏览器', '已加载本地持久化指纹');
return savedData;
}
} catch (e) {
logger.warn('浏览器', `读取指纹文件失败,将重新生成: ${e.message}`);
}
}
// 生成新指纹
const currentOS = getCurrentOS();
logger.info('浏览器', `正在为系统 [${currentOS}] 生成新指纹...`);
// 为不同系统使用不同的配置策略
const generatorOptions = {
browsers: ['firefox'],
operatingSystems: [currentOS],
devices: ['desktop'],
locales: ['en-US'],
screen: {
minWidth: 1280, maxWidth: 1366,
minHeight: 720, maxHeight: 768
}
};
const generator = new FingerprintGenerator(generatorOptions);
const result = generator.getFingerprint();
// 关键点:我们只需要 result.fingerprint 部分
const fingerprintToSave = result.fingerprint;
// 保存到文件
fs.writeFileSync(filePath, JSON.stringify(fingerprintToSave, null, 2));
logger.info('浏览器', `新指纹已保存至: ${filePath}`);
return fingerprintToSave;
}
/**
* 初始化浏览器 (统一启动逻辑)
* @param {object} config - 配置对象
* @param {object} [config.browser] - Browser 配置
* @param {boolean} [config.browser.headless] - 是否开启 Headless 模式
* @param {string} [config.browser.path] - Camoufox 可执行文件路径
* @param {object} [config.browser.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] - 自定义输入框等待验证函数
* @param {Function} [options.navigationHandler] - 全局导航处理器,用于自动处理登录等跳转
* @returns {Promise<{browser: object, page: object, client: object}>}
*/
export async function initBrowserBase(config, options) {
const {
userDataDir,
targetUrl,
productName,
waitInputValidator = null,
navigationHandler = null
} = options;
// 检测登录模式和 Xvfb 模式
const isLoginMode = process.argv.includes('-login');
const isXvfbMode = process.env.XVFB_RUNNING === 'true';
const ENABLE_AUTOMATION_MODE = !isLoginMode;
logger.info('浏览器', `开始初始化浏览器 (${productName})`);
logger.info('浏览器', `自动化模式: ${ENABLE_AUTOMATION_MODE ? '开启' : '关闭'}`);
if (isLoginMode) {
logger.warn('浏览器', '当前为登录模式,请手动完成登录后关闭登录模式以继续自动化程序!');
}
if (isXvfbMode) {
logger.info('浏览器', '检测到 Xvfb 环境,强制禁用无头模式');
}
const browserConfig = config?.browser || {};
// 获取指纹对象
const fingerprintPath = path.join(process.cwd(), 'data', 'camoufoxFingerprints.json');
const myFingerprint = getPersistentFingerprint(fingerprintPath);
// 构造 Camoufox 启动选项
logger.info('浏览器', '正在启动 Camoufox 浏览器...');
const currentOS = getCurrentOS();
const camoufoxLaunchOptions = {
// 基础选项 (snake_case)
executable_path: browserConfig.path || undefined,
headless: browserConfig.headless && !isLoginMode && !isXvfbMode,
user_data_dir: userDataDir,
window: [1366, 768],
ff_version: 135,
fingerprint: myFingerprint,
os: currentOS,
i_know_what_im_doing: true,
block_webrtc: true,
exclude_addons: ['UBO'],
geoip: false
};
// Headless 模式配置
if (browserConfig.headless && !isLoginMode && !isXvfbMode) {
logger.info('浏览器', 'Headless 模式: 启用');
} else {
const reasons = [];
if (isLoginMode) reasons.push('登录模式');
if (isXvfbMode) reasons.push('Xvfb 模式');
if (!browserConfig.headless) reasons.push('配置禁用');
logger.info('浏览器', 'Headless 模式: 禁用' + (reasons.length > 0 ? ` (${reasons.join(', ')})` : ''));
}
// 代理配置适配
const proxyObject = await getBrowserProxy(browserConfig.proxy);
if (proxyObject) {
camoufoxLaunchOptions.proxy = proxyObject;
}
// 启动 Camoufox
const context = await Camoufox(camoufoxLaunchOptions);
globalContext = context; // 存储全局 Context
logger.info('浏览器', 'Camoufox 浏览器已启动');
// 注册清理处理器
registerCleanupHandlers();
// 注册断开连接事件
context.on('close', async () => {
logger.warn('浏览器', 'Camoufox 浏览器已断开连接');
await cleanup();
process.exit(0);
});
// 获取或创建 Page
let page;
const existingPages = context.pages(); // 获取启动时自动打开的页面
if (!page) {
if (existingPages.length > 0) {
page = existingPages[0];
logger.debug('浏览器', '复用浏览器启动时的默认标签页');
} else {
page = await context.newPage();
logger.debug('浏览器', '浏览器没有标签,已创建新标签页');
}
}
// 强制刷新一下视口大小,防止复用默认窗口时尺寸不对
if (camoufoxLaunchOptions.viewport) {
await page.setViewportSize(camoufoxLaunchOptions.viewport);
}
// 注册全局导航处理器(用于自动处理登录等跳转)
if (navigationHandler) {
page.on('framenavigated', async () => {
try {
await navigationHandler(page);
} catch (e) {
logger.warn('浏览器', `全局导航处理器出错: ${e.message}`);
}
});
logger.debug('浏览器', '已注册全局导航处理器');
}
// 登录模式挂起逻辑
if (isLoginMode) {
// 尝试导航到目标页面方便用户登录
try {
logger.info('浏览器', `正在连接 ${productName}...`);
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
} catch (e) {
logger.warn('浏览器', `打开页面失败: ${e.message}`);
}
logger.info('浏览器', '请在弹出的浏览器窗口中手动完成登录操作');
logger.info('浏览器', '完成后可直接关闭浏览器窗口或在终端结束程序');
await new Promise((resolve) => {
context.on('close', () => {
logger.info('浏览器', '检测到浏览器窗口关闭,程序即将退出');
resolve();
});
});
await cleanup();
process.exit(0);
}
// 初始化 ghost-cursor
page.cursor = createCursor(page);
// --- 行为预热建立人机检测信任 ---
const urlDomain = new URL(targetUrl).hostname;
if (!page.url().includes(urlDomain)) {
logger.info('浏览器', `正在连接 ${productName}...`);
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
} 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);
// 移动鼠标 (增加拟人化)
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: context,
page
};
}
+537
View File
@@ -0,0 +1,537 @@
/**
* @fileoverview 浏览器自动化工具函数
* @description 封装 Playwright 页面常用操作(等待、查找、点击、上传、拟人化行为等),供后端适配器复用。
*
* 主要函数:
* - `random` / `sleep`:随机与延迟工具
* - `getMimeType`:根据文件扩展名推断 MIME
* - `getRealViewport` / `clamp`:视口与坐标工具(防止越界)
* - `queryDeep`:深层查询(包含 Shadow DOM / iframe
* - `safeClick` / `humanType`:拟人化点击与输入
* - `pasteImages` / `uploadFilesViaChooser`:图片粘贴/上传辅助
* - `isPageValid` / `createPageCloseWatcher`:页面有效性与关闭/崩溃监听
* - `getCookies`:读取 CookiesJSON 格式)
*/
import path from 'path';
import { logger } from '../utils/logger.js';
/**
* 生成指定范围内的随机数
* @param {number} min - 最小值
* @param {number} max - 最大值
* @returns {number} 随机数
*/
export function random(min, max) {
return Math.random() * (max - min) + min;
}
/**
* 随机休眠一段时间
* @param {number} min - 最小毫秒数
* @param {number} max - 最大毫秒数
* @returns {Promise<void>}
*/
export function sleep(min, max) {
return new Promise(r => setTimeout(r, Math.floor(random(min, max))));
}
/**
* 根据文件扩展名获取 MIME 类型
* @param {string} filePath - 文件路径
* @returns {string} MIME 类型
*/
export function getMimeType(filePath) {
const ext = path.extname(filePath).toLowerCase();
const map = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp'
};
return map[ext] || 'application/octet-stream';
}
/**
* 无痕获取当前页面实时视口
* 使用纯净的匿名函数执行,不污染 Global Scope
* @param {import('playwright-core').Page} page - Playwright 页面实例
* @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 };
}
}
/**
* 坐标钳位函数
* 强制将坐标限制在合法视口范围内,防止 "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('playwright-core').Page} page - Playwright 页面实例
* @param {string} selector - CSS 选择器
* @param {import('playwright-core').ElementHandle} [rootHandle=null] - 可选的根节点句柄
* @returns {Promise<import('playwright-core').ElementHandle|null>} 找到的元素句柄或 null
*/
export async function queryDeep(page, selector, rootHandle = null) {
// Playwright evaluateHandle 只接受一个参数,包装成数组传递
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]);
}
/**
* 计算拟人化的随机点击坐标
* @param {object} box - 元素边界框 {x, y, width, height}
* @param {string} [type='random'] - 点击类型: 'input'(偏左) 或 'random'/'button'(随机)
* @returns {{x: number, y: number}} 计算出的坐标
*/
export function getHumanClickPoint(box, type = 'random') {
let x, y;
if (type === 'input') {
// 输入框: 偏左 (5% - 40% 宽度), 垂直居中附近 (20% - 80% 高度)
x = box.x + box.width * random(0.05, 0.4);
y = box.y + box.height * random(0.2, 0.8);
} else {
// 按钮/其他: 中心附近随机 (20% - 80% 宽度/高度)
x = box.x + box.width * random(0.2, 0.8);
y = box.y + box.height * random(0.2, 0.8);
}
return { x, y };
}
/**
* 安全点击元素 (包含拟人化移动和点击)
* 支持 CSS selector、ElementHandle 和 Locator 三种输入
* @param {import('playwright-core').Page} page - Playwright 页面对象
* @param {string|import('playwright-core').ElementHandle|import('playwright-core').Locator} target - CSS 选择器、元素句柄或 Locator
* @param {object} [options] - 点击选项
* @param {string} [options.bias='random'] - 偏移偏好: 'input' 或 'random'
* @returns {Promise<void>}
*/
export async function safeClick(page, target, options = {}) {
try {
let el;
// 判断输入类型
if (typeof target === 'string') {
// CSS selector
el = await page.$(target);
if (!el) throw new Error(`未找到: ${target}`);
} else if (typeof target.elementHandle === 'function') {
// Locator (来自 page.getByRole, page.getByText 等)
el = await target.elementHandle();
if (!el) throw new Error(`Locator 未匹配到元素`);
} else {
// ElementHandle
el = target;
if (!el || !el.asElement()) throw new Error(`Element handle invalid`);
}
// 使用 ghost-cursor 点击
if (page.cursor) {
const box = await el.boundingBox();
if (box) {
const { x, y } = getHumanClickPoint(box, options.bias || 'random');
await page.cursor.moveTo({ x, y });
await page.mouse.click(x, y);
return;
}
// 如果无法获取 box,降级到默认点击
await page.cursor.click(el);
return;
}
// 降级逻辑
await el.click();
} catch (err) {
throw err;
}
}
/**
* 模拟人类键盘输入
* 支持 CSS selector 和 ElementHandle 两种输入
* @param {import('playwright-core').Page} page - Playwright 页面对象
* @param {string|import('playwright-core').ElementHandle} target - CSS 选择器或元素句柄
* @param {string} text - 要输入的文本
* @returns {Promise<void>}
*/
export async function humanType(page, target, text) {
let el;
// 判断是 selector 还是 ElementHandle
if (typeof target === 'string') {
el = await page.$(target);
if (!el) throw new Error(`Element not found: ${target}`);
} else {
el = target;
if (!el) throw new Error(`Element handle invalid`);
}
await el.focus();
// 智能输入策略
if (text.length < 50) {
// 短文本: 保持拟人化逐字输入
for (let i = 0; i < text.length; i++) {
const char = text[i];
const nextChar = text[i + 1];
// 处理换行符 (避免触发发送)
if (char === '\r' && nextChar === '\n') {
// Windows 换行符 (\r\n)
await page.keyboard.down('Shift');
await page.keyboard.press('Enter');
await page.keyboard.up('Shift');
i++; // 跳过 \n
await sleep(30, 100);
continue;
} else if (char === '\n' || char === '\r') {
// Unix/Mac 换行符 (\n 或 \r)
await page.keyboard.down('Shift');
await page.keyboard.press('Enter');
await page.keyboard.up('Shift');
await sleep(30, 100);
continue;
}
// 模拟错字 (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. 停顿思考
await sleep(500, 1000);
// 3. 全选删除 (macOS 使用 Meta/Command, Windows/Linux 使用 Control)
const modifierKey = process.platform === 'darwin' ? 'Meta' : 'Control';
await page.keyboard.down(modifierKey);
await page.keyboard.press('A');
await page.keyboard.up(modifierKey);
await sleep(100, 300);
await page.keyboard.press('Backspace');
await sleep(100, 300);
// 4. 瞬间粘贴全部文本 (始终使用已获取的 ElementHandle,支持 Shadow DOM)
await page.evaluate((content) => {
document.execCommand('insertText', false, content);
}, text);
}
}
/**
* 查找页面上所有的文件输入框 (包括 Shadow DOM)
* @private
* @param {import('playwright-core').Page} page - Playwright 页面对象
* @returns {Promise<import('playwright-core').ElementHandle[]>} 文件输入框 ElementHandle 数组
*/
async function findAllFileInputs(page) {
// 使用 Playwright 的 evaluateHandle 在浏览器上下文中深度遍历
const inputsHandle = await page.evaluateHandle(() => {
const inputs = [];
function traverse(root) {
if (!root) return;
// 1. 检查当前节点下的 input
const nodes = root.querySelectorAll('input[type="file"]');
nodes.forEach(n => inputs.push(n));
// 2. 遍历 Shadow DOM
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, null, false);
while (walker.nextNode()) {
const node = walker.currentNode;
if (node.shadowRoot) {
traverse(node.shadowRoot);
}
}
}
traverse(document.body);
return inputs;
});
const properties = await inputsHandle.getProperties();
const handles = [];
for (const prop of properties.values()) {
const elementHandle = prop.asElement();
if (elementHandle) handles.push(elementHandle);
}
return handles;
}
/**
* 统一图片上传入口 (Camoufox/Playwright 专用稳定版)
* 策略: 深度搜索原生 input[type="file"] -> setInputFiles
* @param {import('playwright-core').Page} page - Playwright 页面对象
* @param {string|import('playwright-core').ElementHandle} target - CSS 选择器或元素句柄 (用于聚焦)
* @param {string[]} filePaths - 图片文件路径数组
* @param {Object} [options] - 可选配置
* @param {Function} [options.uploadValidator] - 自定义上传确认回调函数, 接收 response 参数
* @returns {Promise<void>}
*/
export async function pasteImages(page, target, filePaths, options = {}) {
if (!filePaths || filePaths.length === 0) return;
logger.info('浏览器', `正在处理 ${filePaths.length} 张图片...`);
// 1. 拟人化: 先点击一下目标区域 (让后台看起来像是用户聚焦了输入框)
await safeClick(page, target, { bias: 'input' });
await sleep(500, 1000);
try {
logger.debug('浏览器', '正在深度扫描文件上传控件...');
const fileInputs = await findAllFileInputs(page);
if (fileInputs.length === 0) {
throw new Error('未找到任何 input[type="file"] 控件,无法上传');
}
logger.info('浏览器', `找到 ${fileInputs.length} 个文件输入框,尝试上传...`);
// LMArena 通常只有一个用于聊天的上传控件,或者我们尝试第一个可用的
// 如果有多个,通常最后一个是当前对话框的,或者我们可以尝试全部 (比较暴力但有效)
let uploaded = false;
for (const handle of fileInputs) {
try {
// 检查元素是否连接在 DOM 上
const isConnected = await handle.evaluate(el => el.isConnected);
if (!isConnected) continue;
// 使用 Playwright 原生上传 (绕过所有事件拦截)
await handle.setInputFiles(filePaths);
uploaded = true;
logger.debug('浏览器', '已通过原生控件提交图片');
break; // 只要有一个成功就停止
} catch (e) {
// 忽略不可操作的 input (比如被禁用的)
logger.debug('浏览器', `跳过不可用的文件输入框: ${e.message}`);
}
}
if (!uploaded) {
throw new Error('所有文件控件均无法接受输入');
}
// 如果提供了自定义的上传确认函数,使用它
if (options.uploadValidator && typeof options.uploadValidator === 'function') {
const expectedUploads = filePaths.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);
});
logger.info('浏览器', `已提交图片, 正在等待上传确认...`);
await uploadPromise;
logger.info('浏览器', `所有图片上传完成`);
} else {
// 默认行为: 等待上传预览出现
logger.info('浏览器', `已提交图片, 等待预览生成...`);
await sleep(2000, 4000);
}
} catch (e) {
logger.error('浏览器', `上传失败: ${e.message}`);
throw e;
}
}
/**
* 通过 filechooser 事件上传文件 (适用于无 DOM input 元素的场景,如 Firefox)
* @param {import('playwright-core').Page} page - Playwright 页面对象
* @param {string|import('playwright-core').ElementHandle|import('playwright-core').Locator} triggerTarget - 触发文件选择的按钮
* @param {string[]} filePaths - 文件路径数组
* @param {Object} [options] - 可选配置
* @param {Function} [options.uploadValidator] - 自定义上传确认回调函数, 接收 response 参数,返回 true 表示该响应代表一次成功上传
* @param {number} [options.timeout=60000] - 上传超时时间 (毫秒)
* @returns {Promise<void>}
*/
export async function uploadFilesViaChooser(page, triggerTarget, filePaths, options = {}) {
if (!filePaths || filePaths.length === 0) return;
const timeout = options.timeout || 60000;
const expectedUploads = filePaths.length;
let uploadedCount = 0;
logger.info('浏览器', `正在处理 ${filePaths.length} 张图片 (filechooser 模式)...`);
// 设置上传确认监听
const uploadPromise = new Promise((resolve) => {
if (!options.uploadValidator) {
// 无验证器,直接 resolve
resolve();
return;
}
const timeoutId = setTimeout(() => {
cleanup();
logger.warn('浏览器', `图片上传等待超时 (已确认: ${uploadedCount}/${expectedUploads})`);
resolve();
}, timeout);
const onResponse = (response) => {
if (options.uploadValidator(response)) {
uploadedCount++;
logger.info('浏览器', `图片上传进度: ${uploadedCount}/${expectedUploads}`);
if (uploadedCount >= expectedUploads) {
cleanup();
resolve();
}
}
};
const cleanup = () => {
clearTimeout(timeoutId);
page.off('response', onResponse);
};
page.on('response', onResponse);
});
// 设置等待 filechooser 事件(在点击之前)
const fileChooserPromise = page.waitForEvent('filechooser');
// 点击触发按钮
await safeClick(page, triggerTarget, { bias: 'button' });
// 等待 filechooser 事件并设置文件
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(filePaths);
logger.debug('浏览器', '已通过 filechooser 提交文件');
// 等待上传完成(如果有验证器)
if (options.uploadValidator) {
await uploadPromise;
logger.info('浏览器', '所有图片上传完成');
}
}
/**
* 检查页面是否有效
* @param {import('playwright-core').Page} page
* @returns {boolean}
*/
export function isPageValid(page) {
try {
return page && !page.isClosed();
} catch {
return false;
}
}
/**
* 创建页面关闭/崩溃监听Promise
* @param {import('playwright-core').Page} page
* @returns {{promise: Promise, cleanup: Function}}
*/
export function createPageCloseWatcher(page) {
let closeHandler, crashHandler;
const promise = new Promise((_, reject) => {
closeHandler = () => reject(new Error('PAGE_CLOSED'));
crashHandler = () => reject(new Error('PAGE_CRASHED'));
page.once('close', closeHandler);
page.once('crash', crashHandler);
});
const cleanup = () => {
if (closeHandler) page.off('close', closeHandler);
if (crashHandler) page.off('crash', crashHandler);
};
return { promise, cleanup };
}
/**
* 获取当前页面的所有 Cookies (实时从浏览器获取)
* @param {import('playwright-core').Page} page - Playwright 页面实例
* @returns {Promise<object[]>} Cookies 数组 (JSON 格式)
*/
export async function getCookies(page) {
const context = page.context();
return await context.cookies();
}
+213
View File
@@ -0,0 +1,213 @@
/**
* @fileoverview Xvfb 和 VNC 显示参数处理模块(仅 Linux)
* @description 处理 -xvfb 和 -vnc 命令行参数,启动虚拟显示器和 VNC 服务器
*/
import { spawn, spawnSync } from 'child_process';
import os from 'os';
import net from 'net';
import { logger } from '../utils/logger.js';
/**
* 检查命令是否存在
* @param {string} cmd - 命令名称
* @returns {boolean} 命令是否存在
*/
function checkCommand(cmd) {
const result = spawnSync('which', [cmd], { encoding: 'utf8' });
return result.status === 0;
}
/**
* 检查端口是否可用
* @param {number} port - 端口号
* @returns {Promise<boolean>} 端口是否可用
*/
function isPortAvailable(port) {
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => {
resolve(false);
});
server.once('listening', () => {
server.close();
resolve(true);
});
server.listen(port);
});
}
/**
* 查找可用的 VNC 端口
* @param {number} [startPort=5900] - 起始端口
* @param {number} [maxAttempts=10] - 最大尝试次数
* @returns {Promise<number|null>} 可用端口号,或 null 表示未找到
*/
async function findAvailableVncPort(startPort = 5900, maxAttempts = 10) {
for (let i = 0; i < maxAttempts; i++) {
const port = startPort + i;
if (await isPortAvailable(port)) {
return port;
}
}
return null;
}
/**
* 启动 VNC 服务器
* @param {string} display - 显示器编号(如 ':99'
* @returns {Promise<import('child_process').ChildProcess>} VNC 进程
*/
async function startVncServer(display) {
if (!checkCommand('x11vnc')) {
logger.error('服务器', '未找到 x11vnc 命令');
logger.error('服务器', '请先安装 x11vnc:');
logger.error('服务器', ' - Ubuntu/Debian: sudo apt install x11vnc');
logger.error('服务器', ' - CentOS/RHEL: sudo dnf install x11vnc');
process.exit(1);
}
logger.info('服务器', '正在查找可用的 VNC 端口...');
const vncPort = await findAvailableVncPort(5900, 10);
if (!vncPort) {
logger.error('服务器', '无法找到可用的 VNC 端口 (已尝试 5900-5909)');
process.exit(1);
}
logger.info('服务器', `正在启动 VNC 服务器 (端口 ${vncPort})...`);
const vncProcess = spawn('x11vnc', [
'-display', display,
'-rfbport', vncPort.toString(),
'-localhost',
'-nopw',
'-once',
'-noxdamage',
'-ncache', '10',
'-forever'
], {
stdio: 'ignore',
detached: false
});
vncProcess.on('error', (err) => {
logger.error('服务器', 'VNC 启动失败', { error: err.message });
});
logger.info('服务器', 'VNC 服务器已成功启动');
logger.warn('服务器', `VNC 连接端口: ${vncPort}`);
return vncProcess;
}
/**
* 在 Xvfb 中重启进程
* @param {string[]} args - 当前命令行参数
*/
function restartInXvfb(args) {
logger.info('服务器', '正在启动 Xvfb 虚拟显示器...');
// 构建新的参数列表(移除 -xvfb,保留其他参数)
const newArgs = args.filter(arg => arg !== '-xvfb');
const xvfbArgs = [
'--server-num=99',
'--server-args=-ac -screen 0 1366x768x24',
'env',
'XVFB_RUNNING=true',
'DISPLAY=:99',
process.argv[0],
process.argv[1],
...newArgs
];
const xvfbProcess = spawn('xvfb-run', xvfbArgs, {
stdio: 'inherit'
});
xvfbProcess.on('error', (err) => {
logger.error('服务器', 'Xvfb 启动失败', { error: err.message });
process.exit(1);
});
xvfbProcess.on('exit', (code) => {
process.exit(code || 0);
});
// 处理父进程退出信号
process.on('SIGINT', () => {
xvfbProcess.kill('SIGTERM');
});
process.on('SIGTERM', () => {
xvfbProcess.kill('SIGTERM');
});
}
/**
* 处理 Xvfb 和 VNC 启动参数
* @returns {Promise<'XVFB_REDIRECT'|undefined>} 如果需要重定向到 Xvfb 则返回 'XVFB_REDIRECT'
*/
export async function handleDisplayParams() {
const args = process.argv.slice(2);
const hasXvfb = args.includes('-xvfb');
const hasVnc = args.includes('-vnc');
const isInXvfb = process.env.XVFB_RUNNING === 'true';
// -vnc 必须和 -xvfb 并用(但如果已在 Xvfb 中运行则允许)
if (hasVnc && !hasXvfb && !isInXvfb) {
logger.error('服务器', '-vnc 参数必须和 -xvfb 参数一起使用');
logger.error('服务器', '正确用法: node server.js -xvfb -vnc');
process.exit(1);
}
// 非 Linux 系统检查
if ((hasXvfb || hasVnc) && os.platform() !== 'linux') {
logger.warn('服务器', '忽略参数: -xvfb 和 -vnc 参数仅在 Linux 系统上支持');
return;
}
// 处理 -xvfb 参数
if ((hasXvfb || isInXvfb) && os.platform() === 'linux') {
// 检查 xvfb-run 是否存在(仅在首次启动时需要)
if (hasXvfb && !isInXvfb) {
if (!checkCommand('xvfb-run')) {
logger.error('服务器', '未找到 xvfb-run 命令');
logger.error('服务器', '请先安装 Xvfb:');
logger.error('服务器', ' - Ubuntu/Debian: sudo apt install xvfb');
logger.error('服务器', ' - CentOS/RHEL: sudo dnf install xorg-x11-server-Xvfb');
process.exit(1);
}
}
// 已在 Xvfb 中运行
if (isInXvfb) {
logger.info('服务器', '已在 Xvfb 虚拟显示器中运行', { display: process.env.DISPLAY || ':99' });
// 处理 VNC
if (hasVnc) {
const display = process.env.DISPLAY || ':99';
const vncProcess = await startVncServer(display);
// 处理进程退出信号
process.on('SIGINT', () => {
vncProcess.kill('SIGTERM');
process.exit(0);
});
process.on('SIGTERM', () => {
vncProcess.kill('SIGTERM');
process.exit(0);
});
}
return;
}
// 需要在 Xvfb 中重启
restartInXvfb(args);
return 'XVFB_REDIRECT';
}
}
+112
View File
@@ -0,0 +1,112 @@
/**
* @fileoverview 错误码表与中文消息映射模块
* @description 统一定义服务器错误码及其对应的中文消息和 HTTP 状态码
*/
/**
* 错误码枚举
* @readonly
* @enum {string}
*/
export const ERROR_CODES = {
/** 未授权(Token 无效或缺失) */
UNAUTHORIZED: 'UNAUTHORIZED',
/** 浏览器未初始化 */
BROWSER_NOT_INITIALIZED: 'BROWSER_NOT_INITIALIZED',
/** 服务器繁忙(队列已满) */
SERVER_BUSY: 'SERVER_BUSY',
/** 请求参数缺少 messages */
NO_MESSAGES: 'NO_MESSAGES',
/** messages 中缺少 role=user 的消息 */
NO_USER_MESSAGES: 'NO_USER_MESSAGES',
/** 图片数量超过限制 */
TOO_MANY_IMAGES: 'TOO_MANY_IMAGES',
/** 模型无效/后端不支持 */
INVALID_MODEL: 'INVALID_MODEL',
/** 该模型需要参考图 */
IMAGE_REQUIRED: 'IMAGE_REQUIRED',
/** 该模型不支持图片输入 */
IMAGE_FORBIDDEN: 'IMAGE_FORBIDDEN',
/** 触发人机验证(reCAPTCHA */
RECAPTCHA: 'RECAPTCHA',
/** 服务器内部错误 */
INTERNAL_ERROR: 'INTERNAL_ERROR',
};
/**
* 错误详情映射表
* @type {Record<string, {message: string, status: number}>}
*/
const ERROR_DETAILS = {
[ERROR_CODES.UNAUTHORIZED]: {
message: '未授权(Token 无效或缺失)',
status: 401,
},
[ERROR_CODES.BROWSER_NOT_INITIALIZED]: {
message: '浏览器未初始化',
status: 503,
},
[ERROR_CODES.SERVER_BUSY]: {
message: '服务器繁忙(队列已满)',
status: 429,
},
[ERROR_CODES.NO_MESSAGES]: {
message: '请求参数缺少 messages',
status: 400,
},
[ERROR_CODES.NO_USER_MESSAGES]: {
message: 'messages 中缺少 role=user 的消息',
status: 400,
},
[ERROR_CODES.TOO_MANY_IMAGES]: {
message: '图片数量超过限制',
status: 400,
},
[ERROR_CODES.INVALID_MODEL]: {
message: '模型无效/后端不支持',
status: 400,
},
[ERROR_CODES.IMAGE_REQUIRED]: {
message: '该模型需要参考图',
status: 400,
},
[ERROR_CODES.IMAGE_FORBIDDEN]: {
message: '该模型不支持图片输入',
status: 400,
},
[ERROR_CODES.RECAPTCHA]: {
message: '触发人机验证(reCAPTCHA',
status: 500,
},
[ERROR_CODES.INTERNAL_ERROR]: {
message: '服务器内部错误',
status: 500,
},
};
/**
* 获取错误消息
* @param {string} code - 错误码
* @returns {string} 中文错误消息
*/
export function getErrorMessage(code) {
return ERROR_DETAILS[code]?.message || '未知错误';
}
/**
* 获取错误对应的 HTTP 状态码
* @param {string} code - 错误码
* @returns {number} HTTP 状态码
*/
export function getErrorStatus(code) {
return ERROR_DETAILS[code]?.status || 500;
}
/**
* 获取完整的错误详情
* @param {string} code - 错误码
* @returns {{message: string, status: number}} 错误详情
*/
export function getErrorDetails(code) {
return ERROR_DETAILS[code] || { message: '未知错误', status: 500 };
}
+147
View File
@@ -0,0 +1,147 @@
/**
* @fileoverview 统一响应写出模块
* @description 封装 JSON、SSE 响应和错误响应的统一处理函数
*/
import { getErrorDetails } from '../errors.js';
/**
* 发送 JSON 响应
* @param {import('http').ServerResponse} res - HTTP 响应对象
* @param {number} status - HTTP 状态码
* @param {object} payload - 响应数据
*/
export function sendJson(res, status, payload) {
if (res.writableEnded) return;
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
}
/**
* 发送 SSE 事件
* @param {import('http').ServerResponse} res - HTTP 响应对象
* @param {object} payload - 事件数据
*/
export function sendSse(res, payload) {
if (res.writableEnded) return;
res.write(`data: ${JSON.stringify(payload)}\n\n`);
}
/**
* 发送 SSE 结束标记
* @param {import('http').ServerResponse} res - HTTP 响应对象
*/
export function sendSseDone(res) {
if (res.writableEnded) return;
res.write(`data: [DONE]\n\n`);
res.end();
}
/**
* 发送 SSE 心跳包
* @param {import('http').ServerResponse} res - HTTP 响应对象
* @param {string} mode - 心跳模式 ('comment' | 'content')
* @param {string} [modelName] - 模型名称(content 模式需要)
*/
export function sendHeartbeat(res, mode, modelName) {
if (res.writableEnded) return;
if (mode === 'comment') {
res.write(`:keepalive\n\n`);
} else {
// content 模式:发送空 delta
const chunk = {
id: 'chatcmpl-' + Date.now(),
object: 'chat.completion.chunk',
created: Math.floor(Date.now() / 1000),
model: modelName || 'default-model',
choices: [{
index: 0,
delta: { content: '' },
finish_reason: null
}]
};
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
}
}
/**
* 发送统一 API 错误响应
* @param {import('http').ServerResponse} res - HTTP 响应对象
* @param {object} options - 错误选项
* @param {string} [options.code] - 错误码(使用 ERROR_CODES 枚举)
* @param {string} [options.error] - 自定义错误消息(如提供则覆盖 code 对应的消息)
* @param {number} [options.status] - 自定义 HTTP 状态码
* @param {boolean} [options.isStreaming=false] - 是否为流式响应
* @param {string} [options.raw] - 原始错误信息(用于调试)
*/
export function sendApiError(res, options) {
const { code, error, status, isStreaming = false, raw } = options;
// 获取错误详情
const details = code ? getErrorDetails(code) : null;
const errorMessage = error || (details ? details.message : '未知错误');
const httpStatus = status || (details ? details.status : 500);
// 构造错误响应体
const payload = {
error: errorMessage,
code: code || 'INTERNAL_ERROR',
};
if (raw) {
payload.raw = raw;
}
if (isStreaming) {
// 流式响应
sendSse(res, payload);
sendSseDone(res);
} else {
// 非流式响应
sendJson(res, httpStatus, payload);
}
}
/**
* 构造 OpenAI 格式的聊天完成响应(非流式)
* @param {string} content - 响应内容
* @param {string} [modelName] - 模型名称
* @returns {object} OpenAI 格式的响应对象
*/
export function buildChatCompletion(content, modelName) {
return {
id: 'chatcmpl-' + Date.now(),
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: modelName || 'default-model',
choices: [{
index: 0,
message: {
role: 'assistant',
content: content
},
finish_reason: 'stop'
}]
};
}
/**
* 构造 OpenAI 格式的流式聊天完成响应块
* @param {string} content - 响应内容
* @param {string} [modelName] - 模型名称
* @param {string|null} [finishReason='stop'] - 完成原因
* @returns {object} OpenAI 格式的流式响应块
*/
export function buildChatCompletionChunk(content, modelName, finishReason = 'stop') {
return {
id: 'chatcmpl-' + Date.now(),
object: 'chat.completion.chunk',
created: Math.floor(Date.now() / 1000),
model: modelName || 'default-model',
choices: [{
index: 0,
delta: { content },
finish_reason: finishReason
}]
};
}
+193
View File
@@ -0,0 +1,193 @@
/**
* @fileoverview HTTP 路由分发模块
* @description 处理 API 路由分发和请求鉴权
*/
import crypto from 'crypto';
import { logger } from '../../utils/logger.js';
import { ERROR_CODES } from '../errors.js';
import { sendJson, sendApiError } from './respond.js';
import { parseRequest } from '../parseChat.js';
/**
* 鉴权检查
* @param {import('http').IncomingMessage} req - HTTP 请求
* @param {string} authToken - 有效的认证令牌
* @returns {boolean} 是否通过鉴权
*/
function checkAuth(req, authToken) {
const authHeader = req.headers['authorization'];
return authHeader === `Bearer ${authToken}`;
}
/**
* 创建路由处理器
* @param {object} context - 路由上下文
* @param {string} context.authToken - 认证令牌
* @param {string} context.backendName - 后端名称
* @param {Function} context.getModels - 获取模型列表函数
* @param {Function} context.resolveModelId - 解析模型 ID 函数
* @param {Function} context.getImagePolicy - 获取图片策略函数
* @param {string} context.tempDir - 临时目录
* @param {number} context.imageLimit - 图片数量限制
* @param {object} context.queueManager - 队列管理器
* @returns {Function} 请求处理函数
*/
export function createRouter(context) {
const {
authToken,
backendName,
getModels,
resolveModelId,
getImagePolicy,
tempDir,
imageLimit,
queueManager
} = context;
/**
* 处理 GET /v1/models
* @param {import('http').ServerResponse} res - HTTP 响应
*/
function handleModels(res) {
const models = getModels();
sendJson(res, 200, models);
}
/**
* 处理 GET /v1/cookies
* @param {import('http').ServerResponse} res - HTTP 响应
* @param {string} requestId - 请求 ID
*/
async function handleCookies(res, requestId) {
const browserContext = queueManager.getBrowserContext();
if (!browserContext?.page) {
sendApiError(res, { code: ERROR_CODES.BROWSER_NOT_INITIALIZED });
return;
}
try {
const context = browserContext.page.context();
const cookies = await context.cookies();
sendJson(res, 200, { cookies });
} catch (err) {
logger.error('服务器', '获取 Cookies 失败', { id: requestId, error: err.message });
sendApiError(res, {
code: ERROR_CODES.INTERNAL_ERROR,
error: err.message
});
}
}
/**
* 处理 POST /v1/chat/completions
* @param {import('http').IncomingMessage} req - HTTP 请求
* @param {import('http').ServerResponse} res - HTTP 响应
* @param {string} requestId - 请求 ID
*/
async function handleChatCompletions(req, res, requestId) {
// 读取请求体
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
try {
const body = Buffer.concat(chunks).toString();
const data = JSON.parse(body);
const isStreaming = data.stream === true;
// 限流检查:非流式请求在队列满时拒绝
if (!isStreaming && !queueManager.canAcceptNonStreaming()) {
const status = queueManager.getStatus();
logger.warn('服务器', '非流式请求被拒绝 (队列已满)', { id: requestId, queueSize: status.total });
sendApiError(res, {
code: ERROR_CODES.SERVER_BUSY,
error: `服务器繁忙(队列: ${status.total}/${queueManager.maxQueueSize})。请使用流式模式 (stream: true) 或稍后重试。`
});
return;
}
// 设置 SSE 响应头(流式请求)
if (isStreaming) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
}
// 解析请求
const parseResult = await parseRequest(data, {
tempDir,
imageLimit,
backendName,
resolveModelId,
getImagePolicy,
requestId,
logger
});
if (!parseResult.success) {
sendApiError(res, {
code: parseResult.error.code,
error: parseResult.error.error,
isStreaming
});
return;
}
const { prompt, imagePaths, modelId, modelName } = parseResult.data;
logger.info('服务器', `[队列] 请求入队: ${prompt.slice(0, 10)}...`, { id: requestId, images: imagePaths.length });
// 将任务加入队列
queueManager.addTask({
req,
res,
prompt,
imagePaths,
modelId,
modelName,
id: requestId,
isStreaming
});
} catch (err) {
logger.error('服务器', '请求处理失败', { id: requestId, error: err.message });
sendApiError(res, {
code: ERROR_CODES.INTERNAL_ERROR,
error: err.message
});
}
}
/**
* 主路由处理函数
* @param {import('http').IncomingMessage} req - HTTP 请求
* @param {import('http').ServerResponse} res - HTTP 响应
*/
return async function handleRequest(req, res) {
// 生成请求 ID
const requestId = crypto.randomUUID().slice(0, 8);
// 鉴权检查
if (!checkAuth(req, authToken)) {
sendApiError(res, { code: ERROR_CODES.UNAUTHORIZED });
return;
}
// 路由分发
if (req.method === 'GET' && req.url === '/v1/models') {
handleModels(res);
} else if (req.method === 'GET' && req.url === '/v1/cookies') {
await handleCookies(res, requestId);
} else if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) {
await handleChatCompletions(req, res, requestId);
} else {
res.writeHead(404);
res.end();
}
};
}
+19
View File
@@ -0,0 +1,19 @@
/**
* @fileoverview src/server 模块入口
* @description 导出服务器相关模块
*/
export { ERROR_CODES, getErrorMessage, getErrorStatus, getErrorDetails } from './errors.js';
export {
sendJson,
sendSse,
sendSseDone,
sendHeartbeat,
sendApiError,
buildChatCompletion,
buildChatCompletionChunk
} from './http/respond.js';
export { handleDisplayParams } from './display.js';
export { createQueueManager } from './queue.js';
export { parseRequest } from './parseChat.js';
export { createRouter } from './http/routes.js';
+193
View File
@@ -0,0 +1,193 @@
/**
* @fileoverview 请求解析模块
* @description 负责解析聊天请求、提取提示词和处理图片
*/
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
import { IMAGE_POLICY } from '../backend/models.js';
import { ERROR_CODES, getErrorMessage } from './errors.js';
/**
* 构造解析错误结果
* @param {string} code - 错误码
* @param {string} [customMessage] - 自定义消息(可选,用于包含动态参数)
* @returns {{success: false, error: {code: string, error: string}}}
*/
function parseError(code, customMessage) {
return {
success: false,
error: {
code,
error: customMessage || getErrorMessage(code)
}
};
}
/**
* @typedef {object} ParsedRequest
* @property {string} prompt - 提取的提示词
* @property {string[]} imagePaths - 图片临时文件路径
* @property {string|null} modelId - 解析后的模型 ID
* @property {string|null} modelName - 原始模型名称
* @property {boolean} isStreaming - 是否流式请求
*/
/**
* @typedef {object} ParseError
* @property {string} code - 错误码
* @property {string} error - 错误消息
*/
/**
* @typedef {object} ParseResult
* @property {boolean} success - 是否解析成功
* @property {ParsedRequest} [data] - 解析结果(成功时)
* @property {ParseError} [error] - 错误信息(失败时)
*/
/**
* 解析聊天请求
* @param {object} data - 请求体数据
* @param {object} options - 解析选项
* @param {string} options.tempDir - 临时目录路径
* @param {number} options.imageLimit - 图片数量限制
* @param {string} options.backendName - 后端名称
* @param {Function} options.resolveModelId - 模型 ID 解析函数
* @param {Function} options.getImagePolicy - 获取图片策略函数
* @param {string} options.requestId - 请求 ID
* @param {Function} options.logger - 日志函数
* @returns {Promise<ParseResult>} 解析结果
*/
export async function parseRequest(data, options) {
const {
tempDir,
imageLimit,
backendName,
resolveModelId,
getImagePolicy,
requestId,
logger
} = options;
const messages = data.messages;
const isStreaming = data.stream === true;
// 验证 messages
if (!messages || messages.length === 0) {
return parseError(ERROR_CODES.NO_MESSAGES);
}
// 筛选用户消息
const userMessages = messages.filter(m => m.role === 'user');
if (userMessages.length === 0) {
return parseError(ERROR_CODES.NO_USER_MESSAGES);
}
const lastMessage = userMessages[userMessages.length - 1];
let prompt = '';
const imagePaths = [];
let imageCount = 0;
// 解析内容
if (Array.isArray(lastMessage.content)) {
for (const item of lastMessage.content) {
if (item.type === 'text') {
prompt += item.text + ' ';
} else if (item.type === 'image_url' && item.image_url?.url) {
imageCount++;
// 图片数量检查
if (imageLimit <= 10) {
if (imageCount > imageLimit) {
return parseError(ERROR_CODES.TOO_MANY_IMAGES, `图片数量超过限制(最大 ${imageLimit} 张)`);
}
} else {
// imageLimit > 10:超过浏览器硬限制时忽略
if (imageCount > 10) {
continue;
}
}
// 处理 data URL
const url = item.image_url.url;
if (url.startsWith('data:image')) {
const imagePath = await saveBase64Image(url, tempDir);
if (imagePath) {
imagePaths.push(imagePath);
}
}
}
}
} else {
prompt = lastMessage.content;
}
prompt = prompt.trim();
// 解析模型参数
let modelId = null;
if (data.model) {
modelId = resolveModelId(data.model);
if (modelId) {
logger.info('服务器', `触发模型: ${data.model} (${modelId})`, { id: requestId });
} else {
return parseError(ERROR_CODES.INVALID_MODEL, `模型无效/后端 ${backendName} 不支持: ${data.model}`);
}
} else {
logger.info('服务器', '未指定模型,使用网页默认', { id: requestId });
}
// 图片策略校验
const hasImage = imagePaths.length > 0;
const policy = data.model ? getImagePolicy(data.model) : IMAGE_POLICY.OPTIONAL;
if (policy === IMAGE_POLICY.REQUIRED && !hasImage) {
return parseError(ERROR_CODES.IMAGE_REQUIRED, `模型 ${data.model} 需要参考图`);
}
if (policy === IMAGE_POLICY.FORBIDDEN && hasImage) {
return parseError(ERROR_CODES.IMAGE_FORBIDDEN, `模型 ${data.model} 不支持图片输入`);
}
return {
success: true,
data: {
prompt,
imagePaths,
modelId,
modelName: data.model || null,
isStreaming
}
};
}
/**
* 保存 Base64 图片到临时文件
* @param {string} dataUrl - data URL 格式的图片
* @param {string} tempDir - 临时目录
* @returns {Promise<string|null>} 保存的文件路径,失败返回 null
*/
async function saveBase64Image(dataUrl, tempDir) {
const matches = dataUrl.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/);
if (!matches || matches.length !== 3) {
return null;
}
try {
const buffer = Buffer.from(matches[2], 'base64');
// 压缩图片
const processedBuffer = await sharp(buffer)
.jpeg({ quality: 90 })
.toBuffer();
const filename = `img_${Date.now()}_${Math.random().toString(36).substring(7)}.jpg`;
const filePath = path.join(tempDir, filename);
fs.writeFileSync(filePath, processedBuffer);
return filePath;
} catch (e) {
return null;
}
}
+223
View File
@@ -0,0 +1,223 @@
/**
* @fileoverview 任务队列管理模块
* @description 负责请求队列、并发控制和心跳机制
*/
import { logger } from '../utils/logger.js';
import {
sendJson,
sendSse,
sendSseDone,
sendHeartbeat,
sendApiError,
buildChatCompletion,
buildChatCompletionChunk
} from './http/respond.js';
/**
* @typedef {object} TaskContext
* @property {import('http').IncomingMessage} req - HTTP 请求对象
* @property {import('http').ServerResponse} res - HTTP 响应对象
* @property {string} prompt - 用户提示词
* @property {string[]} imagePaths - 图片路径列表
* @property {string|null} modelId - 模型 ID
* @property {string|null} modelName - 模型名称
* @property {string} id - 请求唯一标识
* @property {boolean} isStreaming - 是否流式请求
*/
/**
* @typedef {object} QueueConfig
* @property {number} maxConcurrent - 最大并发数
* @property {number} maxQueueSize - 最大队列大小
* @property {string} keepaliveMode - 心跳模式 ('comment' | 'content')
*/
/**
* @typedef {object} BrowserContext
* @property {object} browser - Playwright 浏览器实例
* @property {object} page - Playwright 页面实例
* @property {object} config - 配置对象
*/
/**
* 创建任务队列管理器
* @param {QueueConfig} queueConfig - 队列配置
* @param {object} callbacks - 回调函数
* @param {Function} callbacks.initBrowser - 初始化浏览器函数
* @param {Function} callbacks.generateImage - 生成图片函数
* @param {object} callbacks.config - 配置对象
* @returns {object} 队列管理器
*/
export function createQueueManager(queueConfig, callbacks) {
const { maxConcurrent, maxQueueSize, keepaliveMode } = queueConfig;
const { initBrowser, generateImage, config } = callbacks;
/** @type {TaskContext[]} */
const queue = [];
/** @type {number} */
let processingCount = 0;
/** @type {BrowserContext|null} */
let browserContext = null;
/**
* 清理任务临时文件
* @param {TaskContext} task - 任务上下文
*/
async function cleanupTask(task) {
if (task?.imagePaths) {
const fs = await import('fs');
for (const p of task.imagePaths) {
try { fs.unlinkSync(p); } catch (e) { /* ignore */ }
}
}
}
/**
* 处理单个任务
* @param {TaskContext} task - 任务上下文
*/
async function processTask(task) {
const { res, prompt, imagePaths, modelId, modelName, id, isStreaming } = task;
logger.info('服务器', '[队列] 开始处理任务', { id, remaining: queue.length });
// 启动心跳(流式请求)
let heartbeatInterval = null;
if (isStreaming) {
heartbeatInterval = setInterval(() => {
if (res.writableEnded) {
clearInterval(heartbeatInterval);
return;
}
sendHeartbeat(res, keepaliveMode, modelName);
}, 3000);
}
try {
// 确保浏览器已初始化
if (!browserContext) {
browserContext = await initBrowser(config);
}
// 调用核心生图逻辑
const result = await generateImage(browserContext, prompt, imagePaths, modelId, { id });
// 清除心跳
if (heartbeatInterval) clearInterval(heartbeatInterval);
// 处理结果
let finalContent = '';
if (result.error) {
// 适配器层已归一化错误,直接构造错误响应
finalContent = `[生成错误] ${result.error}`;
} else if (result.image) {
finalContent = `![generated](${result.image})`;
logger.info('服务器', '图片已准备就绪 (Base64)', { id });
} else {
finalContent = result.text || '生成失败';
}
// 发送响应
if (isStreaming) {
const chunk = buildChatCompletionChunk(finalContent, modelName);
sendSse(res, chunk);
sendSseDone(res);
} else {
const response = buildChatCompletion(finalContent, modelName);
sendJson(res, 200, response);
}
} catch (err) {
// 清除心跳
if (heartbeatInterval) clearInterval(heartbeatInterval);
logger.error('服务器', '任务处理失败', { id, error: err.message });
sendApiError(res, {
code: ERROR_CODES.INTERNAL_ERROR,
error: err.message,
isStreaming
});
}
}
/**
* 处理队列中的任务
*/
async function processQueue() {
// 如果正在处理的任务已满,或队列为空,则停止
if (processingCount >= maxConcurrent || queue.length === 0) return;
// 取出下一个任务
const task = queue.shift();
processingCount++;
try {
await processTask(task);
} finally {
// 清理临时文件
cleanupTask(task);
processingCount--;
// 递归处理下一个任务
processQueue();
}
}
/**
* 添加任务到队列
* @param {TaskContext} task - 任务上下文
*/
function addTask(task) {
queue.push(task);
processQueue();
}
/**
* 获取当前队列状态
* @returns {{queueLength: number, processing: number, total: number}}
*/
function getStatus() {
return {
queueLength: queue.length,
processing: processingCount,
total: processingCount + queue.length
};
}
/**
* 检查是否可以接受新请求(非流式)
* @returns {boolean}
*/
function canAcceptNonStreaming() {
return processingCount + queue.length < maxQueueSize;
}
/**
* 初始化浏览器
* @returns {Promise<BrowserContext>}
*/
async function initializeBrowser() {
browserContext = await initBrowser(config);
return browserContext;
}
/**
* 获取浏览器上下文
* @returns {BrowserContext|null}
*/
function getBrowserContext() {
return browserContext;
}
return {
addTask,
getStatus,
canAcceptNonStreaming,
initializeBrowser,
getBrowserContext,
maxQueueSize
};
}
+128
View File
@@ -0,0 +1,128 @@
/**
* @fileoverview 配置加载模块
* @description 负责读取/解析 `config.yaml`,并提供 API Key 生成能力(供脚本使用)。
*
* 约定:
* - 该模块只负责“读取 + 校验 + 默认值补全”,不负责创建/写入配置文件。
* - 初始化/拷贝配置请使用 `config.example.yaml` + `scripts/config-init.js`。
*/
import fs from 'fs';
import path from 'path';
import yaml from 'yaml';
import { logger } from './logger.js';
const CONFIG_PATH = path.join(process.cwd(), 'config.yaml');
const EXAMPLE_CONFIG_PATH = path.join(process.cwd(), 'config.example.yaml');
// 模块级缓存:确保配置只从磁盘读取一次
let cachedConfig = null;
/**
* 加载并校验配置(只读)
* @returns {object} 配置对象
*/
export function loadConfig() {
// 如果已有缓存,直接返回
if (cachedConfig) return cachedConfig;
if (!fs.existsSync(CONFIG_PATH)) {
const hint = fs.existsSync(EXAMPLE_CONFIG_PATH)
? `请复制 ${EXAMPLE_CONFIG_PATH}${CONFIG_PATH}`
: `请创建 ${CONFIG_PATH}(仓库根目录通常提供 config.example.yaml 作为模板)`;
throw new Error(`未找到配置文件: ${CONFIG_PATH}${hint}`);
}
const configFile = fs.readFileSync(CONFIG_PATH, 'utf8');
const config = yaml.parse(configFile);
if (!config || typeof config !== 'object') {
throw new Error(`配置文件解析失败: ${CONFIG_PATH}`);
}
// Docker 路径兼容处理
if ((!config.browser?.path || !fs.existsSync(config.browser.path)) &&
fs.existsSync('/app/camoufox/camoufox')) {
logger.info('配置器', '检测到容器环境,自动修正浏览器路径为 /app/camoufox/camoufox');
if (!config.browser) config.browser = {};
config.browser.path = '/app/camoufox/camoufox';
}
// 基础配置校验
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;
}
// 设置 keepalive 配置默认值(兼容旧字段:keepalive.enable
if (!config.server.keepalive) {
config.server.keepalive = { mode: 'comment' };
} else {
if (config.server.keepalive.mode === undefined) config.server.keepalive.mode = 'comment';
// 验证 mode 值
if (!['comment', 'content'].includes(config.server.keepalive.mode)) {
logger.warn('配置器', `无效的 keepalive.mode: ${config.server.keepalive.mode},使用默认值 comment`);
config.server.keepalive.mode = 'comment';
}
}
// 设置 backend 配置默认值
if (!config.backend) {
config.backend = {
type: 'lmarena',
geminiBiz: { entryUrl: '' }
};
}
// 新增:Merge 配置初始化
if (!config.backend.merge) {
config.backend.merge = {
enable: false,
type: ['zai_is', 'lmarena']
};
}
// 校验 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.backend.type);
logger.debug('配置器', '流式心跳模式:', config.server.keepalive.mode);
if (config.backend.type === 'gemini_biz') {
logger.debug('配置器', `GeminiBiz 入口: ${config.backend.geminiBiz.entryUrl}`);
}
// 设置日志级别
if (config.logLevel) {
logger.setLevel(config.logLevel);
}
// 缓存配置
cachedConfig = config;
return config;
}
// 默认导出为函数
export default loadConfig;
+111
View File
@@ -0,0 +1,111 @@
/**
* @fileoverview 控制台日志模块
* @description 提供带时间戳/级别/模块名的彩色日志输出,并支持通过环境变量控制日志等级。
*
* - 环境变量:LOG_LEVEL=debug|info|warn|error
* - 输出格式:YYYY-MM-DD HH:mm:ss.SSS [LEVEL] [模块] 消息 | k=v ...
*/
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
};
+167
View File
@@ -0,0 +1,167 @@
/**
* @fileoverview 代理适配模块
* @description 将配置中的 HTTP/SOCKS5 代理转换为 Playwright 可用的代理配置,并在需要时通过 proxy-chain 搭建本地 HTTP 代理桥接。
*/
import { anonymizeProxy, closeAnonymizedProxy } from 'proxy-chain';
import { logger } from './logger.js';
// 全局代理状态:用于清理 proxy-chain 创建的本地代理资源
const proxyState = {
anonymizedProxyUrl: null, // 转换后的 HTTP 代理地址
originalProxyUrl: null // 原始代理地址
};
/**
* 构建代理 URL
* @param {object} proxyConfig - 代理配置对象
* @param {string} proxyConfig.type - 代理类型 ('http' 或 'socks5')
* @param {string} proxyConfig.host - 代理主机地址
* @param {number} proxyConfig.port - 代理端口
* @param {string} [proxyConfig.user] - 可选的用户名
* @param {string} [proxyConfig.passwd] - 可选的密码
* @returns {string} - 代理 URL
*/
export function buildProxyUrl(proxyConfig) {
const { type, host, port, user, passwd } = proxyConfig;
// 构建带认证的代理 URL
if (user && passwd) {
return `${type}://${user}:${passwd}@${host}:${port}`;
}
// 构建不带认证的代理 URL
if (type === 'socks5') {
return `socks5://${host}:${port}`;
}
return `http://${host}:${port}`;
}
/**
* 将代理转换为 HTTP 代理
* - HTTP 代理:直接返回
* - SOCKS5 代理:使用 proxy-chain 转换为本地 HTTP 代理
* @param {object} proxyConfig - 代理配置对象
* @returns {Promise<string|null>} - 转换后的 HTTP 代理 URL,如果无需代理则返回 null
*/
export async function getHttpProxy(proxyConfig) {
if (!proxyConfig || !proxyConfig.enable) {
return null;
}
const { type, host, port } = proxyConfig;
const originalUrl = buildProxyUrl(proxyConfig);
// 如果是 HTTP 代理,直接返回
if (type === 'http') {
logger.debug('代理器', `使用 HTTP 代理: ${host}:${port}`);
return originalUrl;
}
// 如果是 SOCKS5 代理,需要转换为 HTTP 代理
if (type === 'socks5') {
try {
logger.info('代理器', `检测到 SOCKS5 代理,正在转换为 HTTP 代理: ${host}:${port}`);
const httpProxyUrl = await anonymizeProxy(originalUrl);
// 保存状态用于后续清理
proxyState.anonymizedProxyUrl = httpProxyUrl;
proxyState.originalProxyUrl = originalUrl;
logger.info('代理器', `SOCKS5 代理已转换为 HTTP 代理: ${httpProxyUrl}`);
return httpProxyUrl;
} catch (error) {
logger.error('代理器', `SOCKS5 代理转换失败: ${error.message}`);
throw error;
}
}
logger.warn('代理器', `不支持的代理类型: ${type}`);
return null;
}
/**
* 获取用于浏览器的代理配置
* 返回 Playwright 可以使用的代理对象
* @param {object} proxyConfig - 代理配置对象
* @returns {Promise<object|null>} - Playwright 代理配置对象
*/
export async function getBrowserProxy(proxyConfig) {
if (!proxyConfig || !proxyConfig.enable) {
return null;
}
const { type, host, port, user, passwd } = proxyConfig;
// 对于 SOCKS5 + 认证,需要转换为 HTTP 代理
if (type === 'socks5' && user && passwd) {
try {
const originalUrl = buildProxyUrl(proxyConfig);
logger.info('代理器', `检测到需鉴权的 SOCKS5 代理,正在创建本地代理桥接: ${host}:${port}`);
const httpProxyUrl = await anonymizeProxy(originalUrl);
// 保存状态用于后续清理
proxyState.anonymizedProxyUrl = httpProxyUrl;
proxyState.originalProxyUrl = originalUrl;
logger.info('代理器', `本地代理桥接已建立: ${httpProxyUrl} -> ${host}:${port}`);
return {
server: httpProxyUrl
};
} catch (error) {
logger.error('代理器', `本地代理桥接创建失败: ${error.message}`);
throw error;
}
}
// 对于其他情况(HTTP 代理、不带认证的 SOCKS5
const proxyUrl = type === 'socks5' ? `socks5://${host}:${port}` : `${host}:${port}`;
const proxyObject = {
server: proxyUrl
};
// 如果有认证信息,添加到代理对象
if (user && passwd) {
proxyObject.username = user;
proxyObject.password = passwd;
}
logger.info('代理器', `代理配置: ${type}://${host}:${port}`);
return proxyObject;
}
/**
* 清理代理资源
* 关闭由 proxy-chain 创建的本地代理服务器
*/
export async function cleanupProxy() {
if (proxyState.anonymizedProxyUrl) {
try {
logger.debug('代理器', '正在关闭本地代理桥接...');
await closeAnonymizedProxy(proxyState.anonymizedProxyUrl, true);
logger.debug('代理器', '本地代理桥接已关闭');
// 清理状态
proxyState.anonymizedProxyUrl = null;
proxyState.originalProxyUrl = null;
} catch (error) {
logger.error('代理器', `关闭本地代理桥接失败: ${error.message}`);
}
}
}
/**
* 从配置文件读取代理配置
* @param {object} config - 配置对象
* @returns {object|null} - 代理配置对象或 null
*/
export function getProxyConfig(config) {
if (config?.browser?.proxy?.enable) {
return config.browser.proxy;
}
return null;
}