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}` }; }