mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
feat: 删除冗余代码,并修复可能出现的BUG
This commit is contained in:
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [2.1.0] - 2025-12-09
|
||||
|
||||
### Changed
|
||||
- **优化性能**
|
||||
- 逐步减少冗余代码,提升性能
|
||||
- 增加适配器代码可读性
|
||||
- **日志优化**
|
||||
- 已可以将错误原因透传至客户端,减少故障排查成本
|
||||
|
||||
### Fixed
|
||||
- **减少故障**
|
||||
- 填补或删除可能会出现BUG的相关代码
|
||||
|
||||
## [2.0.2] - 2025-12-09
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
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('puppeteer').Page} page
|
||||
* @param {string} targetUrl - 目标 URL,用于判断跳转完成
|
||||
* @returns {Promise<boolean>} 是否处理了跳转
|
||||
*/
|
||||
async function handleAccountChooser(page) {
|
||||
try {
|
||||
const currentUrl = page.url();
|
||||
if (currentUrl.includes('auth.business.gemini.google/account-chooser')) {
|
||||
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);
|
||||
return true;
|
||||
} else {
|
||||
logger.warn('适配器', '[登录器] 未找到确认按钮 button[type="submit"]');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('适配器', `[登录器] 处理账户选择页面失败: ${err.message}`);
|
||||
}
|
||||
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;
|
||||
|
||||
// 设置导航监听器,自动处理账户选择页面跳转
|
||||
const navigationHandler = async () => {
|
||||
await handleAccountChooser(page);
|
||||
};
|
||||
page.on('framenavigated', navigationHandler);
|
||||
|
||||
try {
|
||||
// 先检查一次当前页面
|
||||
await handleAccountChooser(page);
|
||||
|
||||
// 轮询等待输入框,同时处理账户选择
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeout) {
|
||||
if (await handleAccountChooser(page)) {
|
||||
// 处理了账户选择,重置等待
|
||||
continue;
|
||||
}
|
||||
|
||||
const inputHandle = await page.$(INPUT_SELECTOR);
|
||||
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);
|
||||
}
|
||||
} finally {
|
||||
// 清理监听器
|
||||
page.off('framenavigated', navigationHandler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化浏览器
|
||||
* @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
|
||||
});
|
||||
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');
|
||||
}
|
||||
|
||||
// 开启新对话
|
||||
logger.info('适配器', '开启新会话', meta);
|
||||
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// 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 };
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
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 };
|
||||
@@ -0,0 +1,151 @@
|
||||
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 };
|
||||
|
||||
@@ -1,447 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { initBrowserBase } from '../browser/launcher.js';
|
||||
import {
|
||||
random,
|
||||
sleep,
|
||||
getRealViewport,
|
||||
clamp,
|
||||
queryDeep,
|
||||
safeClick,
|
||||
humanType,
|
||||
pasteImages,
|
||||
getHumanClickPoint,
|
||||
isPageValid,
|
||||
createPageCloseWatcher
|
||||
} from '../browser/utils.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
// --- 配置常量 ---
|
||||
const USER_DATA_DIR = path.join(process.cwd(), 'data', 'camoufoxUserData');
|
||||
const TEMP_DIR = path.join(process.cwd(), 'data', 'temp');
|
||||
|
||||
// 确保临时目录存在
|
||||
if (!fs.existsSync(TEMP_DIR)) {
|
||||
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找 Shadow DOM 中的输入框
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @returns {Promise<ElementHandle|null>}
|
||||
*/
|
||||
async function findInput(page) {
|
||||
return await page.evaluateHandle(() => {
|
||||
function queryDeep(root, selector) {
|
||||
let found = root.querySelector(selector);
|
||||
if (found) return found;
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, null, false);
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode;
|
||||
if (node.shadowRoot) {
|
||||
found = queryDeep(node.shadowRoot, selector);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const editor = queryDeep(document.body, 'ucs-prosemirror-editor');
|
||||
if (!editor) return null;
|
||||
return queryDeep(editor.shadowRoot, '.ProseMirror');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理账户选择页面跳转
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {string} targetUrl - 目标 URL,用于判断跳转完成
|
||||
* @returns {Promise<boolean>} 是否处理了跳转
|
||||
*/
|
||||
async function handleAccountChooser(page) {
|
||||
try {
|
||||
const currentUrl = page.url();
|
||||
if (currentUrl.includes('auth.business.gemini.google/account-chooser')) {
|
||||
logger.info('适配器', '检测到账户选择页面,尝试自动确认...');
|
||||
|
||||
// 尝试查找提交按钮 (通常是标准的 button[type="submit"])
|
||||
const submitBtn = await page.$('button[type="submit"]');
|
||||
if (submitBtn) {
|
||||
// 确保按钮在可视区域
|
||||
await submitBtn.scrollIntoViewIfNeeded();
|
||||
await sleep(300, 500);
|
||||
|
||||
// 使用 safeClick 模拟人类点击行为
|
||||
logger.info('适配器', '正在点击提交按钮...');
|
||||
const submitHandle = await page.evaluateHandle((btn) => btn, submitBtn);
|
||||
await safeClick(page, submitHandle, { bias: 'button' });
|
||||
|
||||
// 点击后等待跳转回目标页面
|
||||
logger.info('适配器', '等待跳转回目标页面...');
|
||||
try {
|
||||
// 等待 URL 变化:排除所有认证相关页面,只接受目标页面
|
||||
// 不包含 accounts.google.com 和 auth.business.gemini.google
|
||||
// 但包含 business.gemini.google(目标域名)
|
||||
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 });
|
||||
|
||||
const newUrl = page.url();
|
||||
logger.info('适配器', `已跳转回目标页面`);
|
||||
} catch (timeoutErr) {
|
||||
const finalUrl = page.url();
|
||||
logger.warn('适配器', `等待跳转回目标页面超时,尝试继续... 当前URL: ${finalUrl}`);
|
||||
}
|
||||
|
||||
// 额外缓冲时间,确保页面完全加载
|
||||
await sleep(2000, 3000);
|
||||
return true;
|
||||
} else {
|
||||
logger.warn('适配器', '未找到提交按钮 button[type="submit"]');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('适配器', `处理账户选择页面失败: ${err.message}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化浏览器
|
||||
* @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) {
|
||||
throw new Error('GeminiBiz backend missing entry URL: backend.geminiBiz.entryUrl');
|
||||
}
|
||||
|
||||
// Gemini Biz 特定的输入框验证
|
||||
const waitInputValidator = async (page) => {
|
||||
let inputHandle = null;
|
||||
let retries = 0;
|
||||
const maxRetries = 20;
|
||||
|
||||
logger.info('适配器', '正在寻找输入框 (如果您需要登录,请使用登录模式)...');
|
||||
|
||||
while (retries < maxRetries) {
|
||||
try {
|
||||
// 检测并处理账户选择页面
|
||||
if (await handleAccountChooser(page)) {
|
||||
// 重置重试计数,给更多时间查找输入框
|
||||
retries = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
inputHandle = await findInput(page);
|
||||
if (inputHandle && inputHandle.asElement()) {
|
||||
logger.info('适配器', '已找到输入框');
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.message.includes('Execution context was destroyed')) {
|
||||
logger.info('适配器', '页面跳转中,继续等待...');
|
||||
}
|
||||
}
|
||||
await sleep(1000, 1500);
|
||||
retries++;
|
||||
if (retries % 10 === 0) logger.info('适配器', `仍在寻找输入框... (${retries}/${maxRetries})`);
|
||||
}
|
||||
|
||||
if (!inputHandle || !inputHandle.asElement()) {
|
||||
logger.error('适配器', '等待超时,未找到输入框');
|
||||
}
|
||||
|
||||
if (inputHandle && inputHandle.asElement()) {
|
||||
const box = await inputHandle.boundingBox();
|
||||
if (box) {
|
||||
if (page.cursor) {
|
||||
const { x, y } = getHumanClickPoint(box, 'input');
|
||||
await page.cursor.moveTo({ x, y });
|
||||
}
|
||||
await sleep(500, 1000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return await initBrowserBase(config, {
|
||||
userDataDir: USER_DATA_DIR,
|
||||
targetUrl,
|
||||
productName: 'Gemini Enterprise Business',
|
||||
waitInputValidator
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成图片
|
||||
* @param {object} context - 浏览器上下文 { page, client, config }
|
||||
* @param {string} prompt - 提示词
|
||||
* @param {string[]} imgPaths - 参考图片路径数组
|
||||
* @param {string} modelId - 模型 ID (目前未使用,固定为 gemini-3-pro-preview)
|
||||
* @returns {Promise<{image?: string, error?: string}>} 生成结果
|
||||
*/
|
||||
async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
const { page } = context;
|
||||
|
||||
try {
|
||||
// 获取配置 (通过闭包或全局)
|
||||
const { loadConfig } = await import('../utils/config.js');
|
||||
const config = loadConfig();
|
||||
const targetUrl = config.backend?.geminiBiz?.entryUrl;
|
||||
|
||||
if (!targetUrl) {
|
||||
throw new Error('GeminiBiz backend missing entry URL');
|
||||
}
|
||||
|
||||
// 开启新对话
|
||||
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// 1. 查找输入框
|
||||
logger.debug('适配器', '正在寻找输入框...', meta);
|
||||
|
||||
let inputHandle = await findInput(page);
|
||||
let retries = 0;
|
||||
// 增加重试次数以适应可能的跳转
|
||||
while ((!inputHandle || !inputHandle.asElement()) && retries < 30) {
|
||||
// 检测并处理账户选择页面
|
||||
if (await handleAccountChooser(page)) {
|
||||
// 重置重试计数,给更多时间查找输入框
|
||||
retries = 0;
|
||||
} else {
|
||||
await sleep(1000, 1500);
|
||||
}
|
||||
|
||||
inputHandle = await findInput(page);
|
||||
retries++;
|
||||
}
|
||||
|
||||
if (!inputHandle || !inputHandle.asElement()) {
|
||||
throw new Error('未找到输入框 (.ProseMirror)');
|
||||
}
|
||||
|
||||
// 2. 粘贴图片 (使用自定义验证器)
|
||||
if (imgPaths && imgPaths.length > 0) {
|
||||
const expectedUploads = imgPaths.length;
|
||||
let uploadedCount = 0;
|
||||
let metadataCount = 0;
|
||||
|
||||
await pasteImages(page, inputHandle, imgPaths, {
|
||||
uploadValidator: (response) => {
|
||||
const url = response.url();
|
||||
if (response.status() === 200) {
|
||||
if (url.includes('global/widgetAddContextFile')) {
|
||||
uploadedCount++;
|
||||
logger.debug('适配器', `图片上传进度 (Add): ${uploadedCount}/${expectedUploads}`, meta);
|
||||
return false; // 未完成,继续等待
|
||||
} else if (url.includes('global/widgetListSessionFileMetadata')) {
|
||||
metadataCount++;
|
||||
logger.info('适配器', `图片上传进度: ${metadataCount}/${expectedUploads}`, meta);
|
||||
|
||||
// 两个检查都满足才算完成
|
||||
if (uploadedCount >= expectedUploads && metadataCount >= expectedUploads) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
await sleep(1000, 2000); // 额外缓冲
|
||||
}
|
||||
|
||||
// 3. 输入文字
|
||||
logger.info('适配器', '正在输入提示词...', meta);
|
||||
await humanType(page, inputHandle, prompt);
|
||||
await sleep(1000, 2000);
|
||||
|
||||
// 4. 设置拦截器 (使用 Playwright Route)
|
||||
logger.debug('适配器', '已启用请求拦截', meta);
|
||||
|
||||
// 清理旧的 route
|
||||
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. 点击发送
|
||||
logger.debug('适配器', '点击发送...', meta);
|
||||
const sendBtnHandle = await page.evaluateHandle(() => {
|
||||
function queryDeep(root, selector) {
|
||||
let found = root.querySelector(selector);
|
||||
if (found) return found;
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, null, false);
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode;
|
||||
if (node.shadowRoot) {
|
||||
found = queryDeep(node.shadowRoot, selector);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// 精准匹配发送按钮
|
||||
return queryDeep(document.body, 'md-icon-button.send-button.submit, button[aria-label="提交"], button[aria-label="Send"], .send-button');
|
||||
});
|
||||
|
||||
if (sendBtnHandle && sendBtnHandle.asElement()) {
|
||||
// 确保按钮在可视区域
|
||||
await sendBtnHandle.asElement().scrollIntoViewIfNeeded();
|
||||
await sleep(300, 500);
|
||||
await safeClick(page, sendBtnHandle, { bias: 'button' });
|
||||
} else {
|
||||
logger.warn('适配器', '未找到发送按钮,尝试回车提交', meta);
|
||||
await inputHandle.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
logger.info('适配器', '等待生成结果中...', meta);
|
||||
|
||||
// 6. 创建页面关闭监听器
|
||||
const pageWatcher = createPageCloseWatcher(page);
|
||||
|
||||
try {
|
||||
// 检查页面状态
|
||||
if (!isPageValid(page)) {
|
||||
throw new Error('PAGE_INVALID');
|
||||
}
|
||||
|
||||
// 7. 等待 API 响应 (使用 Promise.race 监听页面事件)
|
||||
const apiResponsePromise = page.waitForResponse(response =>
|
||||
response.url().includes('global/widgetStreamAssist') &&
|
||||
response.request().method() === 'POST' &&
|
||||
(response.status() === 200 || response.status() >= 400),
|
||||
{ timeout: 120000 }
|
||||
);
|
||||
|
||||
const apiResponse = await Promise.race([
|
||||
apiResponsePromise,
|
||||
pageWatcher.promise
|
||||
]).catch(e => {
|
||||
// 错误分类
|
||||
if (e.message === 'PAGE_CLOSED') {
|
||||
logger.error('适配器', '页面已关闭(API响应等待期间)', meta);
|
||||
throw new Error('页面已关闭,请勿在生图过程中刷新页面');
|
||||
}
|
||||
if (e.message === 'PAGE_CRASHED') {
|
||||
logger.error('适配器', '页面崩溃(API响应等待期间)', meta);
|
||||
throw new Error('页面崩溃,请重试');
|
||||
}
|
||||
if (e.name === 'TimeoutError') {
|
||||
logger.error('适配器', 'API 请求超时(120秒)', meta);
|
||||
throw new Error('API 请求超时(120秒),请检查网络或稍后重试');
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
|
||||
if (apiResponse.status() !== 200) {
|
||||
logger.error('适配器', `API 返回错误状态码: ${apiResponse.status()}`, meta);
|
||||
return { error: `API 错误: HTTP ${apiResponse.status()}` };
|
||||
}
|
||||
|
||||
// 8. 等待图片下载响应
|
||||
logger.info('适配器', 'API 请求成功,等待图片下载...', meta);
|
||||
|
||||
const imageDownloadPromise = page.waitForResponse(response =>
|
||||
response.url().includes('download/v1alpha/projects') &&
|
||||
response.request().method() === 'GET' &&
|
||||
response.status() === 200,
|
||||
{ timeout: 120000 }
|
||||
);
|
||||
|
||||
const imageResponse = await Promise.race([
|
||||
imageDownloadPromise,
|
||||
pageWatcher.promise
|
||||
]).catch(e => {
|
||||
// 错误分类
|
||||
if (e.message === 'PAGE_CLOSED') {
|
||||
logger.error('适配器', '页面已关闭(图片下载期间)', meta);
|
||||
throw new Error('页面已关闭,请勿在生图过程中刷新页面');
|
||||
}
|
||||
if (e.message === 'PAGE_CRASHED') {
|
||||
logger.error('适配器', '页面崩溃(图片下载期间)', meta);
|
||||
throw new Error('页面崩溃,请重试');
|
||||
}
|
||||
if (e.name === 'TimeoutError') {
|
||||
logger.error('适配器', 'API 请求成功,但图片下载超时(120秒)', meta);
|
||||
throw new Error('API 请求成功,但图片下载超时(120秒),请重试');
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
|
||||
logger.info('适配器', '捕获到图片下载请求', meta);
|
||||
// 响应体本身就是 base64 字符串,直接获取文本即可,不需要再次 base64 编码
|
||||
const base64 = await imageResponse.text();
|
||||
const dataUri = `data:image/png;base64,${base64}`;
|
||||
|
||||
logger.info('适配器', '生图成功', meta);
|
||||
|
||||
// 任务结束,移开鼠标
|
||||
if (page.cursor) {
|
||||
const currentVp = await getRealViewport(page);
|
||||
const relativeX = currentVp.safeWidth * random(0.85, 0.95);
|
||||
const relativeY = currentVp.height * random(0.3, 0.7);
|
||||
const finalX = clamp(relativeX, 0, currentVp.safeWidth);
|
||||
const finalY = clamp(relativeY, 0, currentVp.safeHeight);
|
||||
await page.cursor.moveTo({ x: finalX, y: finalY });
|
||||
}
|
||||
|
||||
return { image: dataUri };
|
||||
|
||||
} finally {
|
||||
// 清理页面事件监听器
|
||||
pageWatcher.cleanup();
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
// 详细的错误分类和日志
|
||||
if (err.message === 'PAGE_INVALID') {
|
||||
logger.error('适配器', '页面状态无效', meta);
|
||||
return { error: '页面状态无效,请重新初始化' };
|
||||
}
|
||||
|
||||
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
||||
return { error: err.message };
|
||||
} finally {
|
||||
// 清理拦截器
|
||||
try {
|
||||
await page.unroute('**/*').catch(() => { });
|
||||
} catch (e) {
|
||||
// 忽略清理错误
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { initBrowser, generateImage, TEMP_DIR };
|
||||
+47
-25
@@ -1,35 +1,57 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { loadConfig } from '../utils/config.js';
|
||||
import * as lmarenaBackend from './lmarena.js';
|
||||
import * as geminiBackend from './gemini_biz.js';
|
||||
import * as nanobananafreeBackend from './nanobananafree_ai.js';
|
||||
import * as lmarenaBackend from './adapter/lmarena.js';
|
||||
import * as geminiBackend from './adapter/gemini_biz.js';
|
||||
import * as nanobananafreeBackend from './adapter/nanobananafree_ai.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 config = loadConfig();
|
||||
|
||||
// 将路径常量注入 config 对象
|
||||
config.paths = {
|
||||
userDataDir: USER_DATA_DIR,
|
||||
tempDir: TEMP_DIR
|
||||
};
|
||||
|
||||
let activeBackend;
|
||||
|
||||
if (config.backend?.type === 'gemini_biz') {
|
||||
activeBackend = {
|
||||
name: 'gemini_biz',
|
||||
initBrowser: (cfg) => geminiBackend.initBrowser(cfg),
|
||||
generateImage: (ctx, prompt, paths, model, meta) => geminiBackend.generateImage(ctx, prompt, paths, model, meta),
|
||||
TEMP_DIR: geminiBackend.TEMP_DIR
|
||||
};
|
||||
} else if (config.backend?.type === 'nanobananafree_ai') {
|
||||
activeBackend = {
|
||||
name: 'nanobananafree_ai',
|
||||
initBrowser: (cfg) => nanobananafreeBackend.initBrowser(cfg),
|
||||
generateImage: (ctx, prompt, paths, model, meta) => nanobananafreeBackend.generateImage(ctx, prompt, paths, model, meta),
|
||||
TEMP_DIR: nanobananafreeBackend.TEMP_DIR
|
||||
};
|
||||
} else {
|
||||
activeBackend = {
|
||||
name: 'lmarena',
|
||||
initBrowser: (cfg) => lmarenaBackend.initBrowser(cfg),
|
||||
generateImage: (ctx, prompt, paths, model, meta) => lmarenaBackend.generateImage(ctx, prompt, paths, model, meta),
|
||||
TEMP_DIR: lmarenaBackend.TEMP_DIR
|
||||
};
|
||||
switch (config.backend?.type) {
|
||||
case 'gemini_biz':
|
||||
activeBackend = {
|
||||
name: 'gemini_biz',
|
||||
initBrowser: (cfg) => geminiBackend.initBrowser(cfg),
|
||||
generateImage: (ctx, prompt, paths, model, meta) => geminiBackend.generateImage(ctx, prompt, paths, model, meta)
|
||||
};
|
||||
break;
|
||||
case 'nanobananafree_ai':
|
||||
activeBackend = {
|
||||
name: 'nanobananafree_ai',
|
||||
initBrowser: (cfg) => nanobananafreeBackend.initBrowser(cfg),
|
||||
generateImage: (ctx, prompt, paths, model, meta) => nanobananafreeBackend.generateImage(ctx, prompt, paths, model, meta)
|
||||
};
|
||||
break;
|
||||
case 'lmarena':
|
||||
default:
|
||||
activeBackend = {
|
||||
name: 'lmarena',
|
||||
initBrowser: (cfg) => lmarenaBackend.initBrowser(cfg),
|
||||
generateImage: (ctx, prompt, paths, model, meta) => lmarenaBackend.generateImage(ctx, prompt, paths, model, meta)
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
export function getBackend() {
|
||||
return { config, ...activeBackend };
|
||||
return { config, TEMP_DIR, ...activeBackend };
|
||||
}
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { gotScraping } from 'got-scraping';
|
||||
import { initBrowserBase } from '../browser/launcher.js';
|
||||
import {
|
||||
random,
|
||||
sleep,
|
||||
getRealViewport,
|
||||
clamp,
|
||||
safeClick,
|
||||
humanType,
|
||||
pasteImages,
|
||||
getHumanClickPoint,
|
||||
isPageValid,
|
||||
createPageCloseWatcher
|
||||
} from '../browser/utils.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { loadConfig } from '../utils/config.js';
|
||||
import { getProxyConfig, getHttpProxy } from '../utils/proxy.js';
|
||||
|
||||
// --- 配置常量 ---
|
||||
const USER_DATA_DIR = path.join(process.cwd(), 'data', 'camoufoxUserData');
|
||||
const TARGET_URL = 'https://lmarena.ai/c/new?mode=direct&chat-modality=image';
|
||||
const TEMP_DIR = path.join(process.cwd(), 'data', 'temp');
|
||||
|
||||
// 确保临时目录存在
|
||||
if (!fs.existsSync(TEMP_DIR)) {
|
||||
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 从响应文本中提取图片 URL
|
||||
* @param {string} text - 响应文本内容
|
||||
* @returns {string|null} 提取到的图片 URL,如果未找到则返回 null
|
||||
*/
|
||||
function extractImage(text) {
|
||||
if (!text) return null;
|
||||
const lines = text.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('a2:')) {
|
||||
try {
|
||||
const data = JSON.parse(line.substring(3));
|
||||
if (data?.[0]?.image) return data[0].image;
|
||||
} catch (e) { }
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化浏览器会话
|
||||
* @param {object} config - 全局配置对象
|
||||
* @returns {Promise<{browser: object, page: object, client: object}>} 初始化后的浏览器上下文
|
||||
*/
|
||||
async function initBrowser(config) {
|
||||
// LMArena 特定的输入框验证逻辑
|
||||
const waitInputValidator = async (page) => {
|
||||
const textareaSelector = 'textarea';
|
||||
await page.waitForSelector(textareaSelector, { timeout: 60000 });
|
||||
const box = await (await page.$(textareaSelector)).boundingBox();
|
||||
if (box) {
|
||||
if (page.cursor) {
|
||||
const { x, y } = getHumanClickPoint(box, 'input');
|
||||
await page.cursor.moveTo({ x, y });
|
||||
}
|
||||
await sleep(500, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
return await initBrowserBase(config, {
|
||||
userDataDir: USER_DATA_DIR,
|
||||
targetUrl: TARGET_URL,
|
||||
productName: 'LMArena',
|
||||
waitInputValidator
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行生图任务
|
||||
* @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' });
|
||||
|
||||
// 等待输入框加载
|
||||
await page.waitForSelector(textareaSelector, { timeout: 30000 });
|
||||
await sleep(1500, 2500);
|
||||
|
||||
// 1. 上传图片
|
||||
if (imgPaths && imgPaths.length > 0) {
|
||||
await pasteImages(page, textareaSelector, imgPaths);
|
||||
// 确保焦点在输入框
|
||||
await safeClick(page, textareaSelector, { bias: 'input' });
|
||||
}
|
||||
|
||||
// 2. 输入提示词
|
||||
logger.info('适配器', '正在输入提示词...', meta);
|
||||
await humanType(page, textareaSelector, prompt);
|
||||
await sleep(800, 1500);
|
||||
|
||||
// 3. 配置请求拦截 (用于修改模型 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();
|
||||
});
|
||||
}
|
||||
|
||||
// 4. 创建页面关闭监听器
|
||||
const pageWatcher = createPageCloseWatcher(page);
|
||||
|
||||
try {
|
||||
// 检查页面状态
|
||||
if (!isPageValid(page)) {
|
||||
throw new Error('PAGE_INVALID');
|
||||
}
|
||||
|
||||
// 5. 点击发送按钮
|
||||
logger.debug('适配器', '点击发送...', meta);
|
||||
const btnSelector = 'button[type="submit"]';
|
||||
await safeClick(page, btnSelector, { bias: 'button' });
|
||||
|
||||
logger.info('适配器', '等待生成结果...', meta);
|
||||
|
||||
// 6. 等待响应 (使用 Promise.race)
|
||||
const responsePromise = page.waitForResponse(response =>
|
||||
response.url().includes('/nextjs-api/stream') &&
|
||||
response.request().method() === 'POST' &&
|
||||
(response.status() === 200 || response.status() >= 400),
|
||||
{ timeout: 120000 }
|
||||
);
|
||||
|
||||
const response = await Promise.race([
|
||||
responsePromise,
|
||||
pageWatcher.promise
|
||||
]).catch(e => {
|
||||
// 错误分类
|
||||
if (e.message === 'PAGE_CLOSED') {
|
||||
logger.error('适配器', '页面已关闭(等待响应期间)', meta);
|
||||
throw new Error('页面已关闭,请勿在生图过程中刷新页面');
|
||||
}
|
||||
if (e.message === 'PAGE_CRASHED') {
|
||||
logger.error('适配器', '页面崩溃(等待响应期间)', meta);
|
||||
throw new Error('页面崩溃,请重试');
|
||||
}
|
||||
if (e.name === 'TimeoutError') {
|
||||
logger.error('适配器', 'API 请求超时(120秒)', meta);
|
||||
throw new Error('API 请求超时(120秒),请检查网络或稍后重试');
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
|
||||
// 检查状态码
|
||||
if (response.status() === 429 || content.includes('Too Many Requests')) {
|
||||
logger.warn('适配器', '触发限流/上游繁忙', meta);
|
||||
return { error: 'Rate limit exceeded or CAPTCHA triggered (HTTP 429)' };
|
||||
}
|
||||
|
||||
if (response.status() !== 200) {
|
||||
logger.warn('适配器', `返回异常状态码: ${response.status()}`, meta);
|
||||
return { error: `Server error: HTTP ${response.status()}` };
|
||||
}
|
||||
|
||||
// 解析成功响应
|
||||
const content = await response.text();
|
||||
|
||||
// 检查业务错误
|
||||
if (content.includes('recaptcha validation failed')) {
|
||||
logger.warn('适配器', '触发人机验证', meta);
|
||||
return { error: 'recaptcha validation failed' };
|
||||
}
|
||||
|
||||
const img = extractImage(content);
|
||||
if (img) {
|
||||
logger.info('适配器', '已获取生图结果,正在下载图片...', meta);
|
||||
try {
|
||||
// 获取代理配置
|
||||
const config = loadConfig();
|
||||
const proxyConfig = getProxyConfig(config);
|
||||
const proxyUrl = await getHttpProxy(proxyConfig);
|
||||
|
||||
const options = {
|
||||
url: img,
|
||||
responseType: 'buffer',
|
||||
http2: true,
|
||||
headerGeneratorOptions: {
|
||||
browsers: [{ name: 'firefox', minVersion: 100 }],
|
||||
devices: ['desktop'],
|
||||
locales: ['en-US'],
|
||||
operatingSystems: ['windows'],
|
||||
}
|
||||
};
|
||||
|
||||
if (proxyUrl) {
|
||||
options.proxyUrl = proxyUrl;
|
||||
}
|
||||
|
||||
const imgRes = await gotScraping(options);
|
||||
const base64 = imgRes.body.toString('base64');
|
||||
return { image: `data:image/png;base64,${base64}` };
|
||||
} catch (e) {
|
||||
return { error: `Image download failed: ${e.message}` };
|
||||
}
|
||||
} else {
|
||||
logger.info('适配器', 'AI 返回文本回复', { ...meta, preview: content.substring(0, 150) });
|
||||
return { text: content };
|
||||
}
|
||||
|
||||
} finally {
|
||||
// 清理页面事件监听器
|
||||
pageWatcher.cleanup();
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
if (err.name === 'TimeoutError') return { error: 'Timeout: 生成响应超时' };
|
||||
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
||||
return { error: err.message };
|
||||
} finally {
|
||||
// 清理拦截器
|
||||
if (modelId) await page.unroute('**/*').catch(() => { });
|
||||
|
||||
// 任务结束,将鼠标移至安全区域
|
||||
if (page.cursor) {
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { initBrowser, generateImage, TEMP_DIR };
|
||||
@@ -1,194 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { initBrowserBase } from '../browser/launcher.js';
|
||||
import {
|
||||
random,
|
||||
sleep,
|
||||
getRealViewport,
|
||||
clamp,
|
||||
safeClick,
|
||||
humanType,
|
||||
pasteImages,
|
||||
getHumanClickPoint,
|
||||
isPageValid,
|
||||
createPageCloseWatcher
|
||||
} from '../browser/utils.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
// --- 配置常量 ---
|
||||
const USER_DATA_DIR = path.join(process.cwd(), 'data', 'camoufoxUserData');
|
||||
const TARGET_URL = 'https://nanobananafree.ai/';
|
||||
const TEMP_DIR = path.join(process.cwd(), 'data', 'temp');
|
||||
|
||||
// 确保临时目录存在
|
||||
if (!fs.existsSync(TEMP_DIR)) {
|
||||
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化浏览器
|
||||
* @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 });
|
||||
const box = await (await page.$(textareaSelector)).boundingBox();
|
||||
if (box) {
|
||||
if (page.cursor) {
|
||||
const { x, y } = getHumanClickPoint(box, 'input');
|
||||
await page.cursor.moveTo({ x, y });
|
||||
}
|
||||
await sleep(500, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
return await initBrowserBase(config, {
|
||||
userDataDir: USER_DATA_DIR,
|
||||
targetUrl: TARGET_URL,
|
||||
productName: 'NanoBananaFree AI',
|
||||
waitInputValidator
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行生图任务
|
||||
* @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' });
|
||||
|
||||
// 等待输入框加载
|
||||
await page.waitForSelector(textareaSelector, { timeout: 30000 });
|
||||
await sleep(1500, 2500);
|
||||
|
||||
// 1. 上传图片 (仅取第一张,多余的丢弃)
|
||||
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);
|
||||
// 确保焦点在输入框
|
||||
await safeClick(page, textareaSelector, { bias: 'input' });
|
||||
}
|
||||
|
||||
// 2. 输入提示词
|
||||
logger.info('适配器', '正在输入提示词...', meta);
|
||||
await humanType(page, textareaSelector, prompt);
|
||||
await sleep(800, 1500);
|
||||
|
||||
// 3. 创建页面关闭监听器
|
||||
const pageWatcher = createPageCloseWatcher(page);
|
||||
|
||||
try {
|
||||
// 检查页面状态
|
||||
if (!isPageValid(page)) {
|
||||
throw new Error('PAGE_INVALID');
|
||||
}
|
||||
|
||||
// 4. 点击发送按钮 (匹配 class 包含 _sendButton_ 的 div)
|
||||
logger.debug('适配器', '点击发送...', meta);
|
||||
|
||||
// 使用更通用的选择器匹配发送按钮
|
||||
const sendBtnSelector = 'div[class*="_sendButton_"]';
|
||||
await page.waitForSelector(sendBtnSelector, { timeout: 10000 });
|
||||
await safeClick(page, sendBtnSelector, { bias: 'button' });
|
||||
|
||||
logger.info('适配器', '等待生成结果...', meta);
|
||||
|
||||
// 5. 等待响应 (使用 Promise.race)
|
||||
const responsePromise = page.waitForResponse(response =>
|
||||
response.url().includes('v1/generateContent') &&
|
||||
response.request().method() === 'POST' &&
|
||||
(response.status() === 200 || response.status() >= 400),
|
||||
{ timeout: 120000 }
|
||||
);
|
||||
|
||||
const response = await Promise.race([
|
||||
responsePromise,
|
||||
pageWatcher.promise
|
||||
]).catch(e => {
|
||||
// 错误分类
|
||||
if (e.message === 'PAGE_CLOSED') {
|
||||
logger.error('适配器', '页面已关闭(等待响应期间)', meta);
|
||||
throw new Error('页面已关闭,请勿在生图过程中刷新页面');
|
||||
}
|
||||
if (e.message === 'PAGE_CRASHED') {
|
||||
logger.error('适配器', '页面崩溃(等待响应期间)', meta);
|
||||
throw new Error('页面崩溃,请重试');
|
||||
}
|
||||
if (e.name === 'TimeoutError') {
|
||||
logger.error('适配器', 'API 请求超时(120秒)', meta);
|
||||
throw new Error('API 请求超时(120秒),请检查网络或稍后重试');
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
|
||||
// 检查状态码
|
||||
if (response.status() !== 200) {
|
||||
// 非200状态,尝试读取错误信息
|
||||
try {
|
||||
const body = await response.json();
|
||||
const errMessage = body?.errMessage || body?.error?.message || `HTTP ${response.status()}`;
|
||||
logger.warn('适配器', `请求返回错误: ${errMessage}`, meta);
|
||||
return { error: errMessage };
|
||||
} catch (e) {
|
||||
logger.warn('适配器', `返回异常状态码: ${response.status()}`, meta);
|
||||
return { error: `Server error: HTTP ${response.status()}` };
|
||||
}
|
||||
}
|
||||
|
||||
// 解析成功响应
|
||||
const body = await response.json();
|
||||
|
||||
// 尝试从响应中提取 base64 图片
|
||||
// 路径: data.candidates[0].content.parts[0].inlineData.data
|
||||
const inlineData = body?.data?.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
|
||||
|
||||
if (inlineData) {
|
||||
logger.info('适配器', '已获取生图结果', meta);
|
||||
// 返回带有 data URI 前缀的 base64 图片
|
||||
return { image: `data:image/png;base64,${inlineData}` };
|
||||
} else {
|
||||
// 没有找到图片数据,可能是文本回复或其他格式
|
||||
logger.info('适配器', 'AI 返回非图片响应', { ...meta, preview: JSON.stringify(body).substring(0, 150) });
|
||||
return { text: JSON.stringify(body) };
|
||||
}
|
||||
|
||||
} finally {
|
||||
// 清理页面事件监听器
|
||||
pageWatcher.cleanup();
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
if (err.name === 'TimeoutError') return { error: 'Timeout: 生成响应超时' };
|
||||
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
||||
return { error: err.message };
|
||||
} finally {
|
||||
// 任务结束,将鼠标移至安全区域
|
||||
if (page.cursor) {
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { initBrowser, generateImage, TEMP_DIR };
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* 适配器公共流程函数
|
||||
*
|
||||
* - 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}` };
|
||||
}
|
||||
}
|
||||
|
||||
+8
-17
@@ -250,19 +250,10 @@ export async function humanType(page, target, text) {
|
||||
await page.keyboard.press('Backspace');
|
||||
await sleep(100, 300);
|
||||
|
||||
// 4. 瞬间粘贴全部文本
|
||||
if (typeof target === 'string') {
|
||||
await page.evaluate(({ sel, content }) => {
|
||||
const input = document.querySelector(sel);
|
||||
input.focus();
|
||||
document.execCommand('insertText', false, content);
|
||||
}, { sel: target, content: text });
|
||||
} else {
|
||||
await page.evaluate(({ el, content }) => {
|
||||
el.focus();
|
||||
document.execCommand('insertText', false, content);
|
||||
}, { el: target, content: text });
|
||||
}
|
||||
// 4. 瞬间粘贴全部文本 (始终使用已获取的 ElementHandle,支持 Shadow DOM)
|
||||
await page.evaluate((content) => {
|
||||
document.execCommand('insertText', false, content);
|
||||
}, text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,7 +317,7 @@ export async function pasteImages(page, target, filePaths, options = {}) {
|
||||
await sleep(500, 1000);
|
||||
|
||||
try {
|
||||
logger.info('浏览器', '正在深度扫描文件上传控件...');
|
||||
logger.debug('浏览器', '正在深度扫描文件上传控件...');
|
||||
const fileInputs = await findAllFileInputs(page);
|
||||
|
||||
if (fileInputs.length === 0) {
|
||||
@@ -348,7 +339,7 @@ export async function pasteImages(page, target, filePaths, options = {}) {
|
||||
// 使用 Playwright 原生上传 (绕过所有事件拦截)
|
||||
await handle.setInputFiles(filePaths);
|
||||
uploaded = true;
|
||||
logger.info('浏览器', '已通过原生控件注入文件');
|
||||
logger.debug('浏览器', '已通过原生控件提交图片');
|
||||
break; // 只要有一个成功就停止
|
||||
} catch (e) {
|
||||
// 忽略不可操作的 input (比如被禁用的)
|
||||
@@ -391,12 +382,12 @@ export async function pasteImages(page, target, filePaths, options = {}) {
|
||||
page.on('response', onResponse);
|
||||
});
|
||||
|
||||
logger.info('浏览器', `文件已注入,正在等待上传确认...`);
|
||||
logger.info('浏览器', `已提交图片, 正在等待上传确认...`);
|
||||
await uploadPromise;
|
||||
logger.info('浏览器', `所有图片上传完成`);
|
||||
} else {
|
||||
// 默认行为: 等待上传预览出现
|
||||
logger.info('浏览器', `文件已注入,等待预览生成...`);
|
||||
logger.info('浏览器', `已提交图片, 等待预览生成...`);
|
||||
await sleep(2000, 4000);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ import { logger } from './logger.js';
|
||||
|
||||
const CONFIG_PATH = path.join(process.cwd(), 'config.yaml');
|
||||
|
||||
// 模块级缓存,确保配置只从磁盘读取一次
|
||||
let cachedConfig = null;
|
||||
|
||||
/**
|
||||
* 生成随机 API Key
|
||||
* 格式: sk-{48位十六进制字符}
|
||||
@@ -97,6 +100,9 @@ browser:
|
||||
* @returns {object} 配置对象
|
||||
*/
|
||||
export function loadConfig() {
|
||||
// 如果已有缓存,直接返回
|
||||
if (cachedConfig) return cachedConfig;
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(CONFIG_PATH)) {
|
||||
logger.warn('配置器', '配置文件不存在,正在生成默认配置...');
|
||||
@@ -174,6 +180,8 @@ export function loadConfig() {
|
||||
logger.setLevel(config.logLevel);
|
||||
}
|
||||
|
||||
// 缓存配置
|
||||
cachedConfig = config;
|
||||
return config;
|
||||
} catch (e) {
|
||||
logger.error('配置器', '无法加载或生成配置文件', { error: e.message });
|
||||
|
||||
Reference in New Issue
Block a user