feat: 修复 gemini_biz 因懒加载等待图片超时的问题

This commit is contained in:
foxhui
2025-12-25 02:29:43 +08:00
Unverified
parent 801806791d
commit f67eaa8cd5
5 changed files with 104 additions and 6 deletions
+5
View File
@@ -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
+12 -3
View File
@@ -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) {
+1
View File
@@ -215,6 +215,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
urlMatch: 'global/widgetStreamAssist',
method: 'POST',
timeout: 120000,
errorText: ['modelArmorViolation'],
meta
});
} catch (e) {
+1
View File
@@ -35,6 +35,7 @@ export {
tryGotoWithCheck,
moveMouseAway,
waitApiResponse,
scrollToElement,
} from './page.js';
// 错误归一化
+85 -3
View File
@@ -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<import('playwright-core').ElementHandle|null>} 元素句柄,失败返回 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<import('playwright-core').Response>} 响应对象
*/
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();
}