321 lines
10 KiB
JavaScript
321 lines
10 KiB
JavaScript
/**
|
||
* 页面工具模块
|
||
* 提供通用的页面操作函数,避免重复代码
|
||
*
|
||
* 核心原则:
|
||
* - 禁止使用 waitForTimeout(除极少数必须等待后端状态的场景)
|
||
* - 优先使用 waitForLoadState('domcontentloaded') / waitForSelector / locator.waitFor
|
||
*/
|
||
|
||
const logger = require('./logger');
|
||
|
||
/**
|
||
* 等待元素出现(带重试间隔)
|
||
*
|
||
* 在页面上依次尝试多个选择器,每个选择器最多重试 maxRetries 次,
|
||
* 每次间隔 interval ms。解决页面异步渲染/懒加载导致元素不立即出现的问题。
|
||
*
|
||
* @param {import('playwright').Page} page
|
||
* @param {string|string[]} selectors - CSS 选择器(单个或数组,依次兜底)
|
||
* @param {object} [options]
|
||
* @param {number} [options.interval=1000] - 每次重试间隔(ms)
|
||
* @param {number} [options.maxRetries=10] - 每个选择器最大重试次数
|
||
* @param {number} [options.minCount=1] - 期望的最小元素数量
|
||
* @returns {Promise<import('playwright').Locator[]>} 找到的元素数组(可能为空)
|
||
*/
|
||
async function waitForElements(page, selectors, options = {}) {
|
||
const interval = options.interval || 1000;
|
||
const maxRetries = options.maxRetries || 10;
|
||
const minCount = options.minCount || 1;
|
||
const queryTimeout = options.queryTimeout || 5000;
|
||
const selectorList = Array.isArray(selectors) ? selectors : [selectors];
|
||
|
||
for (const sel of selectorList) {
|
||
logger.info(` → 尝试选择器: "${sel}" (最多重试${maxRetries}次,间隔${interval}ms)...`);
|
||
|
||
const locator = page.locator(sel);
|
||
|
||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||
try {
|
||
const count = await withTimeout(
|
||
locator.count(),
|
||
queryTimeout,
|
||
`查询选择器 "${sel}" 数量超时`
|
||
);
|
||
|
||
if (count >= minCount) {
|
||
logger.info(` → ✅ 选择器 "${sel}" 在第${attempt}次找到 ${count} 个元素`);
|
||
|
||
// 返回 Locator 数组,而不是 locator.all()
|
||
const result = [];
|
||
for (let i = 0; i < count; i++) {
|
||
result.push(locator.nth(i));
|
||
}
|
||
return result;
|
||
}
|
||
|
||
if (attempt < maxRetries) {
|
||
logger.info(` → 第${attempt}次: 找到 ${count} 个 (不足${minCount}),等待${interval}ms后重试...`);
|
||
await new Promise(r => setTimeout(r, interval));
|
||
}
|
||
} catch (err) {
|
||
logger.warn(` → 第${attempt}次查询选择器 "${sel}" 失败: ${err.message}`);
|
||
if (attempt < maxRetries) {
|
||
await new Promise(r => setTimeout(r, interval));
|
||
}
|
||
}
|
||
}
|
||
|
||
logger.warn(` → ⚠️ 选择器 "${sel}" 重试${maxRetries}次后仍未找到足够元素`);
|
||
}
|
||
|
||
logger.warn(` → ❌ 所有 ${selectorList.length} 个选择器均未找到匹配元素`);
|
||
return [];
|
||
}
|
||
|
||
function withTimeout(promise, timeoutMs, message) {
|
||
let timer = null;
|
||
const timeout = new Promise((_, reject) => {
|
||
timer = setTimeout(() => reject(new Error(`${message} ${timeoutMs}ms`)), timeoutMs);
|
||
});
|
||
|
||
return Promise.race([promise, timeout]).finally(() => {
|
||
if (timer) clearTimeout(timer);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 安全跳转 + 等待关键元素
|
||
*
|
||
* 使用 waitUntil: 'domcontentloaded' 替代 'networkidle',
|
||
* 避免因页面长轮询/WebSocket 导致一直卡到超时。
|
||
* 加载完成后通过 Promise.race 等待实际目标元素出现。
|
||
*
|
||
* @param {import('playwright').Page} page
|
||
* @param {string} url
|
||
* @param {object} options
|
||
* @param {number} [options.timeout=30000]
|
||
* @param {Array<import('playwright').Locator|string>} [options.waitFor=[]] - 要等待的选择器或 Locator
|
||
* @param {string} [options.label='页面'] - 日志标签
|
||
* @returns {Promise<boolean>} 关键元素是否出现
|
||
*/
|
||
async function safeGoto(page, url, options = {}) {
|
||
const timeout = options.timeout || 30000;
|
||
const label = options.label || '页面';
|
||
const waitFor = options.waitFor || [];
|
||
|
||
// 判断是否已在目标页面:已在则用 reload 强制刷新
|
||
const currentUrl = page.url();
|
||
const samePage = currentUrl.startsWith(url) || currentUrl === url;
|
||
|
||
if (samePage) {
|
||
logger.info(` → 已在 ${label} 页面,执行 reload 强制刷新...`);
|
||
try {
|
||
await page.reload({
|
||
waitUntil: 'domcontentloaded',
|
||
timeout,
|
||
});
|
||
logger.info(` → ${label} reload 完成`);
|
||
} catch (err) {
|
||
logger.warn(` → ${label} reload 超时: ${err.message},继续等待关键元素...`);
|
||
}
|
||
} else {
|
||
logger.info(` → goto ${url}, waitUntil: domcontentloaded, timeout: ${timeout}ms`);
|
||
try {
|
||
await page.goto(url, {
|
||
waitUntil: 'domcontentloaded',
|
||
timeout,
|
||
});
|
||
logger.info(` → ${label} DOM 加载完成`);
|
||
} catch (err) {
|
||
logger.warn(` → ${label} goto 超时: ${err.message},继续等待关键元素...`);
|
||
}
|
||
}
|
||
|
||
if (waitFor.length === 0) {
|
||
return true;
|
||
}
|
||
|
||
// Promise.race: 任意一个关键元素出现即视为加载成功
|
||
logger.info(` → Promise.race 等待关键元素 (${waitFor.length}个)...`);
|
||
const promises = waitFor.map((sel, i) => {
|
||
const locator = typeof sel === 'string' ? page.locator(sel) : sel;
|
||
return locator.waitFor({ state: 'visible', timeout }).then(() => i);
|
||
});
|
||
|
||
// 加一个兜底超时,防止全部 waitFor 都超时
|
||
const fallback = new Promise((_, reject) =>
|
||
setTimeout(() => reject(new Error('所有关键元素均超时')), timeout + 2000)
|
||
);
|
||
|
||
try {
|
||
const idx = await Promise.race([...promises, fallback]);
|
||
logger.info(` → ✅ ${label} 加载完成 (关键元素 #${idx} 已出现)`);
|
||
return true;
|
||
} catch (err) {
|
||
logger.warn(` → ⚠️ ${label} 关键元素未出现: ${err.message}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 安全点击:等待元素可见后点击,自动重试
|
||
* @param {import('playwright').Locator} locator
|
||
* @param {object} [options]
|
||
* @param {number} [options.timeout=10000]
|
||
*/
|
||
async function safeClick(locator, options = {}) {
|
||
const timeout = options.timeout || 10000;
|
||
logger.info(' → safeClick: 等待元素可见...');
|
||
await locator.waitFor({ state: 'visible', timeout });
|
||
logger.info(' → safeClick: 元素可见,点击...');
|
||
await locator.click({ timeout });
|
||
logger.info(' → safeClick: 点击完成');
|
||
}
|
||
|
||
/**
|
||
* 安全关闭页面:先检测页面是否已关闭
|
||
* @param {import('playwright').Page|null|undefined} page
|
||
*/
|
||
async function safeClose(page) {
|
||
if (!page) {
|
||
logger.warn(' → safeClose: page 为 null/undefined,跳过');
|
||
return;
|
||
}
|
||
try {
|
||
if (!page.isClosed()) {
|
||
logger.info(' → 正在关闭页面...');
|
||
await page.close();
|
||
logger.info(' → 页面已关闭');
|
||
} else {
|
||
logger.info(' → 页面已关闭,跳过');
|
||
}
|
||
} catch (err) {
|
||
logger.warn(` → 关闭页面时出现异常: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 打开新页面:执行点击操作后等待新页面出现
|
||
*
|
||
* 示例:
|
||
* const newPage = await openNewPage(context, () => task.locator('.toComplete').click());
|
||
*
|
||
* @param {import('playwright').BrowserContext} context
|
||
* @param {Function} clickAction 执行点击操作的函数
|
||
* @param {object} [options]
|
||
* @param {number} [options.timeout=15000]
|
||
* @returns {Promise<import('playwright').Page>}
|
||
*/
|
||
async function openNewPage(context, clickAction, options = {}) {
|
||
const timeout = options.timeout || 15000;
|
||
logger.info(' → openNewPage: 准备打开新页面...');
|
||
|
||
// 并行等待:新页面事件 + 点击操作
|
||
logger.info(` → Promise.all: waitForEvent('page') + clickAction (timeout: ${timeout}ms)`);
|
||
const [newPage] = await Promise.all([
|
||
context.waitForEvent('page', { timeout }),
|
||
clickAction(),
|
||
]);
|
||
|
||
logger.info(' → 新页面已打开,等待页面加载...');
|
||
|
||
// 等待新页面完全加载
|
||
await waitPageReady(newPage);
|
||
|
||
const info = await getPageInfo(newPage);
|
||
logger.info(` → 新页面: title="${info.title}", url=${info.url}`);
|
||
|
||
return newPage;
|
||
}
|
||
|
||
/**
|
||
* 从链接元素打开页面。
|
||
* 优先读取 href 后直接新建页面 goto;没有 href 时回退到点击等待新页面。
|
||
*
|
||
* @param {import('playwright').BrowserContext} context
|
||
* @param {import('playwright').Locator} locator
|
||
* @param {object} [options]
|
||
* @param {number} [options.timeout=15000]
|
||
* @param {string} [options.label='链接']
|
||
* @returns {Promise<import('playwright').Page>}
|
||
*/
|
||
async function openLinkPage(context, locator, options = {}) {
|
||
const timeout = options.timeout || 15000;
|
||
const label = options.label || '链接';
|
||
|
||
logger.info(` → openLinkPage: 准备打开${label}...`);
|
||
await locator.waitFor({ state: 'visible', timeout });
|
||
|
||
const href = await locator.evaluate((element) => {
|
||
const link = element.closest('a') || element.querySelector('a');
|
||
return link ? link.href : '';
|
||
}).catch(() => '');
|
||
|
||
if (href) {
|
||
logger.info(` → openLinkPage: 读取到 href=${href}`);
|
||
const newPage = await context.newPage();
|
||
try {
|
||
await newPage.goto(href, {
|
||
waitUntil: 'domcontentloaded',
|
||
timeout: Math.max(timeout, 30000),
|
||
});
|
||
} catch (err) {
|
||
logger.warn(` → openLinkPage: goto 超时或失败: ${err.message},继续等待页面关键状态`);
|
||
}
|
||
await waitPageReady(newPage, { timeout: Math.max(timeout, 30000) });
|
||
const info = await getPageInfo(newPage);
|
||
logger.info(` → 新页面: title="${info.title}", url=${info.url}`);
|
||
return newPage;
|
||
}
|
||
|
||
logger.warn(` → openLinkPage: 未读取到 href,回退到点击等待新页面`);
|
||
return openNewPage(context, () => locator.click({ timeout }), { timeout });
|
||
}
|
||
|
||
/**
|
||
* 等待页面准备就绪(替代 waitForTimeout)
|
||
* @param {import('playwright').Page} page
|
||
* @param {object} [options]
|
||
* @param {number} [options.timeout=30000]
|
||
*/
|
||
async function waitPageReady(page, options = {}) {
|
||
const timeout = options.timeout || 30000;
|
||
|
||
logger.info(' → waitPageReady: 等待 domcontentloaded...');
|
||
try {
|
||
await page.waitForLoadState('domcontentloaded', { timeout });
|
||
logger.info(' → waitPageReady: domcontentloaded 完成');
|
||
} catch (err) {
|
||
logger.warn(` → waitPageReady: domcontentloaded 超时 (${err.message})`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取当前页面信息(用于调试)
|
||
* @param {import('playwright').Page} page
|
||
* @returns {Promise<{title: string, url: string}>}
|
||
*/
|
||
async function getPageInfo(page) {
|
||
let title = '';
|
||
let url = '';
|
||
try {
|
||
title = await page.title();
|
||
} catch (_) { /* 忽略 */ }
|
||
try {
|
||
url = page.url();
|
||
} catch (_) { /* 忽略 */ }
|
||
return { title, url };
|
||
}
|
||
|
||
module.exports = {
|
||
safeClick,
|
||
safeClose,
|
||
openNewPage,
|
||
openLinkPage,
|
||
waitPageReady,
|
||
getPageInfo,
|
||
safeGoto,
|
||
waitForElements,
|
||
};
|