// ==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 = `