mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
feat: 支持 ChatGPT 图片生成、优化图片下载方式
This commit is contained in:
+8
-1
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# WebAI2API
|
||||
|
||||

|
||||
<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]
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
/**
|
||||
* @fileoverview Gemini(消费者版)适配器
|
||||
* @fileoverview Google Gemini 图片、视频生成适配器
|
||||
*/
|
||||
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @fileoverview Gemini Business 适配器
|
||||
* @fileoverview Gemini Business 文本生成适配器
|
||||
*/
|
||||
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @fileoverview Gemini Business 适配器
|
||||
* @fileoverview Gemini Business 图片、视频生成适配器
|
||||
*/
|
||||
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @fileoverview Gemini(消费者版)文本适配器
|
||||
* @fileoverview Google Gemini 文本生成适配器
|
||||
*/
|
||||
|
||||
import {
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
/**
|
||||
* @fileoverview LMArena 适配器
|
||||
* @fileoverview LMArena 文本生成适配器
|
||||
*/
|
||||
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @fileoverview NanoBananaFree AI 适配器
|
||||
* @fileoverview NanoBananaFree 图片生成适配器
|
||||
*/
|
||||
|
||||
import {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* @fileoverview ZenMux 文本生成适配器
|
||||
*/
|
||||
|
||||
import {
|
||||
sleep,
|
||||
safeClick,
|
||||
|
||||
@@ -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}` };
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user