feat: 支持 ChatGPT 图片生成、优化图片下载方式

This commit is contained in:
foxhui
2025-12-22 03:32:58 +08:00
Unverified
parent fdd11f9c70
commit ce72c5f9ec
14 changed files with 254 additions and 79 deletions
+8 -1
View File
@@ -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
+6 -2
View File
@@ -1,6 +1,9 @@
# WebAI2API
![Image](https://github.com/user-attachments/assets/296a518e-c42b-4e39-8ff6-9b4381ed4f6e)
<p align="center">
<img src="https://github.com/user-attachments/assets/296a518e-c42b-4e39-8ff6-9b4381ed4f6e" width="49%" />
<img src="https://github.com/user-attachments/assets/06f31024-ecd4-48d2-9789-eedc98c9c5b9" width="49%" />
</p>
## 📑 目录
@@ -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]
+207
View File
@@ -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
};
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* @fileoverview Gemini(消费者版)适配器
* @fileoverview Google Gemini 图片、视频生成适配器
*/
import {
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* @fileoverview Gemini Business 适配器
* @fileoverview Gemini Business 文本生成适配器
*/
import {
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* @fileoverview Gemini Business 适配器
* @fileoverview Gemini Business 图片、视频生成适配器
*/
import {
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* @fileoverview Gemini(消费者版)文本适配器
* @fileoverview Google Gemini 文本生成适配器
*/
import {
+4 -4
View File
@@ -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);
}
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* @fileoverview LMArena 适配器
* @fileoverview LMArena 文本生成适配器
*/
import {
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* @fileoverview NanoBananaFree AI 适配器
* @fileoverview NanoBananaFree 图片生成适配器
*/
import {
+3 -3
View File
@@ -1,5 +1,5 @@
/**
* @fileoverview zAIzai.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;
}
+4
View File
@@ -1,3 +1,7 @@
/**
* @fileoverview ZenMux 文本生成适配器
*/
import {
sleep,
safeClick,
+14 -61
View File
@@ -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}` };
+2 -2
View File
@@ -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';