From 11c768d73fd845083a8befd76a89282ded5df0f3 Mon Sep 17 00:00:00 2001 From: foxhui Date: Sun, 14 Dec 2025 19:07:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=A4=9A=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E5=B9=B6=E8=A1=8C=E4=B8=94=E6=94=AF=E6=8C=81=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E6=95=B0=E6=8D=AE=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 12 + README.md | 63 ++- config.example.yaml | 115 +++- server.js | 35 +- src/backend/adapter/gemini.js | 67 ++- src/backend/adapter/gemini_biz.js | 78 ++- src/backend/adapter/lmarena.js | 106 +++- src/backend/adapter/nanobananafree_ai.js | 70 ++- src/backend/adapter/zai_is.js | 64 ++- src/backend/index.js | 336 ++++------- src/backend/models.js | 261 --------- src/backend/pool.js | 691 +++++++++++++++++++++++ src/backend/registry.js | 274 +++++++++ src/backend/utils.js | 20 +- src/browser/launcher.js | 206 ++----- src/server/http/routes.js | 44 +- src/server/parseChat.js | 14 +- src/server/queue.js | 72 ++- src/utils/config.js | 225 ++++++-- 19 files changed, 1822 insertions(+), 931 deletions(-) delete mode 100644 src/backend/models.js create mode 100644 src/backend/pool.js create mode 100644 src/backend/registry.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c75c002..2879865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ 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.0] - 2025-12-14 + +### Added +- **支持多窗口多账号** + - 支持多个窗口并行进行任务,支持浏览器实例之间的数据隔离 + - Cookies 获取接口可指定浏览器实例 + +### Changed +- **配置文件重构** + - 配置文件格式几乎重构,请重新复制模板修改 + + ## [2.4.0] - 2025-12-13 ### Added diff --git a/README.md b/README.md index 6aac8f8..2fdbfd6 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # LMArenaImagenAutomator -![Image](https://github.com/user-attachments/assets/0a887137-64c3-4919-8ab6-b5cf23e5f751) +![Image](https://github.com/user-attachments/assets/296a518e-c42b-4e39-8ff6-9b4381ed4f6e) ## 📝 项目简介 -LMArenaImagenAutomator 是一个基于 Playwright + Camoufox 的自动化图像生成工具,通过模拟人类操作与 LMArena、Gemini 等网站交互提供图像生成服务到OpenAI格式的接口。 +LMArenaImagenAutomator 是一个基于 Playwright + Camoufox 的自动化图像生成工具,支持多窗口并发与多账号管理(实现浏览器实例数据完全隔离),通过模拟人类操作与 LMArena、Gemini 等网站交互,提供兼容 OpenAI 格式的图像生成接口服务。 当前支持的网站: - [LMArena](https://lmarena.ai/) @@ -16,6 +16,7 @@ LMArenaImagenAutomator 是一个基于 Playwright + Camoufox 的自动化图像 ### ✨ 主要特性 - 🤖 **拟人操作**:模拟人类打字行为和鼠标移动行为 +- 👀 **任务并行**:支持多窗口执行和多账号数据隔离 - 🖼️ **多图支持**:最多支持同时上传 10 张参考图片 - 📊 **队列管理**:支持任务队列,防止请求过载或超时 - 🌐 **代理支持**:支持 HTTP 和 SOCKS5 代理配置 @@ -78,13 +79,50 @@ docker-compose up -d ### ⚠️ 首次使用必读 1. **启动登录模式**: - - 请使用 `npm start -- -login` 进入登录模式(关闭无头模式)。 - - Linux用户使用 `npm start -- -xvfb -vnc` 进入登录模式且创建虚拟显示器到VNC。 + ```bash + npm start -- -login # 启动第一个 Worker 进行登录 + npm start -- -login=workerName # 启动指定 Worker 进行登录 + ``` + - Linux 用户使用 `npm start -- -xvfb -vnc` 进入登录模式且创建虚拟显示器到 VNC。 2. **完成初始化**: - 手动登录账号。 - 在输入框发送任意消息,触发并完成 CloudFlare/reCAPTCHA 验证及服务条款同意。 3. **运行建议**:初始化完成后可切换回标准模式,但为降低风控,**强烈建议长期保持非无头模式运行**。 +### 📑 配置文件结构 + +项目使用 `config.yaml` 进行配置,核心结构如下: + +```yaml +backend: + pool: + strategy: least_busy # 调度策略 + instances: # 浏览器实例列表 + - name: "browser_01" # 实例 ID + userDataMark: "01" # 数据目录标识 + proxy: # 实例级代理 + enable: true + type: socks5 + host: 127.0.0.1 + port: 1080 + workers: # 该实例下的 Worker + - name: "lmarena_01" + type: lmarena + - name: "zai_01" + type: zai_is + - name: "merge" + type: merge # 单标签聚合模式 + mergeTypes: [zai_is, lmarena] + mergeMonitor: zai_is # 空闲时挂机监控的后端 (可选,留空则不启用) +``` + +**说明**: +- 每个 `instance` 代表一个独立的浏览器进程 +- 同一 `instance` 下的 `workers` 共享浏览器数据和登录状态 +- 使用 Google OAuth 等统一登录时,只需登录一次即可用于所有 Worker + +详细配置请参考 `config.example.yaml` 和 `config.md`。 + ### 接口使用说明 @@ -211,13 +249,19 @@ curl -X GET http://127.0.0.1:3000/v1/models \ "id": "seedream-4-high-res-fal", "object": "model", "created": 1732456789, + "owned_by": "internal_server" + }, + { + "id": "lmarena/seedream-4-high-res-fal", + "object": "model", + "created": 1732456789, "owned_by": "lmarena" }, { "id": "gemini-3-pro-image-preview", "object": "model", "created": 1732456789, - "owned_by": "lmarena" + "owned_by": "internal_server" } ] } @@ -225,14 +269,14 @@ curl -X GET http://127.0.0.1:3000/v1/models \ -#### 3. 获取Cookies +#### 3. 获取 Cookies -**功能说明**:可利用本项目的自动续登功能获取最新Cookie给其他工具使用。 +**功能说明**:可利用本项目的自动续登功能获取最新 Cookie 给其他工具使用。 **请求端点** -支持使用`domain`参数获取指定域名的Cookie +支持使用 `name` 参数指定浏览器实例名称,`domain` 参数指定域名。 ``` -GET http://127.0.0.1:3000/v1/cookies (?domain=lmarena.ai) +GET http://127.0.0.1:3000/v1/cookies (?name=browser_default&domain=lmarena.ai) ```
@@ -247,6 +291,7 @@ curl -X GET http://127.0.0.1:3000/v1/cookies \ **响应格式** ```json { + "instance": "browser_default", "cookies": [ { "name": "_GRECAPTCHA", diff --git a/config.example.yaml b/config.example.yaml index 5fc2a4c..cc6008b 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -14,36 +14,97 @@ server: mode: "comment" backend: - # 适配器设置 - # - lmarena (LMArena) - # - gemini (Gemini 网页版) - # - gemini_biz (Gemini Enterprise Business) - # - nanobananafree_ai (Nano Banana Free) - # - zai_is (zAI) - type: lmarena + # ======================================== + # Pool 配置 + # ======================================== + pool: + # 全局调度策略: + # - least_busy (推荐): 优先分配给当前任务最少的 Worker + # - round_robin: 轮询分配 (A -> B -> C -> A) + # - random: 随机分配 + # 任务分发时,会把所有 Instance 下的所有 Worker 扁平化看待 + strategy: least_busy - # 聚合模式设置 (优先级高于 type) - # 启用后,系统将复用单个浏览器标签页处理所有后端任务 - merge: - enable: false - # 聚合列表 (按优先级排序,模型匹配时优先使用靠前的适配器) - type: - - zai_is - - lmarena - # 挂机监控 - monitor: zai_is + # ======================================== + # 浏览器实例列表 + # ======================================== + # 每个 Instance 代表一个独立的浏览器进程 (Context + UserData + Proxy) + # 登录模式: + # npm start -- -login 启动第一个 Worker 进行登录 + # npm start -- -login=workerName 启动指定名称的 Worker 进行登录 + # 注意: Worker 名称在全局必须唯一 + # ======================================== + instances: + # ------------------------------------------------ + # [实例 1] 默认浏览器实例 + # ------------------------------------------------ + - name: "browser_default" # 实例 ID (用于日志显示和Cookie获取) + # userDataMark 不设置时,数据存放在 data/camoufoxUserData + # 同一实例下的所有 Worker 共享浏览器数据和登录状态 + # 使用 Google OAuth 等统一登录时,只需登录一次即可用于所有 Worker + + # 该浏览器实例具备的能力 (适配器列表) + # 相当于在这个浏览器里打开了不同的标签页 + workers: + - name: "default" # 唯一标识 (用于登录模式和日志显示) + type: lmarena # 适配器类型 - # Gemini Business 设置 - geminiBiz: - # 入口链接 - # 示例: "https://business.gemini.google/home/cid/8888a888-b6e0-88be-86e1-888cf3ee8cf4" - entryUrl: "" + # ------------------------------------------------ + # 以下为多实例配置示例 (默认注释) + # ------------------------------------------------ + + # [实例 2] 独立数据目录 + 专属代理 + # - name: "browser_us_01" + # userDataMark: "us_01" # 数据目录: data/camoufoxUserData_us_01 + # + # # 实例级代理 (该实例下所有 Worker 共享此代理) + # proxy: + # enable: true + # type: socks5 + # host: 192.168.1.10 + # port: 1080 + # user: myuser # 可选认证 + # passwd: mypassword + # + # workers: + # - name: "us_lmarena" + # type: lmarena + # + # - name: "us_zai" + # type: zai_is + # + # # 聚合类型 Worker (单标签多后端) + # - name: "us_merged" + # type: merge + # mergeTypes: [gemini_biz, nanobananafree_ai] + # mergeMonitor: gemini_biz # 空闲时挂机监控的后端 (可选,留空则不启用) + + # [实例 3] 强制直连 (不使用代理) + # - name: "browser_direct" + # userDataMark: "direct" + # proxy: + # enable: false # 即使有全局代理也不使用 + # + # workers: + # - name: "direct_gemini" + # type: gemini_biz + + # ======================================== + # 适配器专属配置 (按需填写) + # ======================================== + adapter: + # Gemini Business 设置 + gemini_biz: + # 入口URL + # 示例: "https://business.gemini.google/home/cid/8888a888-b6e0-88be-86e1-888cf3ee8cf4" + entryUrl: "" + queue: - # 最大排队数 - # 仅对未开启流式保活模式时做出限制,非必要不建议更改 - # 因客户端可能有超时保护,队列大于2是一定会触发超时保护的 - maxQueueSize: 2 + # 队列缓冲区大小(非流式请求的额外排队数) + # 实际队列上限 = Workers数量 + queueBuffer + # 设为 0 则不限制非流式请求数量 + queueBuffer: 2 # 图片数量上限 # 网页最多支持10个附件,如果设置大于10则直接丢弃超出10的图片 imageLimit: 5 @@ -57,7 +118,7 @@ browser: # 是否启用无头模式 headless: false - # 代理设置 + # [全局代理] 如果 Instance 没有独立配置代理,将使用此配置 proxy: # 是否启用代理 enable: false diff --git a/server.js b/server.js index 28c06ba..eb12e67 100644 --- a/server.js +++ b/server.js @@ -73,8 +73,8 @@ const KEEPALIVE_MODE = config.server?.keepalive?.mode || 'comment'; /** @type {number} 最大并发数 */ const MAX_CONCURRENT = config.queue?.maxConcurrent || 1; -/** @type {number} 最大队列大小 */ -const MAX_QUEUE_SIZE = config.queue?.maxQueueSize || 2; +/** @type {number} 队列缓冲区(0 表示不限制非流式) */ +const QUEUE_BUFFER = config.queue?.queueBuffer ?? 2; /** @type {number} 图片数量限制 */ const IMAGE_LIMIT = config.queue?.imageLimit || 5; @@ -87,7 +87,7 @@ const IMAGE_LIMIT = config.queue?.imageLimit || 5; const queueManager = createQueueManager( { maxConcurrent: MAX_CONCURRENT, - maxQueueSize: MAX_QUEUE_SIZE, + queueBuffer: QUEUE_BUFFER, keepaliveMode: KEEPALIVE_MODE }, { @@ -95,7 +95,10 @@ const queueManager = createQueueManager( generateImage, config, navigateToMonitor: backend.navigateToMonitor - ? () => backend.navigateToMonitor(config) + ? () => backend.navigateToMonitor() + : null, + getCookies: backend.getCookies + ? (workerName, domain) => backend.getCookies(workerName, domain) : null } ); @@ -116,26 +119,38 @@ const handleRequest = createRouter({ // ==================== 启动服务器 ==================== +/** + * 检测是否为登录模式 + */ +const isLoginMode = process.argv.some(arg => arg.startsWith('-login')); + /** * 启动 HTTP 服务器 * @returns {Promise} */ async function startServer() { - // 预先启动浏览器 + // 预先启动 Pool try { - await queueManager.initializeBrowser(); + await queueManager.initializePool(); } catch (err) { - logger.error('服务器', '浏览器初始化失败', { error: err.message }); + logger.error('服务器', 'Pool 初始化失败', { error: err.message }); process.exit(1); } + // 登录模式:不启动 HTTP 服务器,只等待用户登录 + if (isLoginMode) { + logger.info('服务器', '登录模式已就绪,请在浏览器中完成登录操作'); + logger.info('服务器', '完成后可直接关闭浏览器窗口或按 Ctrl+C 退出'); + return; + } + // 创建并启动 HTTP 服务器 const server = http.createServer(handleRequest); server.listen(PORT, () => { - logger.info('服务器', `HTTP 服务器启动成功,监听端口 ${PORT}`); - logger.info('服务器', `后端: ${backendName},流式心跳模式: ${KEEPALIVE_MODE}`); - logger.info('服务器', `最大并发: ${MAX_CONCURRENT},最大队列: ${MAX_QUEUE_SIZE},最大图片数量: ${IMAGE_LIMIT}`); + logger.info('服务器', `HTTP 服务器已启动,端口: ${PORT}`); + logger.info('服务器', `流式心跳模式: ${KEEPALIVE_MODE}`); + logger.info('服务器', `最大并发: ${MAX_CONCURRENT},队列缓冲: ${QUEUE_BUFFER},最大图片数量: ${IMAGE_LIMIT}`); }); } diff --git a/src/backend/adapter/gemini.js b/src/backend/adapter/gemini.js index bd5c28a..dac53a1 100644 --- a/src/backend/adapter/gemini.js +++ b/src/backend/adapter/gemini.js @@ -3,7 +3,6 @@ * @description 通过自动化方式驱动 Gemini 网页端生成图片,并将结果转换为统一的后端返回结构。 */ -import { initBrowserBase } from '../../browser/launcher.js'; import { sleep, safeClick, @@ -19,27 +18,6 @@ import { logger } from '../../utils/logger.js'; // --- 配置常量 --- const TARGET_URL = 'https://gemini.google.com/app?hl=en'; -/** - * 初始化浏览器会话 - * @param {object} config - 全局配置对象 - * @returns {Promise<{browser: object, page: object, config: object}>} - */ -async function initBrowser(config) { - // 输入框验证逻辑 - const waitInputValidator = async (page) => { - await page.getByRole('textbox').waitFor({ timeout: 60000 }); - await safeClick(page, page.getByRole('textbox'), { bias: 'input' }); - await sleep(500, 1000); - }; - - const base = await initBrowserBase(config, { - userDataDir: config.paths.userDataDir, - targetUrl: TARGET_URL, - productName: 'Gemini', - waitInputValidator - }); - return { ...base, config }; -} /** * 执行生图任务 @@ -186,4 +164,47 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { } } -export { initBrowser, generateImage }; +/** + * 输入框就绪校验 + * @param {import('playwright-core').Page} page + */ +async function waitInputValidator(page) { + await page.getByRole('textbox').waitFor({ timeout: 60000 }); + await safeClick(page, page.getByRole('textbox'), { bias: 'input' }); + await sleep(500, 1000); +} + +/** + * 适配器 manifest + */ +export const manifest = { + id: 'gemini', + displayName: 'Gemini (Consumer)', + + // 入口 URL + getTargetUrl(config, workerConfig) { + return TARGET_URL; + }, + + // 模型列表 + models: [ + { id: 'gemini-3-pro-image-preview', imagePolicy: 'optional' } + ], + + // 模型 ID 解析(直通) + resolveModelId(modelKey) { + const model = this.models.find(m => m.id === modelKey); + return model ? model.id : null; + }, + + // 输入框就绪校验 + waitInput: waitInputValidator, + + // 无需导航处理器 + navigationHandlers: [], + + // 核心生图方法 + generateImage +}; + +export { generateImage }; diff --git a/src/backend/adapter/gemini_biz.js b/src/backend/adapter/gemini_biz.js index e1f9e4b..9e7a8b4 100644 --- a/src/backend/adapter/gemini_biz.js +++ b/src/backend/adapter/gemini_biz.js @@ -3,7 +3,6 @@ * @description 通过自动化方式驱动 Gemini Business 网页端生成图片,并将结果转换为统一的后端返回结构。 */ -import { initBrowserBase } from '../../browser/launcher.js'; import { sleep, safeClick, @@ -146,43 +145,6 @@ async function waitForInputWithAccountChooser(page, options = {}) { } } -/** - * 初始化浏览器 - * @param {object} config - 配置对象 - * @param {object} [config.browser] - Browser 配置 - * @param {boolean} [config.browser.headless] - 是否开启 Headless 模式 - * @param {string} [config.browser.path] - Browser 可执行文件路径 - * @param {object} [config.browser.proxy] - 代理配置 - * @param {object} [config.backend] - 后端配置 - * @param {object} [config.backend.geminiBiz] - Gemini Biz 配置 - * @param {string} config.backend.geminiBiz.entryUrl - Gemini entry URL (必需) - * @returns {Promise<{browser: object, page: object, client: object}>} - */ -async function initBrowser(config) { - // 从配置读取 Gemini Biz entry URL - const backendCfg = config.backend || {}; - const geminiCfg = backendCfg.geminiBiz || {}; - const targetUrl = geminiCfg.entryUrl; - - if (!targetUrl) { - logger.error('适配器', '未找到GeminiBiz的入口URL, 请在配置文件中配置后再启动', meta); - throw new Error('GeminiBiz backend missing entry URL: backend.geminiBiz.entryUrl'); - } - - // 输入框验证逻辑(使用公共函数) - const waitInputValidator = async (page) => { - await waitForInputWithAccountChooser(page); - }; - - const base = await initBrowserBase(config, { - userDataDir: config.paths.userDataDir, - targetUrl, - productName: 'Gemini Enterprise Business', - waitInputValidator, - navigationHandler: handleAccountChooser - }); - return { ...base, config }; -} /** * 生成图片 @@ -196,7 +158,8 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { const { page, config } = context; try { - const targetUrl = config.backend?.geminiBiz?.entryUrl; + // 支持新路径 adapter.gemini_biz.entryUrl,向下兼容旧路径 geminiBiz.entryUrl + const targetUrl = config.backend?.adapter?.gemini_biz?.entryUrl || config.backend?.geminiBiz?.entryUrl; if (!targetUrl) { throw new Error('GeminiBiz backend missing entry URL'); @@ -353,4 +316,39 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { } } -export { initBrowser, generateImage, handleAccountChooser }; +export { generateImage }; + +/** + * 适配器 manifest + */ +export const manifest = { + id: 'gemini_biz', + displayName: 'Gemini Business', + + // 入口 URL (从配置读取,支持新旧路径) + getTargetUrl(config, workerConfig) { + return config?.backend?.adapter?.gemini_biz?.entryUrl || config?.backend?.geminiBiz?.entryUrl || null; + }, + + // 模型列表 + models: [ + { id: 'gemini-3-pro-image-preview', imagePolicy: 'optional' } + ], + + // 模型 ID 解析(直通) + resolveModelId(modelKey) { + const model = this.models.find(m => m.id === modelKey); + return model ? model.id : null; + }, + + // 输入框就绪校验 + async waitInput(page, ctx) { + await waitForInputWithAccountChooser(page); + }, + + // 导航处理器 + navigationHandlers: [handleAccountChooser], + + // 核心生图方法 + generateImage +}; diff --git a/src/backend/adapter/lmarena.js b/src/backend/adapter/lmarena.js index 62b5c4d..3d9774d 100644 --- a/src/backend/adapter/lmarena.js +++ b/src/backend/adapter/lmarena.js @@ -3,7 +3,6 @@ * @description 通过自动化方式驱动 LMArena 网页端生成图片(或解析文本),并将结果转换为统一的后端返回结构。 */ -import { initBrowserBase } from '../../browser/launcher.js'; import { sleep, safeClick, @@ -42,28 +41,6 @@ function extractImage(text) { return null; } -/** - * 初始化浏览器会话 - * @param {object} config - 全局配置对象 - * @returns {Promise<{browser: object, page: object, client: object}>} 初始化后的浏览器上下文 - */ -async function initBrowser(config) { - // 输入框验证逻辑 - const waitInputValidator = async (page) => { - const textareaSelector = 'textarea'; - await page.waitForSelector(textareaSelector, { timeout: 60000 }); - await safeClick(page, textareaSelector, { bias: 'input' }); - await sleep(500, 1000); - }; - - const base = await initBrowserBase(config, { - userDataDir: config.paths.userDataDir, - targetUrl: TARGET_URL, - productName: 'LMArena', - waitInputValidator - }); - return { ...base, config }; -} /** * 执行生图任务 @@ -159,7 +136,10 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { const img = extractImage(content); if (img) { logger.info('适配器', '已获取结果,正在下载图片...', meta); - const result = await downloadImage(img, config); + const result = await downloadImage(img, { + proxyConfig: context.proxyConfig, + userDataDir: context.userDataDir + }); if (result.image) { logger.info('适配器', '已下载图片,任务完成', meta); } @@ -185,4 +165,80 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { } } -export { initBrowser, generateImage }; +/** + * 输入框就绪校验 + * @param {import('playwright-core').Page} page + */ +async function waitInputValidator(page) { + const textareaSelector = 'textarea'; + await page.waitForSelector(textareaSelector, { timeout: 60000 }); + await safeClick(page, textareaSelector, { bias: 'input' }); + await sleep(500, 1000); +} + +/** + * 适配器 manifest + */ +export const manifest = { + id: 'lmarena', + displayName: 'LMArena', + + // 入口 URL + getTargetUrl(config, workerConfig) { + return TARGET_URL; + }, + + // 模型列表(从 models.js 迁移) + models: [ + { id: 'gemini-3-pro-image-preview-2k', codeName: '019abc10-e78d-7932-b725-7f1563ed8a12', imagePolicy: 'optional' }, + { id: 'gemini-3-pro-image-preview', codeName: '019aa208-5c19-7162-ae3b-0a9ddbb1e16a', imagePolicy: 'optional' }, + { id: 'flux-2-flex', codeName: '019abed6-d96e-7a2b-bf69-198c28bef281', imagePolicy: 'optional' }, + { id: 'gemini-2.5-flash-image-preview', codeName: '0199ef2a-583f-7088-b704-b75fd169401d', imagePolicy: 'optional' }, + { id: 'hunyuan-image-3.0', codeName: '7766a45c-1b6b-4fb8-9823-2557291e1ddd', imagePolicy: 'forbidden' }, + { id: 'flux-2-pro', codeName: '019abcf4-5600-7a8b-864d-9b8ab7ab7328', imagePolicy: 'optional' }, + { id: 'seedream-4.5', codeName: '019abd43-b052-7eec-aa57-e895e45c9723', imagePolicy: 'optional' }, + { id: 'seedream-4-high-res-fal', codeName: '32974d8d-333c-4d2e-abf3-f258c0ac1310', imagePolicy: 'optional' }, + { id: 'wan2.5-t2i-preview', codeName: '019a5050-2875-78ed-ae3a-d9a51a438685', imagePolicy: 'forbidden' }, + { id: 'gpt-image-1', codeName: '6e855f13-55d7-4127-8656-9168a9f4dcc0', imagePolicy: 'optional' }, + { id: 'gpt-image-mini', codeName: '0199c238-f8ee-7f7d-afc1-7e28fcfd21cf', imagePolicy: 'optional' }, + { id: 'mai-image-1', codeName: '1b407d5c-1806-477c-90a5-e5c5a114f3bc', imagePolicy: 'forbidden' }, + { id: 'seedream-3', codeName: 'd8771262-8248-4372-90d5-eb41910db034', imagePolicy: 'forbidden' }, + { id: 'qwen-image-prompt-extend', codeName: '9fe82ee1-c84f-417f-b0e7-cab4ae4cf3f3', imagePolicy: 'forbidden' }, + { id: 'flux-1-kontext-pro', codeName: '28a8f330-3554-448c-9f32-2c0a08ec6477', imagePolicy: 'optional' }, + { id: 'imagen-3.0-generate-002', codeName: '51ad1d79-61e2-414c-99e3-faeb64bb6b1b', imagePolicy: 'forbidden' }, + { id: 'ideogram-v3-quality', codeName: '73378be5-cdba-49e7-b3d0-027949871aa6', imagePolicy: 'forbidden' }, + { id: 'photon', codeName: 'e7c9fa2d-6f5d-40eb-8305-0980b11c7cab', imagePolicy: 'forbidden' }, + { id: 'recraft-v3', codeName: 'b88d5814-1d20-49cc-9eb6-e362f5851661', imagePolicy: 'forbidden' }, + { id: 'lucid-origin', codeName: '5a3b3520-c87d-481f-953c-1364687b6e8f', imagePolicy: 'forbidden' }, + { id: 'gemini-2.0-flash-preview-image-generation', codeName: '69bbf7d4-9f44-447e-a868-abc4f7a31810', imagePolicy: 'optional' }, + { id: 'dall-e-3', codeName: 'bb97bc68-131c-4ea4-a59e-03a6252de0d2', imagePolicy: 'forbidden' }, + { id: 'flux-1-kontext-dev', codeName: 'eb90ae46-a73a-4f27-be8b-40f090592c9a', imagePolicy: 'optional' }, + { id: 'vidu-q2-image', codeName: '019adb32-afa4-749e-9992-39653b52fe13', imagePolicy: 'optional' }, + { id: 'imagen-4.0-fast-generate-001', codeName: 'f44fd4f8-af30-480f-8ce2-80b2bdfea55e', imagePolicy: 'forbidden' }, + { id: 'imagen-4.0-ultra-generate-001', codeName: '019ae6da-6438-7077-9d2d-b311a35645f8', imagePolicy: 'forbidden' }, + { id: 'flux-2-dev', codeName: '019ae6a0-4773-77d5-8ffb-cc35813e063c', imagePolicy: 'optional' }, + { id: 'imagen-4.0-generate-001', codeName: '019ae6da-6788-761a-8253-e0bb2bf2e3a9', imagePolicy: 'forbidden' }, + { id: 'wan2.5-i2i-preview', codeName: '019aeb62-c6ea-788e-88f9-19b1b48325b5', imagePolicy: 'required' }, + { id: 'hunyuan-image-2.1', codeName: 'a9a26426-5377-4efa-bef9-de71e29ad943', imagePolicy: 'forbidden' }, + { id: 'qwen-image-edit', codeName: '995cf221-af30-466d-a809-8e0985f83649', imagePolicy: 'required' }, + { id: 'reve-v1', codeName: '0199e980-ba42-737b-9436-927b6e7ca73e', imagePolicy: 'required' }, + { id: 'reve-fast-edit', codeName: '019a5675-0a56-7835-abdd-1cb9e7870afa', imagePolicy: 'required' } + ], + + // 模型 ID 解析 + resolveModelId(modelKey) { + const model = this.models.find(m => m.id === modelKey); + return model ? model.codeName : null; + }, + + // 输入框就绪校验 + waitInput: waitInputValidator, + + // 无需导航处理器 + navigationHandlers: [], + + // 核心生图方法 + generateImage +}; + +export { generateImage }; diff --git a/src/backend/adapter/nanobananafree_ai.js b/src/backend/adapter/nanobananafree_ai.js index 1101adf..3b9a14f 100644 --- a/src/backend/adapter/nanobananafree_ai.js +++ b/src/backend/adapter/nanobananafree_ai.js @@ -3,7 +3,7 @@ * @description 通过自动化方式驱动 nanobananafree.ai 网页端生成图片,并将结果转换为统一的后端返回结构。 */ -import { initBrowserBase } from '../../browser/launcher.js'; + import { sleep, safeClick, @@ -22,28 +22,6 @@ import { logger } from '../../utils/logger.js'; // --- 配置常量 --- const TARGET_URL = 'https://nanobananafree.ai/'; -/** - * 初始化浏览器 - * @param {object} config - 配置对象 - * @returns {Promise<{browser: object, page: object, client: object}>} - */ -async function initBrowser(config) { - // NanoBananaFree AI 特定的输入框验证逻辑 - const waitInputValidator = async (page) => { - const textareaSelector = 'textarea'; - await page.waitForSelector(textareaSelector, { timeout: 60000 }); - await safeClick(page, textareaSelector, { bias: 'input' }); - await sleep(500, 1000); - }; - - const base = await initBrowserBase(config, { - userDataDir: config.paths.userDataDir, - targetUrl: TARGET_URL, - productName: 'NanoBananaFree AI', - waitInputValidator - }); - return { ...base, config }; -} /** * 执行生图任务 @@ -152,4 +130,48 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { } } -export { initBrowser, generateImage }; +/** + * 输入框就绪校验 + * @param {import('playwright-core').Page} page + */ +async function waitInputValidator(page) { + const textareaSelector = 'textarea'; + await page.waitForSelector(textareaSelector, { timeout: 60000 }); + await safeClick(page, textareaSelector, { bias: 'input' }); + await sleep(500, 1000); +} + +/** + * 适配器 manifest + */ +export const manifest = { + id: 'nanobananafree_ai', + displayName: 'NanoBananaFree AI', + + // 入口 URL + getTargetUrl(config, workerConfig) { + return TARGET_URL; + }, + + // 模型列表 + models: [ + { id: 'gemini-2.5-flash-image', imagePolicy: 'optional' } + ], + + // 模型 ID 解析(直通) + resolveModelId(modelKey) { + const model = this.models.find(m => m.id === modelKey); + return model ? model.id : null; + }, + + // 输入框就绪校验 + waitInput: waitInputValidator, + + // 无需导航处理器 + navigationHandlers: [], + + // 核心生图方法 + generateImage +}; + +export { generateImage }; diff --git a/src/backend/adapter/zai_is.js b/src/backend/adapter/zai_is.js index c5b713d..1a37601 100644 --- a/src/backend/adapter/zai_is.js +++ b/src/backend/adapter/zai_is.js @@ -3,7 +3,6 @@ * @description 通过自动化方式驱动 zai.is 网页端生成图片,并将结果转换为统一的后端返回结构。 */ -import { initBrowserBase } from '../../browser/launcher.js'; import { sleep, safeClick, @@ -164,26 +163,6 @@ async function waitForInputWithAuth(page, options = {}) { } } -/** - * 初始化浏览器 - * @param {object} config - 配置对象 - * @returns {Promise<{browser: object, page: object, client: object}>} - */ -async function initBrowser(config) { - // 输入框验证逻辑(使用公共函数) - const waitInputValidator = async (page) => { - await waitForInputWithAuth(page); - }; - - const base = await initBrowserBase(config, { - userDataDir: config.paths.userDataDir, - targetUrl: TARGET_URL, - productName: 'Zai.is', - waitInputValidator, - navigationHandler: handleDiscordAuth - }); - return { ...base, config }; -} /** * 生成图片 @@ -395,7 +374,10 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { logger.info('适配器', `已提取图片链接: ${imageUrl}`, meta); // 下载图片 - const downloadResult = await downloadImage(imageUrl, config); + const downloadResult = await downloadImage(imageUrl, { + proxyConfig: context.proxyConfig, + userDataDir: context.userDataDir + }); if (downloadResult.error) { return downloadResult; } @@ -418,4 +400,40 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { } } -export { initBrowser, generateImage, handleDiscordAuth }; +/** + * 适配器 manifest + */ +export const manifest = { + id: 'zai_is', + displayName: 'zAI (zai.is)', + + // 入口 URL + getTargetUrl(config, workerConfig) { + return TARGET_URL; + }, + + // 模型列表 + models: [ + { id: 'gemini-3-pro-image-preview', imagePolicy: 'optional' }, + { id: 'gemini-2.5-flash-image', imagePolicy: 'optional' } + ], + + // 模型 ID 解析(直通) + resolveModelId(modelKey) { + const model = this.models.find(m => m.id === modelKey); + return model ? model.id : null; + }, + + // 输入框就绪校验 + async waitInput(page, ctx) { + await waitForInputWithAuth(page); + }, + + // 导航处理器 + navigationHandlers: [handleDiscordAuth], + + // 核心生图方法 + generateImage +}; + +export { generateImage }; diff --git a/src/backend/index.js b/src/backend/index.js index 416f277..efb1476 100644 --- a/src/backend/index.js +++ b/src/backend/index.js @@ -1,269 +1,141 @@ /** * @fileoverview 后端适配器入口 - * @description 负责加载配置、准备运行目录(用户数据/临时目录),并根据配置返回“单后端”或“聚合后端”的统一接口。 + * @description 基于 Pool 架构统一管理多浏览器实例,提供统一的对外接口。 * * 对外统一能力: - * - `initBrowser(cfg)` + * - `initBrowser(cfg)` → 初始化 Pool * - `generateImage(ctx, prompt, imagePaths, modelId, meta)` * - `resolveModelId(modelKey)` / `getModels()` / `getImagePolicy(modelKey)` + * - `getCookies(workerName, domain)` - 获取指定 Worker 的 Cookies */ import fs from 'fs'; import path from 'path'; import { loadConfig } from '../utils/config.js'; -import { initBrowserBase } from '../browser/launcher.js'; -import * as modelsModule from './models.js'; +import { PoolManager } from './pool.js'; import { logger } from '../utils/logger.js'; -// 导入适配器 -import * as lmarenaBackend from './adapter/lmarena.js'; -import * as geminiBackend from './adapter/gemini_biz.js'; -import * as geminiConsumerBackend from './adapter/gemini.js'; -import * as nanobananafreeBackend from './adapter/nanobananafree_ai.js'; -import * as zaiIsBackend from './adapter/zai_is.js'; - // --- 集中管理的路径常量 --- -const USER_DATA_DIR = path.join(process.cwd(), 'data', 'camoufoxUserData'); const TEMP_DIR = path.join(process.cwd(), 'data', 'temp'); -// 确保必要目录存在 -if (!fs.existsSync(USER_DATA_DIR)) { - fs.mkdirSync(USER_DATA_DIR, { recursive: true }); -} +// 确保临时目录存在 if (!fs.existsSync(TEMP_DIR)) { fs.mkdirSync(TEMP_DIR, { recursive: true }); } -// 适配器映射表 -const ADAPTER_MAP = { - 'gemini_biz': geminiBackend, - 'gemini': geminiConsumerBackend, - 'nanobananafree_ai': nanobananafreeBackend, - 'zai_is': zaiIsBackend, - 'lmarena': lmarenaBackend -}; - -// 2. 聚合后端模式实现(跨调用复用全局 Page) -const MergedBackend = { - name: 'merge', - _globalBrowser: null, - _globalPage: null, - _config: null, // 保存配置引用 - - initBrowser: async (cfg) => { - MergedBackend._config = cfg; // 保存配置 - if (MergedBackend._globalPage && !MergedBackend._globalPage.isClosed()) { - return { browser: MergedBackend._globalBrowser, page: MergedBackend._globalPage, config: cfg }; - } - - const activeTypes = cfg.backend.merge.type || []; - const handlers = []; - - // 收集导航处理器 - for (const type of activeTypes) { - const adapter = ADAPTER_MAP[type]; - if (type === 'gemini_biz' && adapter.handleAccountChooser) handlers.push(adapter.handleAccountChooser); - if (type === 'zai_is' && adapter.handleDiscordAuth) handlers.push(adapter.handleDiscordAuth); - } - - // 聚合处理器 - const aggregatedHandler = async (page) => { - for (const handler of handlers) { - try { await handler(page); } catch (e) { } - } - }; - - logger.info('适配器', `[后端聚合] 启动全局浏览器,聚合: ${activeTypes.join(', ')}`); - - const base = await initBrowserBase(cfg, { - userDataDir: cfg.paths.userDataDir, - targetUrl: 'about:blank', - productName: '聚合模式', - navigationHandler: aggregatedHandler, - waitInputValidator: async () => { } - }); - - MergedBackend._globalBrowser = base.browser; - MergedBackend._globalPage = base.page; - return { ...base, config: cfg }; - }, - - generateImage: async (ctx, prompt, paths, modelId, meta) => { - if (!modelId || !modelId.includes('|')) return { error: 'Invalid aggregated model ID' }; - - const [adapterType, realId] = modelId.split('|'); - const adapter = ADAPTER_MAP[adapterType]; - - if (!adapter) return { error: `Adapter not found: ${adapterType}` }; - - logger.info('适配器', `[后端聚合] 路由至: ${adapterType}, Model: ${realId}`, meta); - - // 构造子上下文:复用全局 Page,但传入当前 config(适配器会读取 config.backend.geminiBiz 等字段) - const subContext = { - ...ctx, - page: MergedBackend._globalPage, - config: ctx.config - }; - - return adapter.generateImage(subContext, prompt, paths, realId, meta); - }, - - resolveModelId: (modelKey) => { - const types = MergedBackend._config.backend.merge.type; - - // 支持 backend/model 格式指定后端 (如 lmarena/seedream-4.5) - if (modelKey.includes('/')) { - const [specifiedType, actualModel] = modelKey.split('/', 2); - if (ADAPTER_MAP[specifiedType] && types.includes(specifiedType)) { - const realId = modelsModule.resolveModelId(specifiedType, actualModel); - if (realId) return `${specifiedType}|${realId}`; - } - return null; // 指定的后端不存在或不在聚合列表中 - } - - // 按优先级自动匹配 - for (const type of types) { - const realId = modelsModule.resolveModelId(type, modelKey); - if (realId) return `${type}|${realId}`; - } - return null; - }, - - getModels: () => { - const types = MergedBackend._config.backend.merge.type; - const allModels = []; - const seenIds = new Set(); - - // 1. 添加按优先级自动选择的模型 (去重) - for (const type of types) { - const result = modelsModule.getModelsForBackend(type); - if (result?.data) { - for (const m of result.data) { - if (!seenIds.has(m.id)) { - seenIds.add(m.id); - allModels.push({ - ...m, - owned_by: type // 标记优先匹配的后端 - }); - } - } - } - } - - // 2. 添加带前缀的模型 (backend/model 格式,用于指定后端) - for (const type of types) { - const result = modelsModule.getModelsForBackend(type); - if (result?.data) { - for (const m of result.data) { - const prefixedId = `${type}/${m.id}`; - allModels.push({ - ...m, - id: prefixedId, - owned_by: type - }); - } - } - } - - return { object: 'list', data: allModels }; - }, - - getImagePolicy: (modelKey) => { - const types = MergedBackend._config.backend.merge.type; - - // 支持 backend/model 格式 - if (modelKey.includes('/')) { - const [specifiedType, actualModel] = modelKey.split('/', 2); - if (ADAPTER_MAP[specifiedType] && types.includes(specifiedType)) { - return modelsModule.getImagePolicy(specifiedType, actualModel); - } - return 'optional'; - } - - // 按优先级查找 - for (const type of types) { - const realId = modelsModule.resolveModelId(type, modelKey); - if (realId) return modelsModule.getImagePolicy(type, modelKey); - } - return 'optional'; - }, - - /** - * 空闲时导航到监控页面(用于自动续签 Cookie) - * @param {object} cfg - 配置对象 - * @returns {Promise} - */ - navigateToMonitor: async (cfg) => { - const monitorType = cfg.backend.merge?.monitor; - if (!monitorType) return; - - const page = MergedBackend._globalPage; - if (!page || page.isClosed()) return; - - // 适配器目标 URL 映射 - const TARGET_URLS = { - 'zai_is': 'https://zai.is/', - 'lmarena': 'https://lmarena.ai/', - 'gemini_biz': cfg.backend.geminiBiz?.entryUrl || 'https://aistudio.google.com/', - 'gemini': 'https://gemini.google.com/', - 'nanobananafree_ai': 'https://nanobananafree.ai/' - }; - - const targetUrl = TARGET_URLS[monitorType]; - if (!targetUrl) { - logger.warn('适配器', `[Monitor] 未知的监控类型: ${monitorType}`); - return; - } - - // 检查当前是否已在目标网站 - const currentUrl = page.url(); - if (currentUrl.includes(new URL(targetUrl).hostname)) { - logger.debug('适配器', `[Monitor] 已在目标网站: ${monitorType}`); - return; - } - - logger.info('适配器', `[Monitor] 空闲中,跳转至: ${monitorType} (${targetUrl})`); - try { - await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); - // 全局 navigationHandler 会自动处理登录 - } catch (e) { - logger.warn('适配器', `[Monitor] 跳转失败: ${e.message}`); - } - } -}; +// 全局 PoolManager 实例 +let poolManager = null; +/** + * 获取后端接口 + * @returns {object} 后端统一接口 + */ export function getBackend() { const config = loadConfig(); - // 将路径常量注入 config 对象 + // 将临时目录路径注入 config 对象 config.paths = { - userDataDir: USER_DATA_DIR, tempDir: TEMP_DIR }; - // 单一后端模式实现(基于当前配置构建) - const SingleBackend = { - name: config.backend.type, + return { + name: 'pool', + config, + TEMP_DIR, + /** + * 初始化 Pool + * @param {object} cfg - 配置对象 + * @returns {Promise<{poolManager: PoolManager, config: object}>} + */ initBrowser: async (cfg) => { - const adapter = ADAPTER_MAP[cfg.backend.type] || lmarenaBackend; - return adapter.initBrowser(cfg); + if (poolManager && poolManager.initialized) { + return { poolManager, config: cfg }; + } + + poolManager = new PoolManager(cfg); + await poolManager.initAll(); + + return { poolManager, config: cfg }; }, + /** + * 生成图片 + * @param {object} ctx - 浏览器上下文 (来自 initBrowser 返回) + * @param {string} prompt - 提示词 + * @param {string[]} paths - 图片路径 + * @param {string} modelId - 模型 ID + * @param {object} meta - 元信息 + */ generateImage: async (ctx, prompt, paths, modelId, meta) => { - const adapter = ADAPTER_MAP[config.backend.type] || lmarenaBackend; - return adapter.generateImage(ctx, prompt, paths, modelId, meta); + if (!poolManager) { + return { error: 'Pool 未初始化' }; + } + return await poolManager.generateImage(ctx, prompt, paths, modelId, meta); }, - resolveModelId: (modelKey) => modelsModule.resolveModelId(config.backend.type, modelKey), - getModels: () => modelsModule.getModelsForBackend(config.backend.type), - getImagePolicy: (modelKey) => modelsModule.getImagePolicy(config.backend.type, modelKey) + /** + * 解析模型 ID + * @param {string} modelKey - 模型 key + * @returns {string|null} + */ + resolveModelId: (modelKey) => { + if (!poolManager) { + logger.warn('适配器', 'resolveModelId 调用时 Pool 未初始化'); + return null; + } + return poolManager.resolveModelId(modelKey); + }, + + /** + * 获取模型列表 + * @returns {object} + */ + getModels: () => { + if (!poolManager) { + return { object: 'list', data: [] }; + } + return poolManager.getModels(); + }, + + /** + * 获取图片策略 + * @param {string} modelKey - 模型 key + * @returns {string} + */ + getImagePolicy: (modelKey) => { + if (!poolManager) { + return 'optional'; + } + return poolManager.getImagePolicy(modelKey); + }, + + /** + * 获取 Cookies + * @param {string} [workerName] - Worker 名称 + * @param {string} [domain] - 域名 + * @returns {Promise<{worker: string, cookies: object[]}>} + */ + getCookies: async (workerName, domain) => { + if (!poolManager) { + throw new Error('Pool 未初始化'); + } + return await poolManager.getCookies(workerName, domain); + }, + + /** + * 触发监控导航(空闲时) + */ + navigateToMonitor: async () => { + if (poolManager) { + await poolManager.navigateToMonitor(); + } + }, + + /** + * 获取 PoolManager 实例 + * @returns {PoolManager|null} + */ + getPoolManager: () => poolManager }; - - const isMerge = config.backend.merge && config.backend.merge.enable; - const activeBackend = isMerge ? MergedBackend : SingleBackend; - - logger.info('适配器', `后端模式: ${isMerge ? '聚合' : '独立'}`); - - return { config, TEMP_DIR, ...activeBackend }; } diff --git a/src/backend/models.js b/src/backend/models.js deleted file mode 100644 index 6a3bde1..0000000 --- a/src/backend/models.js +++ /dev/null @@ -1,261 +0,0 @@ -/** - * @fileoverview 模型与图片策略映射 - * @description 维护各后端的模型列表/别名与图片输入策略,并提供统一的解析与查询方法供服务器层使用。 - */ - -/** - * 图片输入策略枚举 - * - optional:可带可不带(默认) - * - required:必须有参考图 - * - forbidden:禁止带图 - */ -export const IMAGE_POLICY = { - OPTIONAL: 'optional', // 可带可不带(默认) - REQUIRED: 'required', // 必须有参考图 - FORBIDDEN: 'forbidden' // 禁止带图 -}; - -/** LMArena 后端模型配置(modelKey -> 上游 codeName + 图片策略) */ -export const LMARENA_MODELS = { - "gemini-3-pro-image-preview-2k": { - codeName: "019abc10-e78d-7932-b725-7f1563ed8a12", - imagePolicy: IMAGE_POLICY.OPTIONAL - }, - "gemini-3-pro-image-preview": { - codeName: "019aa208-5c19-7162-ae3b-0a9ddbb1e16a", - imagePolicy: IMAGE_POLICY.OPTIONAL - }, - "flux-2-flex": { - codeName: "019abed6-d96e-7a2b-bf69-198c28bef281", - imagePolicy: IMAGE_POLICY.OPTIONAL - }, - "gemini-2.5-flash-image-preview": { - codeName: "0199ef2a-583f-7088-b704-b75fd169401d", - imagePolicy: IMAGE_POLICY.OPTIONAL - }, - "hunyuan-image-3.0": { - codeName: "7766a45c-1b6b-4fb8-9823-2557291e1ddd", - imagePolicy: IMAGE_POLICY.FORBIDDEN - }, - "flux-2-pro": { - codeName: "019abcf4-5600-7a8b-864d-9b8ab7ab7328", - imagePolicy: IMAGE_POLICY.OPTIONAL - }, - "seedream-4.5": { - codeName: "019abd43-b052-7eec-aa57-e895e45c9723", - imagePolicy: IMAGE_POLICY.OPTIONAL - }, - "seedream-4-high-res-fal": { - codeName: "32974d8d-333c-4d2e-abf3-f258c0ac1310", - imagePolicy: IMAGE_POLICY.OPTIONAL - }, - "wan2.5-t2i-preview": { - codeName: "019a5050-2875-78ed-ae3a-d9a51a438685", - imagePolicy: IMAGE_POLICY.FORBIDDEN - }, - "gpt-image-1": { - codeName: "6e855f13-55d7-4127-8656-9168a9f4dcc0", - imagePolicy: IMAGE_POLICY.OPTIONAL - }, - "gpt-image-mini": { - codeName: "0199c238-f8ee-7f7d-afc1-7e28fcfd21cf", - imagePolicy: IMAGE_POLICY.OPTIONAL - }, - "mai-image-1": { - codeName: "1b407d5c-1806-477c-90a5-e5c5a114f3bc", - imagePolicy: IMAGE_POLICY.FORBIDDEN - }, - "seedream-3": { - codeName: "d8771262-8248-4372-90d5-eb41910db034", - imagePolicy: IMAGE_POLICY.FORBIDDEN - }, - "qwen-image-prompt-extend": { - codeName: "9fe82ee1-c84f-417f-b0e7-cab4ae4cf3f3", - imagePolicy: IMAGE_POLICY.FORBIDDEN - }, - "flux-1-kontext-pro": { - codeName: "28a8f330-3554-448c-9f32-2c0a08ec6477", - imagePolicy: IMAGE_POLICY.OPTIONAL - }, - "imagen-3.0-generate-002": { - codeName: "51ad1d79-61e2-414c-99e3-faeb64bb6b1b", - imagePolicy: IMAGE_POLICY.FORBIDDEN - }, - "ideogram-v3-quality": { - codeName: "73378be5-cdba-49e7-b3d0-027949871aa6", - imagePolicy: IMAGE_POLICY.FORBIDDEN - }, - "photon": { - codeName: "e7c9fa2d-6f5d-40eb-8305-0980b11c7cab", - imagePolicy: IMAGE_POLICY.FORBIDDEN - }, - "recraft-v3": { - codeName: "b88d5814-1d20-49cc-9eb6-e362f5851661", - imagePolicy: IMAGE_POLICY.FORBIDDEN - }, - "lucid-origin": { - codeName: "5a3b3520-c87d-481f-953c-1364687b6e8f", - imagePolicy: IMAGE_POLICY.FORBIDDEN - }, - "gemini-2.0-flash-preview-image-generation": { - codeName: "69bbf7d4-9f44-447e-a868-abc4f7a31810", - imagePolicy: IMAGE_POLICY.OPTIONAL - }, - "dall-e-3": { - codeName: "bb97bc68-131c-4ea4-a59e-03a6252de0d2", - imagePolicy: IMAGE_POLICY.FORBIDDEN - }, - "flux-1-kontext-dev": { - codeName: "eb90ae46-a73a-4f27-be8b-40f090592c9a", - imagePolicy: IMAGE_POLICY.OPTIONAL - }, - "vidu-q2-image": { - codeName: "019adb32-afa4-749e-9992-39653b52fe13", - imagePolicy: IMAGE_POLICY.OPTIONAL - }, - "imagen-4.0-fast-generate-001": { - codeName: "f44fd4f8-af30-480f-8ce2-80b2bdfea55e", - imagePolicy: IMAGE_POLICY.FORBIDDEN - }, - "imagen-4.0-ultra-generate-001": { - codeName: "019ae6da-6438-7077-9d2d-b311a35645f8", - imagePolicy: IMAGE_POLICY.FORBIDDEN - }, - "flux-2-dev": { - codeName: "019ae6a0-4773-77d5-8ffb-cc35813e063c", - imagePolicy: IMAGE_POLICY.OPTIONAL - }, - "imagen-4.0-generate-001": { - codeName: "019ae6da-6788-761a-8253-e0bb2bf2e3a9", - imagePolicy: IMAGE_POLICY.FORBIDDEN - }, - "wan2.5-i2i-preview": { - codeName: "019aeb62-c6ea-788e-88f9-19b1b48325b5", - imagePolicy: IMAGE_POLICY.REQUIRED - }, - "hunyuan-image-2.1": { - codeName: "a9a26426-5377-4efa-bef9-de71e29ad943", - imagePolicy: IMAGE_POLICY.FORBIDDEN - }, - "qwen-image-edit": { - codeName: "995cf221-af30-466d-a809-8e0985f83649", - imagePolicy: IMAGE_POLICY.REQUIRED - }, - "reve-v1": { - codeName: "0199e980-ba42-737b-9436-927b6e7ca73e", - imagePolicy: IMAGE_POLICY.REQUIRED - }, - "reve-fast-edit": { - codeName: "019a5675-0a56-7835-abdd-1cb9e7870afa", - imagePolicy: IMAGE_POLICY.REQUIRED - } -}; - -/** Gemini Business 后端模型配置 */ -export const GEMINI_BIZ_MODELS = { - "gemini-3-pro-image-preview": { - imagePolicy: IMAGE_POLICY.OPTIONAL - } -}; - -/** NanoBananaFree AI 后端模型配置 */ -export const NANOBANANAFREE_AI_MODELS = { - "gemini-2.5-flash-image": { - imagePolicy: IMAGE_POLICY.OPTIONAL - } -}; - -/** zai.is 后端模型配置 */ -export const ZAI_IS_MODELS = { - "gemini-3-pro-image-preview": { - imagePolicy: IMAGE_POLICY.OPTIONAL - }, - "gemini-2.5-flash-image": { - imagePolicy: IMAGE_POLICY.OPTIONAL - } -}; - -// Gemini 后端模型配置 -export const GEMINI_MODELS = { - "gemini-3-pro-image-preview": { - imagePolicy: IMAGE_POLICY.OPTIONAL - } -}; - -/** - * 获取后端对应的模型配置表 - * @param {string} backendName - 后端名称 ('lmarena' 或 'gemini_biz' 或 'nanobananafree_ai') - * @returns {Object} 模型配置对象 - * @private - */ -function getModelsConfigForBackend(backendName) { - switch (backendName) { - case 'lmarena': - return LMARENA_MODELS; - case 'gemini_biz': - return GEMINI_BIZ_MODELS; - case 'gemini': - return GEMINI_MODELS; - case 'nanobananafree_ai': - return NANOBANANAFREE_AI_MODELS; - case 'zai_is': - return ZAI_IS_MODELS; - default: - return {}; - } -} - -/** - * 获取指定后端的模型列表 (OpenAI格式) - * @param {string} backendName - 后端名称 - * @returns {Object} OpenAI 格式的模型列表 - */ -export function getModelsForBackend(backendName) { - const modelsConf = getModelsConfigForBackend(backendName); - const modelIds = Object.keys(modelsConf); - - return { - object: 'list', - data: modelIds.map(id => ({ - id, - object: 'model', - created: Math.floor(Date.now() / 1000), - owned_by: backendName, - // 向前端暴露图片策略 - image_policy: modelsConf[id].imagePolicy || IMAGE_POLICY.OPTIONAL - })) - }; -} - -/** - * 解析模型 ID - * @param {string} backendName - 后端名称 - * @param {string} modelKey - 请求的模型键 - * @returns {string|null} 返回内部使用的 codeName,若模型无效则返回 null - */ -export function resolveModelId(backendName, modelKey) { - const modelsConf = getModelsConfigForBackend(backendName); - const model = modelsConf[modelKey]; - - if (!model) return null; // 未配置的模型 -> 无效 - - // 无 codeName 时,退回到模型 ID 本身 - return model.codeName || modelKey; -} - -/** - * 获取模型的图片策略 - * @param {string} backendName - 后端名称 - * @param {string} modelKey - 模型键 - * @returns {string} 图片策略 ('optional' | 'required' | 'forbidden') - */ -export function getImagePolicy(backendName, modelKey) { - const modelsConf = getModelsConfigForBackend(backendName); - const model = modelsConf[modelKey]; - - if (!model || !model.imagePolicy) { - return IMAGE_POLICY.OPTIONAL; - } - - return model.imagePolicy; -} diff --git a/src/backend/pool.js b/src/backend/pool.js new file mode 100644 index 0000000..2777a98 --- /dev/null +++ b/src/backend/pool.js @@ -0,0 +1,691 @@ +/** + * @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(); + + // 初始化 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; + + // 初始化 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/registry.js b/src/backend/registry.js new file mode 100644 index 0000000..885583f --- /dev/null +++ b/src/backend/registry.js @@ -0,0 +1,274 @@ +/** + * @fileoverview 适配器注册表 + * @description 自动扫描 adapter/ 目录,加载所有适配器的 manifest,提供统一查询接口。 + * + * 设计目标: + * - 新增适配器只需在 adapter/ 目录添加文件,无需修改框架代码 + * - 提供模型查询、策略查询、导航处理器聚合等统一接口 + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { logger } from '../utils/logger.js'; + +// 获取当前目录 +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const ADAPTER_DIR = path.join(__dirname, 'adapter'); + +/** + * 图片输入策略枚举 + */ +export const IMAGE_POLICY = { + OPTIONAL: 'optional', + REQUIRED: 'required', + FORBIDDEN: 'forbidden' +}; + +/** + * 适配器注册表类 + */ +class AdapterRegistry { + constructor() { + /** @type {Map} */ + this.adapters = new Map(); + this.loaded = false; + } + + /** + * 扫描并加载所有适配器 + */ + async loadAll() { + if (this.loaded) return; + + //logger.info('注册表', `正在扫描适配器目录: ${ADAPTER_DIR}`); + logger.info('注册表', `正在扫描适配器目录...`); + + const files = fs.readdirSync(ADAPTER_DIR).filter(f => f.endsWith('.js')); + + for (const file of files) { + const filePath = path.join(ADAPTER_DIR, file); + try { + const module = await import(`file://${filePath}`); + + if (!module.manifest) { + logger.warn('注册表', `跳过 ${file}: 未导出 manifest`); + continue; + } + + const manifest = module.manifest; + + // 校验必需字段 + if (!this.validateManifest(manifest, file)) { + continue; + } + + this.adapters.set(manifest.id, manifest); + logger.debug('注册表', `已加载适配器: ${manifest.id} (${manifest.displayName || file})`); + + } catch (err) { + logger.error('注册表', `加载 ${file} 失败: ${err.message}`); + } + } + + this.loaded = true; + logger.info('注册表', `适配器加载完成,共 ${this.adapters.size} 个可用`); + } + + /** + * 校验 manifest 必需字段 + * @param {object} manifest + * @param {string} fileName + * @returns {boolean} + */ + validateManifest(manifest, fileName) { + const errors = []; + + if (!manifest.id || typeof manifest.id !== 'string') { + errors.push('缺少 id 或类型不正确'); + } + + if (!manifest.generateImage || typeof manifest.generateImage !== 'function') { + errors.push('缺少 generateImage 函数'); + } + + if (!manifest.models || !Array.isArray(manifest.models)) { + errors.push('缺少 models 数组'); + } else { + for (let i = 0; i < manifest.models.length; i++) { + const m = manifest.models[i]; + if (!m.id) { + errors.push(`models[${i}] 缺少 id`); + } + if (!m.imagePolicy || !Object.values(IMAGE_POLICY).includes(m.imagePolicy)) { + errors.push(`models[${i}] imagePolicy 无效`); + } + } + } + + if (errors.length > 0) { + logger.error('注册表', `${fileName} manifest 校验失败: ${errors.join('; ')}`); + return false; + } + + return true; + } + + /** + * 获取适配器 + * @param {string} id - 适配器 ID + * @returns {object|null} + */ + getAdapter(id) { + return this.adapters.get(id) || null; + } + + /** + * 获取所有已注册的适配器 ID + * @returns {string[]} + */ + getAdapterIds() { + return Array.from(this.adapters.keys()); + } + + /** + * 检查适配器是否存在 + * @param {string} id + * @returns {boolean} + */ + hasAdapter(id) { + return this.adapters.has(id); + } + + /** + * 获取适配器的目标 URL + * @param {string} id - 适配器 ID + * @param {object} config - 全局配置 + * @param {object} workerConfig - Worker 配置 + * @returns {string} + */ + getTargetUrl(id, config, workerConfig) { + const adapter = this.getAdapter(id); + if (!adapter) return 'about:blank'; + + if (typeof adapter.getTargetUrl === 'function') { + return adapter.getTargetUrl(config, workerConfig) || 'about:blank'; + } + + return adapter.targetUrl || 'about:blank'; + } + + /** + * 获取适配器的导航处理器 + * @param {string} id - 适配器 ID + * @returns {Function[]} + */ + getNavigationHandlers(id) { + const adapter = this.getAdapter(id); + if (!adapter) return []; + return adapter.navigationHandlers || []; + } + + /** + * 获取适配器的输入框就绪校验函数 + * @param {string} id - 适配器 ID + * @returns {Function|null} + */ + getWaitInput(id) { + const adapter = this.getAdapter(id); + if (!adapter) return null; + return adapter.waitInput || null; + } + + /** + * 获取指定适配器的模型列表 (OpenAI 格式) + * @param {string} id - 适配器 ID + * @returns {object} + */ + getModelsForAdapter(id) { + const adapter = this.getAdapter(id); + if (!adapter || !adapter.models) { + return { object: 'list', data: [] }; + } + + const data = adapter.models.map(m => ({ + id: m.id, + object: 'model', + created: Math.floor(Date.now() / 1000), + owned_by: id, + image_policy: m.imagePolicy + })); + + return { object: 'list', data }; + } + + /** + * 解析模型 ID + * @param {string} adapterId - 适配器 ID + * @param {string} modelKey - 模型 key + * @returns {string|null} 内部 ID,或 null + */ + resolveModelId(adapterId, modelKey) { + const adapter = this.getAdapter(adapterId); + if (!adapter) return null; + + // 如果适配器提供了自定义解析函数 + if (typeof adapter.resolveModelId === 'function') { + return adapter.resolveModelId(modelKey); + } + + // 默认行为:检查 modelKey 是否在 models 中 + const model = adapter.models.find(m => m.id === modelKey); + if (model) { + return model.codeName || model.id; + } + + return null; + } + + /** + * 获取模型的图片策略 + * @param {string} adapterId - 适配器 ID + * @param {string} modelKey - 模型 key + * @returns {string} + */ + getImagePolicy(adapterId, modelKey) { + const adapter = this.getAdapter(adapterId); + if (!adapter || !adapter.models) { + return IMAGE_POLICY.OPTIONAL; + } + + const model = adapter.models.find(m => m.id === modelKey); + return model?.imagePolicy || IMAGE_POLICY.OPTIONAL; + } + + /** + * 聚合所有适配器的模型列表 + * @returns {object} + */ + getAllModels() { + const allModels = []; + + for (const [id, adapter] of this.adapters) { + if (adapter.models) { + for (const m of adapter.models) { + allModels.push({ + id: m.id, + object: 'model', + created: Math.floor(Date.now() / 1000), + owned_by: id, + image_policy: m.imagePolicy + }); + } + } + } + + return { object: 'list', data: allModels }; + } +} + +// 导出单例 +const registry = new AdapterRegistry(); + +export { AdapterRegistry, registry }; diff --git a/src/backend/utils.js b/src/backend/utils.js index 8104b2c..9d5136a 100644 --- a/src/backend/utils.js +++ b/src/backend/utils.js @@ -199,19 +199,26 @@ export function normalizeHttpError(response, content = null) { * 根据 camoufoxFingerprints.json 动态生成请求头,保持与浏览器指纹一致 * * @param {string} url - 图片 URL - * @param {object} config - 配置对象(需包含 proxy 配置) + * @param {object} options - 下载选项 + * @param {object} [options.proxyConfig] - Worker 级代理配置 + * @param {string} [options.userDataDir] - 用户数据目录(用于读取对应的指纹文件) * @returns {Promise<{ image?: string, error?: string }>} 下载结果 */ -export async function downloadImage(url, config) { +export async function downloadImage(url, options = {}) { // 动态导入依赖 const { gotScraping } = await import('got-scraping'); const fs = await import('fs'); const path = await import('path'); - const { getProxyConfig, getHttpProxy } = await import('../utils/proxy.js'); + const { getHttpProxy } = await import('../utils/proxy.js'); + + const { proxyConfig = null, userDataDir } = options; try { - // 读取指纹文件获取浏览器信息 - const fingerprintPath = path.join(process.cwd(), 'data', 'camoufoxFingerprints.json'); + // 读取指纹文件获取浏览器信息(优先使用 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'; @@ -242,8 +249,7 @@ export async function downloadImage(url, config) { } } - // 获取代理配置 - const proxyConfig = getProxyConfig(config); + // 获取代理配置(直接使用传入的 proxyConfig) const proxyUrl = await getHttpProxy(proxyConfig); const options = { diff --git a/src/browser/launcher.js b/src/browser/launcher.js index 913af5d..e96f5e2 100644 --- a/src/browser/launcher.js +++ b/src/browser/launcher.js @@ -1,6 +1,7 @@ /** * @fileoverview 浏览器启动与生命周期管理 - * @description 负责启动 Camoufox(Playwright 内核)、注入指纹与代理、创建/复用页面,并在进程退出时做资源清理。 + * @description 负责启动 Camoufox(Playwright 内核)、注入指纹与代理,并在进程退出时做资源清理。 + * 导航和预热行为由工作池负责,本模块只负责启动浏览器。 * * 约定: * - 登录模式会尽量保留 Profile(用户数据目录) @@ -132,7 +133,6 @@ function getPersistentFingerprint(filePath) { // 简单校验:确保读取的是一个对象 if (savedData && typeof savedData === 'object') { - logger.info('浏览器', '已加载本地持久化指纹'); return savedData; } } catch (e) { @@ -171,57 +171,53 @@ function getPersistentFingerprint(filePath) { } /** - * 初始化浏览器 (统一启动逻辑) - * @param {object} config - 配置对象 - * @param {object} [config.browser] - Browser 配置 - * @param {boolean} [config.browser.headless] - 是否开启 Headless 模式 - * @param {string} [config.browser.path] - Camoufox 可执行文件路径 - * @param {object} [config.browser.proxy] - 代理配置 + * 启动浏览器实例 (仅负责启动,不负责导航和预热) + * + * 导航到目标页面、注册导航处理器、预热行为由工作池 (pool.js) 负责。 + * + * @param {object} config - 全局配置对象 * @param {object} options - 启动选项 * @param {string} options.userDataDir - 用户数据目录路径 - * @param {string} options.targetUrl - 目标 URL - * @param {string} options.productName - 产品名称(用于日志) - * @param {boolean} [options.reuseExistingTab=false] - 是否复用已有特定域名的 tab - * @param {Function} [options.waitInputValidator] - 自定义输入框等待验证函数 - * @param {Function} [options.navigationHandler] - 全局导航处理器,用于自动处理登录等跳转 - * @returns {Promise<{browser: object, page: object, client: object}>} + * @param {string} [options.userDataMark] - 用户数据目录标识 (用于日志显示) + * @param {object} [options.proxyConfig] - Worker 级代理配置 + * @returns {Promise<{context: object, page: object}>} 浏览器上下文和初始页面 */ -export async function initBrowserBase(config, options) { +export async function initBrowserBase(config, options = {}) { const { userDataDir, - targetUrl, - productName, - waitInputValidator = null, - navigationHandler = null + instanceName = null, + proxyConfig = null } = options; - // 检测登录模式和 Xvfb 模式 - const isLoginMode = process.argv.includes('-login'); - const isXvfbMode = process.env.XVFB_RUNNING === 'true'; - const ENABLE_AUTOMATION_MODE = !isLoginMode; + // 日志标识 (优先使用实例名称) + const markLabel = instanceName || '默认'; - logger.info('浏览器', `开始初始化浏览器 (${productName})`); - logger.info('浏览器', `自动化模式: ${ENABLE_AUTOMATION_MODE ? '开启' : '关闭'}`); - if (isLoginMode) { - logger.warn('浏览器', '当前为登录模式,请手动完成登录后关闭登录模式以继续自动化程序!'); - } - if (isXvfbMode) { - logger.info('浏览器', '检测到 Xvfb 环境,强制禁用无头模式'); + // 检测登录模式和 Xvfb 模式 + const isLoginMode = process.argv.some(arg => arg.startsWith('-login')); + const isXvfbMode = process.env.XVFB_RUNNING === 'true'; + const headlessMode = config?.browser?.headless && !isLoginMode && !isXvfbMode; + + // 如果配置了无头模式但被强制禁用,输出原因 + if (config?.browser?.headless && !headlessMode) { + const reasons = []; + if (isLoginMode) reasons.push('登录模式'); + if (isXvfbMode) reasons.push('Xvfb 模式'); + logger.info('浏览器', `[${markLabel}] 无头模式已被禁用 (${reasons.join(' + ')})`); } + logger.info('浏览器', `[${markLabel}] 启动浏览器实例...`); + const browserConfig = config?.browser || {}; - // 获取指纹对象 - const fingerprintPath = path.join(process.cwd(), 'data', 'camoufoxFingerprints.json'); + // 获取指纹对象(指纹文件放在对应的 userDataDir 内) + const fingerprintPath = path.join(userDataDir, 'fingerprint.json'); const myFingerprint = getPersistentFingerprint(fingerprintPath); // 构造 Camoufox 启动选项 - logger.info('浏览器', '正在启动 Camoufox 浏览器...'); const currentOS = getCurrentOS(); const camoufoxLaunchOptions = { - // 基础选项 (snake_case) executable_path: browserConfig.path || undefined, - headless: browserConfig.headless && !isLoginMode && !isXvfbMode, + headless: headlessMode, user_data_dir: userDataDir, window: [1366, 768], ff_version: 135, @@ -233,146 +229,50 @@ export async function initBrowserBase(config, options) { geoip: true }; - // Headless 模式配置 - if (browserConfig.headless && !isLoginMode && !isXvfbMode) { - logger.info('浏览器', 'Headless 模式: 启用'); - } else { - const reasons = []; - if (isLoginMode) reasons.push('登录模式'); - if (isXvfbMode) reasons.push('Xvfb 模式'); - if (!browserConfig.headless) reasons.push('配置禁用'); - - logger.info('浏览器', 'Headless 模式: 禁用' + (reasons.length > 0 ? ` (${reasons.join(', ')})` : '')); - } - - - // 代理配置适配 - const proxyObject = await getBrowserProxy(browserConfig.proxy); - if (proxyObject) { - camoufoxLaunchOptions.proxy = proxyObject; + // 代理配置 + const proxyObj = await getBrowserProxy(proxyConfig); + if (proxyObj) { + camoufoxLaunchOptions.proxy = proxyObj; } // 启动 Camoufox const context = await Camoufox(camoufoxLaunchOptions); - globalContext = context; // 存储全局 Context + globalContext = context; - logger.info('浏览器', 'Camoufox 浏览器已启动'); + // 构建状态描述 + const statusParts = []; + statusParts.push(`无头模式: ${headlessMode ? '是' : '否'}`); + if (proxyObj) statusParts.push('代理: 已配置'); + logger.info('浏览器', `[${markLabel}] 浏览器已启动 (${statusParts.join(', ')})`); // 注册清理处理器 registerCleanupHandlers(); // 注册断开连接事件 context.on('close', async () => { - logger.warn('浏览器', 'Camoufox 浏览器已断开连接'); + logger.warn('浏览器', `[${markLabel}] 浏览器已断开连接`); await cleanup(); process.exit(0); }); // 获取或创建 Page let page; - const existingPages = context.pages(); // 获取启动时自动打开的页面 - if (!page) { - if (existingPages.length > 0) { - page = existingPages[0]; - logger.debug('浏览器', '复用浏览器启动时的默认标签页'); - } else { - page = await context.newPage(); - logger.debug('浏览器', '浏览器没有标签,已创建新标签页'); - } - } - - // 强制刷新一下视口大小,防止复用默认窗口时尺寸不对 - if (camoufoxLaunchOptions.viewport) { - await page.setViewportSize(camoufoxLaunchOptions.viewport); - } - - // 注册全局导航处理器(用于自动处理登录等跳转) - if (navigationHandler) { - page.on('framenavigated', async () => { - try { - await navigationHandler(page); - } catch (e) { - logger.warn('浏览器', `全局导航处理器出错: ${e.message}`); - } - }); - logger.debug('浏览器', '已注册全局导航处理器'); - } - - // 登录模式挂起逻辑 - if (isLoginMode) { - // 尝试导航到目标页面方便用户登录 - try { - logger.info('浏览器', `正在连接 ${productName}...`); - await page.goto(targetUrl, { waitUntil: 'domcontentloaded' }); - } catch (e) { - logger.warn('浏览器', `打开页面失败: ${e.message}`); - } - - logger.info('浏览器', '请在弹出的浏览器窗口中手动完成登录操作'); - logger.info('浏览器', '完成后可直接关闭浏览器窗口或在终端结束程序'); - - await new Promise((resolve) => { - context.on('close', () => { - logger.info('浏览器', '检测到浏览器窗口关闭,程序即将退出'); - resolve(); - }); - }); - - await cleanup(); - process.exit(0); - } - - // 初始化 ghost-cursor - page.cursor = createCursor(page); - - - // --- 行为预热建立人机检测信任 --- - const urlDomain = new URL(targetUrl).hostname; - if (!page.url().includes(urlDomain)) { - logger.info('浏览器', `正在连接 ${productName}...`); - await page.goto(targetUrl, { waitUntil: 'domcontentloaded' }); + const existingPages = context.pages(); + if (existingPages.length > 0) { + page = existingPages[0]; } else { - logger.info('浏览器', `页面已在 ${productName},跳过跳转`); + page = await context.newPage(); } - logger.info('浏览器', '正在随机浏览页面以建立信任...'); + // 强制刷新视口大小 + await page.setViewportSize({ width: 1366, height: 768 }); - // 计算屏幕中心点 (动态获取视口大小) - const vp = await getRealViewport(page); - - // 计算动态中心点 - const centerX = vp.width / 2; - const centerY = vp.height / 2; - - // 第一次移动:从左上角移动到中心附近 - if (page.cursor) { - // 使用 clamp 确保随机偏移后仍在屏幕内 - const targetX = clamp(centerX + random(-200, 200), 10, vp.safeWidth); - const targetY = clamp(centerY + random(-200, 200), 10, vp.safeHeight); - - // 移动鼠标 (增加拟人化) - await page.cursor.moveTo({ x: targetX, y: targetY }); - } - await sleep(500, 1000); - - // 模拟滚动行为 - try { - await page.mouse.wheel({ deltaY: random(100, 300) }); - await sleep(800, 1500); - await page.mouse.wheel({ deltaY: -random(50, 100) }); - } catch (e) { } - - // 如果提供了自定义输入框验证函数,使用它 - if (waitInputValidator && typeof waitInputValidator === 'function') { - await waitInputValidator(page); - } - - logger.info('浏览器', '浏览器初始化完成,系统就绪'); - logger.warn('浏览器', '当任务运行时请勿随意调节窗口大小,以免鼠标轨迹错位!'); - - // 返回对象 (兼容性处理) + // 返回 context 和 page(导航、预热、cursor 初始化由工作池负责) return { - browser: context, + context, page }; } + +// 导出工具函数供 pool.js 使用 +export { createCursor, getRealViewport, clamp, random, sleep }; diff --git a/src/server/http/routes.js b/src/server/http/routes.js index 47cbb4a..0e2bdd5 100644 --- a/src/server/http/routes.js +++ b/src/server/http/routes.js @@ -58,35 +58,38 @@ export function createRouter(context) { * 处理 GET /v1/cookies * @param {import('http').ServerResponse} res - HTTP 响应 * @param {string} requestId - 请求 ID + * @param {string} [workerName] - 可选,指定 Worker 名称 * @param {string} [domain] - 可选,指定获取某个域名的 Cookies */ - async function handleCookies(res, requestId, domain) { - const browserContext = queueManager.getBrowserContext(); + async function handleCookies(res, requestId, workerName, domain) { + const poolContext = queueManager.getPoolContext(); - if (!browserContext?.page) { + if (!poolContext?.poolManager) { sendApiError(res, { code: ERROR_CODES.BROWSER_NOT_INITIALIZED }); return; } try { - const context = browserContext.page.context(); - let cookies; - - if (domain) { - // 指定域名时,只获取该域名的 Cookies - cookies = await context.cookies(domain.startsWith('http') ? domain : `https://${domain}`); - } else { - // 默认获取所有 Cookies - cookies = await context.cookies(); - } - - sendJson(res, 200, { cookies }); + const result = await queueManager.getWorkerCookies(workerName, domain); + sendJson(res, 200, { + worker: result.worker, + cookies: result.cookies + }); } catch (err) { logger.error('服务器', '获取 Cookies 失败', { id: requestId, error: err.message }); - sendApiError(res, { - code: ERROR_CODES.INTERNAL_ERROR, - error: err.message - }); + + // 区分错误类型 + if (err.message.includes('Worker 不存在') || err.message.includes('Worker not found')) { + sendApiError(res, { + code: ERROR_CODES.BAD_REQUEST, + error: err.message + }); + } else { + sendApiError(res, { + code: ERROR_CODES.INTERNAL_ERROR, + error: err.message + }); + } } } @@ -195,8 +198,9 @@ export function createRouter(context) { if (req.method === 'GET' && pathname === '/v1/models') { handleModels(res); } else if (req.method === 'GET' && pathname === '/v1/cookies') { + const workerName = parsedUrl.searchParams.get('name'); const domain = parsedUrl.searchParams.get('domain'); - await handleCookies(res, requestId, domain); + await handleCookies(res, requestId, workerName, domain); } else if (req.method === 'POST' && pathname.startsWith('/v1/chat/completions')) { await handleChatCompletions(req, res, requestId); } else { diff --git a/src/server/parseChat.js b/src/server/parseChat.js index c004e00..d8c0594 100644 --- a/src/server/parseChat.js +++ b/src/server/parseChat.js @@ -6,7 +6,7 @@ import fs from 'fs'; import path from 'path'; import sharp from 'sharp'; -import { IMAGE_POLICY } from '../backend/models.js'; +import { IMAGE_POLICY } from '../backend/registry.js'; import { ERROR_CODES, getErrorMessage } from './errors.js'; /** @@ -128,11 +128,13 @@ export async function parseRequest(data, options) { prompt = prompt.trim(); // 解析模型参数 - let modelId = null; + let modelKey = null; if (data.model) { - modelId = resolveModelId(data.model); - if (modelId) { - logger.info('服务器', `触发模型: ${data.model} (${modelId})`, { id: requestId }); + // 只校验模型是否支持,不解析 + const resolved = resolveModelId(data.model); + if (resolved) { + modelKey = data.model; // 保留原始 modelKey,由 PoolManager 自行解析 + logger.info('服务器', `触发模型: ${data.model}`, { id: requestId }); } else { return parseError(ERROR_CODES.INVALID_MODEL, `模型无效/后端 ${backendName} 不支持: ${data.model}`); } @@ -157,7 +159,7 @@ export async function parseRequest(data, options) { data: { prompt, imagePaths, - modelId, + modelId: modelKey, // 返回原始 modelKey modelName: data.model || null, isStreaming } diff --git a/src/server/queue.js b/src/server/queue.js index 2d1e964..3ba2353 100644 --- a/src/server/queue.js +++ b/src/server/queue.js @@ -1,6 +1,6 @@ /** * @fileoverview 任务队列管理模块 - * @description 负责请求队列、并发控制和心跳机制 + * @description 负责请求队列、并发控制和心跳机制,适配 Pool 模式架构 */ import { logger } from '../utils/logger.js'; @@ -13,6 +13,7 @@ import { buildChatCompletion, buildChatCompletionChunk } from './http/respond.js'; +import { ERROR_CODES } from './errors.js'; /** * @typedef {object} TaskContext @@ -34,9 +35,8 @@ import { */ /** - * @typedef {object} BrowserContext - * @property {object} browser - Playwright 浏览器实例 - * @property {object} page - Playwright 页面实例 + * @typedef {object} PoolContext + * @property {import('../backend/pool.js').PoolManager} poolManager - Pool 管理器 * @property {object} config - 配置对象 */ @@ -44,14 +44,19 @@ import { * 创建任务队列管理器 * @param {QueueConfig} queueConfig - 队列配置 * @param {object} callbacks - 回调函数 - * @param {Function} callbacks.initBrowser - 初始化浏览器函数 + * @param {Function} callbacks.initBrowser - 初始化 Pool 函数 * @param {Function} callbacks.generateImage - 生成图片函数 * @param {object} callbacks.config - 配置对象 + * @param {Function} [callbacks.navigateToMonitor] - 监控导航函数 + * @param {Function} [callbacks.getCookies] - 获取 Cookies 函数 * @returns {object} 队列管理器 */ export function createQueueManager(queueConfig, callbacks) { - const { maxConcurrent, maxQueueSize, keepaliveMode } = queueConfig; - const { initBrowser, generateImage, config, navigateToMonitor } = callbacks; + const { maxConcurrent, queueBuffer, keepaliveMode } = queueConfig; + const { initBrowser, generateImage, config, navigateToMonitor, getCookies } = callbacks; + + // 计算有效队列大小:0 表示不限制,否则为 maxConcurrent + buffer + const effectiveQueueSize = queueBuffer === 0 ? Infinity : (maxConcurrent + queueBuffer); /** @type {TaskContext[]} */ const queue = []; @@ -59,8 +64,8 @@ export function createQueueManager(queueConfig, callbacks) { /** @type {number} */ let processingCount = 0; - /** @type {BrowserContext|null} */ - let browserContext = null; + /** @type {PoolContext|null} */ + let poolContext = null; /** * 清理任务临时文件 @@ -97,13 +102,13 @@ export function createQueueManager(queueConfig, callbacks) { } try { - // 确保浏览器已初始化 - if (!browserContext) { - browserContext = await initBrowser(config); + // 确保 Pool 已初始化 + if (!poolContext) { + poolContext = await initBrowser(config); } - // 调用核心生图逻辑 - const result = await generateImage(browserContext, prompt, imagePaths, modelId, { id }); + // 调用核心生图逻辑 (通过 Pool 分发) + const result = await generateImage(poolContext, prompt, imagePaths, modelId, { id }); // 清除心跳 if (heartbeatInterval) clearInterval(heartbeatInterval); @@ -198,36 +203,49 @@ export function createQueueManager(queueConfig, callbacks) { * @returns {boolean} */ function canAcceptNonStreaming() { - return processingCount + queue.length < maxQueueSize; + return processingCount + queue.length < effectiveQueueSize; } /** - * 初始化浏览器 - * @returns {Promise} + * 初始化 Pool + * @returns {Promise} */ - async function initializeBrowser() { - browserContext = await initBrowser(config); + async function initializePool() { + poolContext = await initBrowser(config); // 初始化完成后,触发首次监控跳转 if (navigateToMonitor) { navigateToMonitor().catch(() => { }); } - return browserContext; + return poolContext; } /** - * 获取浏览器上下文 - * @returns {BrowserContext|null} + * 获取 Pool 上下文 + * @returns {PoolContext|null} */ - function getBrowserContext() { - return browserContext; + function getPoolContext() { + return poolContext; + } + + /** + * 获取指定 Worker 的 Cookies (代理到后端) + * @param {string} [workerName] - Worker 名称 + * @param {string} [domain] - 域名 + * @returns {Promise<{worker: string, cookies: object[]}>} + */ + async function getWorkerCookies(workerName, domain) { + if (!getCookies) { + throw new Error('getCookies 回调未注册'); + } + return await getCookies(workerName, domain); } return { addTask, getStatus, canAcceptNonStreaming, - initializeBrowser, - getBrowserContext, - maxQueueSize + initializePool, + getPoolContext, + getWorkerCookies }; } diff --git a/src/utils/config.js b/src/utils/config.js index cf848dd..d1e2b85 100644 --- a/src/utils/config.js +++ b/src/utils/config.js @@ -3,7 +3,7 @@ * @description 负责读取/解析 `config.yaml`,并提供 API Key 生成能力(供脚本使用)。 * * 约定: - * - 该模块只负责“读取 + 校验 + 默认值补全”,不负责创建/写入配置文件。 + * - 该模块只负责"读取 + 校验 + 默认值补全",不负责创建/写入配置文件。 * - 初始化/拷贝配置请使用 `config.example.yaml` + `scripts/config-init.js`。 */ @@ -19,6 +19,129 @@ const EXAMPLE_CONFIG_PATH = path.join(process.cwd(), 'config.example.yaml'); // 模块级缓存:确保配置只从磁盘读取一次 let cachedConfig = null; +// 有效的适配器类型 +const VALID_ADAPTER_TYPES = ['lmarena', 'gemini', 'gemini_biz', 'nanobananafree_ai', 'zai_is', 'merge']; + +/** + * 解析用户数据目录路径 + * @param {string|undefined} userDataMark - 用户数据标记 + * @returns {string} 完整的用户数据目录路径 + */ +function resolveUserDataDir(userDataMark) { + const baseDir = path.join(process.cwd(), 'data'); + if (!userDataMark) { + return path.join(baseDir, 'camoufoxUserData'); + } + return path.join(baseDir, `camoufoxUserData_${userDataMark}`); +} + +/** + * 解析代理配置(Instance 级优先于全局) + * @param {object|undefined} globalProxy - 全局代理配置 + * @param {object|undefined} instanceProxy - Instance 级代理配置 + * @returns {object|null} 最终代理配置,null 表示直连 + */ +function resolveProxyConfig(globalProxy, instanceProxy) { + // Instance 级显式禁用代理 -> 直连 + if (instanceProxy && instanceProxy.enable === false) { + return null; + } + // Instance 级有配置且启用 -> 使用 Instance 配置 + if (instanceProxy && instanceProxy.enable === true) { + return instanceProxy; + } + // 回退到全局配置 + if (globalProxy && globalProxy.enable === true) { + return globalProxy; + } + return null; +} + +/** + * 校验 Instance 配置 + * @param {object} instance - Instance 配置 + * @param {number} index - Instance 索引 + */ +function validateInstance(instance, index) { + if (!instance.name) { + throw new Error(`instances[${index}] 缺少必需字段: name`); + } + if (!instance.workers || !Array.isArray(instance.workers) || instance.workers.length === 0) { + throw new Error(`instances[${index}] (${instance.name}) 缺少有效的 workers 数组`); + } +} + +/** + * 校验 Worker 配置 + * @param {object} worker - Worker 配置 + * @param {string} instanceName - 所属 Instance 名称 + * @param {number} index - Worker 索引 + */ +function validateWorker(worker, instanceName, index) { + if (!worker.name) { + throw new Error(`instances[${instanceName}].workers[${index}] 缺少必需字段: name`); + } + if (!worker.type) { + throw new Error(`instances[${instanceName}].workers[${index}] (${worker.name}) 缺少必需字段: type`); + } + if (!VALID_ADAPTER_TYPES.includes(worker.type)) { + throw new Error(`Worker "${worker.name}" 的 type "${worker.type}" 无效。有效值: ${VALID_ADAPTER_TYPES.join(', ')}`); + } + if (worker.type === 'merge') { + if (!worker.mergeTypes || !Array.isArray(worker.mergeTypes) || worker.mergeTypes.length === 0) { + throw new Error(`Worker "${worker.name}" 类型为 merge,但缺少有效的 mergeTypes 数组`); + } + } +} + +/** + * 展开 instances 配置为扁平化的 workers 数组 + * @param {object[]} instances - instances 配置数组 + * @param {object} globalProxy - 全局代理配置 + * @returns {object[]} 扁平化的 worker 配置数组 + */ +function flattenInstancesToWorkers(instances, globalProxy) { + const workers = []; + const workerNames = new Set(); + + for (let i = 0; i < instances.length; i++) { + const instance = instances[i]; + validateInstance(instance, i); + + // 解析 Instance 级配置 + const userDataDir = resolveUserDataDir(instance.userDataMark); + const resolvedProxy = resolveProxyConfig(globalProxy, instance.proxy); + + for (let j = 0; j < instance.workers.length; j++) { + const worker = instance.workers[j]; + validateWorker(worker, instance.name, j); + + // 检查 Worker 名称全局唯一性 + if (workerNames.has(worker.name)) { + throw new Error(`Worker 名称 "${worker.name}" 重复。Worker 名称必须全局唯一。`); + } + workerNames.add(worker.name); + + // 构建扁平化的 Worker 配置 + workers.push({ + // Worker 自身属性 + name: worker.name, + type: worker.type, + mergeTypes: worker.mergeTypes || [], + mergeMonitor: worker.mergeMonitor || null, + + // 从 Instance 继承的属性 + instanceName: instance.name, + userDataMark: instance.userDataMark || null, + userDataDir, + resolvedProxy + }); + } + } + + return workers; +} + /** * 加载并校验配置(只读) * @returns {object} 配置对象 @@ -35,7 +158,7 @@ export function loadConfig() { } const configFile = fs.readFileSync(CONFIG_PATH, 'utf8'); - const config = yaml.parse(configFile); + let config = yaml.parse(configFile); if (!config || typeof config !== 'object') { throw new Error(`配置文件解析失败: ${CONFIG_PATH}`); } @@ -56,61 +179,68 @@ export function loadConfig() { throw new Error('配置文件缺少必需字段: server.auth'); } - // 设置队列配置默认值 - if (!config.queue) { - config.queue = { - maxConcurrent: 1, - maxQueueSize: 2, - imageLimit: 5 - }; - } else { - // 强制 maxConcurrent 为 1 - config.queue.maxConcurrent = 1; - if (config.queue.maxQueueSize === undefined) config.queue.maxQueueSize = 2; - if (config.queue.imageLimit === undefined) config.queue.imageLimit = 5; - } - - // 设置 keepalive 配置默认值(兼容旧字段:keepalive.enable) + // 设置 keepalive 配置默认值 if (!config.server.keepalive) { config.server.keepalive = { mode: 'comment' }; } else { if (config.server.keepalive.mode === undefined) config.server.keepalive.mode = 'comment'; - // 验证 mode 值 if (!['comment', 'content'].includes(config.server.keepalive.mode)) { logger.warn('配置器', `无效的 keepalive.mode: ${config.server.keepalive.mode},使用默认值 comment`); config.server.keepalive.mode = 'comment'; } } - // 设置 backend 配置默认值 - if (!config.backend) { - config.backend = { - type: 'lmarena', - geminiBiz: { entryUrl: '' } + // 设置 Pool 配置默认值 + if (!config.backend) config.backend = {}; + if (!config.backend.pool) config.backend.pool = {}; + + if (!config.backend.pool.strategy) { + config.backend.pool.strategy = 'least_busy'; + } + if (!['least_busy', 'round_robin', 'random'].includes(config.backend.pool.strategy)) { + logger.warn('配置器', `无效的 pool.strategy: ${config.backend.pool.strategy},使用默认值 least_busy`); + config.backend.pool.strategy = 'least_busy'; + } + + // 校验 instances 配置 + if (!config.backend.pool.instances || !Array.isArray(config.backend.pool.instances)) { + throw new Error('配置文件缺少必需字段: backend.pool.instances'); + } + if (config.backend.pool.instances.length === 0) { + throw new Error('backend.pool.instances 不能为空数组'); + } + + // 展开 instances 为扁平化的 workers 数组 + config.backend.pool.workers = flattenInstancesToWorkers( + config.backend.pool.instances, + config.browser?.proxy + ); + + // 设置队列配置默认值 + if (!config.queue) { + config.queue = { + queueBuffer: 2, + imageLimit: 5 }; + } else { + if (config.queue.queueBuffer === undefined) config.queue.queueBuffer = 2; + if (config.queue.imageLimit === undefined) config.queue.imageLimit = 5; } - // 新增:Merge 配置初始化 - if (!config.backend.merge) { - config.backend.merge = { - enable: false, - type: ['zai_is', 'lmarena'], - monitor: null - }; + // maxConcurrent 动态计算:等于 Workers 数量 + config.queue.maxConcurrent = config.backend.pool.workers.length; + + // 初始化 adapter 配置容器 + if (!config.backend.adapter) { + config.backend.adapter = {}; } - // 校验 GeminiBiz 配置 - if (config.backend.type === 'gemini_biz') { - if (!config.backend.geminiBiz || !config.backend.geminiBiz.entryUrl) { - throw new Error('backend.type = gemini_biz requires backend.geminiBiz.entryUrl'); - } - } - - logger.debug('配置器', '已加载 config.yaml'); - logger.debug('配置器', '后端类型:', config.backend.type); - logger.debug('配置器', '流式心跳模式:', config.server.keepalive.mode); - if (config.backend.type === 'gemini_biz') { - logger.debug('配置器', `GeminiBiz 入口: ${config.backend.geminiBiz.entryUrl}`); + // 校验 gemini_biz 配置(如果有 Worker 使用) + const hasGeminiBizWorker = config.backend.pool.workers.some( + w => w.type === 'gemini_biz' || (w.type === 'merge' && w.mergeTypes?.includes('gemini_biz')) + ); + if (hasGeminiBizWorker && !config.backend.adapter.gemini_biz?.entryUrl) { + throw new Error('存在 gemini_biz 类型的 Worker,但 backend.adapter.gemini_biz.entryUrl 未配置'); } // 设置日志级别 @@ -118,12 +248,19 @@ export function loadConfig() { logger.setLevel(config.logLevel); } + // 日志输出 + logger.debug('配置器', '已加载 config.yaml'); + logger.debug('配置器', `Instances: ${config.backend.pool.instances.length}, Workers: ${config.backend.pool.workers.length}`); + logger.debug('配置器', `调度策略: ${config.backend.pool.strategy}`); + logger.debug('配置器', `流式心跳模式: ${config.server.keepalive.mode}`); + // 缓存配置 cachedConfig = config; return config; } +// 导出辅助函数供其他模块使用 +export { resolveUserDataDir, resolveProxyConfig }; + // 默认导出为函数 export default loadConfig; - -