From 801eb74b244c04fb8a269438604113919d7d3c7b Mon Sep 17 00:00:00 2001 From: foxhui Date: Tue, 9 Dec 2025 19:46:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=A0=E9=99=A4=E5=86=97=E4=BD=99?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=EF=BC=8C=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=8F=AF?= =?UTF-8?q?=E8=83=BD=E5=87=BA=E7=8E=B0=E7=9A=84BUG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 13 + lib/backend/adapter/gemini_biz.js | 325 ++++++++++++++++ lib/backend/adapter/lmarena.js | 183 ++++++++++ lib/backend/adapter/nanobananafree_ai.js | 151 ++++++++ lib/backend/gemini_biz.js | 447 ----------------------- lib/backend/index.js | 72 ++-- lib/backend/lmarena.js | 261 ------------- lib/backend/nanobananafree_ai.js | 194 ---------- lib/backend/utils.js | 270 ++++++++++++++ lib/browser/utils.js | 25 +- lib/utils/config.js | 8 + 11 files changed, 1005 insertions(+), 944 deletions(-) create mode 100644 lib/backend/adapter/gemini_biz.js create mode 100644 lib/backend/adapter/lmarena.js create mode 100644 lib/backend/adapter/nanobananafree_ai.js delete mode 100644 lib/backend/gemini_biz.js delete mode 100644 lib/backend/lmarena.js delete mode 100644 lib/backend/nanobananafree_ai.js create mode 100644 lib/backend/utils.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c90d79..aece388 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/backend/adapter/gemini_biz.js b/lib/backend/adapter/gemini_biz.js new file mode 100644 index 0000000..d18fb2d --- /dev/null +++ b/lib/backend/adapter/gemini_biz.js @@ -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} 是否处理了跳转 + */ +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} + */ +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 }; + diff --git a/lib/backend/adapter/lmarena.js b/lib/backend/adapter/lmarena.js new file mode 100644 index 0000000..822f472 --- /dev/null +++ b/lib/backend/adapter/lmarena.js @@ -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 }; diff --git a/lib/backend/adapter/nanobananafree_ai.js b/lib/backend/adapter/nanobananafree_ai.js new file mode 100644 index 0000000..2525300 --- /dev/null +++ b/lib/backend/adapter/nanobananafree_ai.js @@ -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 }; + diff --git a/lib/backend/gemini_biz.js b/lib/backend/gemini_biz.js deleted file mode 100644 index c1e50e7..0000000 --- a/lib/backend/gemini_biz.js +++ /dev/null @@ -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} - */ -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} 是否处理了跳转 - */ -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 }; diff --git a/lib/backend/index.js b/lib/backend/index.js index 059312a..aead505 100644 --- a/lib/backend/index.js +++ b/lib/backend/index.js @@ -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 }; } diff --git a/lib/backend/lmarena.js b/lib/backend/lmarena.js deleted file mode 100644 index 9919be7..0000000 --- a/lib/backend/lmarena.js +++ /dev/null @@ -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 }; \ No newline at end of file diff --git a/lib/backend/nanobananafree_ai.js b/lib/backend/nanobananafree_ai.js deleted file mode 100644 index 1b79322..0000000 --- a/lib/backend/nanobananafree_ai.js +++ /dev/null @@ -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 }; diff --git a/lib/backend/utils.js b/lib/backend/utils.js new file mode 100644 index 0000000..4912137 --- /dev/null +++ b/lib/backend/utils.js @@ -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} 是否成功点击按钮(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} 响应对象 + * @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}` }; + } +} + diff --git a/lib/browser/utils.js b/lib/browser/utils.js index a0b4e27..aa7e9f6 100644 --- a/lib/browser/utils.js +++ b/lib/browser/utils.js @@ -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); } diff --git a/lib/utils/config.js b/lib/utils/config.js index cd47e1e..5f8105c 100644 --- a/lib/utils/config.js +++ b/lib/utils/config.js @@ -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 });