Refactor GitCode daily bot
This commit is contained in:
13
.env.example
Normal file
13
.env.example
Normal 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
8
.gitignore
vendored
@@ -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
55
config.js
Normal 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
369
main.js
Normal 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
38
package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
19
package.json
19
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
305
state.json
305
state.json
File diff suppressed because one or more lines are too long
50
tasks/dailyCheckIn.js
Normal file
50
tasks/dailyCheckIn.js
Normal 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
68
tasks/dailyShare.js
Normal 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
228
tasks/dailyStar.js
Normal 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
111
tasks/dailyTrending.js
Normal 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
66
tasks/updateProject.js
Normal 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
104
test.js
@@ -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;
|
||||
}
|
||||
|
||||
console.log('⚠️ 已点击领取奖励,但状态未变化,可能领取失败或延迟刷新');
|
||||
return false;
|
||||
}
|
||||
const isCompleted = await task.locator('.completed').count() > 0
|
||||
|
||||
// 已完成标记
|
||||
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
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