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*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Runtime generated
|
||||||
|
state.json
|
||||||
|
screenshots/
|
||||||
|
|
||||||
|
# Environment variables (sensitive)
|
||||||
|
.env
|
||||||
|
!.env.example
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
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",
|
"name": "gitcode-daily-bot",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "playwright-demo",
|
"name": "gitcode-daily-bot",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
"playwright": "^1.58.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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -26,11 +40,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.58.2",
|
"version": "1.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.58.2"
|
"playwright-core": "1.60.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -43,9 +58,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.58.2",
|
"version": "1.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
},
|
},
|
||||||
|
|||||||
19
package.json
19
package.json
@@ -1,15 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "playwright-demo",
|
"name": "gitcode-daily-bot",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"description": "",
|
"description": "GitCode 每日任务自动化脚本(重构版)",
|
||||||
"main": "index.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"start": "node main.js",
|
||||||
|
"test": "node main.js"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [
|
||||||
"author": "",
|
"gitcode",
|
||||||
|
"playwright",
|
||||||
|
"automation"
|
||||||
|
],
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
"playwright": "^1.58.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;
|
||||||
500
test.js
500
test.js
@@ -1,213 +1,293 @@
|
|||||||
const { chromium } = require('playwright');
|
import * as fs from "fs";
|
||||||
const fs = require('fs');
|
import axios from "axios";
|
||||||
|
import { chromium } from 'playwright'
|
||||||
(async () => {
|
|
||||||
const browser = await chromium.launch({
|
(async () => {
|
||||||
headless: true // 👈 Windows建议先开界面调试
|
const browser = await chromium.launch({
|
||||||
});
|
headless: true // 👈 Windows建议先开界面调试
|
||||||
|
});
|
||||||
|
|
||||||
// 👇 关键就在这里
|
|
||||||
let context = null
|
// 👇 关键就在这里
|
||||||
if (fs.existsSync('state.json')) {
|
let context = null
|
||||||
console.log('✅ state.json 存在');
|
if (fs.existsSync('state.json')) {
|
||||||
context = await browser.newContext({
|
console.log('✅ state.json 存在');
|
||||||
storageState: 'state.json',
|
context = await browser.newContext({
|
||||||
locale: "zh-CN"
|
storageState: 'state.json',
|
||||||
});
|
locale: "zh-CN"
|
||||||
} else {
|
});
|
||||||
console.log('❌ state.json 不存在');
|
} else {
|
||||||
context = await browser.newContext({
|
console.log('❌ state.json 不存在');
|
||||||
locale: "zh-CN"
|
context = await browser.newContext({
|
||||||
})
|
locale: "zh-CN"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto('https://gitcode.com/setting/points');
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
const isLogin = await page.locator('.g-user-avatar').isVisible();
|
||||||
|
if (isLogin) {
|
||||||
|
console.log('✅ 已登录');
|
||||||
|
runTasks(page)
|
||||||
|
} else {
|
||||||
|
console.log('❌ 未登录');
|
||||||
|
await page.getByText('密码登录').click();
|
||||||
|
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();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
await context.storageState({ path: 'state.json' });
|
||||||
|
runTasks(page)
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async function runTasks(page) {
|
||||||
|
console.log('✅ 开始寻找每日任务');
|
||||||
|
const tasks = await page.locator('.novice-task-item-box').all();
|
||||||
|
console.log('✅ 共找到' + tasks.length + '个每日任务');
|
||||||
|
for (const task of tasks) {
|
||||||
|
const title = await task.locator('.task-card-title__label-text').innerText();
|
||||||
|
if (title.includes("更新项目")) {
|
||||||
|
const taskStat = await taskIsCompleted(task)
|
||||||
|
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 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;
|
||||||
|
if (isCompleted) {
|
||||||
|
console.log('✅ 已完成,跳过');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doUpdateProject(page) {
|
||||||
|
console.log('👉 执行项目更新');
|
||||||
|
const context = page.context();
|
||||||
|
const newPage = await context.newPage();
|
||||||
|
await newPage.goto('https://gitcode.com/fzxlzy/test/edit/main/testjpgp11');
|
||||||
|
// 你可以自己补逻辑
|
||||||
|
const frame = newPage.frameLocator('#codeartside');
|
||||||
|
|
||||||
|
// 1️⃣ 点击编辑器
|
||||||
|
await frame.locator('.monaco-editor').click({
|
||||||
|
position: { x: 200, y: 100 }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2️⃣ 把光标移动到末尾(关键!)
|
||||||
|
await newPage.keyboard.press('Control+End');
|
||||||
|
|
||||||
|
// 3️⃣ 追加内容
|
||||||
|
await newPage.keyboard.type(`\n测试图片生成功能
|
||||||
|
11
|
||||||
|
22
|
||||||
|
33`, { delay: 50 });
|
||||||
|
// 等按钮出现
|
||||||
|
await newPage.locator('button:has-text("提交修改")').click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
const submitButton = newPage.locator('button:has-text("提交修改")').nth(1); // 0 是页面按钮,1 是弹框按钮
|
||||||
|
await submitButton.click();
|
||||||
|
await newPage.waitForTimeout(500);
|
||||||
|
await newPage.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dailyShare(task, page) {
|
||||||
|
// 等待新页面打开
|
||||||
|
const [newPage] = await Promise.all([
|
||||||
|
page.context().waitForEvent('page'), // 等待新页面事件
|
||||||
|
task.locator('.toComplete').click()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 点击第一个
|
||||||
|
await newPage.waitForTimeout(1000);
|
||||||
|
await newPage.locator('.immediately-invite-btn').nth(0).click();
|
||||||
|
await newPage.waitForTimeout(2000);
|
||||||
|
await newPage.close();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dailyCheckInTask(task, page) {
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
task.locator('button:has-text("签到")').click()
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dailyViewTrending(task, page) {
|
||||||
|
// 等待新页面打开
|
||||||
|
const [newPage] = await Promise.all([
|
||||||
|
page.context().waitForEvent('page'), // 等待新页面事件
|
||||||
|
task.locator('.toComplete').click()
|
||||||
|
])
|
||||||
|
await newPage.waitForSelector('.hot-selection .recommend-card');
|
||||||
|
// 获取第一个热门项目卡片
|
||||||
|
const firstHotProjectCard = await newPage.$('.hot-selection .recommend-card');
|
||||||
|
await newPage.waitForTimeout(1000);
|
||||||
|
// 点击它
|
||||||
|
const [newPage2] = await Promise.all([
|
||||||
|
newPage.context().waitForEvent('page'), // 等待新页面事件
|
||||||
|
await firstHotProjectCard.click()
|
||||||
|
])
|
||||||
|
await newPage2.waitForTimeout(2000);
|
||||||
|
newPage2.close()
|
||||||
|
newPage.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dailyViewStar(task, page) {
|
||||||
|
// 等待新页面打开
|
||||||
|
const [newPage] = await Promise.all([
|
||||||
|
page.context().waitForEvent('page'), // 等待新页面事件
|
||||||
|
task.locator('.toComplete').click()
|
||||||
|
])
|
||||||
|
// 等待元素出现在 DOM(不要用 visible,容易超时)
|
||||||
|
await newPage.waitForSelector('.hot-selection-list a.recommend-card', { state: 'attached' });
|
||||||
|
const cards = newPage.locator('.hot-selection-list a.recommend-card');
|
||||||
|
const count = await cards.count();
|
||||||
|
|
||||||
|
console.log("热门项目数量:", count);
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
throw new Error("没有找到热门项目");
|
||||||
|
}
|
||||||
|
await newPage.waitForTimeout(2000);
|
||||||
|
// 随机选一个
|
||||||
|
const index = Math.floor(Math.random() * count);
|
||||||
|
console.log("随机选择 index:", index);
|
||||||
|
// 点击并捕获新 tab
|
||||||
|
const [newPage2] = await Promise.all([
|
||||||
|
newPage.context().waitForEvent('page'),
|
||||||
|
cards.nth(index).click()
|
||||||
|
]);
|
||||||
|
// 等待 Star 按钮出现
|
||||||
|
const starBtn = newPage2.locator('#repo-header-tab .status-btn').filter({ hasText: 'Star' }).nth(1);
|
||||||
|
await newPage2.waitForTimeout(2000);
|
||||||
|
await starBtn.waitFor({ state: 'visible' });
|
||||||
|
// 判断是否已 Starred
|
||||||
|
const text = await starBtn.innerText();
|
||||||
|
await newPage2.waitForTimeout(2000);
|
||||||
|
if (text.includes('Starred')) {
|
||||||
|
// 已经 Starred -> 先取消
|
||||||
|
await starBtn.click();
|
||||||
|
|
||||||
|
// 等待变回 Star
|
||||||
|
await newPage2.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
await starBtn.click();
|
||||||
|
await newPage2.waitForTimeout(2000);
|
||||||
|
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 page = await context.newPage();
|
|
||||||
await page.goto('https://gitcode.com/setting/points');
|
|
||||||
await page.waitForTimeout(5000);
|
|
||||||
const isLogin = await page.locator('.g-user-avatar').isVisible();
|
|
||||||
if (isLogin) {
|
|
||||||
console.log('✅ 已登录');
|
|
||||||
runTasks(page)
|
|
||||||
} else {
|
|
||||||
console.log('❌ 未登录');
|
|
||||||
await page.getByText('密码登录').click();
|
|
||||||
await page.getByPlaceholder('请填写手机号/用户名/邮箱').fill('15623979816');
|
|
||||||
await page.getByPlaceholder('请填写密码').fill('Zzy1995115');
|
|
||||||
await page.getByText('登录或注册完成代表你同意').click();
|
|
||||||
await page.getByText('我同意将账号、组织、仓库等信息提供给AtomGit进行 ').click();
|
|
||||||
await page.locator('button:has-text("登 录")').click();
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
await context.storageState({ path: 'state.json' });
|
|
||||||
runTasks(page)
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
async function runTasks(page) {
|
|
||||||
console.log('✅ 开始寻找每日任务');
|
|
||||||
const tasks = await page.locator('.novice-task-item-box').all();
|
|
||||||
console.log('✅ 共找到' + tasks.length + '个每日任务');
|
|
||||||
for (const task of tasks) {
|
|
||||||
const title = await task.locator('.task-card-title__label-text').innerText();
|
|
||||||
if (title.includes("更新项目")) {
|
|
||||||
const taskStat = await taskIsCompleted(task)
|
|
||||||
if (!taskStat) {
|
|
||||||
await doUpdateProject(page)
|
|
||||||
}
|
|
||||||
} else if (title.includes("每日分享")) {
|
|
||||||
const taskStat = await taskIsCompleted(task)
|
|
||||||
if (!taskStat) {
|
|
||||||
await dailyShare(task, page)
|
|
||||||
}
|
|
||||||
} else if (title.includes("每日签到")) {
|
|
||||||
const taskStat = await taskIsCompleted(task)
|
|
||||||
if (!taskStat) {
|
|
||||||
await dailyCheckInTask(task, page)
|
|
||||||
}
|
|
||||||
} else if (title.includes("查看热门")) {
|
|
||||||
const taskStat = await taskIsCompleted(task)
|
|
||||||
if (!taskStat) {
|
|
||||||
await dailyViewTrending(task, page)
|
|
||||||
}
|
|
||||||
} else if (title.includes("每日Star")) {
|
|
||||||
const taskStat = await taskIsCompleted(task)
|
|
||||||
if (!taskStat) {
|
|
||||||
await dailyViewStar(task, page)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
await page.reload({ waitUntil: "domcontentloaded" });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
await page.locator('button.pickup-btn:has-text("一键领取")').click();
|
|
||||||
|
|
||||||
|
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
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 isCompleted = await task.locator('.completed').count() > 0
|
|
||||||
if (isCompleted) {
|
|
||||||
console.log('✅ 已完成,跳过');
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doUpdateProject(page) {
|
|
||||||
console.log('👉 执行项目更新');
|
|
||||||
const context = page.context();
|
|
||||||
const newPage = await context.newPage();
|
|
||||||
await newPage.goto('https://gitcode.com/fzxlzy/test/edit/main/testjpgp11');
|
|
||||||
// 你可以自己补逻辑
|
|
||||||
const frame = newPage.frameLocator('#codeartside');
|
|
||||||
|
|
||||||
// 1️⃣ 点击编辑器
|
|
||||||
await frame.locator('.monaco-editor').click({
|
|
||||||
position: { x: 200, y: 100 }
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2️⃣ 把光标移动到末尾(关键!)
|
|
||||||
await newPage.keyboard.press('Control+End');
|
|
||||||
|
|
||||||
// 3️⃣ 追加内容
|
|
||||||
await newPage.keyboard.type(`\n测试图片生成功能
|
|
||||||
11
|
|
||||||
22
|
|
||||||
33`, { delay: 50 });
|
|
||||||
// 等按钮出现
|
|
||||||
await newPage.locator('button:has-text("提交修改")').click();
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
const submitButton = newPage.locator('button:has-text("提交修改")').nth(1); // 0 是页面按钮,1 是弹框按钮
|
|
||||||
await submitButton.click();
|
|
||||||
await newPage.waitForTimeout(500);
|
|
||||||
await newPage.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dailyShare(task, page) {
|
|
||||||
// 等待新页面打开
|
|
||||||
const [newPage] = await Promise.all([
|
|
||||||
page.context().waitForEvent('page'), // 等待新页面事件
|
|
||||||
task.locator('.toComplete').click()
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 点击第一个
|
|
||||||
await newPage.waitForTimeout(1000);
|
|
||||||
await newPage.locator('.immediately-invite-btn').nth(0).click();
|
|
||||||
await newPage.waitForTimeout(2000);
|
|
||||||
await newPage.close();
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dailyCheckInTask(task, page) {
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
task.locator('button:has-text("签到")').click()
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dailyViewTrending(task, page) {
|
|
||||||
// 等待新页面打开
|
|
||||||
const [newPage] = await Promise.all([
|
|
||||||
page.context().waitForEvent('page'), // 等待新页面事件
|
|
||||||
task.locator('.toComplete').click()
|
|
||||||
])
|
|
||||||
await newPage.waitForSelector('.hot-selection .recommend-card');
|
|
||||||
// 获取第一个热门项目卡片
|
|
||||||
const firstHotProjectCard = await newPage.$('.hot-selection .recommend-card');
|
|
||||||
await newPage.waitForTimeout(1000);
|
|
||||||
// 点击它
|
|
||||||
const [newPage2] = await Promise.all([
|
|
||||||
newPage.context().waitForEvent('page'), // 等待新页面事件
|
|
||||||
await firstHotProjectCard.click()
|
|
||||||
])
|
|
||||||
await newPage2.waitForTimeout(2000);
|
|
||||||
newPage2.close()
|
|
||||||
newPage.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dailyViewStar(task, page) {
|
|
||||||
// 等待新页面打开
|
|
||||||
const [newPage] = await Promise.all([
|
|
||||||
page.context().waitForEvent('page'), // 等待新页面事件
|
|
||||||
task.locator('.toComplete').click()
|
|
||||||
])
|
|
||||||
// 等待元素出现在 DOM(不要用 visible,容易超时)
|
|
||||||
await newPage.waitForSelector('.hot-selection-list a.recommend-card', { state: 'attached' });
|
|
||||||
const cards = newPage.locator('.hot-selection-list a.recommend-card');
|
|
||||||
const count = await cards.count();
|
|
||||||
|
|
||||||
console.log("热门项目数量:", count);
|
|
||||||
|
|
||||||
if (count === 0) {
|
|
||||||
throw new Error("没有找到热门项目");
|
|
||||||
}
|
|
||||||
await newPage.waitForTimeout(2000);
|
|
||||||
// 随机选一个
|
|
||||||
const index = Math.floor(Math.random() * count);
|
|
||||||
console.log("随机选择 index:", index);
|
|
||||||
// 点击并捕获新 tab
|
|
||||||
const [newPage2] = await Promise.all([
|
|
||||||
newPage.context().waitForEvent('page'),
|
|
||||||
cards.nth(index).click()
|
|
||||||
]);
|
|
||||||
// 等待 Star 按钮出现
|
|
||||||
const starBtn = newPage2.locator('#repo-header-tab .status-btn').filter({ hasText: 'Star' }).nth(1);
|
|
||||||
await newPage2.waitForTimeout(2000);
|
|
||||||
await starBtn.waitFor({ state: 'visible' });
|
|
||||||
// 判断是否已 Starred
|
|
||||||
const text = await starBtn.innerText();
|
|
||||||
await newPage2.waitForTimeout(2000);
|
|
||||||
if (text.includes('Starred')) {
|
|
||||||
// 已经 Starred -> 先取消
|
|
||||||
await starBtn.click();
|
|
||||||
|
|
||||||
// 等待变回 Star
|
|
||||||
await newPage2.waitForTimeout(500);
|
|
||||||
}
|
|
||||||
await starBtn.click();
|
|
||||||
await newPage2.waitForTimeout(2000);
|
|
||||||
newPage2.close()
|
|
||||||
newPage.close()
|
|
||||||
}
|
|
||||||
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