// ==UserScript== // @name 课程自动导航助手 // @namespace http://tampermonkey.net/ // @version 2.0.1 // @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, currentType: 'unknown', // 当前内容类型:course | exam // ---- 跳转守卫(防死循环) ---- jumpCount: 0, // 连续跳转次数 lastJumpTime: 0, // 上次跳转时间戳 lastCourseName: null, // 上次跳转前的课程名(验证跳转是否生效) }; // ==================== 持久化状态 ==================== // 用 localStorage 记住用户是否手动停止过自动翻页。 // 关键:用户点了"停止"后,刷新页面不应该再自动开启。 const Store = { KEY_AUTO: 'courseNav_autoStart', /** 读取用户是否允许自动开始(默认跟随 CFG.autoStart) */ getAutoStart() { try { const v = localStorage.getItem(this.KEY_AUTO); if (v === null) return CFG.autoStart; // 首次使用,用配置默认值 return v === '1'; } catch (_) { return CFG.autoStart; } }, /** 记录用户对自动开始的偏好 */ setAutoStart(enabled) { try { localStorage.setItem(this.KEY_AUTO, enabled ? '1' : '0'); } catch (_) {} }, }; // ==================== 课程状态枚举 ==================== // 用于统一描述当前页面内容的状态 const CONTENT_STATE = { UNKNOWN: 'unknown', // 无法识别 LOCKED: 'locked', // 未解锁(需点"刷新") NEED_START: 'need_start', // 需要点击"开始学习"(视频未播放) LEARNING: 'learning', // 学习中(有倒计时) COMPLETED: 'completed', // 已完成 BROWSING: 'browsing', // 浏览中(无倒计时、无锁、无开始按钮) EXAM: 'exam', // 考试/测验 }; // ==================== 配置 ==================== const CFG = { // ---- 按钮选择器 ---- nextBtn: [ "button:has-text('下一个')", "button.yxtf-button.is-icon", ".yxtf-button.is-icon", ], // "上一个"按钮(未解锁时跳转前置课程用) prevBtn: [ "button:has-text('上一个')", "button.yxtf-button--default.is-plain", ], // ---- 时间参数 ---- pageGap: 3000, // 翻页间隔 (ms) waitBtnGone: 5000, // 等旧按钮消失上限 waitBtnAppear: 15000, // 等新按钮出现上限 pollMs: 500, // 按钮轮询间隔 doneGap: 2000, // 完成后到点击的间隙 panelMs: 1000, // UI 刷新间隔 staleMax: 20, // 倒计时卡死阈值(秒) // ---- 滚动 ---- scrollOn: true, scrollStep: 300, scrollDelay: 300, // ---- 上限 ---- maxPages: 50, autoStart: true, showPanel: true, // ---- 跳转守卫(防死循环) ---- maxJumpLoop: 8, // 连续跳转上限(超过则停止) jumpCoolMs: 8000, // 跳转冷却窗口:8秒内计数,超窗口重置 verifyWaitMs: 2500, // 跳转后验证等待(确认页面切换完成) // ---- 选择器 ---- 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', ], // ---- 未解锁检测 ---- // 未解锁时页面出现"刷新"按钮(class 含 o2oPlayFrame-lock) unlockBtnSel: [ "button.yxtf-button.o2oPlayFrame-lock", "button.o2oPlayFrame-lock", "button:has-text('刷新')", ], // ---- 开始学习按钮 ---- // 视频课程刷新后不会自动播放,需点击"开始学习" startBtnSel: [ "button:has-text('开始学习')", "button.yxtf-button--primary.yxtf-button--larger", "button.yxtf-button--primary", ], // ---- 考试/测验检测 ---- // 用于区分"考试"与普通"课程" examSel: [ '.exam-title', // 常见考试标题 '[class*="exam"]', // class 含 exam '[class*="paper"]', // 试卷 '[class*="answer"]', // 答题 ], examTextHints: ['考试', '测验', '试卷', '答题', '作答'], }; // ==================== 工具函数 ==================== 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; } // ---- 找上一个按钮 ---- function findPrevBtn() { for (const sel of CFG.prevBtn) { 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); } } /** * 跳转守卫:记录连续跳转次数,超限则停止。 * @returns {boolean} true=可以跳转;false=触发守卫,应停止 */ function jumpGuard() { const now = Date.now(); // 超过冷却窗口 → 重置计数 if (now - STATE.lastJumpTime > CFG.jumpCoolMs) { STATE.jumpCount = 0; } STATE.jumpCount++; STATE.lastJumpTime = now; if (STATE.jumpCount > CFG.maxJumpLoop) { log(`[守卫] 连续跳转 ${STATE.jumpCount} 次,疑似死循环,停止`); setPnl('🛑 跳转异常', `连续跳转${STATE.jumpCount}次,已停止`); stop(true); return false; } return true; } /** * 跳转验证:检查页面是否真的切换到了新课程。 * 通过比较跳转前后的课程名判断(null 时退化为等待 + 按钮变化检查)。 * @returns {Promise} 是否确认跳转成功 */ async function verifyJump(oldName, oldBtn) { // 等待内容稳定 await sleep(CFG.verifyWaitMs); if (STATE.stopFlag) return false; // 方式1:课程名发生变化 → 跳转成功 const newName = getCourseName(); if (oldName && newName && oldName !== newName) return true; // 方式2:旧按钮已从 DOM 移除/隐藏 → 页面切换过 if (oldBtn && (!document.contains(oldBtn) || !isVisible(oldBtn))) return true; // 方式3:课程名都为 null 时,等新按钮出现(可能加载慢) const nb = await waitForBtn(CFG.waitBtnAppear); if (nb) return true; return false; // 无法确认跳转 } /** * 统一的"点击跳转按钮并验证"。 * 点击后等待页面切换,验证跳转是否生效。 * @param {object} btn 按钮 {el, sel} * @param {string} label 跳转说明('上一个'/'下一个') * @returns {Promise} 是否成功完成跳转 */ async function doJump(btn, label) { // 1. 跳转守卫 if (!jumpGuard()) return false; // 2. 记录跳转前课程名(用于验证) const oldName = getCourseName(); const oldBtn = btn.el; // 3. 点击 setPnl(`点击: ${label}`, '跳转中...'); btn.el.scrollIntoView({ behavior: 'smooth', block: 'center' }); await sleep(400); if (STATE.stopFlag) return false; btn.el.click(); log(`[跳转] 点击"${label}" (${btn.sel})`); // 4. 等待页面切换 + 验证 const ok = await verifyJump(oldName, oldBtn); if (!ok) { log(`[跳转] 点击"${label}"后页面未变化,跳转可能失败`); return false; } // 5. 等待新页面按钮出现 const nb = await waitForBtn(CFG.waitBtnAppear); return !!nb; } // ---- 滚动页面 ---- 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 isExam() { // 方式1: 通过选择器命中考试容器 for (const sel of CFG.examSel) { const el = $(sel); if (el && isVisible(el)) return true; } // 方式2: 通过标题/文本关键字 const name = getCourseName() || ''; for (const hint of CFG.examTextHints) { if (name.includes(hint)) return true; } // 方式3: 扫描页面部分文本(限制范围,避免误判) const bodyText = (document.body.innerText || '').slice(0, 5000); const examCount = CFG.examTextHints.filter(h => bodyText.includes(h)).length; return examCount >= 2; // 至少命中2个关键字才判定为考试 } /** 查找"未解锁"的刷新按钮 */ function findUnlockBtn() { for (const sel of CFG.unlockBtnSel) { try { const el = findOne(sel); if (el && isVisible(el)) return { el, sel }; } catch (_) {} } return null; } /** 查找"开始学习"按钮(视频未播放时出现) */ function findStartBtn() { for (const sel of CFG.startBtnSel) { try { const el = findOne(sel); if (el && isVisible(el)) return { el, sel }; } catch (_) {} } return null; } /** 判断某个侧边栏课程项是否已完成 */ function isItemCompleted(item) { if (!item) return false; if (item.querySelector('svg[class*="success"], svg[class*="done"], svg[class*="check"]')) return true; if (item.querySelector('path[fill*="#4caf50"], path[fill*="#52c41a"], path[stroke*="#4caf50"]')) return true; const t = item.textContent || ''; return /已完成|已学完|已通过/.test(t); } /** * 在侧边栏(div.yxt-scrollbar_view)中查找第一个"未完成且非当前激活"的课程项。 * 用于:当前课程被锁(前置课程未完成)时,自动跳转到最早未完成的课程。 */ function findIncompleteCourse() { const sidebar = $('div.yxt-scrollbar_view'); const scope = sidebar || document; // 优先匹配明确的课程项选择器,找不到再宽泛匹配 let items = $$('.yxtulcdsdk-item, [class*="course-item"], [class*="task-item"]', scope); if (!items.length) items = $$('[class*="item"]', scope); for (const it of items) { if (!isVisible(it)) continue; // 跳过当前激活项(即当前被锁定的课程) if (it.classList.contains('is-active') || it.classList.contains('active')) continue; // 跳过已完成项 if (isItemCompleted(it)) continue; // 找到第一个未完成且非当前项 return it; } return null; } /** * 统一检测当前页面内容状态 * 优先级:考试 > 未解锁 > 已完成 > 待开始 > 学习中 > 浏览中 > 未知 * * 说明: * - "待开始"(NEED_START) 与 "学习中"(LEARNING) 通过倒计时区分: * 有"开始学习"按钮且无倒计时 → 待开始;有倒计时 → 学习中。 * @returns {string} CONTENT_STATE 中的某一项 */ function getContentState() { if (isExam()) return CONTENT_STATE.EXAM; if (findUnlockBtn()) return CONTENT_STATE.LOCKED; if (isCourseCompleted()) return CONTENT_STATE.COMPLETED; const rem = getRemainSeconds(); // 无倒计时,但存在"开始学习"按钮 → 视频还没开始播放 if ((rem === null || rem <= 0) && findStartBtn()) return CONTENT_STATE.NEED_START; // 有倒计时 → 学习中 if (rem !== null && rem > 0) return CONTENT_STATE.LEARNING; // 有倒计时标记但归零 → 视为已完成 if (STATE.hadCountdown && rem === null) return CONTENT_STATE.COMPLETED; return CONTENT_STATE.BROWSING; } 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(), st = getContentState(); const cn = pnl('an-course-name'); if (cn) cn.textContent = (st === CONTENT_STATE.COMPLETED ? '✅ ' : '') + (name || '(未检测到)'); const cs = pnl('an-course-status'); if (cs) { const map = { [CONTENT_STATE.EXAM]: ['📝 考试中', '#e91e63'], [CONTENT_STATE.LOCKED]: ['🔒 未解锁', '#ff9800'], [CONTENT_STATE.NEED_START]:['▶ 待开始', '#4fc3f7'], [CONTENT_STATE.COMPLETED]: ['✅ 已完成', '#4caf50'], [CONTENT_STATE.LEARNING]: ['⏳ 学习中', '#ff9800'], [CONTENT_STATE.BROWSING]: ['📄 浏览中', '#4fc3f7'], [CONTENT_STATE.UNKNOWN]: ['❓ 未知', '#999'], }; const [txt, color] = map[st] || map[CONTENT_STATE.UNKNOWN]; cs.textContent = txt; cs.className = 'value'; cs.style.color = color; } const pg = pnl('an-progress'); if (pg) pg.textContent = getProgress(); const rt = pnl('an-remain-time'); if (rt) { if (st === CONTENT_STATE.EXAM) { rt.textContent = '📝'; rt.className = 'value'; rt.style.color = '#e91e63'; } else if (st === CONTENT_STATE.LOCKED) { rt.textContent = '🔒'; rt.className = 'value'; rt.style.color = '#ff9800'; } else if (st === CONTENT_STATE.NEED_START) { rt.textContent = '▶'; rt.className = 'value'; rt.style.color = '#4fc3f7'; } else 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'), reset = pnl('an-btn-reset'); if (start) start.style.display = run ? 'none' : 'block'; if (stop) stop.style.display = run ? 'block' : 'none'; if (dbgSec) dbgSec.style.display = run ? 'block' : 'none'; // "恢复自动开始"按钮:仅在用户手动停止后(偏好=false)显示 if (reset) reset.style.display = (!run && !Store.getAutoStart()) ? '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(true); pnl('an-btn-debug').onclick = toggleDbg; pnl('an-btn-reset').onclick = () => { Store.setAutoStart(true); // 恢复自动开始偏好 start(); }; 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(); } // ==================== 自动答题接口(预留) ==================== /** * 考试/测验处理器接口。 * 未来实现自动答题时,只需填充此对象的各方法。 * 当前为占位实现:识别到考试后暂停,不做任何答题操作。 */ const ExamHandler = { /** 是否启用自动答题(未来改为 true) */ enabled: false, /** * 识别到考试时调用。 * @param {object} ctx { name, url } * @returns {Promise<'done'|'skip'|'wait'>} 处理结果 * - 'done' 考试已处理完毕,可跳下一个 * - 'skip' 跳过本次考试 * - 'wait' 保持等待(如需要人工介入) */ async handle(ctx) { log('[考试] 识别到考试,但自动答题未启用'); setPnl('📝 考试中', '自动答题未启用,暂停等待...'); // 默认等待 60 秒后跳过,避免卡死 await sleep(60000); return 'skip'; }, /** 判断当前考试是否已作答完成 */ isFinished() { // TODO: 未来实现——检测"提交成功"/"已完成"等标记 return false; }, /** 提交答案(未来实现) */ async submit() { // TODO: 未来实现——点击"提交"按钮 log('[考试] 提交接口未实现'); return false; }, }; // ==================== 主逻辑 ==================== async function waitForCompletion() { STATE.waitingForCompletion = true; setPnl('⏳ 等待中', '课程学习中...'); let lastRemain = -1, staleCount = 0, conflictPaused = false, startClicked = false; while (!STATE.stopFlag) { refreshPanel(); // 视频未播放:优先检查"开始学习"按钮(先于卡死检测) const sb = findStartBtn(); if (sb) { if (!startClicked) { startClicked = true; log('[开始] 检测到"开始学习"按钮,点击播放'); setPnl('▶ 开始学习', '点击开始按钮...'); sb.el.scrollIntoView({ behavior: 'smooth', block: 'center' }); await sleep(500); sb.el.click(); } // 只要有"开始学习"按钮,就重置卡死计数,避免误判 staleCount = 0; lastRemain = -1; await sleep(CFG.panelMs); continue; } 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} 节`); // 状态循环处理:跳转后重新检测新页面状态, // 异常状态(未解锁/已完成)循环处理,带守卫防死循环。 for (let loop = 0; loop < CFG.maxJumpLoop; loop++) { if (STATE.stopFlag) return false; // 确保有"下一个"按钮(首次进入需等待出现) if (!findNextBtn()) { const b = await waitForBtn(CFG.waitBtnAppear); if (!b) { setPnl('⚠ 超时', `第${STATE.pageCount}节无按钮`); return false; } } const name = getCourseName(); const st = getContentState(); // ---- 分派:根据内容状态处理 ---- switch (st) { case CONTENT_STATE.EXAM: return await handleExam(name); case CONTENT_STATE.LOCKED: // 未解锁 → 往前跳,跳转后 continue 重新检测 if (!(await handleLocked())) return false; continue; case CONTENT_STATE.NEED_START: return await handleNeedStart(); case CONTENT_STATE.COMPLETED: // 已完成 → 往后跳,跳转后 continue 重新检测 if (!(await handleCompleted(name))) return false; continue; case CONTENT_STATE.LEARNING: return await handleLearning(name); default: // BROWSING / UNKNOWN return await handleBrowsing(name); } } // 状态循环超限 → 疑似死循环,停止 log('[守卫] 状态循环处理超限,停止'); setPnl('🛑 状态循环异常', '连续跳转过多,已停止'); stop(true); return false; } /** 考试/测验处理 */ async function handleExam(name) { STATE.currentType = 'exam'; log(`[考试] 识别到考试: ${name || '(未知)'}`); setPnl('📝 考试中', '调用答题接口...'); const result = await ExamHandler.handle({ name, url: location.href }); if (result === 'wait') { // 需要人工介入 → 暂停,等待手动操作后继续 setPnl('⏸ 等待人工处理', '考试中,请手动完成后继续'); while (!STATE.stopFlag && !ExamHandler.isFinished()) { await sleep(CFG.panelMs); } } // done / skip → 尝试跳转 return await clickNext(); } /** 未解锁处理:往前跳转到可学习的课程(带跳转验证) */ async function handleLocked() { const name = getCourseName() || '(未命名课程)'; log(`[解锁] 课程"${name}"未解锁,往前找可学习课程`); // 方式1:优先点击页面上的"上一个"按钮(带验证) const prevBtn = findPrevBtn(); if (prevBtn) { setPnl('🔒 未解锁', '往前跳转上一个课程...'); await sleep(1200); // 短延时让用户看到状态 if (STATE.stopFlag) return false; const ok = await doJump(prevBtn, '上一个'); if (ok) return true; // 跳转成功,由 navigateOnePage 重新检测新状态 // 跳转失败/未变化 → 尝试其他方式 } // 方式2:无"上一个"按钮或跳转失败 → 在侧边栏查找未完成课程 log('[解锁] 尝试侧边栏查找前置课程'); const target = findIncompleteCourse(); if (target) { setPnl('🔒 跳转前置课程', '侧边栏查找...'); target.scrollIntoView({ behavior: 'smooth', block: 'center' }); await sleep(400); if (STATE.stopFlag) return false; target.click(); await sleep(CFG.verifyWaitMs); return true; } // 都找不到 → 停止(避免无限循环) log('[解锁] 无前置课程可跳,停止'); setPnl('🛑 无法跳转', '未找到可学习课程,已停止'); stop(true); return false; } /** 待开始处理:点击"开始学习"按钮,等待视频播放 */ async function handleNeedStart() { log('[开始] 检测到"开始学习"按钮'); setPnl('▶ 开始学习', '点击开始按钮...'); const startBtn = findStartBtn(); if (startBtn) { startBtn.el.scrollIntoView({ behavior: 'smooth', block: 'center' }); await sleep(500); startBtn.el.click(); log('[开始] 已点击"开始学习"'); } // 等待倒计时出现(视频开始播放) setPnl('▶ 等待播放', '视频启动中...'); const end = Date.now() + CFG.waitBtnAppear; while (Date.now() < end) { if (STATE.stopFlag) return false; const rem = getRemainSeconds(); if (rem !== null && rem > 0) { log('[开始] 视频已播放,进入学习'); STATE.hadCountdown = true; return await handleLearning(getCourseName()); } await sleep(CFG.panelMs); } // 超时仍未开始 → 跳过当前内容 log('[开始] 等待播放超时,跳过'); setPnl('⚠ 播放超时', '跳过当前...'); return await clickNext(); } /** 已完成课程处理:往后跳转(带验证),直到遇到可学习的课程 */ async function handleCompleted(name) { if (name && !STATE.completedCourses.includes(name)) { STATE.completedCourses.push(name); log(`[跳过] ${name} 已完成`); } // 短延时,然后跳下一个(doJump 内部会验证是否真的跳转) setPnl('⏭ 已完成课程', '跳转下一个...'); await sleep(800); if (STATE.stopFlag) return false; const ok = await clickNext(); if (ok) return true; // 跳转成功,由 navigateOnePage 重新检测新状态 // 跳转失败 → 停止 log('[跳过] 无法跳转,停止'); return false; } /** 学习中处理:等待倒计时 */ async function handleLearning(name) { STATE.hadCountdown = true; const rem = getRemainSeconds(); 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('[加速] 直接跳转'); return await clickNext(); } // 正常完成后 → 滚动 + 跳转 await scrollToEnd(); return await clickNext(); } /** 浏览中处理:滚动 + 跳转 */ async function handleBrowsing(name) { await scrollToEnd(); if (name && !STATE.completedCourses.includes(name)) { STATE.completedCourses.push(name); } return await clickNext(); } /** 统一的"点击下一个"操作(带跳转验证) */ async function clickNext() { const fin = findNextBtn(); if (!fin) { setPnl('⚠ 按钮消失', `第${STATE.pageCount}节`); return false; } return await doJump(fin, '下一个'); } 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 = []; // 重置跳转守卫(上次运行可能残留) STATE.jumpCount = 0; STATE.lastJumpTime = 0; STATE.lastCourseName = null; Store.setAutoStart(true); // 用户主动开启,记住偏好 setUI(true); startTimer(); pnl('auto-nav-panel').classList.remove('collapsed'); setPnl('启动中...', '开始自动翻页'); log('开始'); loop(); } function stop(manual = true) { STATE.running = false; STATE.stopFlag = true; STATE.waitingForCompletion = false; if (manual) { // 手动停止 → 记住"不再自动开始",刷新页面后也不会自动开启 Store.setAutoStart(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 ? '可以开始了' : '请检查选择器'); }); // 关键:尊重用户手动停止的偏好。 // 用户曾点"停止" → 刷新后不再自动开启;否则按配置自动开启。 const shouldAutoStart = Store.getAutoStart(); if (shouldAutoStart) { log('自动开始已启用'); setTimeout(start, 2000); } else { log('用户已手动停止,本次不自动开始'); setPnl('⏸ 已暂停', '上次手动停止,未自动开始'); } } if (document.readyState === 'complete') init(); else addEventListener('load', init); })();