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

13
.env.example Normal file
View File

@@ -0,0 +1,13 @@
# GitCode 每日任务机器人 - 环境配置
# 复制此文件为 .env 并填写真实值
# GitCode 登录凭据(必填)
GITCODE_USERNAME=你的手机号/用户名/邮箱
GITCODE_PASSWORD=你的密码
# 飞书 Webhook可选不填则跳过飞书通知
# FEISHU_WEBHOOK=https://open.feishu.cn/open-apis/bot/v2/hook/你的webhook
# 调试模式:设为 true 会打开浏览器窗口,方便观察执行过程
# 生产环境请设为 false 或删除此行
# DEBUG=false

8
.gitignore vendored
View File

@@ -7,6 +7,14 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Runtime generated
state.json
screenshots/
# Environment variables (sensitive)
.env
!.env.example
node_modules
dist
dist-ssr

55
config.js Normal file
View File

@@ -0,0 +1,55 @@
/**
* 配置文件
* 所有可调整参数集中管理,优先从 .env 文件读取敏感信息
* 也支持通过系统环境变量覆盖(优先级:系统环境变量 > .env 文件 > 默认值)
*/
// 加载 .env 文件(如果存在)
const path = require('path');
const dotenv = require('dotenv');
const envPath = path.resolve(__dirname, '.env');
dotenv.config({ path: envPath });
const config = {
// 目标网址
urls: {
// 积分/任务中心页面
points: 'https://gitcode.com/setting/points',
// 测试仓库编辑页(更新项目任务用)
testRepo: 'https://gitcode.com/fzxlzy/test/edit/main/testjpgp11',
},
// 登录凭据(优先从环境变量读取,兜底使用默认值)
login: {
username: process.env.GITCODE_USERNAME || 'test',
password: process.env.GITCODE_PASSWORD || 'test',
},
// 飞书 Webhook
feishu: {
webhook: process.env.FEISHU_WEBHOOK || '',
},
// 文件路径
statePath: 'state.json',
// 目录
dirs: {
screenshots: 'screenshots',
logs: 'logs',
},
// 重试 & 超时
retryCount: 3,
timeout: 30000,
navigationTimeout: 60000,
taskTimeout: Number(process.env.TASK_TIMEOUT_MS) || 180000,
// 浏览器设置
browser: {
headless: process.env.DEBUG === 'true' ? false : true,
locale: 'zh-CN',
},
};
module.exports = config;

369
main.js Normal file
View File

@@ -0,0 +1,369 @@
/**
* 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);
});

38
package-lock.json generated
View File

@@ -1,22 +1,36 @@
{
"name": "playwright-demo",
"version": "1.0.0",
"name": "gitcode-daily-bot",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "playwright-demo",
"version": "1.0.0",
"name": "gitcode-daily-bot",
"version": "2.0.0",
"license": "ISC",
"dependencies": {
"dotenv": "^17.4.2",
"playwright": "^1.58.2"
}
},
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -26,11 +40,12 @@
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
@@ -43,9 +58,10 @@
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},

View File

@@ -1,15 +1,20 @@
{
"name": "playwright-demo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"name": "gitcode-daily-bot",
"version": "2.0.0",
"description": "GitCode 每日任务自动化脚本(重构版)",
"main": "main.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"start": "node main.js",
"test": "node main.js"
},
"keywords": [],
"author": "",
"keywords": [
"gitcode",
"playwright",
"automation"
],
"license": "ISC",
"dependencies": {
"dotenv": "^17.4.2",
"playwright": "^1.58.2"
}
}

File diff suppressed because one or more lines are too long

50
tasks/dailyCheckIn.js Normal file
View File

@@ -0,0 +1,50 @@
/**
* 任务:每日签到
*
* 逻辑:
* 1. 找到"每日签到"任务卡片
* 2. 点击"签到"按钮
*/
const path = require('path');
const logger = require(path.join(__dirname, '..', 'utils', 'logger'));
/**
* 执行每日签到
* @param {import('playwright').Page} page - 主页面
*/
async function dailyCheckIn(page) {
logger.info('👉 执行每日签到');
// 从主页面找到"每日签到"任务卡片
const tasks = await page.locator('.novice-task-item-box').all();
let found = false;
for (const task of tasks) {
try {
const text = await task.locator('.task-card-title__label-text').innerText();
if (text.includes('每日签到')) {
const signInBtn = task.locator('button:has-text("签到")');
const isVisible = await signInBtn.isVisible().catch(() => false);
if (isVisible) {
await signInBtn.click();
logger.info('签到按钮已点击');
found = true;
} else {
logger.info('签到按钮不可见,可能已签到');
found = true;
}
break;
}
} catch (_) {
// 跳过定位失败的元素
}
}
if (!found) {
throw new Error('未找到"每日签到"任务卡片或签到按钮');
}
}
module.exports = dailyCheckIn;

