mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
feat: 优化点击等待,补充缺少任务ID的日志等
This commit is contained in:
@@ -74,7 +74,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}, meta);
|
||||
logger.info('适配器', '图片上传完成', meta);
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}, meta);
|
||||
}
|
||||
|
||||
// 3. 输入提示词
|
||||
|
||||
@@ -71,7 +71,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
url.includes('bytedanceapi.com') &&
|
||||
url.includes('Action=CommitImageUpload');
|
||||
}
|
||||
});
|
||||
}, meta);
|
||||
|
||||
logger.info('适配器', '图片上传完成', meta);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
url.includes('bytedanceapi.com') &&
|
||||
url.includes('Action=CommitImageUpload');
|
||||
}
|
||||
});
|
||||
}, meta);
|
||||
|
||||
logger.info('适配器', '图片上传完成', meta);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}, meta);
|
||||
logger.info('适配器', '图片上传完成', meta);
|
||||
}
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}, meta);
|
||||
logger.info('适配器', '图片上传完成', meta);
|
||||
}
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}, meta);
|
||||
|
||||
logger.info('适配器', '图片上传完成', meta);
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}, meta);
|
||||
logger.info('适配器', '图片上传完成', meta);
|
||||
}
|
||||
|
||||
|
||||
+102
-38
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { ADAPTER_ERRORS } from '../../utils/constants.js';
|
||||
import { ADAPTER_ERRORS } from '../../server/errors.js';
|
||||
|
||||
// ==========================================
|
||||
// 可重试判定
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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',
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// 人机模拟配置
|
||||
// ==========================================
|
||||
|
||||
Reference in New Issue
Block a user