From 77a07aae0a98839123ac5a3b3acceb7109af71d6 Mon Sep 17 00:00:00 2001 From: zzyinfo Date: Mon, 6 Jul 2026 09:34:39 +0800 Subject: [PATCH] Refactor GitCode daily bot --- .env.example | 13 ++ .gitignore | 8 + config.js | 55 +++++ main.js | 369 ++++++++++++++++++++++++++++++ package-lock.json | 38 +++- package.json | 19 +- state.json | 305 ------------------------- tasks/dailyCheckIn.js | 50 +++++ tasks/dailyShare.js | 68 ++++++ tasks/dailyStar.js | 228 +++++++++++++++++++ tasks/dailyTrending.js | 111 +++++++++ tasks/updateProject.js | 66 ++++++ test.js | 500 ++++++++++++++++++++++++----------------- utils/browser.js | 98 ++++++++ utils/feishu.js | 172 ++++++++++++++ utils/logger.js | 79 +++++++ utils/login.js | 185 +++++++++++++++ utils/page.js | 320 ++++++++++++++++++++++++++ utils/taskRunner.js | 100 +++++++++ 19 files changed, 2251 insertions(+), 533 deletions(-) create mode 100644 .env.example create mode 100644 config.js create mode 100644 main.js delete mode 100644 state.json create mode 100644 tasks/dailyCheckIn.js create mode 100644 tasks/dailyShare.js create mode 100644 tasks/dailyStar.js create mode 100644 tasks/dailyTrending.js create mode 100644 tasks/updateProject.js create mode 100644 utils/browser.js create mode 100644 utils/feishu.js create mode 100644 utils/logger.js create mode 100644 utils/login.js create mode 100644 utils/page.js create mode 100644 utils/taskRunner.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1ff8b30 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 45e53d8..79a0624 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/config.js b/config.js new file mode 100644 index 0000000..d6514e2 --- /dev/null +++ b/config.js @@ -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; diff --git a/main.js b/main.js new file mode 100644 index 0000000..5aa4c17 --- /dev/null +++ b/main.js @@ -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} 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} + */ +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); + }); diff --git a/package-lock.json b/package-lock.json index d5cc1a5..ba727a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }, diff --git a/package.json b/package.json index d011bbc..91b169d 100644 --- a/package.json +++ b/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" } } diff --git a/state.json b/state.json deleted file mode 100644 index a324ceb..0000000 --- a/state.json +++ /dev/null @@ -1,305 +0,0 @@ -{ - "cookies": [ - { - "name": "HWWAFSESTIME", - "value": "1774419940130", - "domain": "gitcode.com", - "path": "/", - "expires": -1, - "httpOnly": false, - "secure": false, - "sameSite": "Lax" - }, - { - "name": "HWWAFSESID", - "value": "f39e37c842c521d4de", - "domain": "gitcode.com", - "path": "/", - "expires": -1, - "httpOnly": false, - "secure": false, - "sameSite": "Lax" - }, - { - "name": "uuid_tt_dd", - "value": "10_37463587150-1774419940380-162871", - "domain": ".gitcode.com", - "path": "/", - "expires": 1808979939.061817, - "httpOnly": false, - "secure": true, - "sameSite": "Lax" - }, - { - "name": "c_gitcode_fref", - "value": "-", - "domain": ".gitcode.com", - "path": "/", - "expires": 1777012060.090405, - "httpOnly": false, - "secure": true, - "sameSite": "Lax" - }, - { - "name": "c_gitcode_um", - "value": "-", - "domain": ".gitcode.com", - "path": "/", - "expires": 1777012060.090433, - "httpOnly": false, - "secure": true, - "sameSite": "Lax" - }, - { - "name": "gitcode_first_time", - "value": "2026-03-25%2014:25:42", - "domain": ".gitcode.com", - "path": "/", - "expires": 1808979942.323148, - "httpOnly": false, - "secure": false, - "sameSite": "Lax" - }, - { - "name": "gitcode_theme", - "value": "white", - "domain": ".gitcode.com", - "path": "/", - "expires": 1805955942, - "httpOnly": false, - "secure": true, - "sameSite": "Lax" - }, - { - "name": "_fr_ssid", - "value": "ab56d024f13e4273b7ff867394c489d1", - "domain": ".gitcode.com", - "path": "/", - "expires": 1774421861, - "httpOnly": false, - "secure": false, - "sameSite": "Lax" - }, - { - "name": "_frid", - "value": "7966419d19a240bd83fe93a73245eb86", - "domain": ".gitcode.com", - "path": "/", - "expires": 1805955942, - "httpOnly": false, - "secure": false, - "sameSite": "Lax" - }, - { - "name": "HWWAFSESTIME", - "value": "1774419941996", - "domain": "web-api.gitcode.com", - "path": "/", - "expires": -1, - "httpOnly": false, - "secure": false, - "sameSite": "Lax" - }, - { - "name": "HWWAFSESID", - "value": "277f2214dd1fa399a20", - "domain": "web-api.gitcode.com", - "path": "/", - "expires": -1, - "httpOnly": false, - "secure": false, - "sameSite": "Lax" - }, - { - "name": "BENSESSCC_TAG", - "value": "10_37463587150-1774419940380-162871", - "domain": "web-api.gitcode.com", - "path": "/", - "expires": -1, - "httpOnly": false, - "secure": false, - "sameSite": "Lax" - }, - { - "name": "pageSize", - "value": "{%22global-pager%22:10}", - "domain": ".gitcode.com", - "path": "/", - "expires": -1, - "httpOnly": false, - "secure": false, - "sameSite": "Lax" - }, - { - "name": "HMACCOUNT_BFESS", - "value": "A48891AE78CC51F7", - "domain": ".hm.baidu.com", - "path": "/", - "expires": 1808979943.943573, - "httpOnly": false, - "secure": true, - "sameSite": "None" - }, - { - "name": "Hm_lvt_62047c952451105d57bab2c4af9ce85b", - "value": "1774419944", - "domain": ".gitcode.com", - "path": "/", - "expires": 1805956064, - "httpOnly": false, - "secure": false, - "sameSite": "Lax" - }, - { - "name": "HMACCOUNT", - "value": "A48891AE78CC51F7", - "domain": ".gitcode.com", - "path": "/", - "expires": -1, - "httpOnly": false, - "secure": false, - "sameSite": "Lax" - }, - { - "name": "UnsafeGitCodeUserName", - "value": "fzxlzy", - "domain": ".gitcode.com", - "path": "/", - "expires": 1774420664.902348, - "httpOnly": false, - "secure": true, - "sameSite": "Lax" - }, - { - "name": "c_gitcode_rid", - "value": "1774420061408_829937", - "domain": ".gitcode.com", - "path": "/", - "expires": 1777012060.090301, - "httpOnly": false, - "secure": true, - "sameSite": "Lax" - }, - { - "name": "gitcode_wechat_from", - "value": "", - "domain": ".gitcode.com", - "path": "/", - "expires": 1774420074.826777, - "httpOnly": true, - "secure": true, - "sameSite": "Lax" - }, - { - "name": "GITCODE_ACCESS_TOKEN", - "value": "eyJhbGciOiJIUzUxMiJ9.eyJqdGkiOiI2NzllMjA3OWFiYjZiNDcxNjE4YzBmNzQiLCJzdWIiOiJmenhsenkiLCJhdXRob3JpdGllcyI6W10sIm9iamVjdElkIjoiNjljMzgwNjE3OGExN2I0MTZmODhlZDc1IiwiaWF0IjoxNzc0NDIwMDY1LCJleHAiOjE3NzQ1MDY0NjV9.DxMWFpOgf6xY6ykCNWf1WYzpBIkzlM1rjm1mX4jC5QDQCUfnUJfUoGcn6gIH2sJHNs5eElOUd1hlQSfEm-fOag", - "domain": ".gitcode.com", - "path": "/", - "expires": 1789972064.598955, - "httpOnly": true, - "secure": true, - "sameSite": "Lax" - }, - { - "name": "GITCODE_REFRESH_TOKEN", - "value": "eyJhbGciOiJIUzUxMiJ9.eyJqdGkiOiI2NzllMjA3OWFiYjZiNDcxNjE4YzBmNzQiLCJzdWIiOiJmenhsenkiLCJhdXRob3JpdGllcyI6W10sIm9iamVjdElkIjoiNjljMzgwNjE3OGExN2I0MTZmODhlZDc1IiwiaWF0IjoxNzc0NDIwMDY1LCJleHAiOjE3Nzk2MDQwNjV9.CRsXs5tgO48SNxLz5mSFZYv86MkZ9R-heDnFKmCDpla37gmhLXRTUl01ryaoJtL-Qo8FWEXyErzCN5D4jtFhrw", - "domain": ".gitcode.com", - "path": "/", - "expires": 1789972064.598989, - "httpOnly": true, - "secure": true, - "sameSite": "Lax" - }, - { - "name": "GitCodeUserName", - "value": "fzxlzy", - "domain": ".gitcode.com", - "path": "/", - "expires": 1789972064.9033, - "httpOnly": true, - "secure": true, - "sameSite": "Lax" - }, - { - "name": "Hm_lpvt_62047c952451105d57bab2c4af9ce85b", - "value": "1774420065", - "domain": ".gitcode.com", - "path": "/", - "expires": -1, - "httpOnly": false, - "secure": false, - "sameSite": "Lax" - } - ], - "origins": [ - { - "origin": "https://gitcode.com", - "localStorage": [ - { - "name": "user-custom-theme-data", - "value": "{\"devui-global-bg\":\"var(--theme-global-bg)\",\"devui-glass-morphism-bg\":\"rgba(245, 246, 248, 0.9)\",\"devui-global-bg-normal\":\"#ffffff\",\"devui-base-bg\":\"var(--theme-default-bg)\",\"devui-base-bg-dark\":\"#252b3a\",\"devui-brand\":\"var(--theme-ui-brand)\",\"devui-brand-foil\":\"var(--theme-brand-foil)\",\"devui-brand-hover\":\"#7693f5\",\"devui-brand-active\":\"#526ecc\",\"devui-brand-active-focus\":\"#344899\",\"devui-contrast\":\"var(--theme-contrast)\",\"devui-text\":\"var(--theme-text)\",\"devui-text-weak\":\"var(--theme-aide-text)\",\"devui-aide-text\":\"var(--theme-aide-text)\",\"devui-aide-text-hover\":\"#252b3a\",\"devui-aide-text-stress\":\"#575d6c\",\"devui-placeholder\":\"var(--theme-placeholder)\",\"devui-light-text\":\"var(--theme-light-text)\",\"devui-dark-text\":\"#252b3a\",\"devui-link\":\"var(--theme-link)\",\"devui-link-active\":\"#526ecc\",\"devui-link-light\":\"var(--theme-link-light)\",\"devui-link-light-active\":\"#beccfa\",\"devui-line\":\"var(--theme-line-split)\",\"devui-dividing-line\":\"var(--theme-dividing-line)\",\"devui-block\":\"var(--theme-block)\",\"devui-area\":\"var(--theme-area)\",\"devui-danger\":\"var(--theme-danger)\",\"devui-warning\":\"var(--theme-warning)\",\"devui-waiting\":\"#beccfa\",\"devui-success\":\"var(--theme-success)\",\"devui-info\":\"var(--theme-info)\",\"devui-initial\":\"#e9edfa\",\"devui-unavailable\":\"#f5f5f5\",\"devui-shadow\":\"var(--theme-shadow)\",\"devui-light-shadow\":\"rgba(37, 43, 58, 0.12)\",\"devui-connected-overlay-shadow\":\"rgba(37, 43, 58, 0.16)\",\"devui-feedback-overlay-shadow\":\"rgba(37, 43, 58, 0.2)\",\"devui-icon-text\":\"var(--theme-aide-text)\",\"devui-icon-bg\":\"var(--theme-icon-bg)\",\"devui-icon-fill\":\"var(--theme-icon-fill)\",\"devui-icon-fill-weak\":\"var(--theme-icon-fill-weak)\",\"devui-icon-fill-hover\":\"var(--theme-icon-fill-hover)\",\"devui-icon-fill-active\":\"#252b3a\",\"devui-icon-fill-active-hover\":\"#252b3a\",\"devui-shape-icon-fill\":\"var(--theme-shape-icon-fill)\",\"devui-shape-icon-fill-active\":\"#babbc0\",\"devui-shape-icon-fill-hover\":\"var(--theme-shape-icon-fill-hover)\",\"devui-shape-icon-fill-disabled\":\"var(--theme-shape-icon-fill-disabled)\",\"devui-form-control-line\":\"var(--theme-line-border)\",\"devui-form-control-bg\":\"var(--theme-default-bg)\",\"devui-form-control-line-hover\":\"var(--theme-form-control-line-hover)\",\"devui-form-control-line-active\":\"var(--theme-form-control-line-active)\",\"devui-form-control-interactive-outline\":\"rgba(94,124,224,0.08)\",\"devui-form-control-line-active-hover\":\"#344899\",\"devui-list-item-active-bg\":\"var(--theme-overlay-hover-bg)\",\"devui-list-item-active-text\":\"var(--theme-list-item-active-text)\",\"devui-list-item-active-hover-bg\":\"#f2f5fc\",\"devui-list-item-hover-bg\":\"var(--theme-overlay-hover-bg)\",\"devui-list-item-hover-text\":\"var(--theme-list-item-hover-text)\",\"devui-list-item-selected-bg\":\"var(--theme-list-item-selected-bg)\",\"devui-list-item-strip-bg\":\"#f2f5fc\",\"devui-disabled-bg\":\"var(--theme-disabled-bg)\",\"devui-disabled-line\":\"var(--theme-disabled-line)\",\"devui-disabled-text\":\"var(--theme-disable-text)\",\"devui-primary-disabled\":\"var(--theme-btn-primary-disabled)\",\"devui-icon-fill-active-disabled\":\"#beccfa\",\"devui-label-bg\":\"var(--theme-label-bg)\",\"devui-connected-overlay-bg\":\"var(--theme-default-bg)\",\"devui-connected-overlay-line\":\"#526ecc\",\"devui-fullscreen-overlay-bg\":\"var(--theme-fullscreen-overlay-bg)\",\"devui-feedback-overlay-bg\":\"var(--theme-feedback-overlay-bg)\",\"devui-feedback-overlay-text\":\"#dfe1e6\",\"devui-embed-search-bg\":\"#f2f2f3\",\"devui-embed-search-bg-hover\":\"#eef0f5\",\"devui-float-block-shadow\":\"rgba(94, 124, 224, 0.3)\",\"devui-highlight-overlay\":\"rgba(255, 255, 255, 0.8)\",\"devui-range-item-hover-bg\":\"var(--theme-range-item-hover-bg)\",\"devui-primary\":\"var(--theme-btn-primary)\",\"devui-primary-hover\":\"var(--theme-btn-primary-hover)\",\"devui-primary-active\":\"var(--theme-btn-primary-active)\",\"devui-contrast-hover\":\"var(--theme-contrast-hover)\",\"devui-contrast-active\":\"var(--theme-contrast-active)\",\"devui-danger-line\":\"var(--theme-danger-line)\",\"devui-danger-bg\":\"var(--theme-danger-bg)\",\"devui-warning-line\":\"var(--theme-warning-line)\",\"devui-warning-bg\":\"var(--theme-warning-bg)\",\"devui-info-line\":\"var(--theme-info-line)\",\"devui-info-bg\":\"var(--theme-info-bg)\",\"devui-success-line\":\"var(--theme-success-line)\",\"devui-success-bg\":\"var(--theme-success-bg)\",\"devui-primary-line\":\"#5e7ce0\",\"devui-primary-bg\":\"#f2f5fc\",\"devui-default-line\":\"#5e7ce0\",\"devui-default-bg\":\"var(--theme-default-ui-bg)\",\"devui-font-size\":\"14px\",\"devui-font-size-card-title\":\"16px\",\"devui-font-size-page-title\":\"16px\",\"devui-font-size-modal-title\":\"18px\",\"devui-font-size-price\":\"20px\",\"devui-font-size-data-overview\":\"24px\",\"devui-font-size-icon\":\"16px\",\"devui-font-size-sm\":\"12px\",\"devui-font-size-md\":\"14px\",\"devui-font-size-lg\":\"14px\",\"devui-font-title-weight\":\"bold\",\"devui-font-content-weight\":\"normal\",\"devui-line-height-base\":\"1.5\",\"devui-border-radius\":\"4px\",\"devui-border-radius-feedback\":\"4px\",\"devui-border-radius-card\":\"4px\",\"devui-border-radius-full\":\"100px\",\"devui-shadow-length-base\":\"0 2px 6px 0\",\"devui-shadow-length-slide-left\":\"-2px 0 8px 0\",\"devui-shadow-length-slide-right\":\"2px 0 8px 0\",\"devui-shadow-length-connected-overlay\":\"0 2px 12px 0\",\"devui-shadow-length-hover\":\"0 4px 16px 0\",\"devui-shadow-length-feedback-overlay\":\"0 8px 16px 0\",\"devui-shadow-length-fullscreen-overlay\":\"0 10px 24px 0\",\"devui-animation-ease-in-smooth\":\"cubic-bezier(0.645, 0.045, 0.355, 1)\",\"devui-animation-duration-slow\":\"300ms\",\"devui-animation-duration-base\":\"200ms\",\"devui-animation-duration-fast\":\"100ms\",\"devui-animation-ease-in\":\"cubic-bezier(0.5, 0, 0.84, 0.25)\",\"devui-animation-ease-out\":\"cubic-bezier(0.16, 0.75, 0.5, 1)\",\"devui-animation-ease-in-out\":\"cubic-bezier(0.5, 0.05, 0.5, 0.95)\",\"devui-animation-ease-in-out-smooth\":\"cubic-bezier(0.645, 0.045, 0.355, 1)\",\"devui-animation-linear\":\"cubic-bezier(0, 0, 1, 1)\",\"devui-z-index-full-page-overlay\":\"1080\",\"devui-z-index-pop-up\":\"1060\",\"devui-z-index-dropdown\":1070,\"devui-z-index-modal\":\"1050\",\"devui-z-index-drawer\":\"1040\",\"devui-z-index-framework\":\"1000\",\"devui-menu-item\":\"var(--theme-menu-item)\",\"devui-menu-item-sub\":\"#6C6C6C\",\"devui-menu-item-hover\":\"var(--theme-menu-item-hover)\",\"devui-menu-disabled\":\"#919191\",\"devui-icon-active-color\":\"#ffffff\",\"devui-gray-form-control-bg\":\"var(--theme-table-header-bg)\",\"devui-gray-form-control-hover-bg\":\"var(--theme-table-body-bg-hover)\",\"devui-icon-disabled-color\":\"var(--theme-icon-disabled-color)\",\"devui-btn-common-bg\":\"var(--theme-btn-default)\",\"devui-btn-common-bg-hover\":\"var(--theme-btn-default-hover)\",\"devui-btn-common-border-color\":\"var(--theme-btn-default-border)\",\"devui-btn-common-border-color-hover\":\"var(--devui-btn-common-border-color-hover)\",\"devui-btn-common-border-disabled\":\"var(--theme-btn-default-border-disabled)\",\"devui-link-disabled\":\"var(--theme-link-disabled)\",\"devui-icon-hover-bg\":\"var(--theme-icon-hover)\",\"devui-icon-active-bg\":\"var(--theme-icon-active)\",\"devui-connected-overlay-b\":\"var(--theme-overlay-bg)\",\"devui-danger-text\":\"var(--theme-danger-text)\",\"devui-warning-text\":\"var(--theme-warning-text)\",\"devui-success-text\":\"var(--theme-success-text)\",\"devui-nav-expand-bg\":\"#fbfbfc\",\"devui-btn-common-color-hover\":\"#252B3A\",\"devui-btn-common-bg-active\":\"#BDBDBD\",\"devui-btn-common-color-active\":\"#252B3A\",\"devui-btn-common-border-color-active\":\"transparent\"}" - }, - { - "name": "validator_email", - "value": "valid" - }, - { - "name": "userInfo", - "value": "{\"access_token\":\"eyJhbGciOiJIUzUxMiJ9.eyJqdGkiOiI2NzllMjA3OWFiYjZiNDcxNjE4YzBmNzQiLCJzdWIiOiJmenhsenkiLCJhdXRob3JpdGllcyI6W10sIm9iamVjdElkIjoiNjljMzgwNjE3OGExN2I0MTZmODhlZDc1IiwiaWF0IjoxNzc0NDIwMDY1LCJleHAiOjE3NzQ1MDY0NjV9.DxMWFpOgf6xY6ykCNWf1WYzpBIkzlM1rjm1mX4jC5QDQCUfnUJfUoGcn6gIH2sJHNs5eElOUd1hlQSfEm-fOag\",\"refresh_token\":\"eyJhbGciOiJIUzUxMiJ9.eyJqdGkiOiI2NzllMjA3OWFiYjZiNDcxNjE4YzBmNzQiLCJzdWIiOiJmenhsenkiLCJhdXRob3JpdGllcyI6W10sIm9iamVjdElkIjoiNjljMzgwNjE3OGExN2I0MTZmODhlZDc1IiwiaWF0IjoxNzc0NDIwMDY1LCJleHAiOjE3Nzk2MDQwNjV9.CRsXs5tgO48SNxLz5mSFZYv86MkZ9R-heDnFKmCDpla37gmhLXRTUl01ryaoJtL-Qo8FWEXyErzCN5D4jtFhrw\",\"xauth_token\":\"\",\"id\":\"679e2079abb6b471618c0f74\",\"object_id\":\"679e2079abb6b471618c0f74\",\"username\":\"fzxlzy\",\"nickname\":\"哇丶\",\"avatar\":\"https://cdn-img.gitcode.com/ab/ab/763aba9bdd0022303702c4539fcdc6908e865b2da78b995d5f0c1619b8b058fe.png\",\"email\":\"810528574@qq.com\",\"domain_id\":\"d90bdf01e8264138a87a92cc82237ba4\",\"arts_id\":3943132,\"iam_id\":\"b56de14a53a74479984b00cc040466bf\",\"login_id\":\"69c3806178a17b416f88ed75\",\"status\":0,\"audit_status\":10,\"has_password\":true,\"create_time\":\"2025-02-01T13:24:09.000+00:00\",\"mfa_enable\":false,\"user_status_enum\":\"SUCCESS\",\"is_new\":false,\"mobile\":\"156****9816\",\"score\":1211,\"growth\":113,\"level\":3,\"next_level\":0,\"need_tips\":false,\"style_tips\":\"prompt_red_dot\"}" - }, - { - "name": "xauth_token", - "value": "" - }, - { - "name": "quickAppLoginReportParams", - "value": "{}" - }, - { - "name": "refresh_token", - "value": "eyJhbGciOiJIUzUxMiJ9.eyJqdGkiOiI2NzllMjA3OWFiYjZiNDcxNjE4YzBmNzQiLCJzdWIiOiJmenhsenkiLCJhdXRob3JpdGllcyI6W10sIm9iamVjdElkIjoiNjljMzgwNjE3OGExN2I0MTZmODhlZDc1IiwiaWF0IjoxNzc0NDIwMDY1LCJleHAiOjE3Nzk2MDQwNjV9.CRsXs5tgO48SNxLz5mSFZYv86MkZ9R-heDnFKmCDpla37gmhLXRTUl01ryaoJtL-Qo8FWEXyErzCN5D4jtFhrw" - }, - { - "name": "user-custom-theme", - "value": "gitcode-theme" - }, - { - "name": "UDESK_on_force_chat", - "value": "" - }, - { - "name": "asideDefaultWidth", - "value": "240" - }, - { - "name": "Hm_lvt_62047c952451105d57bab2c4af9ce85b", - "value": "1805956064634|1774419944" - }, - { - "name": "UDESK_on_go_chat", - "value": "" - }, - { - "name": "orgGuideStatus", - "value": "new" - }, - { - "name": "asideSetShowLittle", - "value": "false" - }, - { - "name": "access_token", - "value": "eyJhbGciOiJIUzUxMiJ9.eyJqdGkiOiI2NzllMjA3OWFiYjZiNDcxNjE4YzBmNzQiLCJzdWIiOiJmenhsenkiLCJhdXRob3JpdGllcyI6W10sIm9iamVjdElkIjoiNjljMzgwNjE3OGExN2I0MTZmODhlZDc1IiwiaWF0IjoxNzc0NDIwMDY1LCJleHAiOjE3NzQ1MDY0NjV9.DxMWFpOgf6xY6ykCNWf1WYzpBIkzlM1rjm1mX4jC5QDQCUfnUJfUoGcn6gIH2sJHNs5eElOUd1hlQSfEm-fOag" - }, - { - "name": "debug", - "value": "undefined" - }, - { - "name": "lastLoginMethod", - "value": "password" - } - ] - } - ] -} \ No newline at end of file diff --git a/tasks/dailyCheckIn.js b/tasks/dailyCheckIn.js new file mode 100644 index 0000000..6be0cc5 --- /dev/null +++ b/tasks/dailyCheckIn.js @@ -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; diff --git a/tasks/dailyShare.js b/tasks/dailyShare.js new file mode 100644 index 0000000..5f22f6a --- /dev/null +++ b/tasks/dailyShare.js @@ -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} + */ +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; diff --git a/tasks/dailyStar.js b/tasks/dailyStar.js new file mode 100644 index 0000000..6f07008 --- /dev/null +++ b/tasks/dailyStar.js @@ -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; diff --git a/tasks/dailyTrending.js b/tasks/dailyTrending.js new file mode 100644 index 0000000..139a43d --- /dev/null +++ b/tasks/dailyTrending.js @@ -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; diff --git a/tasks/updateProject.js b/tasks/updateProject.js new file mode 100644 index 0000000..880375c --- /dev/null +++ b/tasks/updateProject.js @@ -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; diff --git a/test.js b/test.js index 02caf0d..2c8602c 100644 --- a/test.js +++ b/test.js @@ -1,213 +1,293 @@ -const { chromium } = require('playwright'); -const fs = require('fs'); - -(async () => { - const browser = await chromium.launch({ - headless: true // 👈 Windows建议先开界面调试 - }); - - - // 👇 关键就在这里 - let context = null - if (fs.existsSync('state.json')) { - console.log('✅ state.json 存在'); - context = await browser.newContext({ - storageState: 'state.json', - locale: "zh-CN" - }); - } else { - console.log('❌ state.json 不存在'); - context = await browser.newContext({ - locale: "zh-CN" - }) +import * as fs from "fs"; +import axios from "axios"; +import { chromium } from 'playwright' + +(async () => { + const browser = await chromium.launch({ + headless: true // 👈 Windows建议先开界面调试 + }); + + + // 👇 关键就在这里 + let context = null + if (fs.existsSync('state.json')) { + console.log('✅ state.json 存在'); + context = await browser.newContext({ + storageState: 'state.json', + locale: "zh-CN" + }); + } else { + console.log('❌ state.json 不存在'); + 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() -} \ No newline at end of file diff --git a/utils/browser.js b/utils/browser.js new file mode 100644 index 0000000..f506bed --- /dev/null +++ b/utils/browser.js @@ -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} + */ +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, +}; diff --git a/utils/feishu.js b/utils/feishu.js new file mode 100644 index 0000000..c385ac2 --- /dev/null +++ b/utils/feishu.js @@ -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} + */ +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} + */ +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} + */ +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, +}; diff --git a/utils/logger.js b/utils/logger.js new file mode 100644 index 0000000..2446c22 --- /dev/null +++ b/utils/logger.js @@ -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; diff --git a/utils/login.js b/utils/login.js new file mode 100644 index 0000000..25ed82f --- /dev/null +++ b/utils/login.js @@ -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} 登录是否成功 + * @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} 截图文件路径 + */ +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, +}; diff --git a/utils/page.js b/utils/page.js new file mode 100644 index 0000000..a7a67e3 --- /dev/null +++ b/utils/page.js @@ -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} 找到的元素数组(可能为空) + */ +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} [options.waitFor=[]] - 要等待的选择器或 Locator + * @param {string} [options.label='页面'] - 日志标签 + * @returns {Promise} 关键元素是否出现 + */ +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} + */ +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} + */ +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, +}; diff --git a/utils/taskRunner.js b/utils/taskRunner.js new file mode 100644 index 0000000..eb2da82 --- /dev/null +++ b/utils/taskRunner.js @@ -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} 任务是否成功 + */ +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>} 任务名 → 是否成功 + */ +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, +};