68
tasks/dailyShare.js Normal file
View File

@@ -0,0 +1,68 @@
/**
* 任务:每日分享
*
* 逻辑:
* 1. 点击任务卡片上的 .toComplete 按钮
* 2. 等待新页面打开
* 3. 点击邀请按钮
* 4. 关闭新页面
*/
const path = require('path');
const { openNewPage, safeClose } = require(path.join(__dirname, '..', 'utils', 'page'));
const logger = require(path.join(__dirname, '..', 'utils', 'logger'));
/**
* 查找并点击任务卡片中的指定按钮
* @param {import('playwright').Page} page - 主页面
*/
async function dailyShare(page) {
logger.info('👉 执行每日分享');
// 从主页面找到"每日分享"任务卡片
const tasks = await page.locator('.novice-task-item-box').all();
const shareTask = await findTaskByTitle(tasks, '每日分享');
if (!shareTask) {
throw new Error('未找到"每日分享"任务卡片');
}
// 点击 toComplete 打开分享页
const newPage = await openNewPage(
page.context(),
() => shareTask.locator('.toComplete').click()
);
try {
// 点击第一个"立即邀请"按钮
await newPage.locator('.immediately-invite-btn', {
hasText: '立即邀请'
}).click();
logger.info('分享操作已完成');
await newPage.waitForTimeout(3000);
} finally {
await safeClose(newPage);
}
}
/**
* 根据标题查找任务卡片
* @param {import('playwright').Locator[]} tasks
* @param {string} title
* @returns {Promise<import('playwright').Locator|null>}
*/
async function findTaskByTitle(tasks, title) {
for (const task of tasks) {
try {
const text = await task.locator('.task-card-title__label-text').innerText();
if (text.includes(title)) {
return task;
}
} catch (_) {
// 单个元素定位失败则跳过
}
}
return null;
}
module.exports = dailyShare;

228
tasks/dailyStar.js Normal file
View File

