diff --git a/README.md b/README.md
index 1aedbf3..cb2be81 100644
--- a/README.md
+++ b/README.md
@@ -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`)。
可通过 `/v1/models` 接口或查看 `lib/backend/models.js` 获取完整列表。 |
+| **model** | **必填**。指定使用的模型名称(如 `gemini-3-pro-image-preview`)。
可通过 `/v1/models` 接口获取支持的模型列表。 |
| **stream** | **推荐开启**。流式响应包含心跳保活机制,防止生成耗时过长导致连接超时。 |
> **💡 关于流式保活(Heartbeat)**
@@ -323,7 +323,7 @@ curl -X GET http://127.0.0.1:3000/v1/cookies \
-#### 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):运行流畅稳定,为本项目开发测试基准环境。
diff --git a/src/backend/adapter/gemini.js b/src/backend/adapter/gemini.js
index dac53a1..066a66d 100644
--- a/src/backend/adapter/gemini.js
+++ b/src/backend/adapter/gemini.js
@@ -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 };
+};
\ No newline at end of file
diff --git a/src/backend/adapter/gemini_biz.js b/src/backend/adapter/gemini_biz.js
index 9e7a8b4..27f2f12 100644
--- a/src/backend/adapter/gemini_biz.js
+++ b/src/backend/adapter/gemini_biz.js
@@ -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} 是否处理了跳转
*/
-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}
- */
-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 });
},
// 导航处理器
diff --git a/src/backend/adapter/lmarena.js b/src/backend/adapter/lmarena.js
index 3d9774d..17acd37 100644
--- a/src/backend/adapter/lmarena.js
+++ b/src/backend/adapter/lmarena.js
@@ -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 };
diff --git a/src/backend/adapter/nanobananafree_ai.js b/src/backend/adapter/nanobananafree_ai.js
index 3b9a14f..0e31b04 100644
--- a/src/backend/adapter/nanobananafree_ai.js
+++ b/src/backend/adapter/nanobananafree_ai.js
@@ -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 };
diff --git a/src/backend/adapter/zai_is.js b/src/backend/adapter/zai_is.js
index 1a37601..8c340ee 100644
--- a/src/backend/adapter/zai_is.js
+++ b/src/backend/adapter/zai_is.js
@@ -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} 是否处理了登录
*/
-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 };
+};
\ No newline at end of file
diff --git a/src/backend/pool.js b/src/backend/pool.js
index 2777a98..a74fd7f 100644
--- a/src/backend/pool.js
+++ b/src/backend/pool.js
@@ -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);
diff --git a/src/backend/utils.js b/src/backend/utils.js
index 9d5136a..1901b8d 100644
--- a/src/backend/utils.js
+++ b/src/backend/utils.js
@@ -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}
+ */
+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}` };
}