feat: 删除冗余代码,并修复可能出现的BUG

This commit is contained in:
foxhui
2025-12-09 19:46:13 +08:00
Unverified
parent e13c7092af
commit 801eb74b24
11 changed files with 1005 additions and 944 deletions
+13
View File
@@ -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
+325
View File
@@ -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 };
+183
View File
@@ -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 };
+151
View File
@@ -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 };
-447
View File
@@ -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
View File
@@ -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 };
}
-261
View File
@@ -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 };
-194
View File
@@ -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 };
+270
View File
@@ -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
View File
@@ -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);
}
+8
View File
@@ -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 });