@@ -0,0 +1,228 @@
/**
* 任务每日Star
*
* 逻辑:
* 1. 找到"每日Star"任务卡片
* 2. 点击 toComplete 打开热门列表
* 3. 随机选择一个热门项目
* 4. 进入项目详情页
* 5. 找到 Star/Starred 按钮
* 6. 如果已 Starred → 先取消再重新 Star
* 7. 如果未 Star → 直接 Star
*
* 定位器优化:
* - 优先文本匹配Star / Starred
* - 不依赖固定 nth 索引
* - 自动打印页面 title 和 URL
* - 找不到按钮抛异常
*/
const path = require('path');
const logger = require(path.join(__dirname, '..', 'utils', 'logger'));
const {
openNewPage,
openLinkPage,
safeClose,
getPageInfo,
waitForElements,
} = require(path.join(__dirname, '..', 'utils', 'page'));
const PROJECT_CARD_SELECTORS = [
'.hot-selection-list a.recommend-card',
'.hot-selection .recommend-card',
'a.recommend-card',
'a[href*="source_module=home_hot_selection"]',
'a[href*="source_module=home_recommend"]',
'a[href*="/"][href*="?source_module="]',
];
/**
* 执行每日Star任务
* @param {import('playwright').Page} page - 主页面
*/
async function dailyStar(page) {
logger.info('👉 执行每日Star');
// 从主页面找到"每日Star"任务卡片
const tasks = await page.locator('.novice-task-item-box').all();
const starTask = await findTaskByTitle(tasks, '每日Star');
if (!starTask) {
throw new Error('未找到"每日Star"任务卡片');
}
// ── 第一步:打开热门项目列表页 ──
const listPage = await openNewPage(
page.context(),
() => starTask.locator('.toComplete').click()
);
try {
const cards = await findProjectCards(listPage);
const count = cards.length;
logger.info(`热门项目数量: ${count}`);
if (count === 0) {
throw new Error('没有找到热门项目');
}
// 随机选择一个项目
const index = Math.floor(Math.random() * count);
logger.info(`随机选择项目索引: ${index}`);
// ── 第二步:打开项目详情页 ──
const projectPage = await openLinkPage(
listPage.context(),
cards[index],
{ label: 'Star 项目详情页' }
);
try {
// 打印页面信息
const info = await getPageInfo(projectPage);
logger.info(`项目页面: ${info.title}`);
logger.info(`项目URL: ${info.url}`);
// ── 第三步:找到 Star/Starred 按钮 ──
await doStarOperation(projectPage);
} finally {
await safeClose(projectPage);
}
} finally {
await safeClose(listPage);
}
}
async function findProjectCards(page) {
const cards = await waitForElements(page, PROJECT_CARD_SELECTORS, {
interval: 1000,
maxRetries: 15,
minCount: 1,
});
if (cards.length > 0) {
return cards;
}
const info = await getPageInfo(page);
throw new Error(
`未找到热门项目卡片,页面结构可能已变化。页面标题: ${info.title}, 页面URL: ${info.url}`
);
}
/**
* 执行 Star/Unstar 操作
*
* 定位策略:
* 1. 在 #repo-header-tab 区域内查找包含 "Star" 或 "Starred" 文本的按钮
* 2. 优先匹配 .status-btn 类
* 3. 使用文本过滤而非 nth 索引
*
* @param {import('playwright').Page} page
*/
async function doStarOperation(page) {
// 等待页面加载
await page.waitForLoadState('domcontentloaded', { timeout: 30000 }).catch(() => {});
logger.info('项目详情页加载完成');
// 定位 Star 按钮区域
const headerTab = page.locator('#repo-header-tab');
await headerTab.waitFor({ state: 'attached', timeout: 15000 }).catch(() => {
// 如果 #repo-header-tab 不存在,尝试更通用的定位
logger.warn('#repo-header-tab 未找到,尝试更通用的定位方式');
});
// 查找 Star/Starred 按钮
// 策略1: #repo-header-tab 下的 .status-btn
let starBtn = page.locator('#repo-header-tab .status-btn').filter({
hasText: /Star/i,
});
let btnCount = await starBtn.count();
logger.info(`#repo-header-tab .status-btn 含 Star 文本的按钮数量: ${btnCount}`);
// 策略2: 如果策略1没找到全局搜索含 Star 的按钮
if (btnCount === 0) {
logger.warn('未在 #repo-header-tab 下找到 Star 按钮,全局搜索...');
starBtn = page.locator('button, a, .status-btn, .btn').filter({
hasText: /Star/i,
});
btnCount = await starBtn.count();
logger.info(`全局搜索含 Star 文本的按钮数量: ${btnCount}`);
}
// 策略3: 包含 ⭐ 或 svg star 图标
if (btnCount === 0) {
logger.warn('按文本未找到,尝试通过 aria-label 匹配...');
starBtn = page.locator('[aria-label*="star" i], [aria-label*="Star" i]');
btnCount = await starBtn.count();
logger.info(`通过 aria-label 找到的按钮数量: ${btnCount}`);
}
if (btnCount === 0) {
// 最终失败:截图并抛异常
const pageInfo = await getPageInfo(page);
throw new Error(
`Star按钮不存在\n` +
`页面标题: ${pageInfo.title}\n` +
`页面URL: ${pageInfo.url}\n` +
`共尝试了3种定位策略均未找到 Star/Starred 按钮`
);
}
// 取第一个匹配的按钮
const btn = starBtn.nth(btnCount > 1 ? 1 : 0);
const btnText = await btn.innerText().catch(() => '(获取文本失败)');
logger.info(`找到按钮,文本内容: "${btnText}"`);
// 判断当前状态
const isStarred = btnText.toLowerCase().includes('starred');
if (isStarred) {
// 已 Starred → 先取消
logger.info('当前状态: 已 Starred先取消');
await btn.click();
// 等待按钮文本变为 Star
await page.waitForTimeout(800);
const newText = await btn.innerText().catch(() => '');
logger.info(`取消后按钮文本: "${newText}"`);
}
// 点击 Star
logger.info('点击 Star 按钮');
await btn.click();
// 确认操作成功(等待按钮文本变化)
await page.waitForTimeout(1000);
const finalText = await btn.innerText().catch(() => '');
logger.info(`Star操作完成当前按钮文本: "${finalText}"`);
if (finalText.toLowerCase().includes('starred')) {
logger.info('✅ Star 操作成功确认');
} else {
logger.warn('Star 操作后按钮状态未确认,但操作已执行');
}
}
/**
* 根据标题查找任务卡片
*/
async function findTaskByTitle(tasks, title) {
for (const task of tasks) {
try {
const text = await task.locator('.task-card-title__label-text').innerText();
if (text.includes(title)) {
return task;
}
} catch (_) {
// 跳过
}
}
return null;
}
module.exports = dailyStar;

