Refactor GitCode daily bot

This commit is contained in:
2026-07-06 09:34:39 +08:00
parent 120ae7d3f9
commit 77a07aae0a
19 changed files with 2251 additions and 533 deletions

320
utils/page.js Normal file
View File

@@ -0,0 +1,320 @@
/**
* 页面工具模块
* 提供通用的页面操作函数,避免重复代码
*
* 核心原则:
* - 禁止使用 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,
};