370 lines
13 KiB
JavaScript
370 lines
13 KiB
JavaScript
/**
|
||
* GitCode 每日任务自动化脚本
|
||
* =========================
|
||
*
|
||
* 启动入口,按顺序执行:
|
||
* 1. 启动浏览器
|
||
* 2. 创建上下文(加载 state.json)
|
||
* 3. 验证/执行登录(ensureLogin)
|
||
* 4. 扫描每日任务
|
||
* 5. 逐一执行未完成任务(runTasks)
|
||
* 6. 一键领取奖励
|
||
* 7. 发送飞书通知
|
||
* 8. 关闭浏览器
|
||
*/
|
||
|
||
const config = require('./config');
|
||
const logger = require('./utils/logger');
|
||
const { launchBrowser, createContext } = require('./utils/browser');
|
||
const { ensureLogin } = require('./utils/login');
|
||
const { runTasks } = require('./utils/taskRunner');
|
||
const { safeGoto, waitForElements } = require('./utils/page');
|
||
const { notifySuccess, sendScreenshotNotify } = require('./utils/feishu');
|
||
|
||
// 任务模块
|
||
const updateProject = require('./tasks/updateProject');
|
||
const dailyShare = require('./tasks/dailyShare');
|
||
const dailyCheckIn = require('./tasks/dailyCheckIn');
|
||
const dailyTrending = require('./tasks/dailyTrending');
|
||
const dailyStar = require('./tasks/dailyStar');
|
||
|
||
/**
|
||
* 判断任务是否已完成
|
||
* @param {import('playwright').Locator} task - 任务卡片元素
|
||
* @returns {Promise<boolean>} true=已完成
|
||
*/
|
||
async function taskIsCompleted(task) {
|
||
// 检测"领取奖励"按钮
|
||
const rewardBtn = task.locator('button:has-text("领取奖励")');
|
||
logger.info(' → 检查是否有可领取的奖励...');
|
||
const rewardCount = await rewardBtn.count();
|
||
logger.info(` → "领取奖励"按钮数量: ${rewardCount}`);
|
||
|
||
if (rewardCount > 0) {
|
||
logger.info('🎁 检测到可领取奖励,正在领取...');
|
||
logger.info(' → 点击领取奖励按钮...');
|
||
await rewardBtn.click();
|
||
logger.info(' → 等待 800ms 刷新状态...');
|
||
await task.page().waitForTimeout(800);
|
||
const stillHasRewardBtn = await rewardBtn.count() > 0;
|
||
const isCompleted = await task.locator('.completed').count() > 0;
|
||
logger.info(` → 奖励按钮仍存在: ${stillHasRewardBtn}, 已完成标记: ${isCompleted}`);
|
||
if (!stillHasRewardBtn || isCompleted) {
|
||
logger.info('✅ 奖励领取成功');
|
||
return true;
|
||
}
|
||
logger.warn('⚠️ 已点击领取奖励,但状态未立即变化,按已完成处理避免重复执行任务');
|
||
return true;
|
||
}
|
||
|
||
// 检测已完成标记
|
||
const isCompleted = await task.locator('.completed').count() > 0;
|
||
logger.info(` → 已完成标记(.completed): ${isCompleted}`);
|
||
if (isCompleted) {
|
||
logger.info('✅ 已完成,跳过');
|
||
return true;
|
||
}
|
||
|
||
logger.info(' → 任务未完成,需要执行');
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 查找任务卡片(多选择器兜底 + 自动重试)
|
||
* @param {import('playwright').Page} page
|
||
* @returns {Promise<import('playwright').Locator[]>}
|
||
*/
|
||
async function findTaskCards(page) {
|
||
return waitForElements(page, [
|
||
'.novice-task-item-box',
|
||
'[class*="task-item"]',
|
||
'[class*="Task"]',
|
||
'[class*="novice"]',
|
||
'.item-box',
|
||
'[class*="card"]',
|
||
], {
|
||
interval: 1000,
|
||
maxRetries: 10,
|
||
minCount: 1,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 主函数
|
||
*/
|
||
async function main() {
|
||
let browser = null;
|
||
let page = null;
|
||
|
||
try {
|
||
// ── 1. 启动浏览器 ──
|
||
logger.divider();
|
||
logger.info('===== 第1步: 启动浏览器 =====');
|
||
browser = await launchBrowser();
|
||
|
||
// ── 2. 创建上下文 ──
|
||
logger.divider();
|
||
logger.info('===== 第2步: 创建浏览器上下文 =====');
|
||
const { context } = await createContext(browser);
|
||
|
||
logger.info('创建新页面...');
|
||
page = await context.newPage();
|
||
logger.info('✅ 新页面已创建');
|
||
|
||
// ── 3. 登录验证 ──
|
||
logger.divider();
|
||
logger.info('===== 第3步: 登录验证 =====');
|
||
await ensureLogin(page, context);
|
||
|
||
// ── 4. 扫描每日任务 ──
|
||
logger.divider();
|
||
logger.info('===== 第4步: 扫描每日任务 =====');
|
||
|
||
logger.info('重新访问积分中心页面(确保最新状态)...');
|
||
await safeGoto(page, config.urls.points, {
|
||
label: '积分中心',
|
||
timeout: config.navigationTimeout,
|
||
waitFor: [
|
||
'.g-user-avatar',
|
||
'text=密码登录',
|
||
],
|
||
});
|
||
|
||
// ── 查找任务卡片(支持多选择器兜底) ──
|
||
logger.info('正在查找任务卡片元素...');
|
||
let taskElements = await findTaskCards(page);
|
||
|
||
logger.info(`共找到 ${taskElements.length} 个任务卡片`);
|
||
|
||
if (taskElements.length === 0) {
|
||
// 终极诊断:dump 页面关键区域的 HTML
|
||
logger.warn('❌ 所有选择器均未找到任务卡片,开始诊断...');
|
||
try {
|
||
const title = await page.title();
|
||
const url = page.url();
|
||
logger.info(`当前URL: ${url}`);
|
||
logger.info(`页面标题: ${title}`);
|
||
|
||
// 截取页面 body 中前 5000 字符的 HTML 用于诊断
|
||
const bodyHTML = await page.evaluate(() => {
|
||
// 找最有可能包含任务区域的父容器
|
||
const main = document.querySelector('main') ||
|
||
document.querySelector('.main-content') ||
|
||
document.querySelector('.page-content') ||
|
||
document.querySelector('#app') ||
|
||
document.body;
|
||
return (main instanceof HTMLElement ? main.innerHTML : document.body.innerHTML).substring(0, 5000);
|
||
});
|
||
logger.info('===== 页面关键区域 HTML (前5000字符) =====');
|
||
// 分行输出以便阅读
|
||
const lines = bodyHTML.split('\n').filter(l => l.trim());
|
||
for (let i = 0; i < Math.min(lines.length, 100); i++) {
|
||
const line = lines[i].trim();
|
||
if (line) logger.info(` ${line}`);
|
||
}
|
||
logger.info('===== HTML 输出结束 =====');
|
||
|
||
// 额外:列出页面所有 class 名称去重(帮助找正确的选择器)
|
||
const allClasses = await page.evaluate(() => {
|
||
const all = document.querySelectorAll('[class]');
|
||
const set = new Set();
|
||
all.forEach(el => {
|
||
el.className.split(/\\s+/).forEach(c => {
|
||
if (c && (c.includes('task') || c.includes('Task') || c.includes('novice') || c.includes('item') || c.includes('card'))) {
|
||
set.add(c);
|
||
}
|
||
});
|
||
});
|
||
return Array.from(set).sort();
|
||
});
|
||
if (allClasses.length > 0) {
|
||
logger.info('包含 task/novice/item/card 的 class 列表:');
|
||
allClasses.forEach(c => logger.info(` .${c}`));
|
||
}
|
||
} catch (diagErr) {
|
||
logger.warn(`诊断过程出错: ${diagErr.message}`);
|
||
}
|
||
|
||
throw new Error(
|
||
'未找到任务卡片,页面结构可能已变化。\n' +
|
||
'请查看上方 "页面关键区域 HTML" 和 class 列表,\n' +
|
||
'将包含任务卡片的 HTML 片段和正确类名发给我,我来更新选择器。'
|
||
);
|
||
}
|
||
|
||
// ── 5. 构建待执行任务列表 ──
|
||
logger.divider();
|
||
logger.info('===== 第5步: 构建待执行任务列表 =====');
|
||
const pendingTasks = [];
|
||
|
||
for (let i = 0; i < taskElements.length; i++) {
|
||
const taskElement = taskElements[i];
|
||
logger.info(`--- 任务卡片 #${i + 1} ---`);
|
||
let title = '';
|
||
try {
|
||
title = await taskElement.locator('.task-card-title__label-text').innerText();
|
||
logger.info(`任务标题: "${title}"`);
|
||
} catch (err) {
|
||
logger.warn(` → 获取标题失败: ${err.message},跳过此卡片`);
|
||
continue;
|
||
}
|
||
|
||
logger.info('检查完成状态...');
|
||
const completed = await taskIsCompleted(taskElement);
|
||
if (completed) {
|
||
logger.info(` → "${title}" 已完成,跳过`);
|
||
continue;
|
||
}
|
||
|
||
// 根据标题匹配任务
|
||
if (title.includes('更新项目')) {
|
||
logger.info(` → 匹配到任务: 更新项目`);
|
||
pendingTasks.push({ name: '更新项目', handler: updateProject });
|
||
} else if (title.includes('每日分享')) {
|
||
logger.info(` → 匹配到任务: 每日分享`);
|
||
pendingTasks.push({ name: '每日分享', handler: dailyShare });
|
||
} else if (title.includes('每日签到')) {
|
||
logger.info(` → 匹配到任务: 每日签到`);
|
||
pendingTasks.push({ name: '每日签到', handler: dailyCheckIn });
|
||
} else if (title.includes('查看热门')) {
|
||
logger.info(` → 匹配到任务: 查看热门`);
|
||
pendingTasks.push({ name: '查看热门', handler: dailyTrending });
|
||
} else if (title.includes('每日Star')) {
|
||
logger.info(` → 匹配到任务: 每日Star`);
|
||
pendingTasks.push({ name: '每日Star', handler: dailyStar });
|
||
} else {
|
||
logger.warn(` → 未知任务类型: "${title}",跳过`);
|
||
}
|
||
}
|
||
|
||
logger.info(`待执行任务: ${pendingTasks.length} 个`);
|
||
|
||
if (pendingTasks.length === 0) {
|
||
logger.info('所有任务已完成,无需执行');
|
||
} else {
|
||
// ── 6. 执行任务(互不影响) ──
|
||
logger.divider();
|
||
logger.info('===== 第6步: 依次执行任务 =====');
|
||
const results = await runTasks(page, pendingTasks);
|
||
|
||
// ── 7. 一键领取奖励 ──
|
||
logger.divider();
|
||
logger.info('===== 第7步: 一键领取奖励 =====');
|
||
|
||
logger.info('重新访问积分中心页面...');
|
||
await safeGoto(page, config.urls.points, {
|
||
label: '积分中心',
|
||
timeout: config.navigationTimeout,
|
||
waitFor: ['.g-user-avatar'],
|
||
});
|
||
|
||
logger.info('查找"一键领取"按钮...');
|
||
const claimBtns = await waitForElements(page, [
|
||
'button:has-text("一键领取")',
|
||
'button.pickup-btn',
|
||
'[class*="pickup"]',
|
||
'button:has-text("领取")',
|
||
], {
|
||
interval: 1000,
|
||
maxRetries: 6,
|
||
minCount: 1,
|
||
});
|
||
const claimVisible = claimBtns.length > 0;
|
||
const claimBtn = claimBtns.length > 0 ? claimBtns[0] : null;
|
||
logger.info(` → 按钮可见: ${claimVisible}`);
|
||
|
||
if (claimBtn) {
|
||
logger.info(' → 点击一键领取...');
|
||
try {
|
||
await claimBtn.click({ timeout: 5000 });
|
||
logger.info(' → 等待 2s...');
|
||
await page.waitForTimeout(2000);
|
||
logger.info('✅ 一键领取已点击');
|
||
await safeGoto(page, config.urls.points, {
|
||
label: '积分中心',
|
||
timeout: config.navigationTimeout,
|
||
waitFor: ['.g-user-avatar'],
|
||
});
|
||
} catch (err) {
|
||
logger.error(`点击一键领取失败: ${err.message}`);
|
||
}
|
||
} else {
|
||
logger.info('没有可领取的一键奖励');
|
||
}
|
||
|
||
// ── 8. 打印所有任务完成状态 ──
|
||
logger.divider();
|
||
logger.info('===== 第8步: 最终任务状态检查 =====');
|
||
const finalTasks = await findTaskCards(page);
|
||
logger.info(`共 ${finalTasks.length} 个任务卡片`);
|
||
for (const task of finalTasks) {
|
||
try {
|
||
const title = await task.locator('.task-card-title__label-text').innerText();
|
||
const isCompleted = await task.locator('.completed').count() > 0;
|
||
logger.info(` ${isCompleted ? '✅' : '❌'} ${title} ${isCompleted ? '已完成' : '未完成'}`);
|
||
} catch (_) {
|
||
// 跳过
|
||
}
|
||
}
|
||
|
||
// ── 9. 飞书通知 ──
|
||
logger.divider();
|
||
logger.info('===== 第9步: 发送飞书通知 =====');
|
||
// 最终结果截图+飞书
|
||
sendScreenshotNotify(page, '每日任务', true).catch(() => { });
|
||
await notifySuccess(results);
|
||
}
|
||
|
||
logger.divider();
|
||
logger.info('✅ 全部流程执行完毕');
|
||
} catch (err) {
|
||
logger.divider();
|
||
logger.error(`❌ 主流程异常: ${err.message}`);
|
||
logger.error(err.stack);
|
||
|
||
// 截图+飞书通知
|
||
if (typeof page !== 'undefined') {
|
||
sendScreenshotNotify(page, '主流程', false, err.message).catch(() => { });
|
||
} else {
|
||
const feishu = require('./utils/feishu');
|
||
feishu.sendFeishuText(`❌ 脚本执行异常\n\n${err.message}`).catch(() => { });
|
||
}
|
||
} finally {
|
||
// ── 10. 关闭浏览器 ──
|
||
logger.divider();
|
||
logger.info('===== 第10步: 关闭浏览器 =====');
|
||
if (browser) {
|
||
try {
|
||
logger.info('正在关闭浏览器...');
|
||
await browser.close();
|
||
logger.info('✅ 浏览器已关闭');
|
||
} catch (closeErr) {
|
||
logger.warn(`关闭浏览器时出现异常: ${closeErr.message}`);
|
||
}
|
||
}
|
||
process.exit(0)
|
||
}
|
||
}
|
||
|
||
// 执行主函数
|
||
|
||
async function runWithTimeout() {
|
||
const timeoutMs = 25 * 60 * 1000; // 25分钟
|
||
return Promise.race([
|
||
main(),
|
||
new Promise((_, reject) =>
|
||
setTimeout(() => reject(new Error(`任务超时 ${timeoutMs}ms`)), timeoutMs)
|
||
),
|
||
]);
|
||
}
|
||
|
||
runWithTimeout()
|
||
.then(() => {
|
||
logger.info('任务完成');
|
||
process.exit(0);
|
||
})
|
||
.catch(err => {
|
||
logger.error(`任务失败: ${err.stack || err.message}`);
|
||
process.exit(1);
|
||
});
|