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

98
utils/browser.js Normal file
View File

@@ -0,0 +1,98 @@
/**
* 浏览器管理模块
* 负责:启动浏览器、创建上下文、加载/保存登录状态
*/
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
const config = require('../config');
const logger = require('./logger');
/**
* 启动浏览器实例
* @returns {Promise<import('playwright').Browser>}
*/
async function launchBrowser() {
const headless = config.browser.headless;
logger.info(`启动浏览器... (headless: ${headless})`);
logger.info(' → 调用 chromium.launch()...');
const browser = await chromium.launch({
headless,
});
logger.info('✅ 浏览器启动成功');
return browser;
}
/**
* 创建浏览器上下文
* - 如果 state.json 存在且有效,加载登录状态
* - 否则创建干净的上下文
* @param {import('playwright').Browser} browser
* @returns {Promise<{context: import('playwright').BrowserContext, stateLoaded: boolean}>}
*/
async function createContext(browser) {
let stateLoaded = false;
const statePath = path.resolve(config.statePath);
logger.info('检查 state.json...');
logger.info(` → 路径: ${statePath}`);
if (fs.existsSync(statePath)) {
logger.info(' → state.json 文件存在,开始验证内容...');
try {
const raw = fs.readFileSync(statePath, 'utf-8');
logger.info(` → 文件大小: ${raw.length} 字节`);
const state = JSON.parse(raw);
const hasCookies = Array.isArray(state.cookies) && state.cookies.length > 0;
const hasOrigins = Array.isArray(state.origins) && state.origins.length > 0;
logger.info(` → cookies: ${hasCookies ? state.cookies.length + '条' : '空'}`);
logger.info(` → origins: ${hasOrigins ? state.origins.length + '条' : '空'}`);
if (!hasCookies && !hasOrigins) {
logger.warn(' → state.json 内容无效(空 cookies 和 origins将重新登录');
stateLoaded = false;
} else {
logger.info(' → state.json 有效,将加载登录状态');
stateLoaded = true;
}
} catch (err) {
logger.warn(` → state.json 解析失败: ${err.message},将重新登录`);
stateLoaded = false;
}
} else {
logger.info(' → state.json 不存在,将创建新上下文');
stateLoaded = false;
}
const contextOptions = {
locale: config.browser.locale,
};
if (stateLoaded) {
contextOptions.storageState = statePath;
}
logger.info('正在创建浏览器上下文...');
const context = await browser.newContext(contextOptions);
logger.info(stateLoaded ? '✅ 上下文已创建(已加载登录状态)' : '✅ 上下文已创建(未登录)');
return { context, stateLoaded };
}
/**
* 保存登录状态到 state.json
* @param {import('playwright').BrowserContext} context
*/
async function saveState(context) {
const statePath = path.resolve(config.statePath);
logger.info(' → 正在保存 state.json...');
await context.storageState({ path: statePath });
logger.info(` → state.json 已保存到 ${statePath}`);
}
module.exports = {
launchBrowser,
createContext,
saveState,
};

172
utils/feishu.js Normal file
View File

