From f67eaa8cd538af768eea8cbdc5dce8425c456f33 Mon Sep 17 00:00:00 2001 From: foxhui Date: Thu, 25 Dec 2025 02:29:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E5=A4=8D=20gemini=5Fbiz=20?= =?UTF-8?q?=E5=9B=A0=E6=87=92=E5=8A=A0=E8=BD=BD=E7=AD=89=E5=BE=85=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E8=B6=85=E6=97=B6=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 5 ++ src/backend/adapter/gemini_biz.js | 15 ++++- src/backend/adapter/gemini_biz_text.js | 1 + src/backend/utils/index.js | 1 + src/backend/utils/page.js | 88 +++++++++++++++++++++++++- 5 files changed, 104 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b80513..620f344 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ 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.4.1] - 2025-12-24 + +### 🐛 Fixed +- **Gemini Business**:修复因懒加载导致的等待图片超时问题 + ## [3.4.0] - 2025-12-23 ### ✨ Added diff --git a/src/backend/adapter/gemini_biz.js b/src/backend/adapter/gemini_biz.js index 137249c..f3dc74b 100644 --- a/src/backend/adapter/gemini_biz.js +++ b/src/backend/adapter/gemini_biz.js @@ -19,7 +19,8 @@ import { unlockPageAuth, isPageAuthLocked, waitForInput, - gotoWithCheck + gotoWithCheck, + scrollToElement } from '../utils/index.js'; import { logger } from '../../utils/logger.js'; @@ -190,7 +191,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { await route.continue(); }); - // 5. 提交 (submit - 使用公共函数) + // 5. 提交 logger.debug('适配器', '点击发送...', meta); await submit(page, { btnSelector: 'md-icon-button.send-button.submit, button[aria-label="提交"], button[aria-label="Send"], .send-button', @@ -207,6 +208,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { urlMatch: 'global/widgetStreamAssist', method: 'POST', timeout: 120000, + errorText: ['modelArmorViolation'], meta }); } catch (e) { @@ -227,12 +229,19 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { let imageResponse; try { - imageResponse = await waitApiResponse(page, { + // 先启动监听器,再滚动触发懒加载,避免错过请求 + const imageResponsePromise = waitApiResponse(page, { urlMatch: 'download/v1alpha/projects', method: 'GET', timeout: 120000, + errorText: ['is unable to reply as the prompt'], meta }); + + // 等待图片元素出现并滚动到可视范围,触发懒加载 + await scrollToElement(page, 'ucs-markdown-image', { timeout: 10000 }); + + imageResponse = await imageResponsePromise; } catch (e) { const pageError = normalizePageError(e, meta); if (pageError) { diff --git a/src/backend/adapter/gemini_biz_text.js b/src/backend/adapter/gemini_biz_text.js index ba781f8..bfa7819 100644 --- a/src/backend/adapter/gemini_biz_text.js +++ b/src/backend/adapter/gemini_biz_text.js @@ -215,6 +215,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { urlMatch: 'global/widgetStreamAssist', method: 'POST', timeout: 120000, + errorText: ['modelArmorViolation'], meta }); } catch (e) { diff --git a/src/backend/utils/index.js b/src/backend/utils/index.js index 85954f8..c3b1188 100644 --- a/src/backend/utils/index.js +++ b/src/backend/utils/index.js @@ -35,6 +35,7 @@ export { tryGotoWithCheck, moveMouseAway, waitApiResponse, + scrollToElement, } from './page.js'; // 错误归一化 diff --git a/src/backend/utils/page.js b/src/backend/utils/page.js index 424722d..62f7e76 100644 --- a/src/backend/utils/page.js +++ b/src/backend/utils/page.js @@ -215,23 +215,76 @@ export async function moveMouseAway(page) { } /** - * 等待 API 响应 (带页面关闭监听) + * 等待元素出现并滚动到可视范围 + * @param {import('playwright-core').Page} page - Playwright 页面对象 + * @param {string|import('playwright-core').Locator} selectorOrLocator - CSS 选择器或 Locator 对象 + * @param {object} [options={}] - 选项 + * @param {number} [options.timeout=30000] - 超时时间(毫秒) + * @returns {Promise} 元素句柄,失败返回 null + */ +export async function scrollToElement(page, selectorOrLocator, options = {}) { + const { timeout = 30000 } = options; + try { + const isLocator = typeof selectorOrLocator !== 'string'; + let element; + + if (isLocator) { + // Locator 对象 (getByRole, getByText 等) + await selectorOrLocator.first().waitFor({ timeout, state: 'attached' }); + element = await selectorOrLocator.first().elementHandle(); + } else { + // CSS 选择器字符串 + element = await page.waitForSelector(selectorOrLocator, { timeout, state: 'attached' }); + } + + if (element) { + await element.scrollIntoViewIfNeeded(); + return element; + } + } catch { + // 元素未找到或超时 + } + return null; +} + + +/** + * 等待 API 响应 (带页面关闭监听和错误关键词检测) * @param {import('playwright-core').Page} page - Playwright 页面对象 * @param {object} options - 等待选项 * @param {string} options.urlMatch - URL 匹配字符串 * @param {string|string[]} [options.urlContains] - URL 必须额外包含的字符串(可选,可以是数组) * @param {string} [options.method='POST'] - HTTP 方法 * @param {number} [options.timeout=120000] - 超时时间(毫秒) + * @param {string|string[]} [options.errorText] - 错误关键词,页面 UI 或 API 响应体中出现时立即停止并返回错误 * @returns {Promise} 响应对象 */ export async function waitApiResponse(page, options = {}) { - const { urlMatch, urlContains, method = 'POST', timeout = 120000 } = options; + const { urlMatch, urlContains, method = 'POST', timeout = 120000, errorText } = options; if (!isPageValid(page)) { throw new Error('PAGE_INVALID'); } const pageWatcher = createPageCloseWatcher(page); + const patterns = errorText ? (Array.isArray(errorText) ? errorText : [errorText]) : []; + + // 页面 UI 错误关键词检测 + let uiErrorPromise = null; + if (patterns.length > 0) { + let combinedLocator = null; + for (const pattern of patterns) { + const loc = page.getByText(pattern); + combinedLocator = combinedLocator ? combinedLocator.or(loc) : loc; + } + if (combinedLocator) { + uiErrorPromise = combinedLocator.first().waitFor({ timeout, state: 'attached' }) + .then(async () => { + const matchedText = await combinedLocator.first().textContent().catch(() => '未知错误'); + throw new Error(`PAGE_ERROR_DETECTED: ${matchedText}`); + }); + } + } try { const responsePromise = page.waitForResponse( @@ -254,7 +307,36 @@ export async function waitApiResponse(page, options = {}) { { timeout } ); - return await Promise.race([responsePromise, pageWatcher.promise]); + const promises = [responsePromise, pageWatcher.promise]; + if (uiErrorPromise) promises.push(uiErrorPromise); + + const response = await Promise.race(promises); + + // API 响应体错误关键词检测 (在返回前同步检查) + if (patterns.length > 0) { + try { + // 使用 body() 获取 Buffer,避免 text() 的某些内部状态问题 + const bodyBuffer = await response.body(); + const body = bodyBuffer.toString('utf-8'); + for (const pattern of patterns) { + const keyword = typeof pattern === 'string' ? pattern : pattern.source; + if (body.includes(keyword)) { + throw new Error(`API_ERROR_DETECTED: ${keyword}`); + } + } + // 返回代理对象,缓存 body 以支持调用方重复读取 + const cachedResponse = Object.create(response); + cachedResponse.text = async () => body; + cachedResponse.json = async () => JSON.parse(body); + cachedResponse.body = async () => bodyBuffer; + return cachedResponse; + } catch (e) { + if (e.message.startsWith('API_ERROR_DETECTED')) throw e; + // 如果读取响应体失败,直接返回原始 response + } + } + + return response; } finally { pageWatcher.cleanup(); }