diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fc6d70..14e3fb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,13 @@ 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). -## [2.0.0] - 2025-12-06 +## [2.0.1] - 2025-12-09 + +### Fixed +- **修复超时逻辑** + - 修复在等待生成结果时超时,但是客户端任务未终止且无任何通知的问题 + +## [2.0.0] - 2025-12-08 ### Added - **自动续登** diff --git a/lib/backend/gemini_biz.js b/lib/backend/gemini_biz.js index 12a4c13..c1e50e7 100644 --- a/lib/backend/gemini_biz.js +++ b/lib/backend/gemini_biz.js @@ -10,7 +10,9 @@ import { safeClick, humanType, pasteImages, - getHumanClickPoint + getHumanClickPoint, + isPageValid, + createPageCloseWatcher } from '../browser/utils.js'; import { logger } from '../utils/logger.js'; @@ -327,70 +329,118 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { logger.info('适配器', '等待生成结果中...', meta); - // 6. 等待结果 (使用 Playwright waitForResponse) - // 我们需要等待两个响应: - // 1. widgetStreamAssist (API 响应,检查是否成功) - // 2. download/v1alpha/projects (图片下载请求) + // 6. 创建页面关闭监听器 + const pageWatcher = createPageCloseWatcher(page); - const apiResponsePromise = page.waitForResponse(response => - response.url().includes('global/widgetStreamAssist') && - response.request().method() === 'POST' && - (response.status() === 200 || response.status() >= 400), - { timeout: 120000 } - ).catch(e => e); + try { + // 检查页面状态 + if (!isPageValid(page)) { + throw new Error('PAGE_INVALID'); + } - const imageDownloadPromise = page.waitForResponse(response => - response.url().includes('download/v1alpha/projects') && - response.request().method() === 'GET' && - response.status() === 200, - { timeout: 120000 } - ).catch(e => e); + // 7. 等待 API 响应 (使用 Promise.race 监听页面事件) + const apiResponsePromise = page.waitForResponse(response => + response.url().includes('global/widgetStreamAssist') && + response.request().method() === 'POST' && + (response.status() === 200 || response.status() >= 400), + { timeout: 120000 } + ); - // 等待 API 响应 - const apiResponse = await apiResponsePromise; + const apiResponse = await Promise.race([ + apiResponsePromise, + pageWatcher.promise + ]).catch(e => { + // 错误分类 + if (e.message === 'PAGE_CLOSED') { + logger.error('适配器', '页面已关闭(API响应等待期间)', meta); + throw new Error('页面已关闭,请勿在生图过程中刷新页面'); + } + if (e.message === 'PAGE_CRASHED') { + logger.error('适配器', '页面崩溃(API响应等待期间)', meta); + throw new Error('页面崩溃,请重试'); + } + if (e.name === 'TimeoutError') { + logger.error('适配器', 'API 请求超时(120秒)', meta); + throw new Error('API 请求超时(120秒),请检查网络或稍后重试'); + } + throw e; + }); - if (apiResponse instanceof Error) { - throw apiResponse; + if (apiResponse.status() !== 200) { + logger.error('适配器', `API 返回错误状态码: ${apiResponse.status()}`, meta); + return { error: `API 错误: HTTP ${apiResponse.status()}` }; + } + + // 8. 等待图片下载响应 + logger.info('适配器', 'API 请求成功,等待图片下载...', meta); + + const imageDownloadPromise = page.waitForResponse(response => + response.url().includes('download/v1alpha/projects') && + response.request().method() === 'GET' && + response.status() === 200, + { timeout: 120000 } + ); + + const imageResponse = await Promise.race([ + imageDownloadPromise, + pageWatcher.promise + ]).catch(e => { + // 错误分类 + if (e.message === 'PAGE_CLOSED') { + logger.error('适配器', '页面已关闭(图片下载期间)', meta); + throw new Error('页面已关闭,请勿在生图过程中刷新页面'); + } + if (e.message === 'PAGE_CRASHED') { + logger.error('适配器', '页面崩溃(图片下载期间)', meta); + throw new Error('页面崩溃,请重试'); + } + if (e.name === 'TimeoutError') { + logger.error('适配器', 'API 请求成功,但图片下载超时(120秒)', meta); + throw new Error('API 请求成功,但图片下载超时(120秒),请重试'); + } + throw e; + }); + + logger.info('适配器', '捕获到图片下载请求', meta); + // 响应体本身就是 base64 字符串,直接获取文本即可,不需要再次 base64 编码 + const base64 = await imageResponse.text(); + const dataUri = `data:image/png;base64,${base64}`; + + logger.info('适配器', '生图成功', meta); + + // 任务结束,移开鼠标 + if (page.cursor) { + const currentVp = await getRealViewport(page); + const relativeX = currentVp.safeWidth * random(0.85, 0.95); + const relativeY = currentVp.height * random(0.3, 0.7); + const finalX = clamp(relativeX, 0, currentVp.safeWidth); + const finalY = clamp(relativeY, 0, currentVp.safeHeight); + await page.cursor.moveTo({ x: finalX, y: finalY }); + } + + return { image: dataUri }; + + } finally { + // 清理页面事件监听器 + pageWatcher.cleanup(); } - if (apiResponse.status() !== 200) { - logger.error('适配器', `请求返回错误状态码: ${apiResponse.status()}`, meta); - return { error: `API Error: ${apiResponse.status()}` }; - } - - // 等待图片下载响应 - logger.info('适配器', 'API 请求成功,等待图片下载...', meta); - const imageResponse = await imageDownloadPromise; - - if (imageResponse instanceof Error) { - throw imageResponse; - } - - logger.info('适配器', '捕获到图片下载请求', meta); - // 响应体本身就是 base64 字符串,直接获取文本即可,不需要再次 base64 编码 - const base64 = await imageResponse.text(); - const dataUri = `data:image/png;base64,${base64}`; - - logger.info('适配器', '生图成功', meta); - - // 任务结束,移开鼠标 - if (page.cursor) { - const currentVp = await getRealViewport(page); - const relativeX = currentVp.safeWidth * random(0.85, 0.95); - const relativeY = currentVp.height * random(0.3, 0.7); - const finalX = clamp(relativeX, 0, currentVp.safeWidth); - const finalY = clamp(relativeY, 0, currentVp.safeHeight); - await page.cursor.moveTo({ x: finalX, y: finalY }); - } - - return { image: dataUri }; - } catch (err) { + // 详细的错误分类和日志 + if (err.message === 'PAGE_INVALID') { + logger.error('适配器', '页面状态无效', meta); + return { error: '页面状态无效,请重新初始化' }; + } + logger.error('适配器', '生成任务失败', { ...meta, error: err.message }); return { error: err.message }; } finally { // 清理拦截器 - await page.unroute('**/*').catch(() => { }); + try { + await page.unroute('**/*').catch(() => { }); + } catch (e) { + // 忽略清理错误 + } } } diff --git a/lib/backend/lmarena.js b/lib/backend/lmarena.js index a8b38c1..9919be7 100644 --- a/lib/backend/lmarena.js +++ b/lib/backend/lmarena.js @@ -10,7 +10,9 @@ import { safeClick, humanType, pasteImages, - getHumanClickPoint + getHumanClickPoint, + isPageValid, + createPageCloseWatcher } from '../browser/utils.js'; import { logger } from '../utils/logger.js'; import { loadConfig } from '../utils/config.js'; @@ -130,81 +132,109 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { }); } - // 4. 建立响应监听器 - // 只要 URL 匹配且是 POST,无论状态码是 200 还是 429,都立即返回,防止超时死等 - const responsePromise = page.waitForResponse(response => - response.url().includes('/nextjs-api/stream') && - response.request().method() === 'POST' && - (response.status() === 200 || response.status() >= 400), - { timeout: 120000 } - ).catch(e => e); + // 4. 创建页面关闭监听器 + const pageWatcher = createPageCloseWatcher(page); - logger.debug('适配器', '点击发送...', meta); - const btnSelector = 'button[type="submit"]'; - await safeClick(page, btnSelector, { bias: 'button' }); - - logger.info('适配器', '等待生成结果...', meta); - - // 5. 等待并处理响应 - const response = await responsePromise; - - if (response instanceof Error) { - throw response; - } - - // 检查状态码 - if (response.status() === 429) { - logger.warn('适配器', '触发限流/人机验证', meta); - return { error: 'Rate limit exceeded or CAPTCHA triggered (HTTP 429)' }; - } - - if (response.status() !== 200) { - logger.warn('适配器', `返回异常状态码: ${response.status()}`, meta); - return { error: `Server error: HTTP ${response.status()}` }; - } - - // 解析成功响应 - const content = await response.text(); - - // 检查业务错误 - if (content.includes('recaptcha validation failed')) { - return { error: 'recaptcha validation failed' }; - } - - const img = extractImage(content); - if (img) { - logger.info('适配器', '已获取生图结果,正在下载图片...', meta); - try { - // 获取代理配置 - const config = loadConfig(); - const proxyConfig = getProxyConfig(config); - const proxyUrl = await getHttpProxy(proxyConfig); - - const options = { - url: img, - responseType: 'buffer', - http2: true, - headerGeneratorOptions: { - browsers: [{ name: 'firefox', minVersion: 100 }], - devices: ['desktop'], - locales: ['en-US'], - operatingSystems: ['windows'], - } - }; - - if (proxyUrl) { - options.proxyUrl = proxyUrl; - } - - const imgRes = await gotScraping(options); - const base64 = imgRes.body.toString('base64'); - return { image: `data:image/png;base64,${base64}` }; - } catch (e) { - return { error: `Image download failed: ${e.message}` }; + try { + // 检查页面状态 + if (!isPageValid(page)) { + throw new Error('PAGE_INVALID'); } - } else { - logger.info('适配器', 'AI 返回文本回复', { ...meta, preview: content.substring(0, 150) }); - return { text: content }; + + // 5. 点击发送按钮 + logger.debug('适配器', '点击发送...', meta); + const btnSelector = 'button[type="submit"]'; + await safeClick(page, btnSelector, { bias: 'button' }); + + logger.info('适配器', '等待生成结果...', meta); + + // 6. 等待响应 (使用 Promise.race) + const responsePromise = page.waitForResponse(response => + response.url().includes('/nextjs-api/stream') && + response.request().method() === 'POST' && + (response.status() === 200 || response.status() >= 400), + { timeout: 120000 } + ); + + const response = await Promise.race([ + responsePromise, + pageWatcher.promise + ]).catch(e => { + // 错误分类 + if (e.message === 'PAGE_CLOSED') { + logger.error('适配器', '页面已关闭(等待响应期间)', meta); + throw new Error('页面已关闭,请勿在生图过程中刷新页面'); + } + if (e.message === 'PAGE_CRASHED') { + logger.error('适配器', '页面崩溃(等待响应期间)', meta); + throw new Error('页面崩溃,请重试'); + } + if (e.name === 'TimeoutError') { + logger.error('适配器', 'API 请求超时(120秒)', meta); + throw new Error('API 请求超时(120秒),请检查网络或稍后重试'); + } + throw e; + }); + + // 检查状态码 + if (response.status() === 429 || content.includes('Too Many Requests')) { + logger.warn('适配器', '触发限流/上游繁忙', meta); + return { error: 'Rate limit exceeded or CAPTCHA triggered (HTTP 429)' }; + } + + if (response.status() !== 200) { + logger.warn('适配器', `返回异常状态码: ${response.status()}`, meta); + return { error: `Server error: HTTP ${response.status()}` }; + } + + // 解析成功响应 + const content = await response.text(); + + // 检查业务错误 + if (content.includes('recaptcha validation failed')) { + logger.warn('适配器', '触发人机验证', meta); + return { error: 'recaptcha validation failed' }; + } + + const img = extractImage(content); + if (img) { + logger.info('适配器', '已获取生图结果,正在下载图片...', meta); + try { + // 获取代理配置 + const config = loadConfig(); + const proxyConfig = getProxyConfig(config); + const proxyUrl = await getHttpProxy(proxyConfig); + + const options = { + url: img, + responseType: 'buffer', + http2: true, + headerGeneratorOptions: { + browsers: [{ name: 'firefox', minVersion: 100 }], + devices: ['desktop'], + locales: ['en-US'], + operatingSystems: ['windows'], + } + }; + + if (proxyUrl) { + options.proxyUrl = proxyUrl; + } + + const imgRes = await gotScraping(options); + const base64 = imgRes.body.toString('base64'); + return { image: `data:image/png;base64,${base64}` }; + } catch (e) { + return { error: `Image download failed: ${e.message}` }; + } + } else { + logger.info('适配器', 'AI 返回文本回复', { ...meta, preview: content.substring(0, 150) }); + return { text: content }; + } + + } finally { + // 清理页面事件监听器 + pageWatcher.cleanup(); } } catch (err) { diff --git a/lib/backend/nanobananafree_ai.js b/lib/backend/nanobananafree_ai.js index 162b1c3..1b79322 100644 --- a/lib/backend/nanobananafree_ai.js +++ b/lib/backend/nanobananafree_ai.js @@ -9,7 +9,9 @@ import { safeClick, humanType, pasteImages, - getHumanClickPoint + getHumanClickPoint, + isPageValid, + createPageCloseWatcher } from '../browser/utils.js'; import { logger } from '../utils/logger.js'; @@ -88,61 +90,87 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { await humanType(page, textareaSelector, prompt); await sleep(800, 1500); - // 3. 建立响应监听器 - // 监听包含 v1/generateContent 路径的 POST 请求 - const responsePromise = page.waitForResponse(response => - response.url().includes('v1/generateContent') && - response.request().method() === 'POST' && - (response.status() === 200 || response.status() >= 400), - { timeout: 120000 } - ).catch(e => e); + // 3. 创建页面关闭监听器 + const pageWatcher = createPageCloseWatcher(page); - // 4. 点击发送按钮 (匹配 class 包含 _sendButton_ 的 div) - logger.debug('适配器', '点击发送...', meta); - - // 使用更通用的选择器匹配发送按钮 - const sendBtnSelector = 'div[class*="_sendButton_"]'; - await page.waitForSelector(sendBtnSelector, { timeout: 10000 }); - await safeClick(page, sendBtnSelector, { bias: 'button' }); - - logger.info('适配器', '等待生成结果...', meta); - - // 5. 等待并处理响应 - const response = await responsePromise; - - if (response instanceof Error) { - throw response; - } - - // 检查状态码 - if (response.status() !== 200) { - // 非200状态,尝试读取错误信息 - try { - const body = await response.json(); - const errMessage = body?.errMessage || body?.error?.message || `HTTP ${response.status()}`; - logger.warn('适配器', `请求返回错误: ${errMessage}`, meta); - return { error: errMessage }; - } catch (e) { - logger.warn('适配器', `返回异常状态码: ${response.status()}`, meta); - return { error: `Server error: HTTP ${response.status()}` }; + try { + // 检查页面状态 + if (!isPageValid(page)) { + throw new Error('PAGE_INVALID'); } - } - // 解析成功响应 - const body = await response.json(); + // 4. 点击发送按钮 (匹配 class 包含 _sendButton_ 的 div) + logger.debug('适配器', '点击发送...', meta); - // 尝试从响应中提取 base64 图片 - // 路径: data.candidates[0].content.parts[0].inlineData.data - const inlineData = body?.data?.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data; + // 使用更通用的选择器匹配发送按钮 + const sendBtnSelector = 'div[class*="_sendButton_"]'; + await page.waitForSelector(sendBtnSelector, { timeout: 10000 }); + await safeClick(page, sendBtnSelector, { bias: 'button' }); - if (inlineData) { - logger.info('适配器', '已获取生图结果', meta); - // 返回带有 data URI 前缀的 base64 图片 - return { image: `data:image/png;base64,${inlineData}` }; - } else { - // 没有找到图片数据,可能是文本回复或其他格式 - logger.info('适配器', 'AI 返回非图片响应', { ...meta, preview: JSON.stringify(body).substring(0, 150) }); - return { text: JSON.stringify(body) }; + logger.info('适配器', '等待生成结果...', meta); + + // 5. 等待响应 (使用 Promise.race) + const responsePromise = page.waitForResponse(response => + response.url().includes('v1/generateContent') && + response.request().method() === 'POST' && + (response.status() === 200 || response.status() >= 400), + { timeout: 120000 } + ); + + const response = await Promise.race([ + responsePromise, + pageWatcher.promise + ]).catch(e => { + // 错误分类 + if (e.message === 'PAGE_CLOSED') { + logger.error('适配器', '页面已关闭(等待响应期间)', meta); + throw new Error('页面已关闭,请勿在生图过程中刷新页面'); + } + if (e.message === 'PAGE_CRASHED') { + logger.error('适配器', '页面崩溃(等待响应期间)', meta); + throw new Error('页面崩溃,请重试'); + } + if (e.name === 'TimeoutError') { + logger.error('适配器', 'API 请求超时(120秒)', meta); + throw new Error('API 请求超时(120秒),请检查网络或稍后重试'); + } + throw e; + }); + + // 检查状态码 + if (response.status() !== 200) { + // 非200状态,尝试读取错误信息 + try { + const body = await response.json(); + const errMessage = body?.errMessage || body?.error?.message || `HTTP ${response.status()}`; + logger.warn('适配器', `请求返回错误: ${errMessage}`, meta); + return { error: errMessage }; + } catch (e) { + logger.warn('适配器', `返回异常状态码: ${response.status()}`, meta); + return { error: `Server error: HTTP ${response.status()}` }; + } + } + + // 解析成功响应 + const body = await response.json(); + + // 尝试从响应中提取 base64 图片 + // 路径: data.candidates[0].content.parts[0].inlineData.data + const inlineData = body?.data?.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data; + + if (inlineData) { + logger.info('适配器', '已获取生图结果', meta); + // 返回带有 data URI 前缀的 base64 图片 + return { image: `data:image/png;base64,${inlineData}` }; + } else { + // 没有找到图片数据,可能是文本回复或其他格式 + logger.info('适配器', 'AI 返回非图片响应', { ...meta, preview: JSON.stringify(body).substring(0, 150) }); + return { text: JSON.stringify(body) }; + } + + } finally { + // 清理页面事件监听器 + pageWatcher.cleanup(); } } catch (err) { diff --git a/lib/browser/utils.js b/lib/browser/utils.js index 96239f9..a0b4e27 100644 --- a/lib/browser/utils.js +++ b/lib/browser/utils.js @@ -405,3 +405,40 @@ export async function pasteImages(page, target, filePaths, options = {}) { throw e; } } + +/** + * 检查页面是否有效 + * @param {import('playwright-core').Page} page + * @returns {boolean} + */ +export function isPageValid(page) { + try { + return page && !page.isClosed(); + } catch { + return false; + } +} + +/** + * 创建页面关闭/崩溃监听Promise + * @param {import('playwright-core').Page} page + * @returns {{promise: Promise, cleanup: Function}} + */ +export function createPageCloseWatcher(page) { + let closeHandler, crashHandler; + + const promise = new Promise((_, reject) => { + closeHandler = () => reject(new Error('PAGE_CLOSED')); + crashHandler = () => reject(new Error('PAGE_CRASHED')); + + page.once('close', closeHandler); + page.once('crash', crashHandler); + }); + + const cleanup = () => { + if (closeHandler) page.off('close', closeHandler); + if (crashHandler) page.off('crash', crashHandler); + }; + + return { promise, cleanup }; +}