Refactor GitCode daily bot
This commit is contained in:
98
utils/browser.js
Normal file
98
utils/browser.js
Normal 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
172
utils/feishu.js
Normal 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
79
utils/logger.js
Normal 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
185
utils/login.js
Normal 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
320
utils/page.js
Normal 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
100
utils/taskRunner.js
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user