111
tasks/dailyTrending.js Normal file
View File

@@ -0,0 +1,111 @@
/**
* 任务:查看热门
*
* 逻辑:
* 1. 找到"查看热门"任务卡片
* 2. 点击 toComplete 打开热门页面
* 3. 点击第一个热门项目卡片
* 4. 在新页面停留后关闭
*/
const path = require('path');
const logger = require(path.join(__dirname, '..', 'utils', 'logger'));
const {
openNewPage,
openLinkPage,
safeClose,
waitPageReady,
waitForElements,
getPageInfo,
} = require(path.join(__dirname, '..', 'utils', 'page'));
const PROJECT_CARD_SELECTORS = [
'.hot-selection .recommend-card',
'.hot-selection-list a.recommend-card',
'a.recommend-card',
'a[href*="source_module=home_hot_selection"]',
'a[href*="source_module=home_recommend"]',
'a[href*="/"][href*="?source_module="]',
];
/**
* 执行查看热门任务
* @param {import('playwright').Page} page - 主页面
*/
async function dailyTrending(page) {
logger.info('👉 执行查看热门');
// 从主页面找到"查看热门"任务卡片
const tasks = await page.locator('.novice-task-item-box').all();
const trendingTask = await findTaskByTitle(tasks, '查看热门');
if (!trendingTask) {
throw new Error('未找到"查看热门"任务卡片');
}
// ── 第一步:打开热门页面 ──
const trendingPage = await openNewPage(
page.context(),
() => trendingTask.locator('.toComplete').click()
);
try {
const cards = await findProjectCards(trendingPage);
// ── 第二步:点击第一个热门项目,打开详情页 ──
const firstCard = cards[0];
const detailPage = await openLinkPage(
trendingPage.context(),
firstCard,
{ label: '热门项目详情页' }
);
try {
// 等待详情页加载
await waitPageReady(detailPage);
logger.info('热门项目详情页已打开');
} finally {
await safeClose(detailPage);
}
} finally {
await safeClose(trendingPage);
}
}
async function findProjectCards(page) {
const cards = await waitForElements(page, PROJECT_CARD_SELECTORS, {
interval: 1000,
maxRetries: 15,
minCount: 1,
});
if (cards.length > 0) {
logger.info(`热门项目候选数量: ${cards.length}`);
return cards;
}
const info = await getPageInfo(page);
throw new Error(
`未找到热门项目卡片,页面结构可能已变化。页面标题: ${info.title}, 页面URL: ${info.url}`
);
}
/**
* 根据标题查找任务卡片
*/
async function findTaskByTitle(tasks, title) {
for (const task of tasks) {
try {
const text = await task.locator('.task-card-title__label-text').innerText();
if (text.includes(title)) {
return task;
}
} catch (_) {
// 跳过
}
}
return null;
}
module.exports = dailyTrending;

