// ==UserScript== // @name 课程自动翻页导航 // @namespace http://tampermonkey.net/ // @version 2.0 // @description 自动翻页、倒计时等待、课程状态检测,适用于自有学习平台管理 // @author You // @match https://avaryholding.yunxuetang.cn/* // @grant none // @run-at document-end // @supportURL https://docs.scriptcat.org/ // @license MIT // ==/UserScript== (function () { 'use strict'; // ==================== 全局状态 ==================== const STATE = { pageCount: 0, running: false, stopFlag: false, waitingForCompletion: false, forceNextFlag: false, completedCourses: [], hadCountdown: false, }; // ==================== 配置 ==================== const CFG = { // ---- 按钮选择器 ---- nextBtn: [ "button:has-text('下一个')", "button.yxtf-button.is-icon", ".yxtf-button.is-icon", ], // ---- 时间参数 ---- pageGap: 3000, // 翻页间隔 (ms) waitBtnGone: 5000, // 等旧按钮消失上限 waitBtnAppear: 15000, // 等新按钮出现上限 pollMs: 500, // 按钮轮询间隔 doneGap: 2000, // 完成后到点击的间隙 panelMs: 1000, // UI 刷新间隔 staleMax: 15, // 倒计时卡死阈值(秒) // ---- 滚动 ---- scrollOn: true, scrollStep: 300, scrollDelay: 300, // ---- 上限 ---- maxPages: 50, autoStart: true, showPanel: true, // ---- 选择器 ---- countdownSel: 'span.yxt-color-warning', conflictSel: 'div.mt12.color-gray-9', progressSel: 'span.text-8c.ml12.font-size-12, span.text-8c.ml12, span.text-8c', titleSel: [ 'span.yxtf-tooltip.ulcdsdk-ellipsis-2', 'span.yxtf-tooltip[class*="ulcdsdk-ellipsis"]', '.group-title', '.yxtulcdsdk-course-player_title', 'h3.course-name', ], }; // ==================== 工具函数 ==================== const $ = (sel, parent) => (parent || document).querySelector(sel); const $$ = (sel, parent) => (parent || document).querySelectorAll(sel); const sleep = ms => new Promise(r => setTimeout(r, ms)); const log = (...a) => console.log('[导航]', ...a); function isVisible(el) { if (!el) return false; const s = getComputedStyle(el); return s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0' && el.offsetWidth > 0 && el.offsetHeight > 0; } // ---- 元素查找(支持 :has-text 语法) ---- function findOne(selector, root) { const match = selector.match(/:has-text\(['"](.+?)['"]\)/); if (match) { const base = selector.replace(/:has-text\(['"].+?['"]\)/, '').trim(); const text = match[1]; for (const el of $$(base || '*', root)) { if (el.textContent && el.textContent.includes(text)) return el; } return null; } return $(selector, root); } // ---- 找下一个按钮 ---- function findNextBtn() { for (const sel of CFG.nextBtn) { try { const el = findOne(sel); if (el && isVisible(el)) return { el, sel }; } catch (_) {} } return null; } // ---- 等待按钮出现 ---- async function waitForBtn(timeout) { const end = Date.now() + timeout; while (Date.now() < end) { if (STATE.stopFlag) return null; const r = findNextBtn(); if (r) return r; await sleep(CFG.pollMs); } return null; } // ---- 等待按钮消失 ---- async function waitBtnGone(el, timeout) { const end = Date.now() + timeout; while (Date.now() < end) { if (STATE.stopFlag) return; if (!document.contains(el) || !isVisible(el)) return; await sleep(300); } } // ---- 滚动页面 ---- async function scrollToEnd() { if (!CFG.scrollOn) return; return new Promise(resolve => { let prev = 0, tries = 0; const go = () => { const h = document.body.scrollHeight; if (h === prev || tries++ >= 50) { window.scrollTo(0, 0); resolve(); return; } prev = h; window.scrollBy(0, CFG.scrollStep); setTimeout(go, CFG.scrollDelay); }; go(); }); } // ==================== 课程信息 ==================== function parseTime(text) { if (!text) return null; if (/已完成|已学完|已通过|恭喜|完成/.test(text)) return 0; // X小时Y分Z秒 let t = 0; const h = text.match(/(\d+)\s*小时/), m = text.match(/(\d+)\s*分/), s = text.match(/(\d+)\s*秒/); if (h) t += +h[1] * 3600; if (m) t += +m[1] * 60; if (s) t += +s[1]; if (t > 0) return t; // HH:MM:SS / HH:MM const ft = text.match(/(\d+):(\d{2}):(\d{2})/); if (ft) return +ft[1] * 3600 + +ft[2] * 60 + +ft[3]; const tm = text.match(/(\d+):(\d{2})/); if (tm) return +tm[1] * 60 + +tm[2]; // "X Y" 空格分隔 const tn = text.match(/^(\d+)\s+(\d+)/); if (tn) { const a = +tn[1], b = +tn[2]; return b < 60 ? a * 60 + b : Math.max(a, b); } // 纯数字 const nm = text.match(/^\s*(\d+)\s*$/); if (nm) return +nm[1]; const fn = text.match(/(\d+)/); if (fn) return +fn[1]; return null; } function fmtTime(sec) { if (sec < 60) return sec + ' 秒'; if (sec < 3600) { const m = ~~(sec / 60), s = sec % 60; return s ? m + '分' + s + '秒' : m + ' 分钟'; } const h = ~~(sec / 3600), m = ~~((sec % 3600) / 60), s = sec % 60; if (!m && !s) return h + ' 小时'; if (!s) return h + '小时' + m + '分'; return h + '小时' + m + '分' + s + '秒'; } function isCourseCompleted() { // 方式1: 完成图标/class for (const sel of ['.yxt-color-success', '[class*="done"] svg', '[class*="success"] svg', '[class*="completed"] svg', '.yxtulcdsdk-item.is-active.is-completed', '.task-item.completed']) { if ($(sel) && isVisible($(sel))) return true; } // 方式2: 倒计时不在但文字有完成标记 if (!$(CFG.countdownSel)) { for (const el of $$('span, div, .yxtulcdsdk-course-player_countdown')) { const t = el.textContent || ''; if (t.includes('已完成') || t.includes('已学完') || t.includes('已通过')) return true; } } // 方式3: 侧边栏当前激活项内有绿色勾 const act = $('.yxtulcdsdk-item.is-active, .yxtulcdsdk-item.active'); if (act) { if ($('svg[class*="success"], svg[class*="done"], svg[class*="check"]', act)) return true; if ($('path[fill*="#4caf50"], path[fill*="#52c41a"]', act)) return true; } return false; } function getCourseName() { for (const sel of CFG.titleSel) { const el = $(sel); if (el && el.textContent.trim()) return el.textContent.trim().slice(0, 60); } return null; } function getRemainSeconds() { const el = $(CFG.countdownSel) || $('.yxt-color-warning'); if (el) { log('倒计时:', JSON.stringify(el.textContent.trim())); return parseTime(el.textContent.trim()); } log('未找到倒计时元素'); return null; } function getProgress() { const el = $(CFG.progressSel); if (!el) return '---'; const t = el.textContent.trim(), p = t.match(/(\d+)%/); if (!p) return t.slice(0, 30); const f = t.match(/(\d+)\s*\/\s*(\d+)/); return f ? p[1] + '% (' + f[1] + '/' + f[2] + ')' : p[1] + '%'; } function isCourseDone() { const rem = getRemainSeconds(); if (rem !== null && rem <= 0) return true; if (STATE.hadCountdown && rem === null) return true; if (isCourseCompleted()) return true; return false; } function hasConflict() { const el = $(CFG.conflictSel); if (!el || !isVisible(el)) return false; const t = el.textContent || ''; return t.includes('同时学习') || t.includes('其他课程将暂停'); } // ==================== UI 面板 ==================== let dbgTimer = null, dbgRemain = null; function pnl(id) { return document.getElementById(id); } function setPnl(btnText, status) { const pi = pnl('an-page'); if (pi) pi.textContent = STATE.pageCount + ' / ' + CFG.maxPages; const bi = pnl('an-btn-info'); if (bi) bi.textContent = btnText || '---'; const st = pnl('an-status'); if (st) { st.textContent = status || ''; st.className = 'status' + (STATE.running ? ' running' : ''); } } function refreshPanel() { const name = getCourseName(), rem = getRemainSeconds(), done = isCourseCompleted(); const cn = pnl('an-course-name'); if (cn) cn.textContent = (done ? '✅ ' : '') + (name || '(未检测到)'); const cs = pnl('an-course-status'); if (cs) { if (done) { cs.textContent = '✅ 已完成'; cs.className = 'value done'; } else if (rem !== null && rem > 0) { cs.textContent = '⏳ 学习中'; cs.className = 'value'; cs.style.color = '#ff9800'; } else { cs.textContent = '📄 浏览中'; cs.className = 'value'; cs.style.color = '#4fc3f7'; } } const pg = pnl('an-progress'); if (pg) pg.textContent = getProgress(); const rt = pnl('an-remain-time'); if (rt) { if (rem === null) { rt.textContent = '---'; rt.className = 'value countdown'; } else if (rem <= 0) { rt.textContent = '✅ 已完成'; rt.className = 'value done'; } else { rt.textContent = fmtTime(rem); rt.className = 'value countdown' + (rem < 60 ? ' warn' : ''); } } const di = pnl('an-debug-info'); if (di) { if (dbgRemain !== null) { if (dbgRemain <= 0) { di.textContent = '✅ 触发跳转'; di.className = 'value done'; } else { di.textContent = fmtTime(dbgRemain); di.className = 'value warn'; } } else { di.textContent = '---'; di.className = 'value warn'; } } } function setUI(run) { const start = pnl('an-btn-start'), stop = pnl('an-btn-stop'), dbgSec = pnl('an-debug-section'); if (start) start.style.display = run ? 'none' : 'block'; if (stop) stop.style.display = run ? 'block' : 'none'; if (dbgSec) dbgSec.style.display = run ? 'block' : 'none'; if (!run) stopDbg(); } function createPanel() { if (!CFG.showPanel) return; const div = document.createElement('div'); div.id = 'auto-nav-panel'; div.innerHTML = `
🎓 课程导航面板
📘 课程:
---
⏱ 剩余时间:---
📋 课程状态:检测中...
📊 总进度:---
📄 页面:0 / ${CFG.maxPages}
🔘 按钮:检测中...
等待就绪...
⚠ 仅供自有平台管理测试
禁止用于违反平台规则的行为
使用者自行承担全部责任
`; document.body.appendChild(div); pnl('an-btn-start').onclick = start; pnl('an-btn-stop').onclick = stop; pnl('an-btn-debug').onclick = toggleDbg; div.querySelector('.title').onclick = e => div.classList.toggle('collapsed'); div.classList.add('collapsed'); makeDraggable(div); } // ---- 拖拽 ---- function makeDraggable(el) { const bar = el.querySelector('.title'); let sx, sy, sl, st, on = false; bar.onmousedown = e => { if (e.target.tagName === 'BUTTON') return; on = true; const r = el.getBoundingClientRect(); sx = e.clientX; sy = e.clientY; sl = r.left; st = r.top; el.style.transition = 'none'; el.style.right = 'auto'; el.style.left = sl + 'px'; }; document.onmousemove = e => { if (!on) return; el.style.left = Math.max(0, Math.min(sl + e.clientX - sx, innerWidth - el.offsetWidth)) + 'px'; el.style.top = Math.max(0, Math.min(st + e.clientY - sy, innerHeight - 40)) + 'px'; }; document.onmouseup = () => { on = false; el.style.transition = ''; }; } // ---- UI 定时刷新 ---- let _tRunning = false, _tId = null; function startTimer() { stopTimer(); refreshPanel(); _tRunning = true; tick(); } function tick() { if (!_tRunning) return; refreshPanel(); _tId = setTimeout(tick, CFG.panelMs); } function stopTimer() { _tRunning = false; if (_tId) { clearTimeout(_tId); _tId = null; } } // ---- 加速计时器 ---- function startDbg() { stopDbg(); const rem = getRemainSeconds(); if (rem === null || rem <= 0) { setPnl('⚠ 无需加速', '倒计时已完成'); return; } const sec = Math.max(1, rem % 60); STATE.forceNextFlag = false; dbgRemain = sec; refreshPanel(); log(`加速: 原${fmtTime(rem)} → ${sec}秒`); const b = pnl('an-btn-debug'); if (b) { b.textContent = '⏹ 取消加速'; b.className = 'btn-debug on'; } dbgTimer = setInterval(() => { if (STATE.stopFlag || !STATE.running) { stopDbg(); return; } dbgRemain--; refreshPanel(); if (dbgRemain <= 0) { STATE.forceNextFlag = true; dbgRemain = 0; refreshPanel(); log('⏰ 加速归零,强制跳转'); clearInterval(dbgTimer); dbgTimer = null; dbgRemain = null; const d = pnl('an-debug-info'); if (d) { d.textContent = '✅ 触发跳转'; d.className = 'value done'; } const b2 = pnl('an-btn-debug'); if (b2) { b2.textContent = '⚡ 启动加速跳转'; b2.className = 'btn-debug'; } } }, 1000); } function stopDbg() { if (dbgTimer) { clearInterval(dbgTimer); dbgTimer = null; } dbgRemain = null; STATE.forceNextFlag = false; const d = pnl('an-debug-info'); if (d) { d.textContent = '---'; d.className = 'value warn'; } const b = pnl('an-btn-debug'); if (b) { b.textContent = '⚡ 启动加速跳转'; b.className = 'btn-debug'; } } function toggleDbg() { dbgTimer ? (stopDbg(), setPnl('取消加速', '正常等待中...')) : startDbg(); } // ==================== 主逻辑 ==================== async function waitForCompletion() { STATE.waitingForCompletion = true; setPnl('⏳ 等待中', '课程学习中...'); let lastRemain = -1, staleCount = 0, conflictPaused = false; while (!STATE.stopFlag) { refreshPanel(); if (hasConflict()) { if (!conflictPaused) { conflictPaused = true; staleCount = 0; log('[冲突] 多设备同时学习,暂停等待'); } setPnl('📱 其他设备学习中', '等待冲突解除...'); await sleep(CFG.panelMs); continue; } if (conflictPaused) { conflictPaused = false; staleCount = 0; setPnl('⏳ 等待中', '课程学习中...'); log('[冲突] 已解除,恢复正常'); } if (isCourseDone() || STATE.forceNextFlag) { setPnl('✅ 已完成', '准备跳转...'); await sleep(CFG.doneGap); STATE.waitingForCompletion = false; return true; } const rem = getRemainSeconds(); if (rem !== null) { if (rem === lastRemain) { if (++staleCount >= CFG.staleMax) { log(`[卡死] 倒计时${lastRemain}秒不变,强制跳转`); setPnl('⚠ 页面卡死', '强制跳转...'); await sleep(CFG.doneGap); STATE.waitingForCompletion = false; return true; } } else { staleCount = 0; } lastRemain = rem; } await sleep(CFG.panelMs); } STATE.waitingForCompletion = false; return false; } async function navigateOnePage() { STATE.pageCount++; stopDbg(); STATE.forceNextFlag = false; STATE.hadCountdown = false; setPnl('按钮: 等待出现...', `第 ${STATE.pageCount} 节`); const btn = await waitForBtn(CFG.waitBtnAppear); if (!btn) { setPnl('⚠ 超时', `第${STATE.pageCount}节无按钮`); return false; } setPnl(`按钮: ${btn.sel}`, `第 ${STATE.pageCount} 节`); const name = getCourseName(), rem = getRemainSeconds(), done = isCourseCompleted(); // 已完成课程 → 10秒倒计时跳过 if (done && name && !STATE.completedCourses.includes(name)) { STATE.completedCourses.push(name); log(`[跳过] ${name} 已完成`); for (let i = 10; i >= 1; i--) { if (STATE.stopFlag) return false; setPnl('⏭ 已完成课程', `${i}秒后跳过...`); await sleep(1000); } const skip = findNextBtn(); if (skip) { setPnl('⏭ 跳过中', '该课程已完成'); skip.el.click(); await sleep(CFG.pageGap); } return true; } // 有倒计时 → 等待 if (rem !== null && rem > 0) { STATE.hadCountdown = true; setPnl(`⏳ 剩余 ${fmtTime(rem)}`, '等待完成...'); if (!(await waitForCompletion()) || STATE.stopFlag) return false; if (name && !STATE.completedCourses.includes(name)) { STATE.completedCourses.push(name); log(`[完成] ${name} 累计${STATE.completedCourses.length}门`); } if (STATE.forceNextFlag) { log('[加速] 直接跳转'); const jb = findNextBtn(); if (jb) { jb.el.click(); await sleep(CFG.pageGap); } return true; } } // 正常翻页 await scrollToEnd(); if (!rem && name && !STATE.completedCourses.includes(name)) { STATE.completedCourses.push(name); } const fin = findNextBtn(); if (!fin) { setPnl('⚠ 按钮消失', `第${STATE.pageCount}节`); return false; } setPnl(`点击: ${fin.sel}`, '跳转中...'); fin.el.scrollIntoView({ behavior: 'smooth', block: 'center' }); await sleep(500); fin.el.click(); log(`第${STATE.pageCount}节 → 点击: ${fin.sel}`); await waitBtnGone(btn.el, CFG.waitBtnGone); await sleep(CFG.pageGap); return true; } async function loop() { if (STATE.pageCount >= CFG.maxPages) { log(`已达上限${CFG.maxPages}`); stop(); return; } if (STATE.stopFlag) { stop(); return; } try { if (!(await navigateOnePage())) { stop(); return; } } catch (e) { log('出错:', e); setPnl('❌ 出错', String(e).slice(0, 30)); stop(); return; } if (!STATE.stopFlag) setTimeout(loop, 300); } function start() { if (STATE.running) return; STATE.running = true; STATE.stopFlag = false; STATE.pageCount = 0; STATE.completedCourses = []; setUI(true); startTimer(); pnl('auto-nav-panel').classList.remove('collapsed'); setPnl('启动中...', '开始自动翻页'); log('开始'); loop(); } function stop() { STATE.running = false; STATE.stopFlag = true; STATE.waitingForCompletion = false; stopDbg(); stopTimer(); setUI(false); setPnl('已停止', `共 ${STATE.pageCount} 节`); refreshPanel(); log(`停止,共${STATE.pageCount}节`); } // ==================== 入口 ==================== function init() { createPanel(); startTimer(); setPnl('检测中...', '等待页面就绪'); waitForBtn(10000).then(r => { setPnl(r ? `✅ 按钮: ${r.sel}` : '⚠ 未匹配', r ? '可以开始了' : '请检查选择器'); }); if (CFG.autoStart) setTimeout(start, 2000); } if (document.readyState === 'complete') init(); else addEventListener('load', init); })();