diff --git a/CHANGELOG.md b/CHANGELOG.md index 86d1ba7..3d77ff9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +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.3.0] - 2025-12-20 +## [3.3.0] - 2025-12-21 ### ✨ Added - **新增适配器** - 支持 Gemini 网页版文本生成 + - 支持 ChatGPT 图片生成 +- **支持视频生成** + - 支持在 Gemini 网页版和 Gemini Enterprise Business 图片生成适配器中生成视频 + +### 🔄 Changed +- **优化图片下载方式** + - 让文件下载步骤直接继承浏览器上下文减少特征 ## [3.3.0] - 2025-12-20 diff --git a/README.md b/README.md index 9f1984c..34e4390 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # WebAI2API -![Image](https://github.com/user-attachments/assets/296a518e-c42b-4e39-8ff6-9b4381ed4f6e) +

+ + +

## 📑 目录 @@ -22,7 +25,7 @@ - 🔄 **接口兼容**: 提供标准 OpenAI 格式接口,支持流式响应与心跳保活 - 🚀 **并发隔离**: 支持多窗口并发执行,可配置独立代理,实现多账号浏览器实例级数据隔离 - 🛡️ **稳定防护**: 内置任务队列、负载均衡、故障转移、错误重试等基础功能 -- 🎨 **Web 管理界面**: 提供可视化管理界面,支持实时日志查看、VNC 连接、适配器管理等 +- 🎨 **Web 管理界面**: 提供可视化管理界面,支持实时日志查看、VNC 连接、适配器管理等 ### 📋 支持列表 @@ -34,6 +37,7 @@ | [**zAI**](https://zai.is/) | ❌ | ✅ | 🚫 | | [**Google Gemini**](https://gemini.google.com/) | ✅ | ✅ | ✅ | | [**ZenMux**](https://zenmux.ai/) | ✅ | ❌ | 🚫 | +| [**ChatGPT**](https://chatgpt.com/) | ❌ | ✅ | 🚫 | | 待续... | - | - | - | > [!NOTE] diff --git a/src/backend/adapter/chatgpt.js b/src/backend/adapter/chatgpt.js new file mode 100644 index 0000000..fdcd468 --- /dev/null +++ b/src/backend/adapter/chatgpt.js @@ -0,0 +1,207 @@ +/** + * @fileoverview ChatGPT 图片生成适配器 + */ + +import { + sleep, + safeClick, + uploadFilesViaChooser +} from '../engine/utils.js'; +import { + fillPrompt, + normalizePageError, + moveMouseAway, + waitForInput, + gotoWithCheck, + waitApiResponse, + useContextDownload +} from '../utils/index.js'; +import { logger } from '../../utils/logger.js'; + +// --- 配置常量 --- +const TARGET_URL = 'https://chatgpt.com/images/'; +const INPUT_SELECTOR = '.ProseMirror'; + +/** + * 执行生图任务 + * @param {object} context - 浏览器上下文 { page, config } + * @param {string} prompt - 提示词 + * @param {string[]} imgPaths - 图片路径数组 + * @param {string} [modelId] - 模型 ID (此适配器未使用) + * @param {object} [meta={}] - 日志元数据 + * @returns {Promise<{image?: string, error?: string}>} + */ +async function generate(context, prompt, imgPaths, modelId, meta = {}) { + const { page } = context; + const sendBtnLocator = page.getByRole('button', { name: 'Send prompt' }); + + try { + logger.info('适配器', '开启新会话...', meta); + await gotoWithCheck(page, TARGET_URL); + + // 1. 等待输入框加载 + await waitForInput(page, INPUT_SELECTOR, { click: false }); + await sleep(1500, 2500); + + // 2. 上传图片 + if (imgPaths && imgPaths.length > 0) { + const expectedUploads = imgPaths.length; + let uploadedCount = 0; + let processedCount = 0; + + logger.debug('适配器', '点击添加文件按钮...', meta); + const addFilesBtn = page.getByRole('button', { name: 'Add files and more' }); + + await uploadFilesViaChooser(page, addFilesBtn, imgPaths, { + uploadValidator: (response) => { + const url = response.url(); + if (response.status() === 200) { + // 上传请求 + if (url.includes('backend-api/files') && !url.includes('process_upload_stream')) { + uploadedCount++; + logger.debug('适配器', `图片上传进度: ${uploadedCount}/${expectedUploads}`, meta); + return false; + } + // 处理完成请求 + if (url.includes('backend-api/files/process_upload_stream')) { + processedCount++; + logger.info('适配器', `图片处理进度: ${processedCount}/${expectedUploads}`, meta); + + if (processedCount >= expectedUploads) { + return true; + } + } + } + return false; + } + }); + + await sleep(1000, 2000); + } + + // 3. 填写提示词 + await safeClick(page, INPUT_SELECTOR, { bias: 'input' }); + await fillPrompt(page, INPUT_SELECTOR, prompt, meta); + await sleep(500, 1000); + + // 4. 点击发送 + logger.debug('适配器', '点击发送...', meta); + await safeClick(page, sendBtnLocator, { bias: 'button' }); + + logger.info('适配器', '等待生成结果...', meta); + + // 5. 等待 conversation API 返回 + let conversationResponse; + try { + conversationResponse = await waitApiResponse(page, { + urlMatch: 'backend-api/f/conversation', + method: 'POST', + timeout: 180000, // 图片生成可能较慢 + meta + }); + } catch (e) { + const pageError = normalizePageError(e, meta); + if (pageError) return pageError; + throw e; + } + + // 检查响应状态 + if (conversationResponse.status() !== 200) { + logger.error('适配器', `API 返回错误: HTTP ${conversationResponse.status()}`, meta); + return { error: `API 返回错误: HTTP ${conversationResponse.status()}` }; + } + + logger.info('适配器', '生成中,等待图片就绪...', meta); + + // 6. 监听文件状态接口,等待图片生成完成 + // 通过 file_name 是否包含 .part 判断是否生成完成 + let downloadUrl = null; + let fileName = null; + + try { + await page.waitForResponse(async (response) => { + const url = response.url(); + if (!url.includes('backend-api/files/download/file_')) return false; + if (response.status() !== 200) return false; + + try { + const json = await response.json(); + const fn = json.file_name; + const dl = json.download_url; + + // 检查是否生成完成: + // 1. 必须有 file_name + // 2. file_name 不能包含 .part(表示中间状态) + // 3. 必须有 download_url + if (fn && !fn.includes('.part') && dl) { + fileName = fn; + downloadUrl = dl; + logger.info('适配器', `图片生成完成: ${fn}`, meta); + return true; + } else { + logger.debug('适配器', `图片生成中: ${fn || '无文件名'}`, meta); + return false; + } + } catch { + return false; + } + }, { timeout: 120000 }); + } catch (e) { + const pageError = normalizePageError(e, meta); + if (pageError) return pageError; + throw e; + } + + if (!downloadUrl) { + logger.error('适配器', '未获取到图片下载链接', meta); + return { error: '未获取到图片下载链接' }; + } + + logger.info('适配器', '正在下载图片...', meta); + + // 7. 使用 useContextDownload 下载图片 + const result = await useContextDownload(downloadUrl, page); + if (result.error) { + logger.error('适配器', result.error, meta); + return result; + } + + logger.info('适配器', '已获取图片,任务完成', meta); + return result; + + } catch (err) { + // 顶层错误处理 + const pageError = normalizePageError(err, meta); + if (pageError) return pageError; + + logger.error('适配器', '生成任务失败', { ...meta, error: err.message }); + return { error: `生成任务失败: ${err.message}` }; + } finally { + // 任务结束,将鼠标移至安全区域 + await moveMouseAway(page); + } +} + +/** + * 适配器 manifest + */ +export const manifest = { + id: 'chatgpt', + displayName: 'ChatGPT (图片生成)', + + // 入口 URL + getTargetUrl(config, workerConfig) { + return TARGET_URL; + }, + + // 模型列表 + models: [ + { id: 'gpt-image-1', imagePolicy: 'optional' } + ], + + // 无需导航处理器 + navigationHandlers: [], + + // 核心生图方法 + generate +}; diff --git a/src/backend/adapter/gemini.js b/src/backend/adapter/gemini.js index a93839e..ae4454c 100644 --- a/src/backend/adapter/gemini.js +++ b/src/backend/adapter/gemini.js @@ -1,5 +1,5 @@ /** - * @fileoverview Gemini(消费者版)适配器 + * @fileoverview Google Gemini 图片、视频生成适配器 */ import { diff --git a/src/backend/adapter/gemini_biz.js b/src/backend/adapter/gemini_biz.js index dfc69bd..137249c 100644 --- a/src/backend/adapter/gemini_biz.js +++ b/src/backend/adapter/gemini_biz.js @@ -1,5 +1,5 @@ /** - * @fileoverview Gemini Business 适配器 + * @fileoverview Gemini Business 文本生成适配器 */ import { diff --git a/src/backend/adapter/gemini_biz_text.js b/src/backend/adapter/gemini_biz_text.js index 28dd4fd..ba781f8 100644 --- a/src/backend/adapter/gemini_biz_text.js +++ b/src/backend/adapter/gemini_biz_text.js @@ -1,5 +1,5 @@ /** - * @fileoverview Gemini Business 适配器 + * @fileoverview Gemini Business 图片、视频生成适配器 */ import { diff --git a/src/backend/adapter/gemini_text.js b/src/backend/adapter/gemini_text.js index 4672603..98badd1 100644 --- a/src/backend/adapter/gemini_text.js +++ b/src/backend/adapter/gemini_text.js @@ -1,5 +1,5 @@ /** - * @fileoverview Gemini(消费者版)文本适配器 + * @fileoverview Google Gemini 文本生成适配器 */ import { diff --git a/src/backend/adapter/lmarena.js b/src/backend/adapter/lmarena.js index 4a5886c..0a9682e 100644 --- a/src/backend/adapter/lmarena.js +++ b/src/backend/adapter/lmarena.js @@ -1,5 +1,5 @@ /** - * @fileoverview LMArena 适配器 + * @fileoverview LMArena 图片生成适配器 */ import { @@ -13,10 +13,10 @@ import { waitApiResponse, normalizePageError, normalizeHttpError, - downloadImage, moveMouseAway, waitForInput, - gotoWithCheck + gotoWithCheck, + useContextDownload } from '../utils/index.js'; import { logger } from '../../utils/logger.js'; @@ -148,7 +148,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { } logger.info('适配器', '已获取结果,正在下载图片...', meta); - const result = await downloadImage(img, context); + const result = await useContextDownload(img, page); if (result.image) { logger.info('适配器', '已下载图片,任务完成', meta); } diff --git a/src/backend/adapter/lmarena_text.js b/src/backend/adapter/lmarena_text.js index 4f09578..44bfd3e 100644 --- a/src/backend/adapter/lmarena_text.js +++ b/src/backend/adapter/lmarena_text.js @@ -1,5 +1,5 @@ /** - * @fileoverview LMArena 适配器 + * @fileoverview LMArena 文本生成适配器 */ import { diff --git a/src/backend/adapter/nanobananafree_ai.js b/src/backend/adapter/nanobananafree_ai.js index ab5e11a..c3751da 100644 --- a/src/backend/adapter/nanobananafree_ai.js +++ b/src/backend/adapter/nanobananafree_ai.js @@ -1,5 +1,5 @@ /** - * @fileoverview NanoBananaFree AI 适配器 + * @fileoverview NanoBananaFree 图片生成适配器 */ import { diff --git a/src/backend/adapter/zai_is.js b/src/backend/adapter/zai_is.js index 125ad8a..df3eb2a 100644 --- a/src/backend/adapter/zai_is.js +++ b/src/backend/adapter/zai_is.js @@ -1,5 +1,5 @@ /** - * @fileoverview zAI(zai.is)适配器 + * @fileoverview zAI 图片生成适配器 */ import { @@ -14,7 +14,7 @@ import { normalizeHttpError, waitApiResponse, moveMouseAway, - downloadImage, + useContextDownload, waitForPageAuth, lockPageAuth, unlockPageAuth, @@ -320,7 +320,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { logger.info('适配器', `已提取图片链接: ${imageUrl}`, meta); // 下载图片 - const downloadResult = await downloadImage(imageUrl, context); + const downloadResult = await useContextDownload(imageUrl, page); if (downloadResult.error) { return downloadResult; } diff --git a/src/backend/adapter/zenmux_ai_text.js b/src/backend/adapter/zenmux_ai_text.js index 285aed7..00af4da 100644 --- a/src/backend/adapter/zenmux_ai_text.js +++ b/src/backend/adapter/zenmux_ai_text.js @@ -1,3 +1,7 @@ +/** + * @fileoverview ZenMux 文本生成适配器 + */ + import { sleep, safeClick, diff --git a/src/backend/utils/download.js b/src/backend/utils/download.js index f3944c6..ced95cd 100644 --- a/src/backend/utils/download.js +++ b/src/backend/utils/download.js @@ -4,76 +4,29 @@ */ /** - * 下载图片并转换为 Base64 + * 使用页面上下文下载图片并转换为 Base64 + * 自动继承页面的 Cookie 和 Session,解决鉴权问题 * @param {string} url - 图片 URL - * @param {object} context - 上下文对象,包含 proxyConfig 和 userDataDir + * @param {import('playwright-core').Page} page - Playwright 页面对象 + * @param {object} [options] - 可选配置 + * @param {number} [options.timeout=60000] - 超时时间(毫秒) * @returns {Promise<{ image?: string, error?: string }>} 下载结果 */ -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 } = context; +export async function useContextDownload(url, page, options = {}) { + const { timeout = 60000 } = options; try { - // 读取指纹文件获取浏览器信息 - let fingerprintPath = userDataDir - ? path.join(userDataDir, 'fingerprint.json') - : path.join(process.cwd(), 'data', 'camoufoxUserData', 'fingerprint.json'); + const response = await page.request.get(url, { timeout }); - let browserName = 'firefox'; - let browserMinVersion = 100; - let os = 'windows'; - let locale = 'en-US'; - - if (fs.existsSync(fingerprintPath)) { - try { - const fingerprint = JSON.parse(fs.readFileSync(fingerprintPath, 'utf8')); - if (fingerprint.navigator?.userAgent) { - const versionMatch = fingerprint.navigator.userAgent.match(/Firefox\/(\d+)/i); - if (versionMatch) { - browserMinVersion = parseInt(versionMatch[1], 10); - } - } - if (fingerprint.navigator?.platform) { - const platform = fingerprint.navigator.platform.toLowerCase(); - if (platform.includes('win')) os = 'windows'; - else if (platform.includes('mac')) os = 'macos'; - else if (platform.includes('linux')) os = 'linux'; - } - if (fingerprint.navigator?.language) { - locale = fingerprint.navigator.language; - } - } catch (e) { - // 解析失败使用默认值 - } + if (!response.ok()) { + return { error: `下载失败: HTTP ${response.status()}` }; } - const proxyUrl = await getHttpProxy(proxyConfig); - - const options = { - url, - responseType: 'buffer', - http2: true, - headerGeneratorOptions: { - browsers: [{ name: browserName, minVersion: browserMinVersion }], - devices: ['desktop'], - locales: [locale], - operatingSystems: [os], - } - }; - - if (proxyUrl) { - options.proxyUrl = proxyUrl; - } - - const response = await gotScraping(options); - const base64 = response.body.toString('base64'); - const contentType = response.headers['content-type'] || 'image/png'; + const buffer = await response.body(); + const base64 = buffer.toString('base64'); + const contentType = response.headers()['content-type'] || 'image/png'; const mimeType = contentType.split(';')[0].trim(); + return { image: `data:${mimeType};base64,${base64}` }; } catch (e) { return { error: `已获取结果,但图片下载时遇到错误: ${e.message}` }; diff --git a/src/backend/utils/index.js b/src/backend/utils/index.js index 09d15c2..85954f8 100644 --- a/src/backend/utils/index.js +++ b/src/backend/utils/index.js @@ -19,7 +19,7 @@ * - normalizeError: 通用错误归一化 * * - 资源下载 (download.js): - * - downloadImage: 下载图片并转换为 Base64 + * - useContextDownload: 使用页面上下文下载图片并转换为 Base64 */ // 页面交互 @@ -46,4 +46,4 @@ export { } from './error.js'; // 资源下载 -export { downloadImage } from './download.js'; +export { useContextDownload } from './download.js';