From bf7c50015da7f13e3f5ec7f2110dea33eded5626 Mon Sep 17 00:00:00 2001 From: foxhui Date: Thu, 11 Dec 2025 02:04:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9D=E6=AD=A5=E6=94=AF=E6=8C=81=20?= =?UTF-8?q?zAI/zai.is=20=E4=B8=94=E6=94=AF=E6=8C=81=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E7=BB=AD=E7=99=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 +- config.example.yaml | 1 + lib/backend/adapter/zai_is.js | 371 ++++++++++++++++++++++++++++++++++ lib/backend/index.js | 8 + lib/backend/models.js | 12 ++ lib/utils/config.js | 1 + 6 files changed, 398 insertions(+), 4 deletions(-) create mode 100644 lib/backend/adapter/zai_is.js diff --git a/README.md b/README.md index 68b20e4..5fc06ff 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,11 @@ LMArenaImagenAutomator 是一个基于 Playwright + Camoufox 的自动化图像生成工具,通过模拟人类操作与 LMArena、Gemini 等网站交互提供图像生成服务到OpenAI格式的接口。 当前支持的网站: - - LMArena - - Gemini Enterprise Business - - Nano Banana Free - - 未来可能支持更多网站... + - [LMArena](https://lmarena.ai/) + - [Gemini Enterprise Business](https://business.gemini.google/) + - [Nano Banana Free](https://nanobananafree.ai/) + - [zAI](https://zai.is/) + - 未来可能支持更多网站。。。 ### ✨ 主要特性 diff --git a/config.example.yaml b/config.example.yaml index 9bb1a0a..493693e 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -23,6 +23,7 @@ backend: # - lmarena (LMArena) # - gemini_biz (Gemini Enterprise Business) # - nanobananafree_ai (Nano Banana Free) + # - zai_is (zAI) type: lmarena # Gemini Business 设置 diff --git a/lib/backend/adapter/zai_is.js b/lib/backend/adapter/zai_is.js new file mode 100644 index 0000000..04c17ae --- /dev/null +++ b/lib/backend/adapter/zai_is.js @@ -0,0 +1,371 @@ +import { initBrowserBase } from '../../browser/launcher.js'; +import { + sleep, + safeClick, + pasteImages +} from '../../browser/utils.js'; +import { + fillPrompt, + submit, + normalizePageError, + normalizeHttpError, + waitApiResponse, + moveMouseAway, + downloadImage +} from '../utils.js'; +import { logger } from '../../utils/logger.js'; + +// zai.is 输入框选择器 +const INPUT_SELECTOR = '.tiptap.ProseMirror'; + +// 入口 URL +const TARGET_URL = 'https://zai.is/'; + +// 防止重复处理登录的锁 +let isHandlingAuth = false; + +/** + * 处理 Discord OAuth2 登录流程 + * @param {import('playwright-core').Page} page + * @returns {Promise} 是否处理了登录 + */ +async function handleDiscordAuth(page) { + // 防止重复处理 + if (isHandlingAuth) return false; + + const currentUrl = page.url(); + + // 1. 检查是否在 zai.is/auth 页面 + if (currentUrl.includes('zai.is/auth')) { + isHandlingAuth = true; + logger.info('适配器', '[登录器] 检测到登录页面,正在处理 Discord 登录...'); + + try { + // 等待页面加载完成,点击唯一的 button 标签 + await page.waitForSelector('button', { timeout: 30000 }); + await sleep(1000, 1500); + await safeClick(page, 'button', { bias: 'button' }); + logger.info('适配器', '[登录器] 已点击登录按钮,等待跳转到 Discord...'); + + // 2. 等待跳转到 Discord OAuth2 授权页面 + await page.waitForURL(url => url.href.includes('discord.com/oauth2/authorize'), { timeout: 60000 }); + logger.info('适配器', '[登录器] 已到达 Discord 授权页面'); + await sleep(2000, 3000); + + // 3. 使用鼠标滚轮滚动 main 元素,直到授权按钮可用 + // 授权按钮选择器: data-align="stretch" 的 div 中的最后一个按钮 (授权按钮在右边) + const authorizeBtnSelector = 'div[data-align="stretch"] button:last-child'; + + for (let i = 0; i < 15; i++) { + const authorizeBtn = await page.$(authorizeBtnSelector); + if (authorizeBtn) { + const isDisabled = await authorizeBtn.evaluate(el => el.disabled).catch(() => true); + if (!isDisabled) { + logger.info('适配器', '[登录器] 授权按钮已可用,正在点击...'); + await sleep(500, 1000); + await safeClick(page, authorizeBtn, { bias: 'button' }); + break; + } + } + // 使用鼠标滚轮在 main 元素中滚动 + const mainElement = await page.$('main'); + if (mainElement) { + const box = await mainElement.boundingBox(); + if (box) { + // 将鼠标移动到 main 元素中心并滚动 + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.wheel(0, 200); + } + } + await sleep(800, 1200); + } + + // 4. 等待跳转回 zai.is (不包含 auth 和 discord) + logger.info('适配器', '[登录器] 等待跳转回目标页面...'); + await page.waitForURL(url => { + const href = url.href; + return href.includes('zai.is') && + !href.includes('/auth') && + !href.includes('discord.com'); + }, { timeout: 60000 }); + + logger.info('适配器', '[登录器] Discord 登录完成'); + await sleep(2000, 3000); + isHandlingAuth = false; + return true; + } catch (err) { + logger.warn('适配器', `[登录器] Discord 登录处理失败: ${err.message}`); + isHandlingAuth = false; + } + } + + + 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; + + // 设置导航监听器,自动处理登录页面跳转 + const navigationHandler = async () => { + await handleDiscordAuth(page); + }; + page.on('framenavigated', navigationHandler); + + try { + // 先检查一次当前页面 + await handleDiscordAuth(page); + + // 轮询等待输入框,同时处理登录 + const startTime = Date.now(); + while (Date.now() - startTime < timeout) { + if (await handleDiscordAuth(page)) { + continue; + } + + const inputHandle = await page.$(INPUT_SELECTOR); + 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); + } + } finally { + page.off('framenavigated', navigationHandler); + } +} + +/** + * 初始化浏览器 + * @param {object} config - 配置对象 + * @returns {Promise<{browser: object, page: object, client: object}>} + */ +async function initBrowser(config) { + // 输入框验证逻辑(使用公共函数) + const waitInputValidator = async (page) => { + await waitForInputWithAuth(page); + }; + + const base = await initBrowserBase(config, { + userDataDir: config.paths.userDataDir, + targetUrl: TARGET_URL, + productName: 'Zai.is', + waitInputValidator + }); + return { ...base, config }; +} + +/** + * 生成图片 + * @param {object} context - 浏览器上下文 { page, client, config } + * @param {string} prompt - 提示词 + * @param {string[]} imgPaths - 参考图片路径数组 + * @param {string} modelId - 模型 ID + * @param {object} meta - 日志元数据 + * @returns {Promise<{image?: string, error?: string}>} 生成结果 + */ +async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { + const { page, config } = context; + + try { + // 开启新对话 + logger.info('适配器', '开启新会话', meta); + await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' }); + + // 1. 等待输入框加载(使用公共函数处理登录) + logger.debug('适配器', '正在寻找输入框...', meta); + await waitForInputWithAuth(page, { click: false }); + await sleep(1500, 2500); + + // 2. 上传图片 (如果有多张图片,会一张一张上传,每次都是 v1/files POST 请求) + if (imgPaths && imgPaths.length > 0) { + const expectedUploads = imgPaths.length; + let uploadedCount = 0; + + await pasteImages(page, INPUT_SELECTOR, imgPaths, { + uploadValidator: (response) => { + const url = response.url(); + if (response.status() === 200 && url.includes('v1/files')) { + uploadedCount++; + logger.info('适配器', `图片上传进度: ${uploadedCount}/${expectedUploads}`, meta); + if (uploadedCount >= 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. 设置请求拦截器 - 修改 chat/completions 请求中的 model 参数 + logger.debug('适配器', '已启用请求拦截', meta); + await page.unroute('**/*').catch(() => { }); + + await page.route(url => url.href.includes('chat/completions'), async (route) => { + const request = route.request(); + if (request.method() !== 'POST') return route.continue(); + + try { + const postData = request.postDataJSON(); + if (postData) { + logger.debug('适配器', '已拦截 chat/completions 请求,正在修改 model...', meta); + postData.model = modelId; + logger.info('适配器', `已拦截请求,使用模型: ${modelId}`, meta); + await route.continue({ postData: JSON.stringify(postData) }); + return; + } + } catch (e) { + logger.error('适配器', '请求拦截处理失败', { ...meta, error: e.message }); + } + await route.continue(); + }); + + // 5. 提交 + logger.debug('适配器', '点击发送...', meta); + await submit(page, { + btnSelector: 'button[type="submit"]', + inputTarget: INPUT_SELECTOR, + meta + }); + + logger.info('适配器', '等待生成结果中...', meta); + + // 6. 等待 v1/chats/new 响应 (状态码 200 且响应体中有 id) + let chatsNewResponse; + try { + chatsNewResponse = await waitApiResponse(page, { + urlMatch: 'v1/chats/new', + method: 'POST', + timeout: 60000, + meta + }); + } catch (e) { + const pageError = normalizePageError(e, meta); + if (pageError) return pageError; + throw e; + } + + // 检查 chats/new 响应 + const httpError = normalizeHttpError(chatsNewResponse); + if (httpError) { + logger.error('适配器', `创建对话失败: ${httpError.error}`, meta); + return { error: `创建对话失败: ${httpError.error}` }; + } + + try { + const chatsNewBody = await chatsNewResponse.json(); + if (!chatsNewBody.id) { + logger.error('适配器', '创建对话响应中没有无 id', meta); + return { error: '创建对话响应中没有 id' }; + } + logger.debug('适配器', `对话创建成功, id: ${chatsNewBody.id}`, meta); + } catch (e) { + logger.error('适配器', '解析 chats/new 响应失败', { ...meta, error: e.message }); + return { error: '解析对话响应失败' }; + } + + // 7. 等待 chat/completions 响应 (状态码 200 且 status: true) + let completionsResponse; + try { + completionsResponse = await waitApiResponse(page, { + urlMatch: 'chat/completions', + method: 'POST', + timeout: 120000, + meta + }); + } catch (e) { + const pageError = normalizePageError(e, meta); + if (pageError) return pageError; + throw e; + } + + const completionsHttpError = normalizeHttpError(completionsResponse); + if (completionsHttpError) { + logger.error('适配器', `生成请求失败: ${completionsHttpError.error}`, meta); + return { error: `生成请求失败: ${completionsHttpError.error}` }; + } + + try { + const completionsBody = await completionsResponse.json(); + if (!completionsBody.status) { + logger.error('适配器', '生成响应 status 不为 true', meta); + return { error: '生成失败,响应状态异常' }; + } + logger.debug('适配器', '生成请求成功', meta); + } catch (e) { + logger.error('适配器', '解析 completions 响应失败', { ...meta, error: e.message }); + return { error: '解析生成响应失败' }; + } + + // 8. 等待并捕获图片下载响应 (zai.is/media/ GET 链接) + logger.debug('适配器', '已获取结果,正在等待图片...', meta); + + let imageResponse; + try { + imageResponse = await waitApiResponse(page, { + urlMatch: 'zai.is/media/', + method: 'GET', + timeout: 120000, + meta + }); + } catch (e) { + const pageError = normalizePageError(e, meta); + if (pageError) { + if (e.name === 'TimeoutError') { + return { error: '已获取结果, 但图片下载时超时 (120秒)' }; + } + return pageError; + } + throw e; + } + + // 下载图片 + const imageUrl = imageResponse.url(); + logger.info('适配器', '已获取结果,正在下载图片...', meta); + + const downloadResult = await downloadImage(imageUrl, config); + if (downloadResult.error) { + return downloadResult; + } + + logger.info('适配器', '已下载图片,任务完成', meta); + return { image: downloadResult.image }; + + } catch (err) { + // 顶层错误处理 + const pageError = normalizePageError(err, meta); + if (pageError) return pageError; + + logger.error('适配器', '生成任务失败', { ...meta, error: err.message }); + return { error: `生成任务失败: ${err.message}` }; + } finally { + // 清理拦截器 + await page.unroute('**/*').catch(() => { }); + // 任务结束,将鼠标移至安全区域 + await moveMouseAway(page); + } +} + +export { initBrowser, generateImage }; diff --git a/lib/backend/index.js b/lib/backend/index.js index aead505..35e356c 100644 --- a/lib/backend/index.js +++ b/lib/backend/index.js @@ -4,6 +4,7 @@ import { loadConfig } from '../utils/config.js'; import * as lmarenaBackend from './adapter/lmarena.js'; import * as geminiBackend from './adapter/gemini_biz.js'; import * as nanobananafreeBackend from './adapter/nanobananafree_ai.js'; +import * as zaiIsBackend from './adapter/zai_is.js'; // --- 集中管理的路径常量 --- const USER_DATA_DIR = path.join(process.cwd(), 'data', 'camoufoxUserData'); @@ -42,6 +43,13 @@ switch (config.backend?.type) { generateImage: (ctx, prompt, paths, model, meta) => nanobananafreeBackend.generateImage(ctx, prompt, paths, model, meta) }; break; + case 'zai_is': + activeBackend = { + name: 'zai_is', + initBrowser: (cfg) => zaiIsBackend.initBrowser(cfg), + generateImage: (ctx, prompt, paths, model, meta) => zaiIsBackend.generateImage(ctx, prompt, paths, model, meta) + }; + break; case 'lmarena': default: activeBackend = { diff --git a/lib/backend/models.js b/lib/backend/models.js index 1791bde..e274a12 100644 --- a/lib/backend/models.js +++ b/lib/backend/models.js @@ -155,6 +155,16 @@ export const NANOBANANAFREE_AI_MODELS = { } }; +// zai.is 后端模型配置 +export const ZAI_IS_MODELS = { + "gemini-3-pro-image-preview": { + imagePolicy: IMAGE_POLICY.OPTIONAL + }, + "gemini-2.5-flash-image": { + imagePolicy: IMAGE_POLICY.OPTIONAL + } +}; + /** * 获取后端对应的模型配置表 * @param {string} backendName - 后端名称 ('lmarena' 或 'gemini_biz' 或 'nanobananafree_ai') @@ -169,6 +179,8 @@ function getModelsConfigForBackend(backendName) { return GEMINI_BIZ_MODELS; case 'nanobananafree_ai': return NANOBANANAFREE_AI_MODELS; + case 'zai_is': + return ZAI_IS_MODELS; // 将来新增其它后端: // case 'foo_site': // return FOO_SITE_MODELS; diff --git a/lib/utils/config.js b/lib/utils/config.js index 5f8105c..8ccc217 100644 --- a/lib/utils/config.js +++ b/lib/utils/config.js @@ -49,6 +49,7 @@ backend: # - lmarena (LMArena) # - gemini_biz (Gemini Enterprise Business) # - nanobananafree_ai (Nano Banana Free) + # - zai_is (zAI) type: lmarena # Gemini Business 设置