From a8c2763d4fe474f01105824134771a24e06242a1 Mon Sep 17 00:00:00 2001 From: foxhui Date: Mon, 8 Dec 2025 01:51:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20Gemini=20Business?= =?UTF-8?q?=20=E8=BF=87=E6=9C=9F=E8=87=AA=E5=8A=A8=E7=BB=AD=E7=99=BB?= =?UTF-8?q?=E3=80=81=E5=86=85=E7=BD=AEXvfb=E6=8C=87=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 15 +++- lib/backend/gemini_biz.js | 76 +++++++++++++++++- lib/browser/launcher.js | 23 ++++-- server.js | 158 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 261 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90f8df2..6fc6d70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.0.0] - 2025-12-06 +### Added +- **自动续登** + - 支持 Gemini Business 过期自动续登录 +- **内置XVFB指令** + - 内置了xvfb指令和x11vnc指令,只需要添加参数即可,无需记忆繁琐的指令 + +### Changed +- **优化分辨率** + - 优化浏览器窗口分辨率以确保窗口不会过大以及在服务器上消耗性能 + +## [2.0.0] - 2025-12-06 + ### Added - **支持新网站** - 支持对 Nano Banana Free 网站的支持 ### Changed - **代码重构** - - 将项目核心迁移为 Playwright + Camoufox 的方案增强反检测 - - 迁移前的 Puppeteer 版本已在分支中保留,但不影响使用的情况下不会再进行更新和修复! + - 本项目已从 Puppeteer 迁移至 Playwright + Camoufox,以应对日益复杂的反机器人检测机制。基于 Puppeteer 的旧版本代码已归档至 `puppeteer-edition` 分支,仅作留存,**不再提供更新与维护**。 ## [1.3.1] - 2025-12-05 diff --git a/lib/backend/gemini_biz.js b/lib/backend/gemini_biz.js index 12933fd..12a4c13 100644 --- a/lib/backend/gemini_biz.js +++ b/lib/backend/gemini_biz.js @@ -49,6 +49,63 @@ async function findInput(page) { }); } +/** + * 处理账户选择页面跳转 + * @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 - 配置对象 @@ -81,6 +138,13 @@ async function initBrowser(config) { while (retries < maxRetries) { try { + // 检测并处理账户选择页面 + if (await handleAccountChooser(page)) { + // 重置重试计数,给更多时间查找输入框 + retries = 0; + continue; + } + inputHandle = await findInput(page); if (inputHandle && inputHandle.asElement()) { logger.info('适配器', '已找到输入框'); @@ -149,8 +213,16 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { let inputHandle = await findInput(page); let retries = 0; - while ((!inputHandle || !inputHandle.asElement()) && retries < 15) { - await sleep(1000, 1500); + // 增加重试次数以适应可能的跳转 + while ((!inputHandle || !inputHandle.asElement()) && retries < 30) { + // 检测并处理账户选择页面 + if (await handleAccountChooser(page)) { + // 重置重试计数,给更多时间查找输入框 + retries = 0; + } else { + await sleep(1000, 1500); + } + inputHandle = await findInput(page); retries++; } diff --git a/lib/browser/launcher.js b/lib/browser/launcher.js index 5530dce..a7cc32e 100644 --- a/lib/browser/launcher.js +++ b/lib/browser/launcher.js @@ -142,8 +142,8 @@ function getPersistentFingerprint(filePath) { devices: ['desktop'], locales: ['en-US'], screen: { - minWidth: 1280, maxWidth: 1920, - minHeight: 720, maxHeight: 1080 + minWidth: 1280, maxWidth: 1366, + minHeight: 720, maxHeight: 768 } }; @@ -185,8 +185,9 @@ export async function initBrowserBase(config, options) { waitInputValidator = null } = options; - // 检测登录模式 + // 检测登录模式和 Xvfb 模式 const isLoginMode = process.argv.includes('-login'); + const isXvfbMode = process.env.XVFB_RUNNING === 'true'; const ENABLE_AUTOMATION_MODE = !isLoginMode; logger.info('浏览器', `开始初始化浏览器 (${productName})`); @@ -194,6 +195,9 @@ export async function initBrowserBase(config, options) { if (isLoginMode) { logger.warn('浏览器', '当前为登录模式,请手动完成登录后关闭登录模式以继续自动化程序!'); } + if (isXvfbMode) { + logger.info('浏览器', '检测到 Xvfb 环境,强制禁用无头模式'); + } const browserConfig = config?.browser || {}; @@ -207,9 +211,9 @@ export async function initBrowserBase(config, options) { const camoufoxLaunchOptions = { // 基础选项 (snake_case) executable_path: browserConfig.path || undefined, - headless: browserConfig.headless && !isLoginMode, + headless: browserConfig.headless && !isLoginMode && !isXvfbMode, user_data_dir: userDataDir, - window: [1280, 720], + window: [1366, 768], ff_version: 135, fingerprint: myFingerprint, os: currentOS, @@ -226,10 +230,15 @@ export async function initBrowserBase(config, options) { }; // Headless 模式配置 - if (browserConfig.headless && !isLoginMode) { + if (browserConfig.headless && !isLoginMode && !isXvfbMode) { logger.info('浏览器', 'Headless 模式: 启用'); } else { - logger.info('浏览器', 'Headless 模式: 禁用'); + const reasons = []; + if (isLoginMode) reasons.push('登录模式'); + if (isXvfbMode) reasons.push('Xvfb 模式'); + if (!browserConfig.headless) reasons.push('配置禁用'); + + logger.info('浏览器', 'Headless 模式: 禁用' + (reasons.length > 0 ? ` (${reasons.join(', ')})` : '')); } // GPU 配置适配 diff --git a/server.js b/server.js index 1a19783..cde84b6 100644 --- a/server.js +++ b/server.js @@ -6,6 +6,164 @@ import { getBackend } from './lib/backend/index.js'; import { getModelsForBackend, resolveModelId, getImagePolicy, IMAGE_POLICY } from './lib/backend/models.js'; import { logger } from './lib/utils/logger.js'; import crypto from 'crypto'; +import { spawn, spawnSync } from 'child_process'; +import os from 'os'; + +// ==================== 命令行参数处理 ==================== + +/** + * 检查命令是否存在 + * @param {string} cmd - 命令名称 + * @returns {boolean} + */ +function checkCommand(cmd) { + const result = spawnSync('which', [cmd], { encoding: 'utf8' }); + return result.status === 0; +} + +/** + * 处理 Xvfb 和 VNC 启动参数(仅 Linux) + */ +function handleDisplayParams() { + const args = process.argv.slice(2); + const hasXvfb = args.includes('-xvfb'); + const hasVnc = args.includes('-vnc'); + const isInXvfb = process.env.XVFB_RUNNING === 'true'; + + // -vnc 必须和 -xvfb 并用(但如果已在 Xvfb 中运行则允许) + if (hasVnc && !hasXvfb && !isInXvfb) { + logger.error('服务器', '-vnc 参数必须和 -xvfb 参数一起使用'); + logger.error('服务器', '正确用法: node server.js -xvfb -vnc'); + process.exit(1); + } + + // 非 Linux 系统检查 + if ((hasXvfb || hasVnc) && os.platform() !== 'linux') { + logger.warn('服务器', '忽略参数: -xvfb 和 -vnc 参数仅在 Linux 系统上支持'); + return; + } + + // 处理 -xvfb 参数(或已在 Xvfb 中运行) + if ((hasXvfb || isInXvfb) && os.platform() === 'linux') { + // 检查 xvfb-run 是否存在(仅在首次启动时需要) + if (hasXvfb && !isInXvfb) { + if (!checkCommand('xvfb-run')) { + logger.error('服务器', '未找到 xvfb-run 命令'); + logger.error('服务器', '请先安装 Xvfb:'); + logger.error('服务器', ' - Ubuntu/Debian: sudo apt install xvfb'); + logger.error('服务器', ' - CentOS/RHEL: sudo dnf install xorg-x11-server-Xvfb'); + process.exit(1); + } + } + + // 检查是否已在 Xvfb 中运行(通过环境变量判断) + if (isInXvfb) { + // 已在 Xvfb 中,继续正常启动 + logger.info('服务器', '已在 Xvfb 虚拟显示器中运行', { display: process.env.DISPLAY || ':99' }); + + // 处理 VNC(如果需要) + if (hasVnc) { + if (!checkCommand('x11vnc')) { + logger.error('服务器', '未找到 x11vnc 命令'); + logger.error('服务器', '请先安装 x11vnc:'); + logger.error('服务器', ' - Ubuntu/Debian: sudo apt install x11vnc'); + logger.error('服务器', ' - CentOS/RHEL: sudo dnf install x11vnc'); + process.exit(1); + } + + const display = process.env.DISPLAY || ':99'; + logger.info('服务器', '正在启动 VNC 服务器...'); + + const vncProcess = spawn('x11vnc', [ + '-display', display, + '-localhost', + '-nopw', + '-once', + '-noxdamage', + '-ncache', '10', + '-forever' + ], { + stdio: 'ignore', // 忽略 VNC 的输出,避免混入日志 + detached: false + }); + + vncProcess.on('error', (err) => { + logger.error('服务器', 'VNC 启动失败', { error: err.message }); + }); + + // 处理进程退出信号 + process.on('SIGINT', () => { + vncProcess.kill('SIGTERM'); + process.exit(0); + }); + process.on('SIGTERM', () => { + vncProcess.kill('SIGTERM'); + process.exit(0); + }); + + logger.info('服务器', 'VNC 服务器已启动'); + logger.warn('服务器', '连接方式: 在本地运行 VNC 客户端连接 5900 端口'); + } + + return; + } + + // 需要在 Xvfb 中重启 + logger.info('服务器', '正在启动 Xvfb 虚拟显示器...'); + + // 构建新的参数列表(移除 -xvfb,保留其他参数如 -vnc、-login 等) + const newArgs = args.filter(arg => arg !== '-xvfb'); + + // 使用 env 命令来确保环境变量被正确传递 + const xvfbArgs = [ + '--server-num=99', + '--server-args=-ac -screen 0 1366x768x24', + 'env', + 'XVFB_RUNNING=true', + 'DISPLAY=:99', + process.argv[0], // node 可执行文件 + process.argv[1], // server.js 路径 + ...newArgs + ]; + + const xvfbProcess = spawn('xvfb-run', xvfbArgs, { + stdio: 'inherit' + }); + + xvfbProcess.on('error', (err) => { + logger.error('服务器', 'Xvfb 启动失败', { error: err.message }); + process.exit(1); + }); + + xvfbProcess.on('exit', (code) => { + process.exit(code || 0); + }); + + // 处理父进程退出信号 + process.on('SIGINT', () => { + xvfbProcess.kill('SIGTERM'); + }); + process.on('SIGTERM', () => { + xvfbProcess.kill('SIGTERM'); + }); + + // 不再继续执行后续代码 + return 'XVFB_REDIRECT'; + } +} + +// 执行参数处理 +const displayResult = handleDisplayParams(); +if (displayResult === 'XVFB_REDIRECT') { + // 已经重定向到 Xvfb,不再继续执行 + // 这个进程将等待子进程退出 + // eslint-disable-next-line no-process-exit + process.on('exit', () => { }); + // 阻止继续执行 + await new Promise(() => { }); +} + +// ==================== 服务器主逻辑 ==================== // 使用统一后端获取配置和函数 const { config, name, initBrowser, generateImage, TEMP_DIR } = getBackend();