feat: 优化点击等待,补充缺少任务ID的日志等

This commit is contained in:
foxhui
2026-01-16 03:29:13 +08:00
Unverified
parent 3acd8bb17c
commit c0250e43a0
25 changed files with 208 additions and 112 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
}
return false;
}
});
}, meta);
logger.info('适配器', '图片上传完成', meta);
}
+1 -1
View File
@@ -132,7 +132,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
}
return false;
}
});
}, meta);
}
// 3. 输入提示词
+1 -1
View File
@@ -71,7 +71,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
url.includes('bytedanceapi.com') &&
url.includes('Action=CommitImageUpload');
}
});
}, meta);
logger.info('适配器', '图片上传完成', meta);
}
+1 -1
View File
@@ -60,7 +60,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
url.includes('bytedanceapi.com') &&
url.includes('Action=CommitImageUpload');
}
});
}, meta);
logger.info('适配器', '图片上传完成', meta);
}
+1 -1
View File
@@ -60,7 +60,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
url.includes('google.com/upload/') &&
url.includes('upload_id=');
}
});
}, meta);
logger.info('适配器', '图片上传完成', meta);
}
+7 -2
View File
@@ -105,7 +105,12 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
const targetUrl = config.backend?.adapter?.gemini_biz?.entryUrl || config.backend?.geminiBiz?.entryUrl;
if (!targetUrl) {
throw new Error('GeminiBiz backend missing entry URL');
throw new Error('未填写 gemini_biz 适配器的 entry URL');
}
// 验证 URL 域名
if (!targetUrl.includes('business.gemini.google')) {
throw new Error('无效的 Gemini Business URL,必须包含 business.gemini.google 域名');
}
// 开启新对话 - 先等待可能正在进行的登录处理完成
@@ -129,7 +134,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
// 只追踪 widgetAddContextFile 请求,每个请求代表一张图片上传
return response.status() === 200 && url.includes('global/widgetAddContextFile');
}
});
}, meta);
logger.info('适配器', '图片上传完成', meta);
}
+7 -2
View File
@@ -104,7 +104,12 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
const targetUrl = config.backend?.adapter?.gemini_biz?.entryUrl || config.backend?.geminiBiz?.entryUrl;
if (!targetUrl) {
throw new Error('GeminiBiz backend missing entry URL');
throw new Error('未填写 gemini_biz 适配器的 entry URL');
}
// 验证 URL 域名
if (!targetUrl.includes('business.gemini.google')) {
throw new Error('无效的 Gemini Business URL,必须包含 business.gemini.google 域名');
}
// 开启新对话 - 先等待可能正在进行的登录处理完成
@@ -128,7 +133,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
// 只追踪 widgetAddContextFile 请求,每个请求代表一张图片上传
return response.status() === 200 && url.includes('global/widgetAddContextFile');
}
});
}, meta);
logger.info('适配器', '图片上传完成', meta);
}
+1 -1
View File
@@ -57,7 +57,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
url.includes('google.com/upload/') &&
url.includes('upload_id=');
}
});
}, meta);
logger.info('适配器', '图片上传完成', meta);
}
+1 -1
View File
@@ -152,7 +152,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
// 5.2 点击 upload 按钮并选择文件(不等待上传完成)
const uploadBtn = page.getByRole('button', { name: /^upload/ });
await uploadFilesViaChooser(page, uploadBtn, [imgPath]);
await uploadFilesViaChooser(page, uploadBtn, [imgPath], {}, meta);
// 5.3 先启动上传监听,再点击 crop 按钮
const uploadResponsePromise = waitApiResponse(page, {
+1 -1
View File
@@ -90,7 +90,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
// 3. 上传图片
if (imgPaths && imgPaths.length > 0) {
logger.info('适配器', `开始上传 ${imgPaths.length} 张图片`, meta);
await pasteImages(page, textareaSelector, imgPaths);
await pasteImages(page, textareaSelector, imgPaths, {}, meta);
logger.info('适配器', '图片上传完成', meta);
}
+1 -1
View File
@@ -75,7 +75,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
// 3. 上传图片
if (imgPaths && imgPaths.length > 0) {
logger.info('适配器', `开始上传 ${imgPaths.length} 张图片`, meta);
await pasteImages(page, textareaSelector, imgPaths);
await pasteImages(page, textareaSelector, imgPaths, {}, meta);
logger.info('适配器', '图片上传完成', meta);
}
+1 -1
View File
@@ -50,7 +50,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
if (imgPaths.length > 1) {
logger.warn('适配器', `此后端仅支持1张图片, 已丢弃 ${imgPaths.length - 1}`, meta);
}
await pasteImages(page, textareaSelector, singleImage);
await pasteImages(page, textareaSelector, singleImage, {}, meta);
logger.info('适配器', '图片上传完成', meta);
}
+1 -1
View File
@@ -63,7 +63,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
}
return false;
}
});
}, meta);
logger.info('适配器', '图片上传完成', meta);
}
+1 -1
View File
@@ -150,7 +150,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
}
return false;
}
});
}, meta);
logger.info('适配器', '图片上传完成', meta);
}
+1 -1
View File
@@ -167,7 +167,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
}
return false;
}
});
}, meta);
logger.info('适配器', '图片上传完成', meta);
}
+1 -1
View File
@@ -83,7 +83,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
}
return false;
}
});
}, meta);
logger.info('适配器', '图片上传完成', meta);
}
+102 -38
View File
@@ -22,6 +22,7 @@
import path from 'path';
import { logger } from '../../utils/logger.js';
import { TIMEOUTS } from '../../utils/constants.js';
/**
* 生成指定范围内的随机数
@@ -152,6 +153,69 @@ export function getHumanClickPoint(box, type = 'random') {
return { x, y };
}
/**
* 等待元素布局稳定(基于 requestAnimationFrame
* @param {import('playwright-core').Locator} locator - 建议直接传 Locator
* @param {number} stableFrames - 需要连续稳定的帧数,建议提高到 10 (约160ms)
* @param {number} timeout - 总超时时间 (ms)
*/
async function waitForElementStable(locator, stableFrames = 10, timeout = 2000) {
const element = await locator.elementHandle();
if (!element) return;
try {
await element.evaluate((targetEl, { stableFrames, timeout }) => {
return new Promise((resolve) => {
let lastRect = targetEl.getBoundingClientRect();
let consecutiveStable = 0;
const startTime = performance.now();
function check() {
// 1. 超时检查:如果超过总时间,不再等待直接返回,防止死循环
if (performance.now() - startTime > timeout) {
resolve();
return;
}
const rect = targetEl.getBoundingClientRect();
// 检查位置和大小是否变化 (容差 1px)
const isSame =
Math.abs(rect.x - lastRect.x) < 1 &&
Math.abs(rect.y - lastRect.y) < 1 &&
Math.abs(rect.width - lastRect.width) < 1 &&
Math.abs(rect.height - lastRect.height) < 1;
if (isSame) {
consecutiveStable++;
// 2. 只有连续 N 帧都不动才确定
if (consecutiveStable >= stableFrames) {
resolve();
return;
}
} else {
// 只要动了一次,计数器归零,重新开始计数
consecutiveStable = 0;
lastRect = rect;
}
requestAnimationFrame(check);
}
// 3. 稍微延迟启动检测,给响应式框架留启动时间
setTimeout(() => {
requestAnimationFrame(check);
}, 50);
});
}, { stableFrames, timeout });
} catch (e) {
console.log('Stability check failed, continuing anyway:', e);
} finally {
// 清理 handle,防止内存泄漏
await element.dispose();
}
}
/**
* 安全点击元素 (包含滚动、拟人化移动和点击)
* 支持 CSS selector、ElementHandle 和 Locator 三种输入
@@ -161,12 +225,13 @@ export function getHumanClickPoint(box, type = 'random') {
* @param {string} [options.bias='random'] - 偏移偏好: 'input' 或 'random'
* @param {number} [options.clickCount=1] - 点击次数: 1=单击, 2=双击
* @param {number} [options.timeout=15000] - 超时时间 (毫秒)
* @param {boolean} [options.waitStable=false] - 是否等待元素布局稳定后再点击
* @returns {Promise<void>}
*/
export async function safeClick(page, target, options = {}) {
const clickCount = options.clickCount || 1;
const timeout = options.timeout || 15000;
const maxRetries = 1;
const timeout = options.timeout || TIMEOUTS.ELEMENT_CLICK;
const waitStable = options.waitStable !== false; // 默认 true
const doClick = async () => {
let el;
@@ -189,6 +254,11 @@ export async function safeClick(page, target, options = {}) {
// 确保元素在可视区域内
await el.scrollIntoViewIfNeeded().catch(() => { });
// 如果开启了布局稳定等待,等待元素位置稳定
if (waitStable) {
await waitForElementStable(page, el);
}
// 使用 ghost-cursor 点击
if (page.cursor) {
const box = await el.boundingBox();
@@ -207,33 +277,25 @@ export async function safeClick(page, target, options = {}) {
await el.click({ clickCount });
};
// 带超时和重试的执行
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
await Promise.race([
doClick(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('CLICK_TIMEOUT')), timeout)
)
]);
return; // 成功则退出
} catch (err) {
const isTimeout = err.message === 'CLICK_TIMEOUT';
const isLastAttempt = attempt === maxRetries;
// 带超时的执行(移除了重试机制)
let timeoutId;
try {
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => reject(new Error('CLICK_TIMEOUT')), timeout);
});
if (isLastAttempt) {
// 最后一次尝试失败,抛出明确的错误
const selector = typeof target === 'string' ? target : '元素';
throw new Error(`点击操作失败 (${selector}): ${isTimeout ? '超时' : err.message}`);
}
// 非最后一次,记录日志并重试
logger.warn('浏览器', `点击操作${isTimeout ? '超时' : '失败'},正在重试... (${attempt + 1}/${maxRetries + 1})`);
await sleep(300, 500);
}
await Promise.race([
doClick().finally(() => clearTimeout(timeoutId)),
timeoutPromise
]);
} catch (err) {
clearTimeout(timeoutId);
const selector = typeof target === 'string' ? target : '元素';
throw new Error(`点击操作失败 (${selector}): ${err.message}`);
}
}
/**
* 安全滚动 (包含拟人化移动和滚轮滚动)
* 支持 CSS selector、ElementHandle 和 Locator 三种输入
@@ -432,11 +494,12 @@ async function findAllFileInputs(page) {
* @param {string[]} filePaths - 图片文件路径数组
* @param {Object} [options] - 可选配置
* @param {Function} [options.uploadValidator] - 自定义上传确认回调函数, 接收 response 参数
* @param {Object} [meta] - 元数据 (用于日志)
* @returns {Promise<void>}
*/
export async function pasteImages(page, target, filePaths, options = {}) {
export async function pasteImages(page, target, filePaths, options = {}, meta = {}) {
if (!filePaths || filePaths.length === 0) return;
logger.info('浏览器', `正在处理 ${filePaths.length} 张图片...`);
logger.info('浏览器', `正在处理 ${filePaths.length} 张图片...`, meta);
// 1. 拟人化: 先点击一下目标区域 (让后台看起来像是用户聚焦了输入框)
await safeClick(page, target, { bias: 'input' });
@@ -450,7 +513,7 @@ export async function pasteImages(page, target, filePaths, options = {}) {
throw new Error('未找到任何 input[type="file"] 控件,无法上传');
}
logger.info('浏览器', `找到 ${fileInputs.length} 个文件输入框,尝试上传...`);
logger.info('浏览器', `找到 ${fileInputs.length} 个文件输入框,尝试上传...`, meta);
// LMArena 通常只有一个用于聊天的上传控件,或者我们尝试第一个可用的
// 如果有多个,通常最后一个是当前对话框的,或者我们可以尝试全部 (比较暴力但有效)
@@ -485,14 +548,14 @@ export async function pasteImages(page, target, filePaths, options = {}) {
const uploadPromise = new Promise((resolve) => {
const timeout = setTimeout(() => {
cleanup();
logger.warn('浏览器', `图片上传等待超时 (已确认: ${validatedCount}/${expectedUploads})`);
logger.warn('浏览器', `图片上传等待超时 (已确认: ${validatedCount}/${expectedUploads})`, meta);
resolve();
}, 60000); // 60s 超时
const onResponse = (response) => {
if (options.uploadValidator(response)) {
validatedCount++;
logger.info('浏览器', `图片上传进度: ${validatedCount}/${expectedUploads}`);
logger.info('浏览器', `图片上传进度: ${validatedCount}/${expectedUploads}`, meta);
if (validatedCount >= expectedUploads) {
cleanup();
resolve();
@@ -508,12 +571,12 @@ export async function pasteImages(page, target, filePaths, options = {}) {
page.on('response', onResponse);
});
logger.info('浏览器', `已提交图片, 正在等待上传确认...`);
logger.info('浏览器', `已提交图片, 正在等待上传确认...`, meta);
await uploadPromise;
logger.info('浏览器', `所有图片上传完成`);
logger.info('浏览器', `所有图片上传完成`, meta);
} else {
// 默认行为: 等待上传预览出现
logger.info('浏览器', `已提交图片, 等待预览生成...`);
logger.info('浏览器', `已提交图片, 等待预览生成...`, meta);
await sleep(500, 1000);
}
@@ -532,9 +595,10 @@ export async function pasteImages(page, target, filePaths, options = {}) {
* @param {Function} [options.uploadValidator] - 自定义上传确认回调函数, 接收 response 参数,返回 true 表示该响应代表一次成功上传
* @param {number} [options.timeout=60000] - 上传超时时间 (毫秒)
* @param {string} [options.clickAction='click'] - 点击动作: 'click' 或 'dblclick'
* @param {Object} [meta] - 元数据 (用于日志)
* @returns {Promise<void>}
*/
export async function uploadFilesViaChooser(page, triggerTarget, filePaths, options = {}) {
export async function uploadFilesViaChooser(page, triggerTarget, filePaths, options = {}, meta = {}) {
if (!filePaths || filePaths.length === 0) return;
const timeout = options.timeout || 60000;
@@ -542,7 +606,7 @@ export async function uploadFilesViaChooser(page, triggerTarget, filePaths, opti
const expectedUploads = filePaths.length;
let uploadedCount = 0;
logger.info('浏览器', `正在处理 ${filePaths.length} 张图片 (filechooser 模式)...`);
logger.info('浏览器', `正在处理 ${filePaths.length} 张图片 (filechooser 模式)...`, meta);
// 设置上传确认监听
const uploadPromise = new Promise((resolve) => {
@@ -554,14 +618,14 @@ export async function uploadFilesViaChooser(page, triggerTarget, filePaths, opti
const timeoutId = setTimeout(() => {
cleanup();
logger.warn('浏览器', `图片上传等待超时 (已确认: ${uploadedCount}/${expectedUploads})`);
logger.warn('浏览器', `图片上传等待超时 (已确认: ${uploadedCount}/${expectedUploads})`, meta);
resolve();
}, timeout);
const onResponse = (response) => {
if (options.uploadValidator(response)) {
uploadedCount++;
logger.info('浏览器', `图片上传进度: ${uploadedCount}/${expectedUploads}`);
logger.info('浏览器', `图片上传进度: ${uploadedCount}/${expectedUploads}`, meta);
if (uploadedCount >= expectedUploads) {
cleanup();
resolve();
@@ -592,7 +656,7 @@ export async function uploadFilesViaChooser(page, triggerTarget, filePaths, opti
// 等待上传完成(如果有验证器)
if (options.uploadValidator) {
await uploadPromise;
logger.info('浏览器', '所有图片上传完成');
logger.info('浏览器', '所有图片上传完成', meta);
}
}
+1
View File
@@ -23,6 +23,7 @@ export class PoolManager {
this.strategy = config.backend.pool.strategy || 'least_busy';
this.strategySelector = createStrategySelector(this.strategy);
this.initialized = false;
this.roundRobinIndex = 0;
}
/**
+5 -1
View File
@@ -74,7 +74,11 @@ export class Worker {
navigationHandler = handlers.length > 0
? async (page) => {
for (const handler of handlers) {
try { await handler(page); } catch (e) { /* ignore */ }
try {
await handler(page);
} catch (e) {
logger.debug('工作池', `导航处理器执行失败: ${e.message}`);
}
}
}
: null;
+1 -1
View File
@@ -4,7 +4,7 @@
*/
import { logger } from '../../utils/logger.js';
import { ADAPTER_ERRORS } from '../../utils/constants.js';
import { ADAPTER_ERRORS } from '../../server/errors.js';
// ==========================================
// 可重试判定
+5 -4
View File
@@ -4,6 +4,7 @@
*/
import { sleep, safeClick, isPageValid, createPageCloseWatcher, getRealViewport, clamp, random } from '../engine/utils.js';
import { TIMEOUTS } from '../../utils/constants.js';
// ==========================================
// 页面认证锁
@@ -58,7 +59,7 @@ export function isPageAuthLocked(page) {
* @returns {Promise<void>}
*/
export async function waitForInput(page, selectorOrLocator, options = {}) {
const { timeout = 20000, click = true } = options;
const { timeout = TIMEOUTS.INPUT_WAIT, click = true } = options;
const isLocator = typeof selectorOrLocator !== 'string';
const displayName = isLocator ? 'Locator' : selectorOrLocator;
@@ -104,7 +105,7 @@ export async function waitForInput(page, selectorOrLocator, options = {}) {
* @throws {Error} 导航失败时抛出错误
*/
export async function gotoWithCheck(page, url, options = {}) {
const { timeout = 20000 } = options;
const { timeout = TIMEOUTS.NAVIGATION } = options;
try {
const response = await page.goto(url, {
waitUntil: 'domcontentloaded',
@@ -172,7 +173,7 @@ export async function moveMouseAway(page) {
* @returns {Promise<import('playwright-core').ElementHandle|null>} 元素句柄,失败返回 null
*/
export async function scrollToElement(page, selectorOrLocator, options = {}) {
const { timeout = 30000 } = options;
const { timeout = TIMEOUTS.ELEMENT_SCROLL } = options;
try {
const isLocator = typeof selectorOrLocator !== 'string';
let element;
@@ -209,7 +210,7 @@ export async function scrollToElement(page, selectorOrLocator, options = {}) {
* @returns {Promise<import('playwright-core').Response>} 响应对象
*/
export async function waitApiResponse(page, options = {}) {
const { urlMatch, urlContains, method = 'POST', timeout = 120000, errorText } = options;
const { urlMatch, urlContains, method = 'POST', timeout = TIMEOUTS.API_RESPONSE, errorText } = options;
if (!isPageValid(page)) {
throw new Error('PAGE_INVALID');
+41
View File
@@ -142,3 +142,44 @@ export function getErrorStatus(code) {
export function getErrorDetails(code) {
return ERROR_DETAILS[code] || { message: '未知错误', status: 500 };
}
// ==========================================
// 适配器层错误码(从 constants.js 统一到此处)
// ==========================================
/**
* 适配器错误码
* @readonly
*/
export const ADAPTER_ERRORS = {
/** 页面已关闭 */
PAGE_CLOSED: 'PAGE_CLOSED',
/** 页面崩溃 */
PAGE_CRASHED: 'PAGE_CRASHED',
/** 页面状态无效 */
PAGE_INVALID: 'PAGE_INVALID',
/** 网络错误 */
NETWORK_ERROR: 'NETWORK_ERROR',
/** 超时错误 */
TIMEOUT_ERROR: 'TIMEOUT_ERROR',
/** HTTP 错误 */
HTTP_ERROR: 'HTTP_ERROR',
/** 限流 */
RATE_LIMITED: 'RATE_LIMITED',
/** 需要验证码 */
CAPTCHA_REQUIRED: 'CAPTCHA_REQUIRED',
/** 需要登录 */
AUTH_REQUIRED: 'AUTH_REQUIRED',
/** 内容被阻止 (API/页面检测到错误关键词) */
CONTENT_BLOCKED: 'CONTENT_BLOCKED',
};
+6 -2
View File
@@ -77,9 +77,13 @@ export function createQueueManager(queueConfig, callbacks) {
*/
async function cleanupTask(task) {
if (task?.imagePaths) {
const fs = await import('fs');
const fs = await import('fs/promises');
for (const p of task.imagePaths) {
try { fs.unlinkSync(p); } catch (e) { /* ignore */ }
try {
await fs.unlink(p);
} catch (e) {
logger.debug('服务器', `临时文件清理失败: ${p}`);
}
}
}
}
+5
View File
@@ -58,6 +58,11 @@ const PORT = config.server?.port || 3000;
/** @type {string} 认证令牌 */
const AUTH_TOKEN = config.server?.auth;
// 检测默认密钥
if (AUTH_TOKEN === 'sk-change-me-to-your-secure-key') {
logger.warn('服务器', '检测到默认密钥!如果在公网环境下请修改默认密钥');
}
/** @type {string} 心跳模式 */
const KEEPALIVE_MODE = config.server?.keepalive?.mode || 'comment';
+14 -48
View File
@@ -12,24 +12,30 @@
* @readonly
*/
export const TIMEOUTS = {
/** 导航超时(页面跳转) */
NAVIGATION: 30000,
/** 元素点击超时 (safeClick) */
ELEMENT_CLICK: 15000,
/** 输入框等待超时 (waitForInput) */
INPUT_WAIT: 20000,
/** 导航超时(页面跳转 gotoWithCheck */
NAVIGATION: 20000,
/** 元素滚动等待超时 (scrollToElement) */
ELEMENT_SCROLL: 30000,
/** 导航超时(扩展,带重试场景) */
NAVIGATION_EXTENDED: 60000,
/** 输入框等待超时 */
INPUT_WAIT: 10000,
/** API 响应超时(图片生成) */
API_RESPONSE: 120000,
/** 上传确认超时 */
UPLOAD_CONFIRM: 60000,
/** OAuth 登录流程超时 */
OAUTH_FLOW: 60000,
/** API 响应超时(图片生成 waitApiResponse */
API_RESPONSE: 120000,
/** 心跳间隔 */
HEARTBEAT_INTERVAL: 3000,
@@ -60,46 +66,6 @@ export const RETRY = {
],
};
// ==========================================
// 错误码(适配器层,与 server/errors.js 互补)
// ==========================================
/**
* 适配器错误码
* @readonly
*/
export const ADAPTER_ERRORS = {
/** 页面已关闭 */
PAGE_CLOSED: 'PAGE_CLOSED',
/** 页面崩溃 */
PAGE_CRASHED: 'PAGE_CRASHED',
/** 页面状态无效 */
PAGE_INVALID: 'PAGE_INVALID',
/** 网络错误 */
NETWORK_ERROR: 'NETWORK_ERROR',
/** 超时错误 */
TIMEOUT_ERROR: 'TIMEOUT_ERROR',
/** HTTP 错误 */
HTTP_ERROR: 'HTTP_ERROR',
/** 限流 */
RATE_LIMITED: 'RATE_LIMITED',
/** 需要验证码 */
CAPTCHA_REQUIRED: 'CAPTCHA_REQUIRED',
/** 需要登录 */
AUTH_REQUIRED: 'AUTH_REQUIRED',
/** 内容被阻止 (API/页面检测到错误关键词) */
CONTENT_BLOCKED: 'CONTENT_BLOCKED',
};
// ==========================================
// 人机模拟配置
// ==========================================