Files
playwright-gitcode/utils/page.js
2026-07-06 09:34:39 +08:00

321 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 页面工具模块
* 提供通用的页面操作函数,避免重复代码
*
* 核心原则:
* - 禁止使用 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,
};