feat: 支持多窗口并行且支持账号数据隔离

This commit is contained in:
foxhui
2025-12-14 19:07:20 +08:00
Unverified
parent d3129d0641
commit 11c768d73f
19 changed files with 1822 additions and 931 deletions
+12
View File
@@ -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
+54 -9
View File
@@ -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 \
</details>
#### 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)
```
<details>
@@ -247,6 +291,7 @@ curl -X GET http://127.0.0.1:3000/v1/cookies \
**响应格式**
```json
{
"instance": "browser_default",
"cookies": [
{
"name": "_GRECAPTCHA",
+88 -27
View File
@@ -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
+25 -10
View File
@@ -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<void>}
*/
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}`);
});
}
+44 -23
View File
@@ -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 };
+38 -40
View File
@@ -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
};
+81 -25
View File
@@ -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 };
+46 -24
View File
@@ -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 };
+41 -23
View File
@@ -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 };
+104 -232
View File
@@ -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<void>}
*/
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 };
}
-261
View File
@@ -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;
}
+691
View File
@@ -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 实际是 BrowserContextCamoufox 使用 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<object[]>}
*/
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 };
+274
View File
@@ -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<string, object>} */
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 };
+13 -7
View File
@@ -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 = {
+53 -153
View File
@@ -1,6 +1,7 @@
/**
* @fileoverview 浏览器启动与生命周期管理
* @description 负责启动 CamoufoxPlaywright 内核)、注入指纹与代理、创建/复用页面,并在进程退出时做资源清理。
* @description 负责启动 CamoufoxPlaywright 内核)、注入指纹与代理,并在进程退出时做资源清理。
* 导航和预热行为由工作池负责,本模块只负责启动浏览器。
*
* 约定:
* - 登录模式会尽量保留 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 };
+24 -20
View File
@@ -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 {
+8 -6
View File
@@ -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
}
+45 -27
View File
@@ -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<BrowserContext>}
* 初始化 Pool
* @returns {Promise<PoolContext>}
*/
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
};
}
+181 -44
View File
@@ -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;