/** * 页面工具模块 * 提供通用的页面操作函数,避免重复代码 * * 核心原则: * - 禁止使用 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} 找到的元素数组(可能为空) */ 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} [options.waitFor=[]] - 要等待的选择器或 Locator * @param {string} [options.label='页面'] - 日志标签 * @returns {Promise} 关键元素是否出现 */ 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} */ 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} */ 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, };