66
tasks/updateProject.js Normal file
View File

@@ -0,0 +1,66 @@
/**
* 任务:更新项目
*
* 逻辑:
* 1. 打开仓库编辑页面
* 2. 在 Monaco 编辑器末尾追加内容
* 3. 点击提交修改按钮
*/
const path = require('path');
const config = require(path.join(__dirname, '..', 'config'));
const logger = require(path.join(__dirname, '..', 'utils', 'logger'));
const { safeClose, safeGoto } = require(path.join(__dirname, '..', 'utils', 'page'));
/**
* 执行更新项目任务
* @param {import('playwright').Page} page - 主页面
*/
async function updateProject(page) {
logger.info('👉 执行项目更新');
const context = page.context();
const newPage = await context.newPage();
try {
// 打开编辑页面
await safeGoto(newPage, config.urls.testRepo, {
label: '仓库编辑页',
timeout: config.navigationTimeout,
waitFor: ['#codeartside'],
});
logger.info('已进入仓库编辑页面');
// 获取编辑器 iframe
const frame = newPage.frameLocator('#codeartside');
// 点击编辑器区域
await frame.locator('.monaco-editor').click({
position: { x: 200, y: 100 },
});
// 光标移动到末尾
await newPage.keyboard.press('Control+End');
await newPage.waitForTimeout(300); // 极短等待确保光标到位(键盘事件无法 await
// 追加内容
const content = `\n# 自动更新 - ${new Date().toISOString()}`;
await newPage.keyboard.type(content, { delay: 50 });
logger.info('已写入内容');
// 点击"提交修改"按钮(页面上的第一个)
await newPage.locator('button:has-text("提交修改")').first().click();
await page.waitForTimeout(1000);
// 等待弹框中的"提交修改"按钮出现并点击
const confirmBtn = newPage.locator('button:has-text("提交修改")').nth(1);
await confirmBtn.waitFor({ state: 'visible', timeout: 10000 });
await confirmBtn.click();
await page.waitForTimeout(1000);
logger.info('项目更新已提交');
} finally {
await safeClose(newPage);
}
}
module.exports = updateProject;

104
test.js
View File

