mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
feat: 支持多窗口并行且支持账号数据隔离
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# LMArenaImagenAutomator
|
||||

|
||||

|
||||
|
||||
## 📝 项目简介
|
||||
|
||||
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
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<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 };
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
+24
-20
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user