mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
feat: 去除适配器冗余代码和历史遗留
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
|
||||
## 📝 项目简介
|
||||
|
||||
LMArenaImagenAutomator 是一个基于 Playwright + Camoufox 的自动化图像生成工具,支持多窗口并发与多账号管理(实现浏览器实例数据完全隔离),通过模拟人类操作与 LMArena、Gemini 等网站交互,提供兼容 OpenAI 格式的图像生成接口服务。
|
||||
LMArenaImagenAutomator 是一个基于 Playwright + Camoufox 的自动化图像生成工具,支持多窗口并发与多账号管理(浏览器实例数据隔离),通过模拟人类操作与 LMArena、Gemini 等网站交互,提供兼容 OpenAI 格式的图像生成接口服务。
|
||||
|
||||
当前支持的网站:
|
||||
- [LMArena](https://lmarena.ai/)
|
||||
@@ -133,9 +133,9 @@ backend:
|
||||
|
||||
> [!WARNING]
|
||||
> **并发限制与流式保活建议**
|
||||
> 本项目通过模拟真实浏览器操作实现,**必须串行处理任务**,并发请求将进入队列。为防止排队过久导致客户端超时,当积压任务达到 3 个时将拒绝新请求。
|
||||
> 本项目通过模拟真实浏览器操作实现,处理过程根据实际情况时间可能有所变化,当积压的任务超过设置的数量时会直接拒绝非流式模式的请求。
|
||||
>
|
||||
> **💡 强烈建议开启流式模式**:服务器将发送保活心跳包,有效避免因排队等待造成的连接超时。
|
||||
> **💡 强烈建议开启流式模式**:服务器将发送保活心跳包,可无限排队避免超时。
|
||||
|
||||
**请求端点**
|
||||
```
|
||||
@@ -218,7 +218,7 @@ data: [DONE]
|
||||
|
||||
| 参数 | 说明 |
|
||||
| :--- | :--- |
|
||||
| **model** | **必填**。指定使用的模型名称(如 `gemini-3-pro-image-preview`)。<br>可通过 `/v1/models` 接口或查看 `lib/backend/models.js` 获取完整列表。 |
|
||||
| **model** | **必填**。指定使用的模型名称(如 `gemini-3-pro-image-preview`)。<br>可通过 `/v1/models` 接口获取支持的模型列表。 |
|
||||
| **stream** | **推荐开启**。流式响应包含心跳保活机制,防止生成耗时过长导致连接超时。 |
|
||||
|
||||
> **💡 关于流式保活(Heartbeat)**
|
||||
@@ -323,7 +323,7 @@ curl -X GET http://127.0.0.1:3000/v1/cookies \
|
||||
|
||||
</details>
|
||||
|
||||
#### 4. 多模态请求 (图生图/图生文)
|
||||
#### 4. 多模态请求 (图生图/文生图)
|
||||
|
||||
**功能说明**:支持在消息中附带图片进行对话或生成。
|
||||
|
||||
@@ -454,7 +454,7 @@ curl -X GET http://127.0.0.1:3000/v1/cookies \
|
||||
| **CPU** | 1 核 | 2 核及以上 |
|
||||
| **内存** | 1 GB | 2 GB 及以上 |
|
||||
|
||||
**实测环境表现**:
|
||||
**实测环境表现** (均为单浏览器实例):
|
||||
- **Oracle 免费机** (1C1G, Debian 12):资源紧张,比较卡顿,仅供尝鲜或轻度使用。
|
||||
- **阿里云轻量云** (2C2G, Debian 11):运行流畅稳定,为本项目开发测试基准环境。
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/**
|
||||
* @fileoverview Gemini(消费者版)适配器
|
||||
* @description 通过自动化方式驱动 Gemini 网页端生成图片,并将结果转换为统一的后端返回结构。
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -11,7 +10,8 @@ import {
|
||||
import {
|
||||
fillPrompt,
|
||||
normalizePageError,
|
||||
moveMouseAway
|
||||
moveMouseAway,
|
||||
waitForInput
|
||||
} from '../utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
@@ -38,7 +38,7 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// 1. 等待输入框加载
|
||||
await inputLocator.waitFor({ timeout: 30000 });
|
||||
await waitForInput(page, inputLocator, { click: false });
|
||||
await sleep(1500, 2500);
|
||||
|
||||
// 2. 上传图片 (使用 filechooser 事件,因为 Firefox 不会创建 DOM input 元素)
|
||||
@@ -169,9 +169,8 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
* @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);
|
||||
const inputLocator = page.getByRole('textbox');
|
||||
await waitForInput(page, inputLocator, { click: true });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,6 +204,4 @@ export const manifest = {
|
||||
|
||||
// 核心生图方法
|
||||
generateImage
|
||||
};
|
||||
|
||||
export { generateImage };
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
/**
|
||||
* @fileoverview Gemini Business 适配器
|
||||
* @description 通过自动化方式驱动 Gemini Business 网页端生成图片,并将结果转换为统一的后端返回结构。
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -14,7 +13,12 @@ import {
|
||||
normalizePageError,
|
||||
normalizeHttpError,
|
||||
waitApiResponse,
|
||||
moveMouseAway
|
||||
moveMouseAway,
|
||||
waitForPageAuth,
|
||||
lockPageAuth,
|
||||
unlockPageAuth,
|
||||
isPageAuthLocked,
|
||||
waitForInput
|
||||
} from '../utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
@@ -27,23 +31,14 @@ const INPUT_SELECTOR = 'ucs-prosemirror-editor .ProseMirror';
|
||||
* @param {string} targetUrl - 目标 URL,用于判断跳转完成
|
||||
* @returns {Promise<boolean>} 是否处理了跳转
|
||||
*/
|
||||
let isHandlingAuth = false;
|
||||
|
||||
/** 等待登录处理完成 */
|
||||
async function waitForAuthComplete() {
|
||||
while (isHandlingAuth) {
|
||||
await sleep(500, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAccountChooser(page) {
|
||||
// 防止重复处理
|
||||
if (isHandlingAuth) return false;
|
||||
if (isPageAuthLocked(page)) return false;
|
||||
|
||||
try {
|
||||
const currentUrl = page.url();
|
||||
if (currentUrl.includes('auth.business.gemini.google/account-chooser')) {
|
||||
isHandlingAuth = true;
|
||||
lockPageAuth(page);
|
||||
logger.info('适配器', '[登录器] 检测到账户选择页面,尝试自动确认...');
|
||||
|
||||
// 尝试查找提交按钮 (通常是标准的 button[type="submit"])
|
||||
@@ -75,76 +70,23 @@ async function handleAccountChooser(page) {
|
||||
|
||||
// 额外缓冲时间,确保页面完全加载
|
||||
await sleep(2000, 3000);
|
||||
isHandlingAuth = false;
|
||||
unlockPageAuth(page);
|
||||
return true;
|
||||
} else {
|
||||
// 按钮还没加载出来,保持锁,等待下次检查
|
||||
// 不要释放 isHandlingAuth,让全局监听器下次再试
|
||||
logger.debug('适配器', '[登录器] 按钮尚未加载,等待中...');
|
||||
await sleep(500, 1000);
|
||||
isHandlingAuth = false; // 释放锁让下次尝试
|
||||
unlockPageAuth(page); // 释放锁让下次尝试
|
||||
return true; // 返回 true 表示"仍在处理中"
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('适配器', `[登录器] 处理账户选择页面失败: ${err.message}`);
|
||||
isHandlingAuth = false;
|
||||
unlockPageAuth(page);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待输入框出现,同时自动处理账户选择页面跳转
|
||||
*
|
||||
* @param {import('playwright-core').Page} page - 页面对象
|
||||
* @param {object} [options={}] - 选项
|
||||
* @param {number} [options.timeout=60000] - 超时时间(毫秒)
|
||||
* @param {boolean} [options.click=true] - 是否点击输入框
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function waitForInputWithAccountChooser(page, options = {}) {
|
||||
const { timeout = 60000, click = true } = options;
|
||||
|
||||
// 先检查一次当前页面 (全局监听器也会处理,但显式调用确保首次检查)
|
||||
await handleAccountChooser(page);
|
||||
|
||||
// 轮询等待输入框
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeout) {
|
||||
// 如果正在处理跳转,暂停检测输入框
|
||||
if (isHandlingAuth) {
|
||||
await sleep(500, 1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
let inputHandle = null;
|
||||
try {
|
||||
inputHandle = await page.$(INPUT_SELECTOR);
|
||||
} catch (e) {
|
||||
// 忽略执行上下文销毁错误
|
||||
if (e.message.includes('Execution context was destroyed')) {
|
||||
inputHandle = null;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
if (inputHandle) break;
|
||||
|
||||
await sleep(1000, 1500);
|
||||
}
|
||||
|
||||
// 最终确认输入框存在
|
||||
await page.waitForSelector(INPUT_SELECTOR, { timeout: 5000 }).catch(() => {
|
||||
throw new Error('未找到输入框 (.ProseMirror)');
|
||||
});
|
||||
|
||||
if (click) {
|
||||
await safeClick(page, INPUT_SELECTOR, { bias: 'input' });
|
||||
await sleep(500, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 生成图片
|
||||
@@ -166,17 +108,17 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
}
|
||||
|
||||
// 开启新对话 - 先等待可能正在进行的登录处理完成
|
||||
await waitForAuthComplete();
|
||||
await waitForPageAuth(page);
|
||||
|
||||
logger.info('适配器', '开启新会话', meta);
|
||||
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// 如果触发了账户选择跳转,等待全局处理器完成
|
||||
await waitForAuthComplete();
|
||||
await waitForPageAuth(page);
|
||||
|
||||
// 1. 等待输入框加载(使用公共函数处理账户选择)
|
||||
// 1. 等待输入框加载
|
||||
logger.debug('适配器', '正在寻找输入框...', meta);
|
||||
await waitForInputWithAccountChooser(page, { click: false });
|
||||
await waitForInput(page, INPUT_SELECTOR, { click: false });
|
||||
await sleep(1500, 2500);
|
||||
|
||||
// 2. 上传图片 (uploadImages - 使用自定义验证器)
|
||||
@@ -316,8 +258,6 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export { generateImage };
|
||||
|
||||
/**
|
||||
* 适配器 manifest
|
||||
*/
|
||||
@@ -343,7 +283,7 @@ export const manifest = {
|
||||
|
||||
// 输入框就绪校验
|
||||
async waitInput(page, ctx) {
|
||||
await waitForInputWithAccountChooser(page);
|
||||
await waitForInput(page, INPUT_SELECTOR, { click: true });
|
||||
},
|
||||
|
||||
// 导航处理器
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/**
|
||||
* @fileoverview LMArena 适配器
|
||||
* @description 通过自动化方式驱动 LMArena 网页端生成图片(或解析文本),并将结果转换为统一的后端返回结构。
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -15,7 +14,8 @@ import {
|
||||
normalizePageError,
|
||||
normalizeHttpError,
|
||||
downloadImage,
|
||||
moveMouseAway
|
||||
moveMouseAway,
|
||||
waitForInput
|
||||
} from '../utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
@@ -59,8 +59,8 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
logger.info('适配器', '开启新会话...', meta);
|
||||
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// 1. 等待输入框加载 (waitInput)
|
||||
await page.waitForSelector(textareaSelector, { timeout: 30000 });
|
||||
// 1. 等待输入框加载
|
||||
await waitForInput(page, textareaSelector, { click: false });
|
||||
await sleep(1500, 2500);
|
||||
|
||||
// 2. 上传图片 (uploadImages)
|
||||
@@ -136,10 +136,7 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
const img = extractImage(content);
|
||||
if (img) {
|
||||
logger.info('适配器', '已获取结果,正在下载图片...', meta);
|
||||
const result = await downloadImage(img, {
|
||||
proxyConfig: context.proxyConfig,
|
||||
userDataDir: context.userDataDir
|
||||
});
|
||||
const result = await downloadImage(img, context);
|
||||
if (result.image) {
|
||||
logger.info('适配器', '已下载图片,任务完成', meta);
|
||||
}
|
||||
@@ -171,9 +168,7 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
*/
|
||||
async function waitInputValidator(page) {
|
||||
const textareaSelector = 'textarea';
|
||||
await page.waitForSelector(textareaSelector, { timeout: 60000 });
|
||||
await safeClick(page, textareaSelector, { bias: 'input' });
|
||||
await sleep(500, 1000);
|
||||
await waitForInput(page, textareaSelector, { click: true });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -240,5 +235,3 @@ export const manifest = {
|
||||
// 核心生图方法
|
||||
generateImage
|
||||
};
|
||||
|
||||
export { generateImage };
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
/**
|
||||
* @fileoverview NanoBananaFree AI 适配器
|
||||
* @description 通过自动化方式驱动 nanobananafree.ai 网页端生成图片,并将结果转换为统一的后端返回结构。
|
||||
*/
|
||||
|
||||
|
||||
import {
|
||||
sleep,
|
||||
safeClick,
|
||||
@@ -15,7 +13,8 @@ import {
|
||||
waitApiResponse,
|
||||
normalizePageError,
|
||||
normalizeHttpError,
|
||||
moveMouseAway
|
||||
moveMouseAway,
|
||||
waitForInput
|
||||
} from '../utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
@@ -40,8 +39,8 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
logger.info('适配器', '开启新会话', meta);
|
||||
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// 1. 等待输入框加载 (waitInput)
|
||||
await page.waitForSelector(textareaSelector, { timeout: 30000 });
|
||||
// 1. 等待输入框加载
|
||||
await waitForInput(page, textareaSelector, { click: false });
|
||||
await sleep(1500, 2500);
|
||||
|
||||
// 2. 上传图片 (uploadImages - 仅取第一张)
|
||||
@@ -136,9 +135,7 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
*/
|
||||
async function waitInputValidator(page) {
|
||||
const textareaSelector = 'textarea';
|
||||
await page.waitForSelector(textareaSelector, { timeout: 60000 });
|
||||
await safeClick(page, textareaSelector, { bias: 'input' });
|
||||
await sleep(500, 1000);
|
||||
await waitForInput(page, textareaSelector, { click: true });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,5 +170,3 @@ export const manifest = {
|
||||
// 核心生图方法
|
||||
generateImage
|
||||
};
|
||||
|
||||
export { generateImage };
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/**
|
||||
* @fileoverview zAI(zai.is)适配器
|
||||
* @description 通过自动化方式驱动 zai.is 网页端生成图片,并将结果转换为统一的后端返回结构。
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -15,7 +14,12 @@ import {
|
||||
normalizeHttpError,
|
||||
waitApiResponse,
|
||||
moveMouseAway,
|
||||
downloadImage
|
||||
downloadImage,
|
||||
waitForPageAuth,
|
||||
lockPageAuth,
|
||||
unlockPageAuth,
|
||||
isPageAuthLocked,
|
||||
waitForInput
|
||||
} from '../utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
@@ -30,24 +34,15 @@ const TARGET_URL = 'https://zai.is/';
|
||||
* @param {import('playwright-core').Page} page
|
||||
* @returns {Promise<boolean>} 是否处理了登录
|
||||
*/
|
||||
let isHandlingAuth = false;
|
||||
|
||||
/** 等待登录处理完成 */
|
||||
async function waitForAuthComplete() {
|
||||
while (isHandlingAuth) {
|
||||
await sleep(500, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDiscordAuth(page) {
|
||||
// 防止重复处理
|
||||
if (isHandlingAuth) return false;
|
||||
if (isPageAuthLocked(page)) return false;
|
||||
|
||||
const currentUrl = page.url();
|
||||
|
||||
// 1. 检查是否在 zai.is/auth 页面
|
||||
if (currentUrl.includes('zai.is/auth')) {
|
||||
isHandlingAuth = true;
|
||||
lockPageAuth(page);
|
||||
logger.info('适配器', '[登录器] 检测到登录页面,正在处理 Discord 登录...');
|
||||
|
||||
try {
|
||||
@@ -101,11 +96,11 @@ async function handleDiscordAuth(page) {
|
||||
|
||||
logger.info('适配器', '[登录器] Discord 登录完成');
|
||||
await sleep(2000, 3000);
|
||||
isHandlingAuth = false;
|
||||
unlockPageAuth(page);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.warn('适配器', `[登录器] Discord 登录处理失败: ${err.message}`);
|
||||
isHandlingAuth = false;
|
||||
unlockPageAuth(page);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,56 +108,6 @@ async function handleDiscordAuth(page) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待输入框出现,同时自动处理 Discord 登录
|
||||
* @param {import('playwright-core').Page} page
|
||||
* @param {object} [options={}]
|
||||
* @param {number} [options.timeout=60000]
|
||||
* @param {boolean} [options.click=true]
|
||||
*/
|
||||
async function waitForInputWithAuth(page, options = {}) {
|
||||
const { timeout = 60000, click = true } = options;
|
||||
|
||||
// 先检查一次当前页面 (全局监听器也会处理,但显式调用确保首次检查)
|
||||
await handleDiscordAuth(page);
|
||||
|
||||
// 轮询等待输入框
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeout) {
|
||||
// 如果正在处理登录,暂停检测输入框,避免冲突
|
||||
if (isHandlingAuth) {
|
||||
await sleep(500, 1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
let inputHandle = null;
|
||||
try {
|
||||
inputHandle = await page.$(INPUT_SELECTOR);
|
||||
} catch (e) {
|
||||
// 忽略执行上下文销毁错误 (通常发生在页面刷新/跳转时)
|
||||
if (e.message.includes('Execution context was destroyed')) {
|
||||
inputHandle = null;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
if (inputHandle) break;
|
||||
|
||||
await sleep(1000, 1500);
|
||||
}
|
||||
|
||||
// 最终确认输入框存在
|
||||
await page.waitForSelector(INPUT_SELECTOR, { timeout: 5000 }).catch(() => {
|
||||
throw new Error('未找到输入框 (.tiptap.ProseMirror)');
|
||||
});
|
||||
|
||||
if (click) {
|
||||
await safeClick(page, INPUT_SELECTOR, { bias: 'input' });
|
||||
await sleep(500, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 生成图片
|
||||
@@ -178,17 +123,17 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
|
||||
try {
|
||||
// 开启新对话 - 先等待可能正在进行的登录处理完成
|
||||
await waitForAuthComplete();
|
||||
await waitForPageAuth(page);
|
||||
|
||||
logger.info('适配器', '开启新会话', meta);
|
||||
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// 如果触发了登录跳转,等待全局处理器完成
|
||||
await waitForAuthComplete();
|
||||
await waitForPageAuth(page);
|
||||
|
||||
// 1. 等待输入框加载(使用公共函数处理登录)
|
||||
// 1. 等待输入框加载
|
||||
logger.debug('适配器', '正在寻找输入框...', meta);
|
||||
await waitForInputWithAuth(page, { click: false });
|
||||
await waitForInput(page, INPUT_SELECTOR, { click: false });
|
||||
await sleep(1500, 2500);
|
||||
|
||||
// 2. 上传图片 (如果有多张图片,会一张一张上传,每次都是 v1/files POST 请求)
|
||||
@@ -374,10 +319,7 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
logger.info('适配器', `已提取图片链接: ${imageUrl}`, meta);
|
||||
|
||||
// 下载图片
|
||||
const downloadResult = await downloadImage(imageUrl, {
|
||||
proxyConfig: context.proxyConfig,
|
||||
userDataDir: context.userDataDir
|
||||
});
|
||||
const downloadResult = await downloadImage(imageUrl, context);
|
||||
if (downloadResult.error) {
|
||||
return downloadResult;
|
||||
}
|
||||
@@ -426,7 +368,7 @@ export const manifest = {
|
||||
|
||||
// 输入框就绪校验
|
||||
async waitInput(page, ctx) {
|
||||
await waitForInputWithAuth(page);
|
||||
await waitForInput(page, INPUT_SELECTOR, { click: true });
|
||||
},
|
||||
|
||||
// 导航处理器
|
||||
@@ -434,6 +376,4 @@ export const manifest = {
|
||||
|
||||
// 核心生图方法
|
||||
generateImage
|
||||
};
|
||||
|
||||
export { generateImage };
|
||||
};
|
||||
@@ -107,6 +107,9 @@ class Worker {
|
||||
// sharedBrowser 实际是 BrowserContext(Camoufox 使用 launchPersistentContext)
|
||||
this.page = await sharedBrowser.newPage();
|
||||
|
||||
// 挂载页面级认证状态(供适配器使用,避免全局锁导致多 Worker 互相阻塞)
|
||||
this.page.authState = { isHandlingAuth: false };
|
||||
|
||||
// 初始化 ghost-cursor
|
||||
this.page.cursor = createCursor(this.page);
|
||||
|
||||
@@ -154,6 +157,9 @@ class Worker {
|
||||
this.browser = base.context;
|
||||
this.page = base.page;
|
||||
|
||||
// 挂载页面级认证状态(供适配器使用,避免全局锁导致多 Worker 互相阻塞)
|
||||
this.page.authState = { isHandlingAuth: false };
|
||||
|
||||
// 初始化 ghost-cursor
|
||||
this.page.cursor = createCursor(this.page);
|
||||
|
||||
|
||||
+101
-6
@@ -8,11 +8,104 @@
|
||||
* - `waitApiResponse`:等待匹配的 API 响应(包含页面关闭/崩溃监听)
|
||||
* - `normalizePageError`:将页面级异常归一化为可返回给服务器层的错误
|
||||
* - `normalizeHttpError`:将 HTTP 响应错误(含限流/人机验证)归一化
|
||||
* - `waitForPageAuth`/`lockPageAuth`...:页面认证锁机制,防止多任务并发冲突
|
||||
* - `waitForInput`: 等待输入框就绪
|
||||
*/
|
||||
|
||||
import { sleep, humanType, safeClick, isPageValid, createPageCloseWatcher, getRealViewport, clamp, random } from '../browser/utils.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
// ==========================================
|
||||
// 页面认证锁工具函数
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* 等待页面认证完成
|
||||
* @param {import('playwright-core').Page} page - 页面对象
|
||||
*/
|
||||
export async function waitForPageAuth(page) {
|
||||
while (page.authState?.isHandlingAuth) {
|
||||
await sleep(500, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置页面认证锁(加锁)
|
||||
* @param {import('playwright-core').Page} page - 页面对象
|
||||
*/
|
||||
export function lockPageAuth(page) {
|
||||
if (page.authState) page.authState.isHandlingAuth = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放页面认证锁(解锁)
|
||||
* @param {import('playwright-core').Page} page - 页面对象
|
||||
*/
|
||||
export function unlockPageAuth(page) {
|
||||
if (page.authState) page.authState.isHandlingAuth = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查页面是否正在处理认证
|
||||
* @param {import('playwright-core').Page} page - 页面对象
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isPageAuthLocked(page) {
|
||||
return page.authState?.isHandlingAuth === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待输入框出现(自动等待认证完成)
|
||||
*
|
||||
* 使用轮询方式等待输入框出现,同时尊重页面认证锁。
|
||||
* 当页面正在处理登录跳转时会自动暂停检测。
|
||||
*
|
||||
* @param {import('playwright-core').Page} page - 页面对象
|
||||
* @param {string|import('playwright-core').Locator} selectorOrLocator - 输入框选择器或 Locator 对象
|
||||
* @param {object} [options={}] - 选项
|
||||
* @param {number} [options.timeout=60000] - 超时时间(毫秒)
|
||||
* @param {boolean} [options.click=true] - 找到后是否点击输入框
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function waitForInput(page, selectorOrLocator, options = {}) {
|
||||
const { timeout = 60000, click = true } = options;
|
||||
|
||||
// 判断是选择器字符串还是 Locator 对象
|
||||
const isLocator = typeof selectorOrLocator !== 'string';
|
||||
const displayName = isLocator ? 'Locator' : selectorOrLocator;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// 等待认证完成(如果正在处理登录跳转)
|
||||
while (isPageAuthLocked(page)) {
|
||||
if (Date.now() - startTime >= timeout) break;
|
||||
await sleep(500, 1000);
|
||||
}
|
||||
|
||||
// 计算剩余超时时间
|
||||
const elapsed = Date.now() - startTime;
|
||||
const remainingTimeout = Math.max(timeout - elapsed, 5000);
|
||||
|
||||
// 等待输入框出现 - 对字符串选择器使用 waitForSelector,对 Locator 使用 waitFor
|
||||
if (isLocator) {
|
||||
await selectorOrLocator.first().waitFor({ state: 'visible', timeout: remainingTimeout }).catch(() => {
|
||||
throw new Error(`未找到输入框 (${displayName})`);
|
||||
});
|
||||
} else {
|
||||
await page.waitForSelector(selectorOrLocator, { timeout: remainingTimeout }).catch(() => {
|
||||
throw new Error(`未找到输入框 (${displayName})`);
|
||||
});
|
||||
}
|
||||
|
||||
if (click) {
|
||||
const target = isLocator ? selectorOrLocator : selectorOrLocator;
|
||||
await safeClick(page, target, { bias: 'input' });
|
||||
await sleep(500, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* 任务完成后移开鼠标(拟人化行为)
|
||||
*
|
||||
@@ -199,19 +292,17 @@ export function normalizeHttpError(response, content = null) {
|
||||
* 根据 camoufoxFingerprints.json 动态生成请求头,保持与浏览器指纹一致
|
||||
*
|
||||
* @param {string} url - 图片 URL
|
||||
* @param {object} options - 下载选项
|
||||
* @param {object} [options.proxyConfig] - Worker 级代理配置
|
||||
* @param {string} [options.userDataDir] - 用户数据目录(用于读取对应的指纹文件)
|
||||
* @param {object} context - 上下文对象,包含 proxyConfig 和 userDataDir
|
||||
* @returns {Promise<{ image?: string, error?: string }>} 下载结果
|
||||
*/
|
||||
export async function downloadImage(url, options = {}) {
|
||||
export async function downloadImage(url, context = {}) {
|
||||
// 动态导入依赖
|
||||
const { gotScraping } = await import('got-scraping');
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const { getHttpProxy } = await import('../utils/proxy.js');
|
||||
|
||||
const { proxyConfig = null, userDataDir } = options;
|
||||
const { proxyConfig = null, userDataDir } = context;
|
||||
|
||||
try {
|
||||
// 读取指纹文件获取浏览器信息(优先使用 userDataDir 内的指纹)
|
||||
@@ -270,7 +361,11 @@ export async function downloadImage(url, options = {}) {
|
||||
|
||||
const response = await gotScraping(options);
|
||||
const base64 = response.body.toString('base64');
|
||||
return { image: `data:image/png;base64,${base64}` };
|
||||
// 根据响应 content-type 生成正确的 MIME 类型
|
||||
const contentType = response.headers['content-type'] || 'image/png';
|
||||
// 提取 MIME 类型 (去除可能的 charset 等附加信息)
|
||||
const mimeType = contentType.split(';')[0].trim();
|
||||
return { image: `data:${mimeType};base64,${base64}` };
|
||||
} catch (e) {
|
||||
return { error: `已获取结果,但图片下载时遇到错误: ${e.message}` };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user