@@ -1,5 +1,6 @@
const { chromium } = require('playwright');
const fs = require('fs');
import * as fs from "fs";
import axios from "axios";
import { chromium } from 'playwright'
(async () => {
const browser = await chromium.launch({
@@ -31,8 +32,8 @@ const fs = require('fs');
} else {
console.log('❌ 未登录');
await page.getByText('密码登录').click();
await page.getByPlaceholder('请填写手机号/用户名/邮箱').fill('15623979816');
await page.getByPlaceholder('请填写密码').fill('Zzy1995115');
await page.getByPlaceholder('请填写手机号/用户名/邮箱').fill('test');
await page.getByPlaceholder('请填写密码').fill('test');
await page.getByText('登录或注册完成代表你同意').click();
await page.getByText('我同意将账号、组织、仓库等信息提供给AtomGit进行 ').click();
await page.locator('button:has-text("登 录")').click();
@@ -42,6 +43,8 @@ const fs = require('fs');
}
})();
async function runTasks(page) {
console.log('✅ 开始寻找每日任务');
const tasks = await page.locator('.novice-task-item-box').all();
@@ -53,47 +56,82 @@ async function runTasks(page) {
if (!taskStat) {
await doUpdateProject(page)
}
console.log('✅ 更新项目已完成');
} else if (title.includes("每日分享")) {
const taskStat = await taskIsCompleted(task)
if (!taskStat) {
await dailyShare(task, page)
}
console.log('✅ 每日分享已完成');
} else if (title.includes("每日签到")) {
const taskStat = await taskIsCompleted(task)
if (!taskStat) {
await dailyCheckInTask(task, page)
}
console.log('✅ 每日签到已完成');
} else if (title.includes("查看热门")) {
const taskStat = await taskIsCompleted(task)
if (!taskStat) {
await dailyViewTrending(task, page)
}
console.log('✅ 查看热门已完成');
} else if (title.includes("每日Star")) {
const taskStat = await taskIsCompleted(task)
if (!taskStat) {
await dailyViewStar(task, page)
}
console.log('✅ 每日Star已完成');
}
}
await page.waitForTimeout(2000);
await page.reload({ waitUntil: "domcontentloaded" });
await page.waitForTimeout(2000);
await page.locator('button.pickup-btn:has-text("一键领取")').click();
console.log('✅ 已完成所有任务,奖励已领取 , 开始寻找卡片, 判断所有是否完成和领取');
const tasks2 = await page.locator('.novice-task-item-box').all();
console.log('✅ 共找到' + tasks2.length + '个每日任务');
for (const task of tasks2) {
const isCompleted = await task.locator('.completed').count() > 0;
const title = await task.locator('.task-card-title__label-text').innerText();
console.log('✅ ' + title + ' 任务完成状态: ' + (isCompleted ? '已完成' : '未完成'));
}
await page.waitForTimeout(1000);
await sendFeishuText('✅ 已完成所有任务,奖励已领取');
process.exit(0)
}
async function taskIsCompleted(task) {
const taskEnd = await task.locator('button:has-text("领取奖励")').count() > 0
if (taskEnd) {
task.locator('button:has-text("领取奖励")').click()
return true
const rewardBtn = task.locator('button:has-text("领取奖励")');
// 如果存在领取奖励按钮 -> 说明任务已完成但奖励未领取
if (await rewardBtn.count() > 0) {
console.log('🎁 检测到可领取奖励,正在领取...');
await rewardBtn.click();
// 等待领取按钮消失 or completed 出现
await task.page().waitForTimeout(800);
const stillHasRewardBtn = await rewardBtn.count() > 0;
const isCompleted = await task.locator('.completed').count() > 0;
if (!stillHasRewardBtn || isCompleted) {
console.log('✅ 奖励领取成功');
return true;
}
const isCompleted = await task.locator('.completed').count() > 0
console.log('⚠️ 已点击领取奖励,但状态未变化,可能领取失败或延迟刷新');
return false;
}
// 已完成标记
const isCompleted = await task.locator('.completed').count() > 0;
if (isCompleted) {
console.log('✅ 已完成,跳过');
return true
return true;
}
return false
return false;
}
async function doUpdateProject(page) {
@@ -211,3 +249,45 @@ async function dailyViewStar(task, page) {
newPage2.close()
newPage.close()
}
async function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
async function sendFeishuText(text) {
const webhook = process.env.FEISHU_WEBHOOK;
if (!webhook) {
console.log('FEISHU_WEBHOOK 未配置,跳过飞书通知');
return;
}
const payload = {
msg_type: "text",
content: { text }
};
for (let i = 1; i <= 5; i++) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000); // 10秒超时
const res = await fetch(webhook, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: controller.signal
});
clearTimeout(timeout);
const data = await res.text();
console.log("Feishu response:", data);
return;
} catch (err) {
console.error(`Feishu send failed (attempt ${i})`, err);
await sleep(3000);
}
}
throw new Error("Feishu webhook failed after retries");
}

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