From 1c4fc1a314772b427b75af5a41a1eb21bf7b26c8 Mon Sep 17 00:00:00 2001 From: foxhui Date: Tue, 16 Dec 2025 02:38:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=95=85=E9=9A=9C?= =?UTF-8?q?=E8=BD=AC=E7=A7=BB=E5=92=8C=E9=87=8D=E8=AF=95=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E6=95=B4=E7=90=86=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 247 +++----- config.example.yaml | 8 + src/backend/adapter/gemini.js | 20 +- src/backend/adapter/gemini_biz.js | 13 +- src/backend/adapter/lmarena.js | 20 +- src/backend/adapter/nanobananafree_ai.js | 20 +- src/backend/adapter/zai_is.js | 13 +- src/backend/failover.js | 113 ++++ src/backend/index.js | 2 +- src/backend/pool.js | 697 ----------------------- src/backend/pool/PoolManager.js | 290 ++++++++++ src/backend/pool/Worker.js | 458 +++++++++++++++ src/backend/pool/index.js | 6 + src/backend/strategy.js | 73 +++ src/backend/utils.js | 372 ------------ src/backend/utils/download.js | 81 +++ src/backend/utils/error.js | 131 +++++ src/backend/utils/index.js | 48 ++ src/backend/utils/page.js | 229 ++++++++ src/browser/utils.js | 11 +- src/server/errors.js | 36 +- src/server/http/respond.js | 24 +- src/server/http/routes.js | 12 +- src/server/queue.js | 22 +- src/utils/config.js | 19 + src/utils/constants.js | 120 ++++ 26 files changed, 1746 insertions(+), 1339 deletions(-) create mode 100644 src/backend/failover.js delete mode 100644 src/backend/pool.js create mode 100644 src/backend/pool/PoolManager.js create mode 100644 src/backend/pool/Worker.js create mode 100644 src/backend/pool/index.js create mode 100644 src/backend/strategy.js delete mode 100644 src/backend/utils.js create mode 100644 src/backend/utils/download.js create mode 100644 src/backend/utils/error.js create mode 100644 src/backend/utils/index.js create mode 100644 src/backend/utils/page.js create mode 100644 src/utils/constants.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 2879865..f05d695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,213 +5,102 @@ 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). +## [3.0.1] - 2025-12-16 + +### ✨ Added +- **故障转移系统** + - 实现了基于 Pool 的自动故障转移:当某个 Worker 执行任务失败(如 API 超时、页面崩溃、被限流)时,系统会自动寻找下一个支持该模型的 Worker 进行重试。 + - **Merge 模式增强**:Merge Worker 内部也会在不同的适配器之间进行故障转移。 + ## [3.0.0] - 2025-12-14 -### Added -- **支持多窗口多账号** - - 支持多个窗口并行进行任务,支持浏览器实例之间的数据隔离 - - Cookies 获取接口可指定浏览器实例 - -### Changed -- **配置文件重构** - - 配置文件格式几乎重构,请重新复制模板修改 +### ✨ Added +- **多窗口多账号支持** + - 架构升级,支持同时管理多个浏览器实例和多个标签页。 + - 实现了浏览器实例间的数据(Cookies/Storage)完全隔离。 +- **Cookies 管理** + - 新增 `/v1/cookies` 接口,支持获取指定 browser instance 的 Cookies。 +### 🔄 Changed +- **配置系统重构** + - 配置文件结构大幅调整,采用更清晰的 `backend.pool` 结构配置 Worker。 ## [2.4.0] - 2025-12-13 -### Added -- **浏览器伪装** - - 适配了 GEOIP 数据库,支持时区伪装,完善伪装机制 -- **初始化脚本** - - 支持自定义初始化步骤 npm run init -- -custom - - 添加下载 GeoLite2-City.mmdb 数据库的步骤 -- **增加服务器自检** - - 自动排查缺少的依赖和未应用的补丁并提供解决方案,降低使用门槛 -- **聚合模式监控** - - 在聚合模式下闲时跳转需要监控的网站,确保登录过期自动续登保持Cookie为最新 +### ✨ Added +- **浏览器伪装增强** + - 集成 GEOIP 数据库,实现基于 IP 的自动时区伪装。 +- **初始化脚本 (init.js)** + - 支持 `npm run init -- -custom` 自定义初始化。 + - 自动下载 GeoLite2 sum数据库。 +- **服务器自检** + - 启动时自动检查依赖完整性和环境补丁。 +- **Merge 模式监控** + - 闲时自动跳转到指定网站以维持会话活跃(保活)。 -### Changed -- **重构部分** - - 重构服务器部分,并整理项目目录 - - 加强注释,便于后期维护 - - 支持获取指定域名的 Cookie +### 🔄 Changed +- **代码重构** + - 服务器代码模块化 (`src/server/`). + - 目录结构重新整理。 ## [2.3.0] - 2025-12-12 -### Added -- **支持新网站** - - 初步支持对 Gemini 网页版的支持 +### ✨ Added +- **新适配器支持** + - 初步支持 Gemini 网页版 (`gemini.js`). -### Changed -- **接口逻辑优化** - - 移除了流式与非流式接口的统一开关限制,现在两种客户端可同时存在。 - - **行为说明**: - - **流式请求 (stream: true)**:支持无限排队,并通过心跳机制保持连接以等待结果。 - - **非流式请求**:在队列未满时正常处理;当队列满时,将立即拒绝并返回明确的拒绝原因,但仍受 `maxQueueSize` 限制。 +### 🔄 Changed +- **流式接口优化** + - 移除了全局开关,改为由请求体参数 `stream: true` 动态控制。 + - **保活机制**:流式模式下支持无限排队,并通过 SSE 心跳包防止连接超时。 + - **拒绝策略**:非流式请求在队列满时立即拒绝,避免无限等待。 ## [2.2.3] - 2025-12-12 -### Added +### ✨ Added - **后端聚合** - - 可根据请求的模型id自动选择可用的适配器且遵循配置文件中的优先级 + - 实现了根据模型 ID 自动路由到对应适配器的逻辑。 -### Fixed -- **修复杂项** - - 修复在 MacOS 下初始化步骤缺少导致的浏览器启动失败 +### 🐛 Fixed +- **Mac 兼容性** + - 修复了 MacOS 初始化步骤缺失导致的启动失败。 ## [2.2.2] - 2025-12-12 -### Added -- **支持Docker** - - 初步支持 Docker 部署 [foxhui/lmarena-imagen-automator](https://hub.docker.com/r/foxhui/lmarena-imagen-automator) +### ✨ Added +- **Docker 支持** + - 发布 Docker 镜像 [foxhui/lmarena-imagen-automator](https://hub.docker.com/r/foxhui/lmarena-imagen-automator). ## [2.2.1] - 2025-12-12 -### Added -- **Cookie获取** - - 增加 Cookie 获取接口,可利用本项目的自动续登功能获取最新的 Cookie 给其他工具使用 +### ✨ Added +- **Cookie 导出** + - 利用自动续登机制获取最新 Cookie,供外部工具使用。 -### Changed -- **自动续登** - - 监听逻辑修改为全局监听,任何时候触发跳转都会执行自动登录(如会话过期自动跳转) - -### Fixed -- **修复杂项** - - 修复自动登录时可能会出现的问题 - - 修复启动VNC服务器时端口可能冲突的问题 - - 修复遗留的浏览器启动参数导致的小问题 - - 修复 zAI 返回错误时无反馈直到请求超时的问题 +### 🐛 Fixed +- **自动续登修复**:改为全局监听,修复了部分场景下不触发的问题。 +- **杂项修复**:VNC 端口冲突、启动参数优化、zAI 错误反馈优化。 ## [2.2.0] - 2025-12-11 -### Added -- **支持新网站** - - 初步支持 zAI/zai.is 且支持自动续登 +### ✨ Added +- **新适配器支持** + - 支持 zAI (zai.is),含自动 Discord 登录处理。 -### Fixed -- **浏览器功能** - - 修复 Gemini Business 自动续登监听器被多次触发的问题 - - 修复拟人输入在 MacOS 环境下提示词无法全选删除的问题 - -## [2.1.0] - 2025-12-09 - -### Changed -- **优化性能** - - 逐步减少冗余代码,提升性能 - - 增加适配器代码可读性 -- **日志优化** - - 已可以将错误原因透传至客户端,减少故障排查成本 - -### Fixed -- **减少故障** - - 填补或删除可能会出现BUG的相关代码 - -## [2.0.2] - 2025-12-09 - -### Fixed -- **修复超时逻辑** - - 修复在等待生成结果时超时,但是客户端任务未终止且无任何通知的问题 - -## [2.0.1] - 2025-12-08 - -### Added -- **自动续登** - - 支持 Gemini Business 过期自动续登录 -- **内置XVFB指令** - - 内置了xvfb指令和x11vnc指令,只需要添加参数即可,无需记忆繁琐的指令 - -### Changed -- **优化分辨率** - - 优化浏览器窗口分辨率以确保窗口不会过大以及在服务器上消耗性能 +### 🐛 Fixed +- **Gemini Business**:修复监听器重复触发问题。 +- **Mac 输入法**:修复拟人输入无法全选的问题。 ## [2.0.0] - 2025-12-06 -### Added -- **支持新网站** - - 支持对 Nano Banana Free 网站的支持 +### 💥 Breaking Changes +- **核心迁移** + - 从 Puppeteer 迁移至 **Playwright + Camoufox**。 + - 旧版代码归档至 `puppeteer-edition` 分支。 -### Changed -- **代码重构** - - 本项目已从 Puppeteer 迁移至 Playwright + Camoufox,以应对日益复杂的反机器人检测机制。基于 Puppeteer 的旧版本代码已归档至 `puppeteer-edition` 分支,仅作留存,**不再提供更新与维护**。 - -## [1.3.1] - 2025-12-05 - -### Added -- **同步竞技场模型UUID** - - 新增 gemini-3-pro-image-preview-2k 模型的支持 - -## [1.3.0] - 2025-11-28 - -### Added -- **Gemini Enterprise Business 支持** - - 新增对 Gemini Enterprise Business 的初步支持 - - 实现请求拦截机制,强制指定 `Nano Banana Pro` 模型 - -### Changed -- **代码重构** - - 重构代码结构,提升代码复用率并增强项目的可维护性 - - 优化日志输出系统,提高调试信息的可读性 -- **CLI 交互增强** - - 更新 `lib/test.js` 测试工具,支持交互式选择模型和测试方式 - -## [1.2.1] - 2025-11-27 - -### Added -- **登录模式** - - 新增独立登录参数 (`-login`),便于用户在非自动化模式下完成手动登录 - -### Changed -- **浏览器进程解耦** - - 调整架构为程序与浏览器分离模式:主程序现通过连接远程调试端口(Remote Debugging Port)控制浏览器,旨在降低自动化检测特征 - -## [1.2.0] - 2025-11-26 - -### Added -- **浏览器指纹伪装增强** - - 针对 Windows 10 原生 Chrome 环境优化指纹,已在 [antibot](https://bot.sannysoft.com/) 和 [CreepJS](https://abrahamjuliot.github.io/creepjs/) 测试中无红色高危警告 - - 集成 `ghost-cursor` 库,通过贝塞尔曲线算法生成拟人化鼠标轨迹,提升伪装效果 - - *注:Linux 环境下的指纹伪装暂未完全覆盖,建议参考文档中的常见问题进行手动调优* - -### Changed -- **底层拦截机制重构** - - 弃用基于 Fetch 脚本注入和 Puppeteer Request Interception 的旧方案 - - 迁移至 CDP (Chrome DevTools Protocol) 拦截器处理模型 UUID 映射,显著降低被检测风险 -- **环境参数优化** - - 优化浏览器启动参数配置与窗口尺寸计算逻辑,进一步减少特征暴露 - -## [1.1.1] - 2025-11-25 - -### Fixed -- **模型映射修复** - - 修复因 UUID 映射错误导致 `gemini-3-pro-image-preview` 模型请求返回 HTTP 500 的异常 - -## [1.1.0] - 2025-11-24 - -### Added -- **多模型支持体系** - - 新增 `model` 参数,支持指定 Seedream, Gemini, Imagen, DALL-E 等 23+ 种图像生成模型 - - 新增 `/v1/models` 端点,提供可用模型列表查询功能 - - 引入 `lib/models.js` 配置文件,实现模型映射的集中管理与扩展 - - 实现动态 payload 注入,在浏览器上下文中实时修改 `modelAId` -- **API 兼容性更新** - - OpenAI 兼容接口 (`/v1/chat/completions`) 及队列接口 (`/v1/queue/join`) 均已适配 `model` 参数 - - *注:若未指定模型,系统将默认调用网页端的缺省模型* - -## [1.0.1] - 2025-11-23 - -### Fixed -- **代理鉴权修复** - - 修复了带身份验证的 SOCKS5 代理无法建立连接的问题。 - -## [1.0.0] - 2025-11-23 - -### Added -- **初始版本发布** - - 发布基于 Puppeteer 的自动化图像生成核心功能。 - - 提供双运行模式:OpenAI API 兼容模式与 Queue 队列模式 (SSE)。 - - 拟人化交互:内置贝塞尔曲线鼠标移动、智能键盘输入模拟及随机抖动延迟算法。 - - **功能特性**: - - 支持单次最多上传 5 张图片。 - - 支持 Bearer Token 标准认证。 - - 完整支持 HTTP 及 SOCKS5 代理协议。 - - 附带 CLI 测试工具及可配置化系统架构。 +### ✨ Added +- **新适配器支持** + - 支持 Nano Banana Free。 +- **功能特性** + - 内置 XVFB/VNC 支持命令。 + - 支持 Gemini Business 过期自动续登。 diff --git a/config.example.yaml b/config.example.yaml index cc6008b..e2adeaf 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -25,6 +25,14 @@ backend: # 任务分发时,会把所有 Instance 下的所有 Worker 扁平化看待 strategy: least_busy + # ======================================== + # 故障转移配置 + # ======================================== + # 当适配器返回网络错误时,自动尝试其他支持相同模型的 Worker + failover: + enabled: true # 启用故障转移 + maxRetries: 2 # 最多重试次数 (0=无限制) + # ======================================== # 浏览器实例列表 # ======================================== diff --git a/src/backend/adapter/gemini.js b/src/backend/adapter/gemini.js index 066a66d..5273a71 100644 --- a/src/backend/adapter/gemini.js +++ b/src/backend/adapter/gemini.js @@ -11,8 +11,9 @@ import { fillPrompt, normalizePageError, moveMouseAway, - waitForInput -} from '../utils.js'; + waitForInput, + gotoWithCheck +} from '../utils/index.js'; import { logger } from '../../utils/logger.js'; // --- 配置常量 --- @@ -35,7 +36,8 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { try { logger.info('适配器', '开启新会话...', meta); - await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' }); + const gotoResult = await gotoWithCheck(page, TARGET_URL); + if (gotoResult.error) return gotoResult; // 1. 等待输入框加载 await waitForInput(page, inputLocator, { click: false }); @@ -164,15 +166,6 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { } } -/** - * 输入框就绪校验 - * @param {import('playwright-core').Page} page - */ -async function waitInputValidator(page) { - const inputLocator = page.getByRole('textbox'); - await waitForInput(page, inputLocator, { click: true }); -} - /** * 适配器 manifest */ @@ -196,9 +189,6 @@ export const manifest = { return model ? model.id : null; }, - // 输入框就绪校验 - waitInput: waitInputValidator, - // 无需导航处理器 navigationHandlers: [], diff --git a/src/backend/adapter/gemini_biz.js b/src/backend/adapter/gemini_biz.js index ce8e3a9..e1715a2 100644 --- a/src/backend/adapter/gemini_biz.js +++ b/src/backend/adapter/gemini_biz.js @@ -18,8 +18,9 @@ import { lockPageAuth, unlockPageAuth, isPageAuthLocked, - waitForInput -} from '../utils.js'; + waitForInput, + gotoWithCheck +} from '../utils/index.js'; import { logger } from '../../utils/logger.js'; // Gemini Biz 输入框选择器 @@ -111,7 +112,8 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { await waitForPageAuth(page); logger.info('适配器', '开启新会话', meta); - await page.goto(targetUrl, { waitUntil: 'domcontentloaded' }); + const gotoResult = await gotoWithCheck(page, targetUrl); + if (gotoResult.error) return gotoResult; // 如果触发了账户选择跳转,等待全局处理器完成 await waitForPageAuth(page); @@ -281,11 +283,6 @@ export const manifest = { return model ? model.id : null; }, - // 输入框就绪校验 - async waitInput(page, ctx) { - await waitForInput(page, INPUT_SELECTOR, { click: true }); - }, - // 导航处理器 navigationHandlers: [handleAccountChooser], diff --git a/src/backend/adapter/lmarena.js b/src/backend/adapter/lmarena.js index 17acd37..23d0c28 100644 --- a/src/backend/adapter/lmarena.js +++ b/src/backend/adapter/lmarena.js @@ -15,8 +15,9 @@ import { normalizeHttpError, downloadImage, moveMouseAway, - waitForInput -} from '../utils.js'; + waitForInput, + gotoWithCheck +} from '../utils/index.js'; import { logger } from '../../utils/logger.js'; // --- 配置常量 --- @@ -57,7 +58,8 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { try { logger.info('适配器', '开启新会话...', meta); - await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' }); + const gotoResult = await gotoWithCheck(page, TARGET_URL); + if (gotoResult.error) return gotoResult; // 1. 等待输入框加载 await waitForInput(page, textareaSelector, { click: false }); @@ -162,15 +164,6 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { } } -/** - * 输入框就绪校验 - * @param {import('playwright-core').Page} page - */ -async function waitInputValidator(page) { - const textareaSelector = 'textarea'; - await waitForInput(page, textareaSelector, { click: true }); -} - /** * 适配器 manifest */ @@ -226,9 +219,6 @@ export const manifest = { return model ? model.codeName : null; }, - // 输入框就绪校验 - waitInput: waitInputValidator, - // 无需导航处理器 navigationHandlers: [], diff --git a/src/backend/adapter/nanobananafree_ai.js b/src/backend/adapter/nanobananafree_ai.js index 0e31b04..124f6d7 100644 --- a/src/backend/adapter/nanobananafree_ai.js +++ b/src/backend/adapter/nanobananafree_ai.js @@ -14,8 +14,9 @@ import { normalizePageError, normalizeHttpError, moveMouseAway, - waitForInput -} from '../utils.js'; + waitForInput, + gotoWithCheck +} from '../utils/index.js'; import { logger } from '../../utils/logger.js'; // --- 配置常量 --- @@ -37,7 +38,8 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { try { logger.info('适配器', '开启新会话', meta); - await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' }); + const gotoResult = await gotoWithCheck(page, TARGET_URL); + if (gotoResult.error) return gotoResult; // 1. 等待输入框加载 await waitForInput(page, textareaSelector, { click: false }); @@ -129,15 +131,6 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { } } -/** - * 输入框就绪校验 - * @param {import('playwright-core').Page} page - */ -async function waitInputValidator(page) { - const textareaSelector = 'textarea'; - await waitForInput(page, textareaSelector, { click: true }); -} - /** * 适配器 manifest */ @@ -161,9 +154,6 @@ export const manifest = { return model ? model.id : null; }, - // 输入框就绪校验 - waitInput: waitInputValidator, - // 无需导航处理器 navigationHandlers: [], diff --git a/src/backend/adapter/zai_is.js b/src/backend/adapter/zai_is.js index 3814fcd..eebc559 100644 --- a/src/backend/adapter/zai_is.js +++ b/src/backend/adapter/zai_is.js @@ -19,8 +19,9 @@ import { lockPageAuth, unlockPageAuth, isPageAuthLocked, - waitForInput -} from '../utils.js'; + waitForInput, + gotoWithCheck +} from '../utils/index.js'; import { logger } from '../../utils/logger.js'; // zai.is 输入框选择器 @@ -126,7 +127,8 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { await waitForPageAuth(page); logger.info('适配器', '开启新会话', meta); - await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' }); + const gotoResult = await gotoWithCheck(page, TARGET_URL); + if (gotoResult.error) return gotoResult; // 如果触发了登录跳转,等待全局处理器完成 await waitForPageAuth(page); @@ -366,11 +368,6 @@ export const manifest = { return model ? model.id : null; }, - // 输入框就绪校验 - async waitInput(page, ctx) { - await waitForInput(page, INPUT_SELECTOR, { click: true }); - }, - // 导航处理器 navigationHandlers: [handleDiscordAuth], diff --git a/src/backend/failover.js b/src/backend/failover.js new file mode 100644 index 0000000..c29f822 --- /dev/null +++ b/src/backend/failover.js @@ -0,0 +1,113 @@ +/** + * @fileoverview 故障转移模块 + * @description 提供故障转移重试逻辑。 + * + * 核心功能: + * - 故障转移执行器 + * + * 注意: + * - 错误分类在 utils/error.js 中 + * - 负载均衡策略在 strategy.js 中 + */ + +import { logger } from '../utils/logger.js'; +import { RETRY } from '../utils/constants.js'; +import { isRetryableError, normalizeError } from './utils/error.js'; + +// 重新导出错误分类函数以保持兼容性 +export { isRetryableError, normalizeError }; + +// ========================================== +// 故障转移执行器 +// ========================================== + +/** + * 创建故障转移执行器 + * @param {object} options - 配置选项 + * @param {number} [options.maxRetries=2] - 最大重试次数 + * @param {Function} [options.onRetry] - 重试回调 + * @returns {object} 故障转移执行器 + */ +export function createFailoverExecutor(options = {}) { + const maxRetries = options.maxRetries ?? RETRY.MAX_ATTEMPTS; + const onRetry = options.onRetry || (() => { }); + + return { + /** + * 执行带故障转移的操作 + * @param {object[]} candidates - 候选列表 + * @param {Function} execute - 执行函数,接收候选项,返回 {error?, ...result} + * @param {object} [meta={}] - 日志元数据 + * @returns {Promise} 执行结果 + */ + async execute(candidates, execute, meta = {}) { + if (candidates.length === 0) { + return { error: '没有可用的候选' }; + } + + // 计算最大尝试次数 + const maxAttempts = maxRetries === 0 + ? candidates.length + : Math.min(maxRetries + 1, candidates.length); + + let lastError = null; + + for (let i = 0; i < maxAttempts; i++) { + const candidate = candidates[i]; + + try { + const result = await execute(candidate); + + // 成功返回 + if (!result.error) { + return result; + } + + // 记录错误 + lastError = result.error; + + // 检查是否可重试 + const normalized = normalizeError(lastError); + if (!normalized.retryable && i < maxAttempts - 1) { + // 不可重试的错误,但还有候选,继续尝试 + logger.debug('故障转移', `不可重试错误,跳过: ${lastError}`, meta); + } + + // 触发重试回调 + if (i < maxAttempts - 1) { + onRetry(candidate, lastError, i + 1); + } + + } catch (err) { + lastError = err.message || String(err); + if (i < maxAttempts - 1) { + onRetry(candidate, lastError, i + 1); + } + } + } + + // 所有候选都失败 + return { + error: `所有候选都失败: ${lastError}`, + code: 'FAILOVER_EXHAUSTED', + retryable: false + }; + } + }; +} + +// ========================================== +// 便捷函数 +// ========================================== + +/** + * 执行带故障转移的操作(简化版) + * @param {object[]} candidates - 候选列表 + * @param {Function} execute - 执行函数 + * @param {object} [options={}] - 选项 + * @returns {Promise} + */ +export async function executeWithFailover(candidates, execute, options = {}) { + const executor = createFailoverExecutor(options); + return executor.execute(candidates, execute, options.meta); +} diff --git a/src/backend/index.js b/src/backend/index.js index efb1476..88b77e7 100644 --- a/src/backend/index.js +++ b/src/backend/index.js @@ -12,7 +12,7 @@ import fs from 'fs'; import path from 'path'; import { loadConfig } from '../utils/config.js'; -import { PoolManager } from './pool.js'; +import { PoolManager } from './pool/index.js'; import { logger } from '../utils/logger.js'; // --- 集中管理的路径常量 --- diff --git a/src/backend/pool.js b/src/backend/pool.js deleted file mode 100644 index a74fd7f..0000000 --- a/src/backend/pool.js +++ /dev/null @@ -1,697 +0,0 @@ -/** - * @fileoverview Worker Pool 管理模块 - * @description 实现 Worker 类和 PoolManager 类,负责多浏览器实例的生命周期管理和任务分发。 - * 使用 AdapterRegistry 动态加载适配器,无需硬编码适配器列表。 - * - * 对外接口: - * - PoolManager.initAll() - 初始化所有 Worker - * - PoolManager.selectWorker(modelId) - 智能选择 Worker - * - PoolManager.generateImage(ctx, prompt, paths, modelId, meta) - 分发生图任务 - * - PoolManager.getModels() / resolveModelId() / getImagePolicy() - 模型相关 - * - PoolManager.getCookies(instanceName, domain) - 获取指定实例的 Cookies - */ - -import fs from 'fs'; -import { logger } from '../utils/logger.js'; -import { initBrowserBase, createCursor, getRealViewport, clamp, random, sleep } from '../browser/launcher.js'; -import { registry } from './registry.js'; - -/** - * Worker 类 - 封装单个浏览器实例 - */ -class Worker { - /** - * @param {object} globalConfig - 全局配置 - * @param {object} workerConfig - Worker 配置 - */ - constructor(globalConfig, workerConfig) { - this.name = workerConfig.name; - this.type = workerConfig.type; - this.instanceName = workerConfig.instanceName || null; - this.userDataDir = workerConfig.userDataDir; - this.proxyConfig = workerConfig.resolvedProxy; - this.globalConfig = globalConfig; - this.workerConfig = workerConfig; - - // Merge 模式专属 - this.mergeTypes = workerConfig.mergeTypes || []; - this.mergeMonitor = workerConfig.mergeMonitor || null; - - // 运行时状态 - this.browser = null; - this.page = null; - this.busyCount = 0; - this.initialized = false; - } - - /** - * 初始化浏览器实例 - * @param {object} [sharedBrowser] - 可选,共享的浏览器实例 - */ - async init(sharedBrowser = null) { - if (this.initialized) return; - - // 确保用户数据目录存在 - if (!fs.existsSync(this.userDataDir)) { - fs.mkdirSync(this.userDataDir, { recursive: true }); - } - - const productName = this.type === 'merge' - ? `聚合模式 [${this.name}]` - : `${this.type} [${this.name}]`; - - // 获取目标 URL (从 AdapterRegistry 动态获取) - let targetUrl = 'about:blank'; - if (this.type === 'merge') { - // Merge 模式:使用第一个 mergeType 的 URL - const firstType = this.mergeTypes[0]; - targetUrl = registry.getTargetUrl(firstType, this.globalConfig, this.workerConfig) || 'about:blank'; - } else { - targetUrl = registry.getTargetUrl(this.type, this.globalConfig, this.workerConfig) || 'about:blank'; - } - - // 收集导航处理器 (从 AdapterRegistry 动态获取) - const handlers = []; - const typesToHandle = this.type === 'merge' ? this.mergeTypes : [this.type]; - for (const type of typesToHandle) { - const typeHandlers = registry.getNavigationHandlers(type); - handlers.push(...typeHandlers); - } - - // 聚合导航处理器 - const navigationHandler = handlers.length > 0 - ? async (page) => { - for (const handler of handlers) { - try { await handler(page); } catch (e) { /* ignore */ } - } - } - : null; - - // 获取 waitInputValidator (从 AdapterRegistry 动态获取) - let waitInputValidator = null; - if (this.type !== 'merge') { - waitInputValidator = registry.getWaitInput(this.type); - } - - logger.info('工作池', `[${this.name}] 正在初始化浏览器...`); - if (this.proxyConfig) { - logger.debug('工作池', `[${this.name}] 使用代理: ${this.proxyConfig.type}://${this.proxyConfig.host}:${this.proxyConfig.port}`); - } else { - logger.debug('工作池', `[${this.name}] 直连模式(无代理)`); - } - - // 如果有共享浏览器,创建新标签页;否则启动新浏览器 - if (sharedBrowser) { - logger.info('工作池', `[${this.name}] 复用已有浏览器,创建新标签页...`); - this.browser = sharedBrowser; - // sharedBrowser 实际是 BrowserContext(Camoufox 使用 launchPersistentContext) - this.page = await sharedBrowser.newPage(); - - // 挂载页面级认证状态(供适配器使用,避免全局锁导致多 Worker 互相阻塞) - this.page.authState = { isHandlingAuth: false }; - - // 初始化 ghost-cursor - this.page.cursor = createCursor(this.page); - - // 导航到目标 URL - await this.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }); - - // 注册导航处理器 - if (navigationHandler) { - this.page.on('framenavigated', async () => { - try { await navigationHandler(this.page); } catch (e) { /* ignore */ } - }); - } - - // 随机浏览以建立信任 - logger.info('工作池', `[${this.name}] 正在随机浏览页面以建立信任...`); - const vp = await getRealViewport(this.page); - const centerX = vp.width / 2; - const centerY = vp.height / 2; - if (this.page.cursor) { - const targetX = clamp(centerX + random(-200, 200), 10, vp.safeWidth); - const targetY = clamp(centerY + random(-200, 200), 10, vp.safeHeight); - await this.page.cursor.moveTo({ x: targetX, y: targetY }); - } - await sleep(500, 1000); - try { - await this.page.mouse.wheel({ deltaY: random(100, 300) }); - await sleep(800, 1500); - await this.page.mouse.wheel({ deltaY: -random(50, 100) }); - } catch (e) { } - - // 等待输入框就绪 - if (waitInputValidator) { - await waitInputValidator(this.page); - } - - logger.info('工作池', `[${this.name}] 初始化完成`); - } else { - // 启动新浏览器实例 - const base = await initBrowserBase(this.globalConfig, { - userDataDir: this.userDataDir, - instanceName: this.instanceName, - proxyConfig: this.proxyConfig - }); - - this.browser = base.context; - this.page = base.page; - - // 挂载页面级认证状态(供适配器使用,避免全局锁导致多 Worker 互相阻塞) - this.page.authState = { isHandlingAuth: false }; - - // 初始化 ghost-cursor - this.page.cursor = createCursor(this.page); - - // 注册导航处理器 - if (navigationHandler) { - this.page.on('framenavigated', async () => { - try { await navigationHandler(this.page); } catch (e) { /* ignore */ } - }); - } - - // 导航到目标 URL - logger.info('工作池', `[${this.name}] 正在连接目标页面...`); - await this.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }); - - // 登录模式:挂起等待用户手动登录 - const isLoginMode = process.argv.some(arg => arg.startsWith('-login')); - if (isLoginMode) { - logger.info('工作池', `[${this.name}] 登录模式已就绪,请在浏览器中完成登录`); - logger.info('工作池', `[${this.name}] 完成后可直接关闭浏览器窗口或按 Ctrl+C 退出`); - await new Promise(resolve => this.browser.on('close', resolve)); - process.exit(0); - } - - // 预热行为 - logger.info('工作池', `[${this.name}] 正在执行预热操作...`); - const vp = await getRealViewport(this.page); - const centerX = vp.width / 2; - const centerY = vp.height / 2; - if (this.page.cursor) { - const targetX = clamp(centerX + random(-200, 200), 10, vp.safeWidth); - const targetY = clamp(centerY + random(-200, 200), 10, vp.safeHeight); - await this.page.cursor.moveTo({ x: targetX, y: targetY }); - } - await sleep(500, 1000); - try { - await this.page.mouse.wheel({ deltaY: random(100, 300) }); - await sleep(800, 1500); - await this.page.mouse.wheel({ deltaY: -random(50, 100) }); - } catch (e) { } - - // 等待输入框就绪 - if (waitInputValidator) { - await waitInputValidator(this.page); - } - - logger.info('工作池', `[${this.name}] 初始化完成`); - } - - this.initialized = true; - } - - /** - * 检查是否支持指定模型 - * @param {string} modelId - 模型 ID 或 key - * @returns {boolean} - */ - supports(modelId) { - if (this.type === 'merge') { - // Merge 模式:检查所有 mergeTypes - for (const type of this.mergeTypes) { - const resolved = registry.resolveModelId(type, modelId); - if (resolved) return true; - } - // 检查 backend/model 格式 - if (modelId.includes('/')) { - const [specifiedType] = modelId.split('/', 2); - return this.mergeTypes.includes(specifiedType); - } - return false; - } else { - // 单一类型:支持 type/model 格式 - if (modelId.includes('/')) { - const [specifiedType, actualModel] = modelId.split('/', 2); - if (specifiedType === this.type) { - const resolved = registry.resolveModelId(this.type, actualModel); - return !!resolved; - } - return false; - } - const resolved = registry.resolveModelId(this.type, modelId); - return !!resolved; - } - } - - /** - * 解析模型 ID - * @param {string} modelKey - 模型 key - * @returns {{type: string, realId: string}|null} - */ - resolveModelId(modelKey) { - if (this.type === 'merge') { - // 支持 backend/model 格式 - if (modelKey.includes('/')) { - const [specifiedType, actualModel] = modelKey.split('/', 2); - if (this.mergeTypes.includes(specifiedType)) { - const realId = registry.resolveModelId(specifiedType, actualModel); - if (realId) return { type: specifiedType, realId }; - } - return null; - } - // 按优先级匹配 - for (const type of this.mergeTypes) { - const realId = registry.resolveModelId(type, modelKey); - if (realId) return { type, realId }; - } - return null; - } else { - // 单一类型:支持 type/model 格式 - if (modelKey.includes('/')) { - const [specifiedType, actualModel] = modelKey.split('/', 2); - if (specifiedType === this.type) { - const realId = registry.resolveModelId(this.type, actualModel); - return realId ? { type: this.type, realId } : null; - } - return null; - } - const realId = registry.resolveModelId(this.type, modelKey); - return realId ? { type: this.type, realId } : null; - } - } - - /** - * 生成图片 - * @param {object} ctx - 浏览器上下文 - * @param {string} prompt - 提示词 - * @param {string[]} paths - 图片路径 - * @param {string} modelId - 模型 ID - * @param {object} meta - 元信息 - */ - async generateImage(ctx, prompt, paths, modelId, meta) { - const resolved = this.resolveModelId(modelId); - if (!resolved) { - return { error: `Worker [${this.name}] 不支持模型: ${modelId}` }; - } - - const { type, realId } = resolved; - const adapter = registry.getAdapter(type); - if (!adapter) { - return { error: `适配器不存在: ${type}` }; - } - - logger.info('工作池', `[${this.name}] 执行任务 -> ${type}/${realId}`, meta); - - // 构造子上下文 - const subContext = { - ...ctx, - page: this.page, - config: this.globalConfig, - proxyConfig: this.proxyConfig, - userDataDir: this.userDataDir - }; - - this.busyCount++; - try { - return await adapter.generateImage(subContext, prompt, paths, realId, meta); - } finally { - this.busyCount--; - } - } - - /** - * 获取支持的模型列表 - * @returns {object[]} - */ - getModels() { - if (this.type === 'merge') { - const allModels = []; - const seenIds = new Set(); - - // 添加不带前缀的模型 (由系统自动分配适配器) - for (const type of this.mergeTypes) { - const result = registry.getModelsForAdapter(type); - if (result?.data) { - for (const m of result.data) { - if (!seenIds.has(m.id)) { - seenIds.add(m.id); - allModels.push({ ...m, owned_by: 'internal_server' }); - } - } - } - } - - // 添加带前缀的模型 (指定使用特定适配器) - for (const type of this.mergeTypes) { - const result = registry.getModelsForAdapter(type); - if (result?.data) { - for (const m of result.data) { - allModels.push({ - ...m, - id: `${type}/${m.id}`, - owned_by: type - }); - } - } - } - - return allModels; - } else { - // 单一类型:返回不带前缀和带前缀的模型 - const result = registry.getModelsForAdapter(this.type); - const models = result?.data || []; - const allModels = []; - - // 不带前缀的模型 (系统自动分配) - for (const m of models) { - allModels.push({ ...m, owned_by: 'internal_server' }); - } - - // 带前缀的模型 (指定适配器) - for (const m of models) { - allModels.push({ - ...m, - id: `${this.type}/${m.id}`, - owned_by: this.type - }); - } - - return allModels; - } - } - - /** - * 获取图片策略 - * @param {string} modelKey - 模型 key - * @returns {string} - */ - getImagePolicy(modelKey) { - if (this.type === 'merge') { - if (modelKey.includes('/')) { - const [specifiedType, actualModel] = modelKey.split('/', 2); - if (this.mergeTypes.includes(specifiedType)) { - return registry.getImagePolicy(specifiedType, actualModel); - } - } - for (const type of this.mergeTypes) { - const realId = registry.resolveModelId(type, modelKey); - if (realId) return registry.getImagePolicy(type, modelKey); - } - return 'optional'; - } else { - return registry.getImagePolicy(this.type, modelKey); - } - } - - /** - * 导航到监控页面(空闲时) - */ - async navigateToMonitor() { - if (this.type !== 'merge' || !this.mergeMonitor) return; - if (!this.page || this.page.isClosed()) return; - - const targetUrl = registry.getTargetUrl(this.mergeMonitor, this.globalConfig, this.workerConfig); - if (!targetUrl) return; - - // 检查是否已在目标网站 - const currentUrl = this.page.url(); - try { - if (currentUrl.includes(new URL(targetUrl).hostname)) return; - } catch (e) { return; } - - logger.info('工作池', `[${this.name}] 空闲,跳转监控: ${this.mergeMonitor}`); - try { - await this.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); - } catch (e) { - logger.warn('工作池', `[${this.name}] 监控跳转失败: ${e.message}`); - } - } - - /** - * 获取 Cookies - * @param {string} [domain] - 指定域名 - * @returns {Promise} - */ - async getCookies(domain) { - if (!this.page) throw new Error(`Worker [${this.name}] 未初始化`); - const context = this.page.context(); - if (domain) { - return await context.cookies(domain.startsWith('http') ? domain : `https://${domain}`); - } - return await context.cookies(); - } -} - -/** - * PoolManager 类 - 管理 Worker 池 - */ -class PoolManager { - /** - * @param {object} config - 全局配置 - */ - constructor(config) { - this.config = config; - this.workers = []; - this.strategy = config.backend.pool.strategy || 'least_busy'; - this.roundRobinIndex = 0; - this.initialized = false; - } - - /** - * 初始化所有 Worker - */ - async initAll() { - if (this.initialized) return; - - // 先加载所有适配器 - await registry.loadAll(); - - // 解析登录模式参数:-login 或 -login=workerName - let loginWorkerName = null; - const loginArg = process.argv.find(arg => arg.startsWith('-login')); - const isLoginMode = !!loginArg; - if (loginArg && loginArg.includes('=')) { - loginWorkerName = loginArg.split('=')[1]; - logger.info('工作池', `登录模式: 仅初始化 Worker "${loginWorkerName}"`); - } else if (isLoginMode) { - // -login 不带参数:使用第一个 Worker - loginWorkerName = this.config.backend.pool.workers[0]?.name || null; - logger.info('工作池', `登录模式: 仅初始化第一个 Worker "${loginWorkerName}"`); - } - - const workerConfigs = this.config.backend.pool.workers; - - // 登录模式下只显示初始化 1 个 - if (isLoginMode) { - logger.info('工作池', `登录模式: 从 ${workerConfigs.length} 个 Worker 中筛选...`); - } else { - logger.info('工作池', `正在初始化 ${workerConfigs.length} 个 Worker...`); - } - - // 过滤并创建 Worker 实例 - const validWorkers = []; - for (const workerConfig of workerConfigs) { - // 登录模式过滤:只初始化指定名称的 Worker - if (isLoginMode && workerConfig.name !== loginWorkerName) { - logger.debug('工作池', `[${workerConfig.name}] 跳过 (不匹配登录目标)`); - continue; - } - - // 校验 Worker 类型是否有对应的适配器 - if (workerConfig.type !== 'merge' && !registry.hasAdapter(workerConfig.type)) { - logger.error('工作池', `Worker [${workerConfig.name}] 的类型 "${workerConfig.type}" 无对应适配器,跳过`); - continue; - } - - // Merge 模式:校验所有 mergeTypes - if (workerConfig.type === 'merge') { - const invalidTypes = (workerConfig.mergeTypes || []).filter(t => !registry.hasAdapter(t)); - if (invalidTypes.length > 0) { - logger.error('工作池', `Worker [${workerConfig.name}] 的 mergeTypes 包含无效类型: ${invalidTypes.join(', ')}`); - continue; - } - } - - validWorkers.push(new Worker(this.config, workerConfig)); - } - - // 登录模式下如果没有匹配的 Worker - if (isLoginMode && validWorkers.length === 0) { - // 列出可用的 Worker 名称 - const availableNames = workerConfigs.map(w => w.name).join(', '); - throw new Error(`登录模式未找到 Worker "${loginWorkerName}"。可用的 Worker: ${availableNames}`); - } - - // 按 userDataDir 分组 - const browserMap = new Map(); // userDataDir -> { browser, proxyConfig, firstWorkerName } - - for (const worker of validWorkers) { - const existing = browserMap.get(worker.userDataDir); - - if (existing) { - // 复用已有浏览器 - 检测代理配置冲突 - const workerProxy = JSON.stringify(worker.proxyConfig || null); - const existingProxy = JSON.stringify(existing.proxyConfig || null); - if (workerProxy !== existingProxy) { - logger.warn('工作池', `[${worker.name}] 代理配置与 [${existing.firstWorkerName}] 不一致,将使用后者的配置`); - } - - logger.debug('工作池', `[${worker.name}] 将与其他 Worker 共享浏览器 (${worker.userDataDir})`); - await worker.init(existing.browser); - } else { - // 启动新浏览器 - await worker.init(); - browserMap.set(worker.userDataDir, { - browser: worker.browser, - proxyConfig: worker.proxyConfig, - firstWorkerName: worker.name - }); - } - - this.workers.push(worker); - } - - this.initialized = true; - logger.info('工作池', `工作池初始化完成,共 ${this.workers.length} 个 Worker 就绪 (${browserMap.size} 个浏览器实例)`); - } - - /** - * 根据模型选择 Worker - * @param {string} modelId - 模型 ID - * @returns {Worker} - */ - selectWorker(modelId) { - // 1. 筛选:找出所有支持该模型的 Worker - const candidates = this.workers.filter(w => w.supports(modelId)); - - if (candidates.length === 0) { - throw new Error(`没有 Worker 支持模型: ${modelId}`); - } - - if (candidates.length === 1) { - return candidates[0]; - } - - // 2. 决策:根据策略选择 - switch (this.strategy) { - case 'round_robin': { - const idx = this.roundRobinIndex % candidates.length; - this.roundRobinIndex++; - return candidates[idx]; - } - case 'random': { - const idx = Math.floor(Math.random() * candidates.length); - return candidates[idx]; - } - case 'least_busy': - default: { - return candidates.reduce((min, w) => w.busyCount < min.busyCount ? w : min, candidates[0]); - } - } - } - - /** - * 分发生图任务 - */ - async generateImage(ctx, prompt, paths, modelId, meta) { - const worker = this.selectWorker(modelId); - logger.debug('工作池', `任务分发至: ${worker.name} (busy: ${worker.busyCount})`); - return await worker.generateImage(ctx, prompt, paths, modelId, meta); - } - - /** - * 解析模型 ID(用于请求前校验) - * @param {string} modelKey - 模型 key - * @returns {string|null} 返回 workerName|type|realId 格式,或 null - */ - resolveModelId(modelKey) { - for (const worker of this.workers) { - const resolved = worker.resolveModelId(modelKey); - if (resolved) { - return `${worker.name}|${resolved.type}|${resolved.realId}`; - } - } - return null; - } - - /** - * 获取所有模型列表(聚合去重) - * @returns {object} - */ - getModels() { - const allModels = []; - const seenIds = new Set(); - - for (const worker of this.workers) { - const models = worker.getModels(); - for (const m of models) { - if (!seenIds.has(m.id)) { - seenIds.add(m.id); - allModels.push(m); - } - } - } - - return { object: 'list', data: allModels }; - } - - /** - * 获取图片策略 - * @param {string} modelKey - 模型 key - * @returns {string} - */ - getImagePolicy(modelKey) { - for (const worker of this.workers) { - if (worker.supports(modelKey)) { - return worker.getImagePolicy(modelKey); - } - } - return 'optional'; - } - - /** - * 获取指定实例的 Cookies - * @param {string} [instanceName] - 实例名称,不提供则返回第一个 - * @param {string} [domain] - 指定域名 - * @returns {Promise<{instance: string, cookies: object[]}>} - */ - async getCookies(instanceName, domain) { - let worker; - if (instanceName) { - worker = this.workers.find(w => w.instanceName === instanceName); - if (!worker) { - throw new Error(`浏览器实例不存在: ${instanceName}`); - } - } else { - worker = this.workers[0]; - if (!worker) { - throw new Error('工作池中没有可用的 Worker'); - } - } - - const cookies = await worker.getCookies(domain); - return { instance: worker.instanceName, cookies }; - } - - /** - * 触发所有 merge Worker 的监控导航 - */ - async navigateToMonitor() { - for (const worker of this.workers) { - if (worker.type === 'merge' && worker.busyCount === 0) { - await worker.navigateToMonitor(); - } - } - } - - /** - * 获取第一个 Worker 的 page(兼容旧接口) - * @returns {object|null} - */ - getFirstPage() { - return this.workers[0]?.page || null; - } -} - -export { Worker, PoolManager }; diff --git a/src/backend/pool/PoolManager.js b/src/backend/pool/PoolManager.js new file mode 100644 index 0000000..5cb6bf8 --- /dev/null +++ b/src/backend/pool/PoolManager.js @@ -0,0 +1,290 @@ +/** + * @fileoverview PoolManager 类 + * @description 管理 Worker 池,负责初始化、任务分发和故障转移 + */ + +import { logger } from '../../utils/logger.js'; +import { registry } from '../registry.js'; +import { createStrategySelector } from '../strategy.js'; +import { executeWithFailover } from '../failover.js'; +import { normalizeError } from '../utils/error.js'; +import { Worker } from './Worker.js'; + +/** + * PoolManager 类 - 管理 Worker 池 + */ +export class PoolManager { + /** + * @param {object} config - 全局配置 + */ + constructor(config) { + this.config = config; + this.workers = []; + this.strategy = config.backend.pool.strategy || 'least_busy'; + this.strategySelector = createStrategySelector(this.strategy); + this.initialized = false; + } + + /** + * 初始化所有 Worker + */ + async initAll() { + if (this.initialized) return; + + // 先加载所有适配器 + await registry.loadAll(); + + // 解析登录模式参数 + let loginWorkerName = null; + const loginArg = process.argv.find(arg => arg.startsWith('-login')); + const isLoginMode = !!loginArg; + if (loginArg && loginArg.includes('=')) { + loginWorkerName = loginArg.split('=')[1]; + logger.info('工作池', `登录模式: 仅初始化 Worker "${loginWorkerName}"`); + } else if (isLoginMode) { + loginWorkerName = this.config.backend.pool.workers[0]?.name || null; + logger.info('工作池', `登录模式: 仅初始化第一个 Worker "${loginWorkerName}"`); + } + + const workerConfigs = this.config.backend.pool.workers; + + if (isLoginMode) { + logger.info('工作池', `登录模式: 从 ${workerConfigs.length} 个 Worker 中筛选...`); + } else { + logger.info('工作池', `正在初始化 ${workerConfigs.length} 个 Worker...`); + } + + // 过滤并创建 Worker 实例 + const validWorkers = []; + for (const workerConfig of workerConfigs) { + if (isLoginMode && workerConfig.name !== loginWorkerName) { + logger.debug('工作池', `[${workerConfig.name}] 跳过 (不匹配登录目标)`); + continue; + } + + if (workerConfig.type !== 'merge' && !registry.hasAdapter(workerConfig.type)) { + logger.error('工作池', `Worker [${workerConfig.name}] 的类型 "${workerConfig.type}" 无对应适配器,跳过`); + continue; + } + + if (workerConfig.type === 'merge') { + const invalidTypes = (workerConfig.mergeTypes || []).filter(t => !registry.hasAdapter(t)); + if (invalidTypes.length > 0) { + logger.error('工作池', `Worker [${workerConfig.name}] 的 mergeTypes 包含无效类型: ${invalidTypes.join(', ')}`); + continue; + } + } + + validWorkers.push(new Worker(this.config, workerConfig)); + } + + if (isLoginMode && validWorkers.length === 0) { + const availableNames = workerConfigs.map(w => w.name).join(', '); + throw new Error(`登录模式未找到 Worker "${loginWorkerName}"。可用的 Worker: ${availableNames}`); + } + + // 按 userDataDir 分组 + const browserMap = new Map(); + + for (const worker of validWorkers) { + try { + const existing = browserMap.get(worker.userDataDir); + + if (existing) { + const workerProxy = JSON.stringify(worker.proxyConfig || null); + const existingProxy = JSON.stringify(existing.proxyConfig || null); + if (workerProxy !== existingProxy) { + logger.warn('工作池', `[${worker.name}] 代理配置与 [${existing.firstWorkerName}] 不一致,将使用后者的配置`); + } + + logger.debug('工作池', `[${worker.name}] 将与其他 Worker 共享浏览器 (${worker.userDataDir})`); + await worker.init(existing.browser); + } else { + await worker.init(); + browserMap.set(worker.userDataDir, { + browser: worker.browser, + proxyConfig: worker.proxyConfig, + firstWorkerName: worker.name + }); + } + + this.workers.push(worker); + } catch (e) { + logger.error('工作池', `[${worker.name}] 初始化失败,跳过该 Worker`, { error: e.message }); + } + } + + if (this.workers.length === 0) { + throw new Error('所有 Worker 初始化都失败了,无法启动服务'); + } + + this.initialized = true; + logger.info('工作池', `工作池初始化完成,共 ${this.workers.length} 个 Worker 就绪 (${browserMap.size} 个浏览器实例)`); + } + + /** + * 根据模型选择 Worker + */ + selectWorker(modelId) { + const candidates = this.workers.filter(w => w.supports(modelId)); + + if (candidates.length === 0) { + throw new Error(`没有 Worker 支持模型: ${modelId}`); + } + + if (candidates.length === 1) { + return candidates[0]; + } + + switch (this.strategy) { + case 'round_robin': { + const idx = this.roundRobinIndex % candidates.length; + this.roundRobinIndex++; + return candidates[idx]; + } + case 'random': { + const idx = Math.floor(Math.random() * candidates.length); + return candidates[idx]; + } + case 'least_busy': + default: { + return candidates.reduce((min, w) => w.busyCount < min.busyCount ? w : min, candidates[0]); + } + } + } + + /** + * 分发生图任务(支持故障转移) + */ + async generateImage(ctx, prompt, paths, modelId, meta) { + const failoverConfig = this.config.backend?.pool?.failover || {}; + const failoverEnabled = failoverConfig.enabled !== false; + const maxRetries = failoverConfig.maxRetries || 2; + + const candidates = this.workers.filter(w => w.supports(modelId)); + + if (candidates.length === 0) { + return { error: `没有 Worker 支持模型: ${modelId}` }; + } + + const sortedCandidates = this.strategySelector.sort(candidates); + + if (!failoverEnabled) { + const worker = sortedCandidates[0]; + logger.debug('工作池', `任务分发至: ${worker.name} (busy: ${worker.busyCount})`); + return await this._safeExecuteWorker(worker, ctx, prompt, paths, modelId, meta); + } + + return await executeWithFailover( + sortedCandidates, + async (worker) => { + logger.debug('工作池', `任务分发至: ${worker.name} (busy: ${worker.busyCount})`); + return await this._safeExecuteWorker(worker, ctx, prompt, paths, modelId, meta); + }, + { + maxRetries, + meta, + onRetry: (worker, error) => { + logger.warn('工作池', `[${worker.name}] 失败,尝试下一个 Worker...`, { error, ...meta }); + } + } + ); + } + + /** + * 安全执行 Worker(带错误边界) + * @private + */ + async _safeExecuteWorker(worker, ctx, prompt, paths, modelId, meta) { + try { + return await worker.generateImage(ctx, prompt, paths, modelId, meta); + } catch (err) { + logger.error('工作池', `[${worker.name}] 执行异常`, { error: err.message, ...meta }); + return normalizeError(err.message || '执行异常'); + } + } + + /** + * 解析模型 ID + */ + resolveModelId(modelKey) { + for (const worker of this.workers) { + const resolved = worker.resolveModelId(modelKey); + if (resolved) { + return `${worker.name}|${resolved.type}|${resolved.realId}`; + } + } + return null; + } + + /** + * 获取所有模型列表 + */ + getModels() { + const allModels = []; + const seenIds = new Set(); + + for (const worker of this.workers) { + const models = worker.getModels(); + for (const m of models) { + if (!seenIds.has(m.id)) { + seenIds.add(m.id); + allModels.push(m); + } + } + } + + return { object: 'list', data: allModels }; + } + + /** + * 获取图片策略 + */ + getImagePolicy(modelKey) { + for (const worker of this.workers) { + if (worker.supports(modelKey)) { + return worker.getImagePolicy(modelKey); + } + } + return 'optional'; + } + + /** + * 获取指定实例的 Cookies + */ + async getCookies(instanceName, domain) { + let worker; + if (instanceName) { + worker = this.workers.find(w => w.instanceName === instanceName); + if (!worker) { + throw new Error(`浏览器实例不存在: ${instanceName}`); + } + } else { + worker = this.workers[0]; + if (!worker) { + throw new Error('工作池中没有可用的 Worker'); + } + } + + const cookies = await worker.getCookies(domain); + return { instance: worker.instanceName, cookies }; + } + + /** + * 触发所有 merge Worker 的监控导航 + */ + async navigateToMonitor() { + for (const worker of this.workers) { + if (worker.type === 'merge' && worker.busyCount === 0) { + await worker.navigateToMonitor(); + } + } + } + + /** + * 获取第一个 Worker 的 page + */ + getFirstPage() { + return this.workers[0]?.page || null; + } +} diff --git a/src/backend/pool/Worker.js b/src/backend/pool/Worker.js new file mode 100644 index 0000000..5b5b16e --- /dev/null +++ b/src/backend/pool/Worker.js @@ -0,0 +1,458 @@ +/** + * @fileoverview Worker 类 + * @description 封装单个浏览器实例,提供模型匹配和任务执行能力 + */ + +import fs from 'fs'; +import { logger } from '../../utils/logger.js'; +import { initBrowserBase, createCursor } from '../../browser/launcher.js'; +import { registry } from '../registry.js'; +import { gotoWithCheck } from '../utils/page.js'; + +/** + * Worker 类 - 封装单个浏览器实例 + */ +export class Worker { + /** + * @param {object} globalConfig - 全局配置 + * @param {object} workerConfig - Worker 配置 + */ + constructor(globalConfig, workerConfig) { + this.name = workerConfig.name; + this.type = workerConfig.type; + this.instanceName = workerConfig.instanceName || null; + this.userDataDir = workerConfig.userDataDir; + this.proxyConfig = workerConfig.resolvedProxy; + this.globalConfig = globalConfig; + this.workerConfig = workerConfig; + + // Merge 模式专属 + this.mergeTypes = workerConfig.mergeTypes || []; + this.mergeMonitor = workerConfig.mergeMonitor || null; + + // 运行时状态 + this.browser = null; + this.page = null; + this.busyCount = 0; + this.initialized = false; + } + + /** + * 初始化浏览器实例 + * @param {object} [sharedBrowser] - 可选,共享的浏览器实例 + */ + async init(sharedBrowser = null) { + if (this.initialized) return; + + // 确保用户数据目录存在 + if (!fs.existsSync(this.userDataDir)) { + fs.mkdirSync(this.userDataDir, { recursive: true }); + } + + // 获取目标 URL + let targetUrl = 'about:blank'; + if (this.type === 'merge') { + const firstType = this.mergeTypes[0]; + targetUrl = registry.getTargetUrl(firstType, this.globalConfig, this.workerConfig) || 'about:blank'; + } else { + targetUrl = registry.getTargetUrl(this.type, this.globalConfig, this.workerConfig) || 'about:blank'; + } + + // 收集导航处理器 + const handlers = []; + const typesToHandle = this.type === 'merge' ? this.mergeTypes : [this.type]; + for (const type of typesToHandle) { + const typeHandlers = registry.getNavigationHandlers(type); + handlers.push(...typeHandlers); + } + + const navigationHandler = handlers.length > 0 + ? async (page) => { + for (const handler of handlers) { + try { await handler(page); } catch (e) { /* ignore */ } + } + } + : null; + + logger.info('工作池', `[${this.name}] 正在初始化浏览器...`); + if (this.proxyConfig) { + logger.debug('工作池', `[${this.name}] 使用代理: ${this.proxyConfig.type}://${this.proxyConfig.host}:${this.proxyConfig.port}`); + } else { + logger.debug('工作池', `[${this.name}] 直连模式(无代理)`); + } + + if (sharedBrowser) { + await this._initWithSharedBrowser(sharedBrowser, targetUrl, navigationHandler); + } else { + await this._initNewBrowser(targetUrl, navigationHandler); + } + + this.initialized = true; + } + + /** + * 使用共享浏览器初始化 + * @private + */ + async _initWithSharedBrowser(sharedBrowser, targetUrl, navigationHandler) { + logger.info('工作池', `[${this.name}] 复用已有浏览器,创建新标签页...`); + this.browser = sharedBrowser; + this.page = await sharedBrowser.newPage(); + this.page.authState = { isHandlingAuth: false }; + this.page.cursor = createCursor(this.page); + + await this._navigateToTarget(targetUrl); + + if (navigationHandler) { + this.page.on('framenavigated', async () => { + try { await navigationHandler(this.page); } catch (e) { /* ignore */ } + }); + } + + logger.info('工作池', `[${this.name}] 初始化完成`); + } + + /** + * 启动新浏览器初始化 + * @private + */ + async _initNewBrowser(targetUrl, navigationHandler) { + const base = await initBrowserBase(this.globalConfig, { + userDataDir: this.userDataDir, + instanceName: this.instanceName, + proxyConfig: this.proxyConfig + }); + + this.browser = base.context; + this.page = base.page; + this.page.authState = { isHandlingAuth: false }; + this.page.cursor = createCursor(this.page); + + if (navigationHandler) { + this.page.on('framenavigated', async () => { + try { await navigationHandler(this.page); } catch (e) { /* ignore */ } + }); + } + + logger.info('工作池', `[${this.name}] 正在连接目标页面...`); + await this._navigateToTarget(targetUrl); + + // 登录模式处理 + const isLoginMode = process.argv.some(arg => arg.startsWith('-login')); + if (isLoginMode) { + logger.info('工作池', `[${this.name}] 登录模式已就绪,请在浏览器中完成登录`); + logger.info('工作池', `[${this.name}] 完成后可直接关闭浏览器窗口或按 Ctrl+C 退出`); + await new Promise(resolve => this.browser.on('close', resolve)); + process.exit(0); + } + + logger.info('工作池', `[${this.name}] 初始化完成`); + } + + /** + * 导航到目标 URL + * @private + */ + async _navigateToTarget(targetUrl) { + if (this.type === 'merge') { + let gotoSuccess = false; + for (const type of this.mergeTypes) { + const url = registry.getTargetUrl(type, this.globalConfig, this.workerConfig); + if (!url) continue; + const gotoResult = await gotoWithCheck(this.page, url, { timeout: 30000 }); + if (!gotoResult.error) { + gotoSuccess = true; + logger.debug('工作池', `[${this.name}] 使用 ${type} 适配器初始化成功`); + break; + } + logger.warn('工作池', `[${this.name}] ${type} 网站不可用,尝试下一个...`, { error: gotoResult.error }); + } + if (!gotoSuccess) { + logger.warn('工作池', `[${this.name}] 所有适配器网站当前不可用,但 Worker 仍将初始化(请求时可能会失败)`); + } + } else { + const gotoResult = await gotoWithCheck(this.page, targetUrl, { timeout: 60000 }); + if (gotoResult.error) { + logger.warn('工作池', `[${this.name}] 目标网站当前不可用: ${gotoResult.error},但 Worker 仍将初始化`); + } + } + } + + /** + * 检查是否支持指定模型 + */ + supports(modelId) { + if (this.type === 'merge') { + for (const type of this.mergeTypes) { + const resolved = registry.resolveModelId(type, modelId); + if (resolved) return true; + } + if (modelId.includes('/')) { + const [specifiedType] = modelId.split('/', 2); + return this.mergeTypes.includes(specifiedType); + } + return false; + } else { + if (modelId.includes('/')) { + const [specifiedType, actualModel] = modelId.split('/', 2); + if (specifiedType === this.type) { + const resolved = registry.resolveModelId(this.type, actualModel); + return !!resolved; + } + return false; + } + return !!registry.resolveModelId(this.type, modelId); + } + } + + /** + * 解析模型 ID + */ + resolveModelId(modelKey) { + if (this.type === 'merge') { + if (modelKey.includes('/')) { + const [specifiedType, actualModel] = modelKey.split('/', 2); + if (this.mergeTypes.includes(specifiedType)) { + const realId = registry.resolveModelId(specifiedType, actualModel); + if (realId) return { type: specifiedType, realId }; + } + return null; + } + for (const type of this.mergeTypes) { + const realId = registry.resolveModelId(type, modelKey); + if (realId) return { type, realId }; + } + return null; + } else { + if (modelKey.includes('/')) { + const [specifiedType, actualModel] = modelKey.split('/', 2); + if (specifiedType === this.type) { + const realId = registry.resolveModelId(this.type, actualModel); + return realId ? { type: this.type, realId } : null; + } + return null; + } + const realId = registry.resolveModelId(this.type, modelKey); + return realId ? { type: this.type, realId } : null; + } + } + + /** + * 生成图片 + */ + async generateImage(ctx, prompt, paths, modelId, meta) { + const failoverConfig = this.globalConfig.backend?.pool?.failover || {}; + const failoverEnabled = failoverConfig.enabled !== false; + + if (this.type === 'merge' && failoverEnabled) { + return this._generateImageWithFailover(ctx, prompt, paths, modelId, meta, failoverConfig); + } + + const resolved = this.resolveModelId(modelId); + if (!resolved) { + return { error: `Worker [${this.name}] 不支持模型: ${modelId}` }; + } + + const { type, realId } = resolved; + return this._executeAdapter(ctx, type, realId, prompt, paths, meta); + } + + /** + * Merge 模式下的故障转移生成 + * @private + */ + async _generateImageWithFailover(ctx, prompt, paths, modelId, meta, failoverConfig = {}) { + const maxRetries = failoverConfig.maxRetries || 2; + const candidateTypes = this._getCandidateTypes(modelId); + + if (candidateTypes.length === 0) { + return { error: `Worker [${this.name}] 不支持模型: ${modelId}` }; + } + + const maxAttempts = maxRetries === 0 ? candidateTypes.length : Math.min(maxRetries + 1, candidateTypes.length); + let lastError = null; + + for (let i = 0; i < maxAttempts; i++) { + const { type, realId } = candidateTypes[i]; + const result = await this._executeAdapter(ctx, type, realId, prompt, paths, meta); + + if (!result.error) { + return result; + } + + lastError = result.error; + if (i < maxAttempts - 1) { + logger.warn('工作池', `[${this.name}] ${type} 失败,尝试下一个适配器...`, { error: lastError, ...meta }); + } + } + + return { error: `所有支持该模型的适配器都无法使用: ${lastError}` }; + } + + /** + * 获取支持指定模型的候选适配器类型列表 + * @private + */ + _getCandidateTypes(modelKey) { + const candidates = []; + + if (modelKey.includes('/')) { + const [specifiedType, actualModel] = modelKey.split('/', 2); + if (this.mergeTypes.includes(specifiedType)) { + const realId = registry.resolveModelId(specifiedType, actualModel); + if (realId) { + candidates.push({ type: specifiedType, realId }); + } + } + return candidates; + } + + for (const type of this.mergeTypes) { + const realId = registry.resolveModelId(type, modelKey); + if (realId) { + candidates.push({ type, realId }); + } + } + + return candidates; + } + + /** + * 执行单个适配器 + * @private + */ + async _executeAdapter(ctx, type, realId, prompt, paths, meta) { + const adapter = registry.getAdapter(type); + if (!adapter) { + return { error: `适配器不存在: ${type}` }; + } + + logger.info('工作池', `[${this.name}] 执行任务 -> ${type}/${realId}`, meta); + + const subContext = { + ...ctx, + page: this.page, + config: this.globalConfig, + proxyConfig: this.proxyConfig, + userDataDir: this.userDataDir + }; + + this.busyCount++; + try { + return await adapter.generateImage(subContext, prompt, paths, realId, meta); + } finally { + this.busyCount--; + } + } + + /** + * 获取支持的模型列表 + */ + getModels() { + if (this.type === 'merge') { + const allModels = []; + const seenIds = new Set(); + + for (const type of this.mergeTypes) { + const result = registry.getModelsForAdapter(type); + if (result?.data) { + for (const m of result.data) { + if (!seenIds.has(m.id)) { + seenIds.add(m.id); + allModels.push({ ...m, owned_by: 'internal_server' }); + } + } + } + } + + for (const type of this.mergeTypes) { + const result = registry.getModelsForAdapter(type); + if (result?.data) { + for (const m of result.data) { + allModels.push({ + ...m, + id: `${type}/${m.id}`, + owned_by: type + }); + } + } + } + + return allModels; + } else { + const result = registry.getModelsForAdapter(this.type); + const models = result?.data || []; + const allModels = []; + + for (const m of models) { + allModels.push({ ...m, owned_by: 'internal_server' }); + } + + for (const m of models) { + allModels.push({ + ...m, + id: `${this.type}/${m.id}`, + owned_by: this.type + }); + } + + return allModels; + } + } + + /** + * 获取图片策略 + */ + getImagePolicy(modelKey) { + if (this.type === 'merge') { + if (modelKey.includes('/')) { + const [specifiedType, actualModel] = modelKey.split('/', 2); + if (this.mergeTypes.includes(specifiedType)) { + return registry.getImagePolicy(specifiedType, actualModel); + } + } + for (const type of this.mergeTypes) { + const realId = registry.resolveModelId(type, modelKey); + if (realId) return registry.getImagePolicy(type, modelKey); + } + return 'optional'; + } else { + return registry.getImagePolicy(this.type, modelKey); + } + } + + /** + * 导航到监控页面(空闲时) + */ + async navigateToMonitor() { + if (this.type !== 'merge' || !this.mergeMonitor) return; + if (!this.page || this.page.isClosed()) return; + + const targetUrl = registry.getTargetUrl(this.mergeMonitor, this.globalConfig, this.workerConfig); + if (!targetUrl) return; + + const currentUrl = this.page.url(); + try { + if (currentUrl.includes(new URL(targetUrl).hostname)) return; + } catch (e) { return; } + + logger.info('工作池', `[${this.name}] 空闲,跳转监控: ${this.mergeMonitor}`); + try { + await this.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); + } catch (e) { + logger.warn('工作池', `[${this.name}] 监控跳转失败: ${e.message}`); + } + } + + /** + * 获取 Cookies + */ + async getCookies(domain) { + if (!this.page) throw new Error(`Worker [${this.name}] 未初始化`); + const context = this.page.context(); + if (domain) { + return await context.cookies(domain.startsWith('http') ? domain : `https://${domain}`); + } + return await context.cookies(); + } +} diff --git a/src/backend/pool/index.js b/src/backend/pool/index.js new file mode 100644 index 0000000..9a3e460 --- /dev/null +++ b/src/backend/pool/index.js @@ -0,0 +1,6 @@ +/** + * @fileoverview Pool 模块聚合导出 + */ + +export { Worker } from './Worker.js'; +export { PoolManager } from './PoolManager.js'; diff --git a/src/backend/strategy.js b/src/backend/strategy.js new file mode 100644 index 0000000..c872335 --- /dev/null +++ b/src/backend/strategy.js @@ -0,0 +1,73 @@ +/** + * @fileoverview 负载均衡策略模块 + * @description Worker 选择策略,用于任务分发时智能选择 Worker。 + * + * 策略类型: + * - least_busy: 优先选择当前任务最少的 Worker + * - round_robin: 轮询分配 + * - random: 随机分配 + */ + +// ========================================== +// 策略枚举 +// ========================================== + +/** + * 策略枚举 + * @readonly + */ +export const STRATEGIES = { + LEAST_BUSY: 'least_busy', + ROUND_ROBIN: 'round_robin', + RANDOM: 'random', +}; + +// ========================================== +// 策略选择器 +// ========================================== + +/** + * 创建策略选择器 + * @param {string} strategy - 策略名称 + * @returns {object} 策略选择器实例 + */ +export function createStrategySelector(strategy) { + let roundRobinIndex = 0; + + return { + /** + * 根据策略排序候选列表 + * @param {object[]} candidates - 候选列表(需有 busyCount 属性) + * @returns {object[]} 排序后的候选列表 + */ + sort(candidates) { + if (candidates.length <= 1) return candidates; + + switch (strategy) { + case STRATEGIES.ROUND_ROBIN: { + const start = roundRobinIndex % candidates.length; + roundRobinIndex++; + return [...candidates.slice(start), ...candidates.slice(0, start)]; + } + case STRATEGIES.RANDOM: { + return [...candidates].sort(() => Math.random() - 0.5); + } + case STRATEGIES.LEAST_BUSY: + default: { + return [...candidates].sort((a, b) => (a.busyCount || 0) - (b.busyCount || 0)); + } + } + }, + + /** + * 选择单个最优候选 + * @param {object[]} candidates - 候选列表 + * @returns {object} 选中的候选 + */ + select(candidates) { + if (candidates.length === 0) return null; + if (candidates.length === 1) return candidates[0]; + return this.sort(candidates)[0]; + } + }; +} diff --git a/src/backend/utils.js b/src/backend/utils.js deleted file mode 100644 index 1901b8d..0000000 --- a/src/backend/utils.js +++ /dev/null @@ -1,372 +0,0 @@ -/** - * @fileoverview 后端适配器公共流程 - * @description 提供各适配器复用的通用页面操作与错误归一化能力。 - * - * 主要函数: - * - `fillPrompt`:拟人化输入提示词 - * - `submit`:提交表单(点击按钮失败则回退为回车) - * - `waitApiResponse`:等待匹配的 API 响应(包含页面关闭/崩溃监听) - * - `normalizePageError`:将页面级异常归一化为可返回给服务器层的错误 - * - `normalizeHttpError`:将 HTTP 响应错误(含限流/人机验证)归一化 - * - `waitForPageAuth`/`lockPageAuth`...:页面认证锁机制,防止多任务并发冲突 - * - `waitForInput`: 等待输入框就绪 - */ - -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 - 页面对象 - */ -export async function waitForPageAuth(page) { - while (page.authState?.isHandlingAuth) { - await sleep(500, 1000); - } -} - -/** - * 设置页面认证锁(加锁) - * @param {import('playwright-core').Page} page - 页面对象 - */ -export function lockPageAuth(page) { - if (page.authState) page.authState.isHandlingAuth = true; -} - -/** - * 释放页面认证锁(解锁) - * @param {import('playwright-core').Page} page - 页面对象 - */ -export function unlockPageAuth(page) { - if (page.authState) page.authState.isHandlingAuth = false; -} - -/** - * 检查页面是否正在处理认证 - * @param {import('playwright-core').Page} page - 页面对象 - * @returns {boolean} - */ -export function isPageAuthLocked(page) { - return page.authState?.isHandlingAuth === true; -} - -/** - * 等待输入框出现(自动等待认证完成) - * - * 使用轮询方式等待输入框出现,同时尊重页面认证锁。 - * 当页面正在处理登录跳转时会自动暂停检测。 - * - * @param {import('playwright-core').Page} page - 页面对象 - * @param {string|import('playwright-core').Locator} selectorOrLocator - 输入框选择器或 Locator 对象 - * @param {object} [options={}] - 选项 - * @param {number} [options.timeout=60000] - 超时时间(毫秒) - * @param {boolean} [options.click=true] - 找到后是否点击输入框 - * @returns {Promise} - */ -export async function waitForInput(page, selectorOrLocator, options = {}) { - const { timeout = 60000, click = true } = options; - - // 判断是选择器字符串还是 Locator 对象 - const isLocator = typeof selectorOrLocator !== 'string'; - const displayName = isLocator ? 'Locator' : selectorOrLocator; - - const startTime = Date.now(); - - // 等待认证完成(如果正在处理登录跳转) - while (isPageAuthLocked(page)) { - if (Date.now() - startTime >= timeout) break; - await sleep(500, 1000); - } - - // 计算剩余超时时间 - const elapsed = Date.now() - startTime; - const remainingTimeout = Math.max(timeout - elapsed, 5000); - - // 等待输入框出现 - 对字符串选择器使用 waitForSelector,对 Locator 使用 waitFor - if (isLocator) { - await selectorOrLocator.first().waitFor({ state: 'visible', timeout: remainingTimeout }).catch(() => { - throw new Error(`未找到输入框 (${displayName})`); - }); - } else { - await page.waitForSelector(selectorOrLocator, { timeout: remainingTimeout }).catch(() => { - throw new Error(`未找到输入框 (${displayName})`); - }); - } - - if (click) { - const target = isLocator ? selectorOrLocator : selectorOrLocator; - await safeClick(page, target, { bias: 'input' }); - await sleep(500, 1000); - } -} - -// ========================================== - -/** - * 任务完成后移开鼠标(拟人化行为) - * - * @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} context - 上下文对象,包含 proxyConfig 和 userDataDir - * @returns {Promise<{ image?: string, error?: string }>} 下载结果 - */ -export async function downloadImage(url, context = {}) { - // 动态导入依赖 - const { gotScraping } = await import('got-scraping'); - const fs = await import('fs'); - const path = await import('path'); - const { getHttpProxy } = await import('../utils/proxy.js'); - - const { proxyConfig = null, userDataDir } = context; - - try { - // 读取指纹文件获取浏览器信息(优先使用 userDataDir 内的指纹) - let fingerprintPath = userDataDir - ? path.join(userDataDir, 'fingerprint.json') - : path.join(process.cwd(), 'data', 'camoufoxUserData', 'fingerprint.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) { - // 解析失败使用默认值 - } - } - - // 获取代理配置(直接使用传入的 proxyConfig) - 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'); - // 根据响应 content-type 生成正确的 MIME 类型 - const contentType = response.headers['content-type'] || 'image/png'; - // 提取 MIME 类型 (去除可能的 charset 等附加信息) - const mimeType = contentType.split(';')[0].trim(); - return { image: `data:${mimeType};base64,${base64}` }; - } catch (e) { - return { error: `已获取结果,但图片下载时遇到错误: ${e.message}` }; - } -} diff --git a/src/backend/utils/download.js b/src/backend/utils/download.js new file mode 100644 index 0000000..f3944c6 --- /dev/null +++ b/src/backend/utils/download.js @@ -0,0 +1,81 @@ +/** + * @fileoverview 资源下载模块 + * @description 图片下载与 Base64 转换 + */ + +/** + * 下载图片并转换为 Base64 + * @param {string} url - 图片 URL + * @param {object} context - 上下文对象,包含 proxyConfig 和 userDataDir + * @returns {Promise<{ image?: string, error?: string }>} 下载结果 + */ +export async function downloadImage(url, context = {}) { + // 动态导入依赖 + const { gotScraping } = await import('got-scraping'); + const fs = await import('fs'); + const path = await import('path'); + const { getHttpProxy } = await import('../../utils/proxy.js'); + + const { proxyConfig = null, userDataDir } = context; + + try { + // 读取指纹文件获取浏览器信息 + let fingerprintPath = userDataDir + ? path.join(userDataDir, 'fingerprint.json') + : path.join(process.cwd(), 'data', 'camoufoxUserData', 'fingerprint.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) { + 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 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'); + const contentType = response.headers['content-type'] || 'image/png'; + const mimeType = contentType.split(';')[0].trim(); + return { image: `data:${mimeType};base64,${base64}` }; + } catch (e) { + return { error: `已获取结果,但图片下载时遇到错误: ${e.message}` }; + } +} diff --git a/src/backend/utils/error.js b/src/backend/utils/error.js new file mode 100644 index 0000000..257914b --- /dev/null +++ b/src/backend/utils/error.js @@ -0,0 +1,131 @@ +/** + * @fileoverview 错误归一化模块 + * @description 统一处理页面级和 HTTP 级错误,提供可重试判定 + */ + +import { logger } from '../../utils/logger.js'; +import { ADAPTER_ERRORS } from '../../utils/constants.js'; + +// ========================================== +// 可重试判定 +// ========================================== + +/** + * 判断错误是否可重试 + * @param {string} errorMessage - 错误消息 + * @returns {boolean} + */ +export function isRetryableError(errorMessage) { + if (!errorMessage) return false; + + const retryablePatterns = [ + // 网络错误 + /network|net::|econnreset|econnrefused|etimedout/i, + // 超时 + /timeout|timed out/i, + // 页面崩溃 + /crashed|crash/i, + // 5xx 服务端错误 + /5\d{2}|internal server error|bad gateway|service unavailable/i, + // 限流(可能是临时的) + /rate limit|too many requests|429/i, + ]; + + return retryablePatterns.some(pattern => pattern.test(errorMessage)); +} + +// ========================================== +// 页面错误归一化 +// ========================================== + +/** + * 统一处理页面级错误 + * @param {Error} err - 原始错误 + * @param {object} [meta={}] - 日志元数据 + * @returns {{ error: string, code: string, retryable: boolean } | null} + */ +export function normalizePageError(err, meta = {}) { + if (err.message === 'PAGE_CLOSED') { + logger.error('适配器', '页面已关闭', meta); + return { error: '页面已关闭,请勿在生图过程中刷新页面', code: ADAPTER_ERRORS.PAGE_CLOSED, retryable: true }; + } + if (err.message === 'PAGE_CRASHED') { + logger.error('适配器', '页面崩溃', meta); + return { error: '页面崩溃,请重试', code: ADAPTER_ERRORS.PAGE_CRASHED, retryable: true }; + } + if (err.message === 'PAGE_INVALID') { + logger.error('适配器', '页面状态无效', meta); + return { error: '页面状态无效,请重新初始化', code: ADAPTER_ERRORS.PAGE_INVALID, retryable: true }; + } + if (err.name === 'TimeoutError' || err.message?.includes('Timeout')) { + logger.error('适配器', '请求超时', meta); + return { error: '请求超时 (120秒), 请检查网络或稍后重试', code: ADAPTER_ERRORS.TIMEOUT_ERROR, retryable: true }; + } + return null; +} + +// ========================================== +// HTTP 错误归一化 +// ========================================== + +/** + * 统一处理 HTTP 响应错误 + * @param {import('playwright-core').Response} response - HTTP 响应对象 + * @param {string} [content=null] - 响应体内容(可选) + * @returns {{ error: string, code: string, retryable: boolean } | null} + */ +export function normalizeHttpError(response, content = null) { + const status = response.status(); + + // 429 限流检查 + if (status === 429 || content?.includes('Too Many Requests')) { + return { error: '触发限流/上游繁忙', code: ADAPTER_ERRORS.RATE_LIMITED, retryable: true }; + } + + // reCAPTCHA 验证失败 + if (content?.includes('recaptcha validation failed')) { + return { error: '触发人机验证', code: ADAPTER_ERRORS.CAPTCHA_REQUIRED, retryable: false }; + } + + // 5xx 服务端错误(可重试) + if (status >= 500) { + return { error: `上游服务器错误,HTTP错误码: ${status}`, code: ADAPTER_ERRORS.HTTP_ERROR, retryable: true }; + } + + // 4xx 客户端错误(不可重试) + if (status >= 400) { + return { error: `请求错误,HTTP错误码: ${status}`, code: ADAPTER_ERRORS.HTTP_ERROR, retryable: false }; + } + + return null; +} + +// ========================================== +// 通用错误归一化 +// ========================================== + +/** + * 标准化错误对象(通用) + * @param {string} error - 错误消息 + * @returns {{error: string, code: string, retryable: boolean}} + */ +export function normalizeError(error) { + const retryable = isRetryableError(error); + + let code = ADAPTER_ERRORS.NETWORK_ERROR; + if (/timeout/i.test(error)) { + code = ADAPTER_ERRORS.TIMEOUT_ERROR; + } else if (/crashed/i.test(error)) { + code = ADAPTER_ERRORS.PAGE_CRASHED; + } else if (/closed/i.test(error)) { + code = ADAPTER_ERRORS.PAGE_CLOSED; + } else if (/5\d{2}|internal server/i.test(error)) { + code = ADAPTER_ERRORS.HTTP_ERROR; + } else if (/rate limit|429/i.test(error)) { + code = ADAPTER_ERRORS.RATE_LIMITED; + } else if (/captcha|recaptcha/i.test(error)) { + code = ADAPTER_ERRORS.CAPTCHA_REQUIRED; + } + + return { error, code, retryable }; +} diff --git a/src/backend/utils/index.js b/src/backend/utils/index.js new file mode 100644 index 0000000..a514e77 --- /dev/null +++ b/src/backend/utils/index.js @@ -0,0 +1,48 @@ +/** + * @fileoverview 后端工具模块聚合导出 + * @description 统一导出页面交互、错误归一化、资源下载等工具函数 + * + * 主要功能: + * - 页面交互 (page.js): + * - waitForPageAuth/lockPageAuth/unlockPageAuth: 页面认证锁机制 + * - waitForInput: 等待输入框出现(自动等待认证完成) + * - fillPrompt: 拟人化输入提示词 + * - submit: 提交表单(点击按钮失败则回退为回车) + * - gotoWithCheck: 导航到 URL 并检测 HTTP 错误 + * - moveMouseAway: 任务完成后移开鼠标 + * - waitApiResponse: 等待 API 响应(带页面关闭监听) + * + * - 错误处理 (error.js): + * - isRetryableError: 判断错误是否可重试 + * - normalizePageError: 归一化页面级错误 + * - normalizeHttpError: 归一化 HTTP 响应错误 + * - normalizeError: 通用错误归一化 + * + * - 资源下载 (download.js): + * - downloadImage: 下载图片并转换为 Base64 + */ + +// 页面交互 +export { + waitForPageAuth, + lockPageAuth, + unlockPageAuth, + isPageAuthLocked, + waitForInput, + fillPrompt, + submit, + gotoWithCheck, + moveMouseAway, + waitApiResponse, +} from './page.js'; + +// 错误归一化 +export { + isRetryableError, + normalizePageError, + normalizeHttpError, + normalizeError, +} from './error.js'; + +// 资源下载 +export { downloadImage } from './download.js'; diff --git a/src/backend/utils/page.js b/src/backend/utils/page.js new file mode 100644 index 0000000..30fcb4e --- /dev/null +++ b/src/backend/utils/page.js @@ -0,0 +1,229 @@ +/** + * @fileoverview 页面交互工具 + * @description 页面认证锁、输入框等待、表单提交等页面级操作 + */ + +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 - 页面对象 + */ +export async function waitForPageAuth(page) { + while (page.authState?.isHandlingAuth) { + await sleep(500, 1000); + } +} + +/** + * 设置页面认证锁(加锁) + * @param {import('playwright-core').Page} page - 页面对象 + */ +export function lockPageAuth(page) { + if (page.authState) page.authState.isHandlingAuth = true; +} + +/** + * 释放页面认证锁(解锁) + * @param {import('playwright-core').Page} page - 页面对象 + */ +export function unlockPageAuth(page) { + if (page.authState) page.authState.isHandlingAuth = false; +} + +/** + * 检查页面是否正在处理认证 + * @param {import('playwright-core').Page} page - 页面对象 + * @returns {boolean} + */ +export function isPageAuthLocked(page) { + return page.authState?.isHandlingAuth === true; +} + +// ========================================== +// 输入框与表单 +// ========================================== + +/** + * 等待输入框出现(自动等待认证完成) + * @param {import('playwright-core').Page} page - 页面对象 + * @param {string|import('playwright-core').Locator} selectorOrLocator - 输入框选择器或 Locator 对象 + * @param {object} [options={}] - 选项 + * @param {number} [options.timeout=60000] - 超时时间(毫秒) + * @param {boolean} [options.click=true] - 找到后是否点击输入框 + * @returns {Promise} + */ +export async function waitForInput(page, selectorOrLocator, options = {}) { + const { timeout = 60000, click = true } = options; + + const isLocator = typeof selectorOrLocator !== 'string'; + const displayName = isLocator ? 'Locator' : selectorOrLocator; + const startTime = Date.now(); + + // 等待认证完成 + while (isPageAuthLocked(page)) { + if (Date.now() - startTime >= timeout) break; + await sleep(500, 1000); + } + + // 计算剩余超时时间 + const elapsed = Date.now() - startTime; + const remainingTimeout = Math.max(timeout - elapsed, 5000); + + // 等待输入框出现 + if (isLocator) { + await selectorOrLocator.first().waitFor({ state: 'visible', timeout: remainingTimeout }).catch(() => { + throw new Error(`未找到输入框 (${displayName})`); + }); + } else { + await page.waitForSelector(selectorOrLocator, { timeout: remainingTimeout }).catch(() => { + throw new Error(`未找到输入框 (${displayName})`); + }); + } + + if (click) { + await safeClick(page, selectorOrLocator, { bias: 'input' }); + await sleep(500, 1000); + } +} + +/** + * 填写提示词 (通用) + * @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} 是否成功点击按钮 + */ +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; +} + +// ========================================== +// 导航与鼠标 +// ========================================== + +/** + * 导航到指定 URL 并检测 HTTP 错误 + * @param {import('playwright-core').Page} page - 页面对象 + * @param {string} url - 目标 URL + * @param {object} [options={}] - 选项 + * @param {number} [options.timeout=30000] - 超时时间(毫秒) + * @returns {Promise<{success?: boolean, error?: string}>} + */ +export async function gotoWithCheck(page, url, options = {}) { + const { timeout = 30000 } = options; + try { + const response = await page.goto(url, { + waitUntil: 'domcontentloaded', + timeout + }); + if (!response) { + return { error: '页面加载失败: 无响应' }; + } + const status = response.status(); + if (status >= 400) { + return { error: `网站无法访问 (HTTP ${status})` }; + } + return { success: true }; + } catch (e) { + if (e.message.includes('Timeout')) { + return { error: '页面加载超时' }; + } + return { error: `页面加载失败: ${e.message}` }; + } +} + +/** + * 任务完成后移开鼠标(拟人化行为) + * @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) { + // 忽略鼠标移动失败 + } +} + +/** + * 等待 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] - 超时时间(毫秒) + * @returns {Promise} 响应对象 + */ +export async function waitApiResponse(page, options = {}) { + const { urlMatch, method = 'POST', timeout = 120000 } = 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(); + } +} diff --git a/src/browser/utils.js b/src/browser/utils.js index 84aaf27..5d03181 100644 --- a/src/browser/utils.js +++ b/src/browser/utils.js @@ -1,6 +1,14 @@ /** * @fileoverview 浏览器自动化工具函数 - * @description 封装 Playwright 页面常用操作(等待、查找、点击、上传、拟人化行为等),供后端适配器复用。 + * @description 封装 Playwright 页面常用操作,供后端适配器复用。 + * + * 职责边界: + * - 浏览器原子操作(点击、输入、上传等) + * - 页面状态检测(isPageValid、createPageCloseWatcher) + * - 拟人化交互(humanType、safeClick) + * - 工具函数(random、sleep、getMimeType) + * + * 注意:业务逻辑应放在 backend/utils.js * * 主要函数: * - `random` / `sleep`:随机与延迟工具 @@ -10,7 +18,6 @@ * - `safeClick` / `humanType`:拟人化点击与输入 * - `pasteImages` / `uploadFilesViaChooser`:图片粘贴/上传辅助 * - `isPageValid` / `createPageCloseWatcher`:页面有效性与关闭/崩溃监听 - * - `getCookies`:读取 Cookies(JSON 格式) */ import path from 'path'; diff --git a/src/server/errors.js b/src/server/errors.js index 8134d91..70d150c 100644 --- a/src/server/errors.js +++ b/src/server/errors.js @@ -3,6 +3,20 @@ * @description 统一定义服务器错误码及其对应的中文消息和 HTTP 状态码 */ +/** + * 错误类型枚举 (OpenAI 标准) + * @readonly + * @enum {string} + */ +export const ERROR_TYPES = { + /** 无效请求 */ + INVALID_REQUEST: 'invalid_request_error', + /** 服务器错误 */ + SERVER_ERROR: 'server_error', + /** 限流错误 */ + RATE_LIMIT: 'rate_limit_error', +}; + /** * 错误码枚举 * @readonly @@ -31,56 +45,74 @@ export const ERROR_CODES = { RECAPTCHA: 'RECAPTCHA', /** 服务器内部错误 */ INTERNAL_ERROR: 'INTERNAL_ERROR', + /** 生成失败 */ + GENERATION_FAILED: 'GENERATION_FAILED', }; /** * 错误详情映射表 - * @type {Record} + * @type {Record} */ const ERROR_DETAILS = { [ERROR_CODES.UNAUTHORIZED]: { message: '未授权(Token 无效或缺失)', status: 401, + type: ERROR_TYPES.INVALID_REQUEST, }, [ERROR_CODES.BROWSER_NOT_INITIALIZED]: { message: '浏览器未初始化', status: 503, + type: ERROR_TYPES.SERVER_ERROR, }, [ERROR_CODES.SERVER_BUSY]: { message: '服务器繁忙(队列已满)', status: 429, + type: ERROR_TYPES.RATE_LIMIT, }, [ERROR_CODES.NO_MESSAGES]: { message: '请求参数缺少 messages', status: 400, + type: ERROR_TYPES.INVALID_REQUEST, }, [ERROR_CODES.NO_USER_MESSAGES]: { message: 'messages 中缺少 role=user 的消息', status: 400, + type: ERROR_TYPES.INVALID_REQUEST, }, [ERROR_CODES.TOO_MANY_IMAGES]: { message: '图片数量超过限制', status: 400, + type: ERROR_TYPES.INVALID_REQUEST, }, [ERROR_CODES.INVALID_MODEL]: { message: '模型无效/后端不支持', status: 400, + type: ERROR_TYPES.INVALID_REQUEST, }, [ERROR_CODES.IMAGE_REQUIRED]: { message: '该模型需要参考图', status: 400, + type: ERROR_TYPES.INVALID_REQUEST, }, [ERROR_CODES.IMAGE_FORBIDDEN]: { message: '该模型不支持图片输入', status: 400, + type: ERROR_TYPES.INVALID_REQUEST, }, [ERROR_CODES.RECAPTCHA]: { message: '触发人机验证(reCAPTCHA)', - status: 500, + status: 403, + type: ERROR_TYPES.SERVER_ERROR, }, [ERROR_CODES.INTERNAL_ERROR]: { message: '服务器内部错误', status: 500, + type: ERROR_TYPES.SERVER_ERROR, + }, + [ERROR_CODES.GENERATION_FAILED]: { + message: '图片生成失败', + status: 502, + type: ERROR_TYPES.SERVER_ERROR, }, }; diff --git a/src/server/http/respond.js b/src/server/http/respond.js index 62a9126..51be6ec 100644 --- a/src/server/http/respond.js +++ b/src/server/http/respond.js @@ -66,34 +66,34 @@ export function sendHeartbeat(res, mode, modelName) { } /** - * 发送统一 API 错误响应 + * 发送统一 API 错误响应 (OpenAI 标准格式) * @param {import('http').ServerResponse} res - HTTP 响应对象 * @param {object} options - 错误选项 * @param {string} [options.code] - 错误码(使用 ERROR_CODES 枚举) - * @param {string} [options.error] - 自定义错误消息(如提供则覆盖 code 对应的消息) + * @param {string} [options.message] - 自定义错误消息(如提供则覆盖 code 对应的消息) * @param {number} [options.status] - 自定义 HTTP 状态码 * @param {boolean} [options.isStreaming=false] - 是否为流式响应 - * @param {string} [options.raw] - 原始错误信息(用于调试) */ export function sendApiError(res, options) { - const { code, error, status, isStreaming = false, raw } = options; + const { code, message, status, isStreaming = false } = options; // 获取错误详情 const details = code ? getErrorDetails(code) : null; - const errorMessage = error || (details ? details.message : '未知错误'); + const errorMessage = message || (details ? details.message : '未知错误'); + const errorType = details?.type || 'server_error'; const httpStatus = status || (details ? details.status : 500); - // 构造错误响应体 + // 构造 OpenAI 标准错误响应体 const payload = { - error: errorMessage, - code: code || 'INTERNAL_ERROR', + error: { + message: errorMessage, + type: errorType, + code: code || 'INTERNAL_ERROR' + } }; - if (raw) { - payload.raw = raw; - } if (isStreaming) { - // 流式响应 + // 流式响应:发送错误事件然后结束 sendSse(res, payload); sendSseDone(res); } else { diff --git a/src/server/http/routes.js b/src/server/http/routes.js index 0e2bdd5..12c625d 100644 --- a/src/server/http/routes.js +++ b/src/server/http/routes.js @@ -81,13 +81,13 @@ export function createRouter(context) { // 区分错误类型 if (err.message.includes('Worker 不存在') || err.message.includes('Worker not found')) { sendApiError(res, { - code: ERROR_CODES.BAD_REQUEST, - error: err.message + code: ERROR_CODES.INVALID_MODEL, + message: err.message }); } else { sendApiError(res, { code: ERROR_CODES.INTERNAL_ERROR, - error: err.message + message: err.message }); } } @@ -117,7 +117,7 @@ export function createRouter(context) { logger.warn('服务器', '非流式请求被拒绝 (队列已满)', { id: requestId, queueSize: status.total }); sendApiError(res, { code: ERROR_CODES.SERVER_BUSY, - error: `服务器繁忙(队列: ${status.total}/${queueManager.maxQueueSize})。请使用流式模式 (stream: true) 或稍后重试。` + message: `服务器繁忙(队列: ${status.total}/${queueManager.maxQueueSize})。请使用流式模式 (stream: true) 或稍后重试。` }); return; } @@ -145,7 +145,7 @@ export function createRouter(context) { if (!parseResult.success) { sendApiError(res, { code: parseResult.error.code, - error: parseResult.error.error, + message: parseResult.error.error, isStreaming }); return; @@ -171,7 +171,7 @@ export function createRouter(context) { logger.error('服务器', '请求处理失败', { id: requestId, error: err.message }); sendApiError(res, { code: ERROR_CODES.INTERNAL_ERROR, - error: err.message + message: err.message }); } } diff --git a/src/server/queue.js b/src/server/queue.js index 3ba2353..31f7512 100644 --- a/src/server/queue.js +++ b/src/server/queue.js @@ -114,19 +114,27 @@ export function createQueueManager(queueConfig, callbacks) { if (heartbeatInterval) clearInterval(heartbeatInterval); // 处理结果 - let finalContent = ''; - if (result.error) { - // 适配器层已归一化错误,直接构造错误响应 - finalContent = `[生成错误] ${result.error}`; - } else if (result.image) { + // 生成失败:使用标准错误格式返回 + sendApiError(res, { + code: ERROR_CODES.GENERATION_FAILED, + message: result.error, + status: result.retryable ? 503 : 502, + isStreaming + }); + return; + } + + // 生成成功 + let finalContent = ''; + if (result.image) { finalContent = `![generated](${result.image})`; logger.info('服务器', '图片已准备就绪 (Base64)', { id }); } else { finalContent = result.text || '生成失败'; } - // 发送响应 + // 发送成功响应 if (isStreaming) { const chunk = buildChatCompletionChunk(finalContent, modelName); sendSse(res, chunk); @@ -143,7 +151,7 @@ export function createQueueManager(queueConfig, callbacks) { logger.error('服务器', '任务处理失败', { id, error: err.message }); sendApiError(res, { code: ERROR_CODES.INTERNAL_ERROR, - error: err.message, + message: err.message, isStreaming }); } diff --git a/src/utils/config.js b/src/utils/config.js index d1e2b85..3438731 100644 --- a/src/utils/config.js +++ b/src/utils/config.js @@ -175,9 +175,17 @@ export function loadConfig() { if (!config.server || !config.server.port) { throw new Error('配置文件缺少必需字段: server.port'); } + // 端口类型和范围校验 + const port = config.server.port; + if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`server.port 必须是 1-65535 范围内的整数,当前值: ${port}`); + } if (!config.server.auth) { throw new Error('配置文件缺少必需字段: server.auth'); } + if (typeof config.server.auth !== 'string' || config.server.auth.length < 10) { + throw new Error('server.auth 必须是至少 10 个字符的字符串 (建议使用 npm run genkey 生成)'); + } // 设置 keepalive 配置默认值 if (!config.server.keepalive) { @@ -202,6 +210,17 @@ export function loadConfig() { config.backend.pool.strategy = 'least_busy'; } + // 故障转移配置默认值 + if (!config.backend.pool.failover) { + config.backend.pool.failover = {}; + } + if (config.backend.pool.failover.enabled === undefined) { + config.backend.pool.failover.enabled = true; + } + if (config.backend.pool.failover.maxRetries === undefined) { + config.backend.pool.failover.maxRetries = 2; + } + // 校验 instances 配置 if (!config.backend.pool.instances || !Array.isArray(config.backend.pool.instances)) { throw new Error('配置文件缺少必需字段: backend.pool.instances'); diff --git a/src/utils/constants.js b/src/utils/constants.js new file mode 100644 index 0000000..c24eff4 --- /dev/null +++ b/src/utils/constants.js @@ -0,0 +1,120 @@ +/** + * @fileoverview 全局常量管理 + * @description 集中管理超时时间、选择器等常量,便于统一配置和维护 + */ + +// ========================================== +// 超时时间常量 (毫秒) +// ========================================== + +/** + * 超时时间配置 + * @readonly + */ +export const TIMEOUTS = { + /** 导航超时(页面跳转) */ + NAVIGATION: 30000, + + /** 导航超时(扩展,带重试场景) */ + NAVIGATION_EXTENDED: 60000, + + /** 输入框等待超时 */ + INPUT_WAIT: 10000, + + /** API 响应超时(图片生成) */ + API_RESPONSE: 120000, + + /** 上传确认超时 */ + UPLOAD_CONFIRM: 60000, + + /** OAuth 登录流程超时 */ + OAUTH_FLOW: 60000, + + /** 心跳间隔 */ + HEARTBEAT_INTERVAL: 3000, + + /** 轮询间隔(waitForInput 等) */ + POLL_INTERVAL: 500, +}; + +// ========================================== +// 重试配置 +// ========================================== + +/** + * 重试配置 + * @readonly + */ +export const RETRY = { + /** 适配器默认最大重试次数 */ + MAX_ATTEMPTS: 2, + + /** 重试间隔基数(毫秒) */ + BASE_DELAY: 1000, + + /** 可重试的错误类型 */ + RETRYABLE_ERRORS: [ + 'NETWORK_ERROR', + 'TIMEOUT_ERROR', + 'PAGE_CRASHED', + ], +}; + +// ========================================== +// 错误码(适配器层,与 server/errors.js 互补) +// ========================================== + +/** + * 适配器错误码 + * @readonly + */ +export const ADAPTER_ERRORS = { + /** 页面已关闭 */ + PAGE_CLOSED: 'PAGE_CLOSED', + + /** 页面崩溃 */ + PAGE_CRASHED: 'PAGE_CRASHED', + + /** 页面状态无效 */ + PAGE_INVALID: 'PAGE_INVALID', + + /** 网络错误 */ + NETWORK_ERROR: 'NETWORK_ERROR', + + /** 超时错误 */ + TIMEOUT_ERROR: 'TIMEOUT_ERROR', + + /** HTTP 错误 */ + HTTP_ERROR: 'HTTP_ERROR', + + /** 限流 */ + RATE_LIMITED: 'RATE_LIMITED', + + /** 需要验证码 */ + CAPTCHA_REQUIRED: 'CAPTCHA_REQUIRED', + + /** 需要登录 */ + AUTH_REQUIRED: 'AUTH_REQUIRED', +}; + +// ========================================== +// 人机模拟配置 +// ========================================== + +/** + * 人机模拟延迟配置(毫秒) + * @readonly + */ +export const HUMAN_DELAYS = { + /** 短延迟范围 */ + SHORT: { min: 500, max: 1000 }, + + /** 中延迟范围 */ + MEDIUM: { min: 1000, max: 2000 }, + + /** 长延迟范围(页面加载后) */ + LONG: { min: 1500, max: 2500 }, + + /** 打字间隔 */ + TYPING: { min: 30, max: 100 }, +};