@@ -0,0 +1,172 @@
/**
* 飞书 Webhook 通知模块
*
* 功能:
* - 发送文本消息
* - 发送截图:截图 → 上传到图床(telegra.ph) → 飞书消息附带图片URL
* - 如果图片上传失败,自动降级为文本+文件名
*/
const config = require('../config');
const logger = require('./logger');
const path = require('path');
const fs = require('fs');
/**
* 发送飞书文本消息(带重试)
* 调试模式下跳过发送
* @param {string} text 消息内容
* @returns {Promise<boolean>}
*/
async function sendFeishuText(text) {
if (process.env.DEBUG === 'true') {
logger.info('[调试模式] 跳过飞书通知');
return true;
}
if (!config.feishu.webhook) {
logger.info('未配置 FEISHU_WEBHOOK跳过飞书通知');
return true;
}
const payload = {
msg_type: 'text',
content: { text },
};
return sendPayload(payload);
}
/**
* 统一发送请求(带重试)
* @param {object} payload
* @returns {Promise<boolean>}
*/
async function sendPayload(payload) {
const maxRetries = config.retryCount;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
logger.info(`发送飞书通知 (第${attempt}次尝试)...`);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const res = await fetch(config.feishu.webhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal,
});
clearTimeout(timeout);
const data = await res.text();
logger.info(`飞书响应: ${data}`);
return true;
} catch (err) {
logger.warn(`飞书发送失败 (第${attempt}次): ${err.message}`);
if (attempt < maxRetries) {
logger.info(`等待 3 秒后重试...`);
await new Promise((r) => setTimeout(r, 3000));
}
}
}
logger.error(`飞书通知发送失败,已重试 ${maxRetries}`);
return false;
}
/**
* 截图并发送到飞书(成功/失败通用)
*
* 流程:
* 1. 截图保存到本地 screenshots/
* 2. 发送飞书文本消息附带截图文件名(纯本地,不上传任何外部服务)
*
* @param {import('playwright').Page} page
* @param {string} taskName 任务名称
* @param {boolean} isSuccess 是否成功
* @param {string} [errorMessage] 失败时的错误信息
*/
async function sendScreenshotNotify(page, taskName, isSuccess, errorMessage) {
if (process.env.DEBUG === 'true') {
logger.info('[调试模式] 跳过飞书截图通知');
return;
}
// 1. 截图保存本地
const screenshotPath = await takeScreenshot(page, taskName);
// 2. 构建文本消息(附带本地文件名)
const status = isSuccess ? '✅' : '❌';
const lines = [`${status} ${taskName} ${isSuccess ? '执行成功' : '执行失败'}`];
if (!isSuccess && errorMessage) {
lines.push(`错误: ${errorMessage}`);
}
lines.push(`截图已保存: ${path.basename(screenshotPath)}`);
await sendFeishuText(lines.join('\n'));
}
/**
* 截图辅助(本地保存)
* @param {import('playwright').Page} page
* @param {string} label
* @returns {Promise<string>}
*/
async function takeScreenshot(page, label) {
const dir = path.resolve(config.dirs.screenshots);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const safeLabel = label.replace(/[\\/:*?"<>|]/g, '_');
const timestamp = Date.now();
const filename = `screenshot-${safeLabel}-${timestamp}.png`;
const filepath = path.join(dir, filename);
try {
await page.screenshot({ path: filepath, fullPage: true });
logger.info(`截图已保存: ${filepath}`);
} catch (err) {
logger.warn(`截图失败: ${err.message}`);
}
return filepath;
}
/**
* 发送任务完成通知(文本汇总)
* @param {object} results 任务执行结果 { taskName: true/false, ... }
*/
async function notifySuccess(results) {
const successCount = Object.values(results).filter(Boolean).length;
const totalCount = Object.keys(results).length;
const lines = ['✅ 今日任务完成', ''];
for (const [name, ok] of Object.entries(results)) {
lines.push(`${ok ? '✅' : '❌'} ${name}`);
}
lines.push('');
lines.push(`完成: ${successCount}/${totalCount}`);
await sendFeishuText(lines.join('\n'));
}
/**
* 发送任务失败通知(文本)
* @param {string} taskName
* @param {string} reason
*/
async function notifyError(taskName, reason) {
const text = `${taskName} 执行失败\n\n错误原因: ${reason}`;
await sendFeishuText(text);
}
module.exports = {
sendFeishuText,
sendScreenshotNotify,
notifySuccess,
notifyError,
};

79
utils/logger.js Normal file
View File

@@ -0,0 +1,79 @@
/**
* 日志工具
* 统一输出 [INFO] / [WARN] / [ERROR] + 时间戳
* 同时写入 logs/ 目录下的日期文件
*/
const fs = require('fs');
const path = require('path');
const config = require('../config');
// 确保日志目录存在
const logDir = path.resolve(config.dirs.logs);
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
// 今日日志文件
const today = new Date().toISOString().slice(0, 10);
const logFile = path.join(logDir, `${today}.log`);
/**
* 写入文件(异步追加)
*/
function writeToFile(message) {
try {
fs.appendFileSync(logFile, message + '\n', 'utf-8');
} catch (err) {
// 日志写入失败不应影响主流程,静默处理
console.error(`[LOGGER] 写入日志文件失败: ${err.message}`);
}
}
/**
* 获取当前时间戳字符串
*/
function timestamp() {
const now = new Date();
const pad = (n) => String(n).padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ` +
`${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
}
/**
* 格式化日志行
*/
function format(level, message) {
return `[${level}] ${timestamp()} ${message}`;
}
const logger = {
info(message) {
const line = format('INFO', message);
console.log(line);
writeToFile(line);
},
warn(message) {
const line = format('WARN', message);
console.warn(line);
writeToFile(line);
},
error(message) {
const line = format('ERROR', message);
console.error(line);
writeToFile(line);
},
/**
* 打印空行分隔
*/
divider() {
const line = '─'.repeat(60);
console.log(line);
writeToFile(line);
},
};
module.exports = logger;

185
utils/login.js Normal file
View File

@@ -0,0 +1,185 @@
/**
* 登录模块
* 核心函数 ensureLogin(page, context)
*
* 流程:
* 1. 访问 积分中心页面
* 2. 检测 .g-user-avatar 是否存在
* 3. 如果已登录 → 返回 true
* 4. 如果未登录 → 执行账号密码登录 → 保存 state.json
* 5. 登录失败 → 抛出异常,阻止后续任务
*
* 【日志规范】每个 await 前后都有日志,定位卡顿时精确到行
*/
const config = require('../config');
const logger = require('./logger');
const { saveState } = require('./browser');
const { safeGoto } = require('./page');
/**
* 确保已登录,若未登录则执行登录流程
* @param {import('playwright').Page} page
* @param {import('playwright').BrowserContext} context
* @returns {Promise<boolean>} 登录是否成功
* @throws {Error} 登录失败时抛出异常
*/
async function ensureLogin(page, context) {
logger.divider();
logger.info('===== 开始检查登录状态 =====');
// ── 步骤1: 访问积分中心页面 ──
logger.info('[step 1/8] 正在访问积分中心页面...');
const pageReady = await safeGoto(page, config.urls.points, {
label: '积分中心',
timeout: config.navigationTimeout,
waitFor: [
'.g-user-avatar',
'text=密码登录',
],
});
if (!pageReady) {
logger.warn('[step 1/8] ⚠️ 关键元素未全部出现,尝试继续...');
} else {
logger.info('[step 1/8] ✅ 积分中心页面加载完成');
}
// ── 步骤2: 打印当前页面信息 ──
logger.info('[step 2/8] 获取当前页面信息...');
try {
const currentUrl = page.url();
const pageTitle = await page.title();
logger.info(` → 当前URL: ${currentUrl}`);
logger.info(` → 页面标题: ${pageTitle}`);
} catch (err) {
logger.warn(` → 获取页面信息失败: ${err.message}`);
}
// ── 步骤3: 检测登录状态 ──
logger.info('[step 3/8] 检测登录状态(查找 .g-user-avatar...');
let isLoggedIn = false;
try {
const avatarLocator = page.locator('.g-user-avatar');
logger.info(' → 调用 isVisible()...');
isLoggedIn = await avatarLocator.isVisible();
logger.info(` → isVisible() 返回: ${isLoggedIn}`);
} catch (err) {
logger.warn(` → 检测登录状态时出错: ${err.message}`);
isLoggedIn = false;
}
if (isLoggedIn) {
logger.info('✅ 已登录,无需执行登录流程');
return true;
}
// ── 步骤4: 未登录,开始登录 ──
logger.info('❌ 未登录,开始执行登录流程');
logger.info('[step 4/8] 等待页面稳定 (waitForTimeout 1000ms)...');
await page.waitForTimeout(1000);
try {
// ── 步骤4a: 点击密码登录 ──
logger.info('[step 4a] 点击"密码登录"标签...');
const pwdLoginBtn = page.getByText('密码登录');
logger.info(' → 等待密码登录按钮可见...');
await pwdLoginBtn.waitFor({ state: 'visible', timeout: 10000 });
logger.info(' → 点击密码登录按钮...');
await pwdLoginBtn.click();
logger.info('✅ 已切换到密码登录方式');
// ── 步骤4b: 填写用户名 ──
logger.info('[step 4b] 填写用户名...');
const usernameInput = page.getByPlaceholder('请填写手机号/用户名/邮箱');
logger.info(' → 等待输入框可见...');
await usernameInput.waitFor({ state: 'visible', timeout: 10000 });
logger.info(` → 输入用户名: ${config.login.username}`);
await usernameInput.fill(config.login.username);
logger.info('✅ 用户名已填写');
// ── 步骤4c: 填写密码 ──
logger.info('[step 4c] 填写密码...');
const passwordInput = page.getByPlaceholder('请填写密码');
logger.info(' → 等待密码输入框可见...');
await passwordInput.waitFor({ state: 'visible', timeout: 10000 });
logger.info(' → 输入密码 (已隐藏)');
await passwordInput.fill(config.login.password);
logger.info('✅ 密码已填写');
// ── 步骤4d: 勾选协议 ──
logger.info('[step 4d] 勾选同意协议...');
const agree1 = page.getByText('登录或注册完成代表你同意');
logger.info(' → 点击第一个勾选项...');
await agree1.click();
const agree2 = page.getByText('我同意将账号、组织、仓库等信息提供给AtomGit进行 ');
logger.info(' → 点击第二个勾选项...');
await agree2.click();
logger.info('✅ 协议已勾选');
// ── 步骤4e: 点击登录按钮 ──
logger.info('[step 4e] 点击登录按钮...');
const loginBtn = page.locator('button:has-text("登 录")');
logger.info(' → 等待登录按钮可见...');
await loginBtn.waitFor({ state: 'visible', timeout: 10000 });
logger.info(' → 点击登录按钮...');
await loginBtn.click();
logger.info('✅ 登录按钮已点击,等待跳转...');
// ── 步骤4f: 等待跳转 ──
logger.info('[step 4f] 等待页面跳转到积分中心...');
logger.info(` → waitForURL('**/setting/points'), timeout: ${config.navigationTimeout}ms`);
await page.waitForURL('**/setting/points', {
timeout: config.navigationTimeout,
});
logger.info('✅ 页面已跳转到积分中心');
// ── 步骤4g: 验证登录成功 ──
logger.info('[step 4g] 验证登录成功(查找 .g-user-avatar...');
logger.info(` → waitForSelector('.g-user-avatar'), timeout: ${config.navigationTimeout}ms`);
await page.waitForSelector('.g-user-avatar', {
timeout: config.navigationTimeout,
});
logger.info('✅ 登录成功,用户头像已出现');
// ── 步骤5: 保存状态 ──
logger.info('[step 5/8] 保存登录状态到 state.json...');
await saveState(context);
logger.info('✅ state.json 已保存');
return true;
} catch (err) {
// 登录失败:截图并抛异常
logger.error(`❌ 登录流程异常: ${err.message}`);
const screenshotPath = await takeScreenshot(page, 'login-failed');
logger.info(`截图已保存: ${screenshotPath}`);
throw new Error(`登录失败,无法继续执行任务: ${err.message}`);
}
}
/**
* 页面截图辅助
* @param {import('playwright').Page} page
* @param {string} label 截图标签
* @returns {Promise<string>} 截图文件路径
*/
async function takeScreenshot(page, label) {
const fs = require('fs');
const path = require('path');
const config = require('../config');
const dir = path.resolve(config.dirs.screenshots);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const filename = `error-${label}-${Date.now()}.png`;
const filepath = path.join(dir, filename);
logger.info(' → 正在截图...');
await page.screenshot({ path: filepath, fullPage: true }).catch(() => { });
return filepath;
}
module.exports = {
ensureLogin,
};

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,
};

100
utils/taskRunner.js Normal file
View File

@@ -0,0 +1,100 @@
/**
* 统一任务执行器
*
* 功能:
* - try/catch 包裹每个任务
* - 自动记录开始/结束/失败日志
* - 单任务失败不影响其他任务
*/
const logger = require('./logger');
const config = require('../config');
const { safeGoto } = require('./page');
function withTimeout(promise, timeoutMs, taskName) {
let timer = null;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => {
reject(new Error(`${taskName} 单任务超时 ${timeoutMs}ms`));
}, timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => {
if (timer) clearTimeout(timer);
});
}
async function prepareRetry(page, taskName, attempt) {
logger.info(`${taskName} 准备第${attempt}次重试,回到积分中心...`);
await safeGoto(page, config.urls.points, {
label: '积分中心',
timeout: config.navigationTimeout,
waitFor: ['.g-user-avatar', '.novice-task-item-box'],
}).catch((err) => {
logger.warn(` → 重试前刷新积分中心失败: ${err.message}`);
});
await page.waitForTimeout(1000).catch(() => {});
}
/**
* 执行任务(带防护)
*
* @param {string} taskName - 任务显示名称(如 "每日Star"
* @param {Function} handler - 异步任务函数 async (page) => { ... }
* @param {import('playwright').Page} page - 当前页面
* @returns {Promise<boolean>} 任务是否成功
*/
async function runTask(taskName, handler, page) {
logger.divider();
logger.info(`开始执行任务: ${taskName}`);
const maxAttempts = Math.max(1, Number(config.retryCount) || 1);
let lastError = null;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (attempt > 1) {
await prepareRetry(page, taskName, attempt);
}
try {
logger.info(`${taskName}${attempt}/${maxAttempts}次尝试`);
await withTimeout(handler(page), config.taskTimeout, taskName);
logger.info(`${taskName} 执行成功`);
return true;
} catch (err) {
lastError = err;
if (attempt < maxAttempts) {
logger.warn(`⚠️ ${taskName}${attempt}/${maxAttempts}次失败: ${err.message}`);
} else {
logger.error(`${taskName} 执行失败: ${err.message}`);
}
}
}
if (lastError && lastError.stack) {
logger.warn(lastError.stack);
}
return false;
}
/**
* 批量执行任务列表
*
* @param {import('playwright').Page} page - 主页面
* @param {Array<{name: string, handler: Function}>} tasks - 任务列表
* @returns {Promise<Object<string, boolean>>} 任务名 → 是否成功
*/
async function runTasks(page, tasks) {
const results = {};
for (const { name, handler } of tasks) {
results[name] = await runTask(name, handler, page);
}
return results;
}
module.exports = {
runTask,
runTasks,
};