// ==UserScript== // @name 学习通自动刷课·复习 // @namespace http://tampermonkey.net/ // @version 1.5.2 // @description 自动播放学习通视频 - 互动面板·倍速调节·模式切换·进度显示 - 刷课/复习模式 // @author you // @match https://mooc1.chaoxing.com/mycourse/studentstudy* // @match https://mooc1.chaoxing.com/mooc-ans/mycourse/studentstudy* // @icon https://www.chaoxing.com/favicon.ico // @grant GM_addStyle // @grant GM_log // @license MIT // ==/UserScript== (function () { 'use strict' self.addEventListener('error', function (e) { console.error('[学习通助手] 全局错误:', e.message) }) self.addEventListener('unhandledrejection', function (e) { console.error('[学习通助手] 未处理拒绝:', e.reason) }) /* ========================= 全局状态 ========================= */ var CTRL = { paused: false, currentTitle: '', status: '启动中...', } /* ========================= 版本 ========================= */ var SCRIPT_VERSION = '1.5.2' /* ========================= 配置 ========================= */ var CONFIG = { playbackRate: parseFloat(localStorage.getItem('xxt_rate') || '1'), skipCompleted: localStorage.getItem('xxt_mode') !== 'review', autoResumeMs: 800, minStayMs: 35000, stallThreshold: 4, breakInterval: 5, completionThreshold: 0.98, maxWaitForVideo: 15000, maxPages: 200, } function log() { console.log('[学习通]', ...arguments) } function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms) }) } function randomInt(min, max) { return Math.floor(Math.random() * (max - min + 1) + min) } function randomDelay(min, max) { return sleep(randomInt(min, max)) } /* ========================= 暂停检查 ========================= */ async function checkPause() { while (CTRL.paused) { await sleep(300) } } /* ========================= 互动控制面板 ========================= */ function initControlPanel() { GM_addStyle(` #xxt-panel { all: initial; position: fixed; top: 80px; right: 16px; z-index: 999999; width: 260px; background: rgba(28,28,32,0.94); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); font-family: -apple-system, "Microsoft YaHei", sans-serif; font-size: 13px; color: #e8e8e8; user-select: none; overflow: hidden; transition: opacity 0.2s; } #xxt-panel * { box-sizing: border-box; } #xxt-panel .phead { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; background: rgba(255,255,255,0.04); border-bottom: 1px solid rgba(255,255,255,0.06); cursor: pointer; } #xxt-panel .phead .ptitle { font-size: 14px; font-weight: 600; letter-spacing: 0.3px; display: flex; align-items: center; gap: 6px; } #xxt-panel .phead .pcollapse { font-size: 16px; opacity: 0.5; transition: transform 0.2s; } #xxt-panel .phead .pcollapse.open { transform: rotate(180deg); } #xxt-panel .pbody { padding: 12px 14px 14px; } #xxt-panel .pbody.collapsed { display: none; } #xxt-panel .prow { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } #xxt-panel .plabel { opacity: 0.6; font-size: 12px; } #xxt-panel .pbtns { display: flex; gap: 4px; } #xxt-panel .pbtn { padding: 3px 10px; border: 1px solid rgba(255,255,255,0.12); border-radius: 6px; cursor: pointer; font-size: 12px; color: #ccc; background: rgba(255,255,255,0.04); transition: all 0.15s; } #xxt-panel .pbtn:hover { background: rgba(255,255,255,0.1); } #xxt-panel .pbtn.active { background: #4A9EFF; color: #fff; border-color: #4A9EFF; } #xxt-panel .pbtn.active.review { background: #FF9800; border-color: #FF9800; } #xxt-panel .pbtn.speed { min-width: 36px; text-align: center; } #xxt-panel .pbtn.speed.val { min-width: 44px; cursor: default; background: rgba(255,255,255,0.06); font-weight: 600; font-size: 13px; } #xxt-panel .pbtn.speed.val:hover { background: rgba(255,255,255,0.06); } #xxt-panel .psep { height: 1px; background: rgba(255,255,255,0.06); margin: 10px 0; } #xxt-panel .pprog { margin-bottom: 6px; } #xxt-panel .pprog .pbar { height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; margin-top: 4px; overflow: hidden; } #xxt-panel .pprog .pbar .pfill { height: 100%; width: 0%; border-radius: 2px; background: linear-gradient(90deg, #4A9EFF, #6CB4FF); transition: width 0.4s; } #xxt-panel .pprog .pbar .pfill.global { background: linear-gradient(90deg, #8BC34A, #AED581); } #xxt-panel .pstats { font-size: 11px; opacity: 0.5; line-height: 1.6; margin-top: 2px; } #xxt-panel .pcur { font-size: 12px; opacity: 0.7; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 8px; line-height: 1.4; } #xxt-panel .pstatus { display: flex; align-items: center; gap: 6px; font-size: 12px; margin-bottom: 10px; } #xxt-panel .pstatus .pdot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; background: #4CAF50; } #xxt-panel .pstatus .pdot.paused { background: #FF9800; } #xxt-panel .pctrl { display: flex; gap: 6px; } #xxt-panel .pctrl .pbtn { flex: 1; text-align: center; padding: 5px 0; } #xxt-panel .pctrl .pbtn.primary { background: #4A9EFF; color: #fff; border-color: #4A9EFF; } #xxt-panel .pctrl .pbtn.primary:hover { background: #3A8EEF; } #xxt-panel .pctrl .pbtn.primary.warn { background: #FF9800; border-color: #FF9800; } #xxt-panel .pctrl .pbtn.primary.warn:hover { background: #E08700; } #xxt-panel .pctrl .pbtn.danger { background: #e74c3c; color: #fff; border-color: #e74c3c; } #xxt-panel .pctrl .pbtn.danger:hover { background: #c0392b; } `) if (document.getElementById('xxt-panel')) return var panel = document.createElement('div') panel.id = 'xxt-panel' /* ---- Header ---- */ var header = document.createElement('div') header.className = 'phead' header.innerHTML = '
📚 学习通助手 v' + SCRIPT_VERSION + '
' header.addEventListener('click', function (e) { if (e.target.closest('.pctrl')) return body.classList.toggle('collapsed') header.querySelector('.pcollapse').classList.toggle('open') }) /* ---- Body ---- */ var body = document.createElement('div') body.className = 'pbody' /* Mode row */ var modeRow = createRow() modeRow.label.textContent = '模式' var modeStudy = createBtn('刷课') var modeReview = createBtn('复习') modeStudy.className = 'pbtn' + (CONFIG.skipCompleted ? ' active' : '') modeReview.className = 'pbtn' + (CONFIG.skipCompleted ? '' : ' active review') modeStudy.addEventListener('click', function () { setMode('study') }) modeReview.addEventListener('click', function () { setMode('review') }) modeRow.btns.append(modeStudy, modeReview) body.appendChild(modeRow.row) /* Speed row */ var speedRow = createRow() speedRow.label.textContent = '倍速' var speedDown = createBtn('−') speedDown.className = 'pbtn speed' var speedVal = createBtn(CONFIG.playbackRate + 'x') speedVal.className = 'pbtn speed val' var speedUp = createBtn('+') speedUp.className = 'pbtn speed' speedDown.addEventListener('click', function () { adjustSpeed(-0.5) }) speedUp.addEventListener('click', function () { adjustSpeed(0.5) }) speedRow.btns.append(speedDown, speedVal, speedUp) body.appendChild(speedRow.row) body.appendChild(createSep()) /* Current task */ var curEl = document.createElement('div') curEl.className = 'pcur' curEl.textContent = '等待开始...' body.appendChild(curEl) /* Helper to build a progress row */ function makeProgRow(label, cls) { var wrap = document.createElement('div') wrap.className = 'pprog' var head = document.createElement('div') head.style.cssText = 'display:flex;justify-content:space-between;font-size:11px;opacity:0.6' head.innerHTML = '' + label + '0%' var bar = document.createElement('div') bar.className = 'pbar' var fill = document.createElement('div') fill.className = 'pfill ' + cls bar.appendChild(fill) wrap.append(head, bar) return { el: wrap, pct: head.querySelector('.pct'), fill: fill } } /* Page progress */ var pageProg = makeProgRow('📄 页进度', '') body.appendChild(pageProg.el) /* Global progress */ var globalProg = makeProgRow('📊 总进度', 'global') body.appendChild(globalProg.el) body.appendChild(createSep()) /* Status */ var statusRow = document.createElement('div') statusRow.className = 'pstatus' var dot = document.createElement('span') dot.className = 'pdot' var statusText = document.createElement('span') statusText.textContent = '启动中...' statusRow.append(dot, statusText) /* Control buttons */ var ctrlRow = document.createElement('div') ctrlRow.className = 'pctrl' var pauseBtn = document.createElement('div') pauseBtn.className = 'pbtn primary' pauseBtn.textContent = '⏸ 暂停' var stopBtn = document.createElement('div') stopBtn.className = 'pbtn danger' stopBtn.textContent = '⏹ 停止' ctrlRow.append(pauseBtn, stopBtn) body.appendChild(statusRow) body.appendChild(ctrlRow) panel.append(header, body) document.body.appendChild(panel) /* ---- Helper functions ---- */ function createRow() { var row = document.createElement('div') row.className = 'prow' var label = document.createElement('span') label.className = 'plabel' var btns = document.createElement('div') btns.className = 'pbtns' row.append(label, btns) return { row: row, label: label, btns: btns } } function createBtn(text) { var b = document.createElement('span') b.className = 'pbtn' b.textContent = text return b } function createSep() { var s = document.createElement('div') s.className = 'psep' return s } function setMode(mode) { CONFIG.skipCompleted = (mode !== 'review') localStorage.setItem('xxt_mode', mode) modeStudy.className = 'pbtn' + (mode === 'study' ? ' active' : '') modeReview.className = 'pbtn' + (mode === 'review' ? ' active review' : '') log('模式切换: ' + (mode === 'review' ? '复习' : '刷课')) CTRL.status = '模式: ' + (mode === 'review' ? '复习' : '刷课') } function adjustSpeed(delta) { var speeds = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] var idx = speeds.indexOf(CONFIG.playbackRate) if (idx === -1) idx = speeds.indexOf(1) idx = Math.max(0, Math.min(speeds.length - 1, idx + (delta > 0 ? 1 : -1))) CONFIG.playbackRate = speeds[idx] localStorage.setItem('xxt_rate', String(CONFIG.playbackRate)) speedVal.textContent = CONFIG.playbackRate + 'x' /* 实时应用到当前视频 */ try { var iframe = document.getElementById('iframe') if (iframe) { var doc = iframe.contentDocument || iframe.contentWindow.document if (doc) { var vids = doc.querySelectorAll('video#video_html5_api') vids.forEach(function (v) { v.playbackRate = CONFIG.playbackRate }) } } } catch (e) {} log('倍速: ' + CONFIG.playbackRate + 'x') CTRL.status = '倍速: ' + CONFIG.playbackRate + 'x' } /* ---- UI update interval ---- */ function updatePanel() { var all = getEntries() var page = getPageEntries(all) var pageTotal = page.length var pageDone = countCompleted(page) var allTotal = all.length var allDone = countCompleted(all) /* Page progress */ if (pageTotal > 0) { var pct = Math.round(pageDone / pageTotal * 100) pageProg.pct.textContent = pct + '%' pageProg.fill.style.width = pct + '%' } /* Global progress */ if (allTotal > 0) { var gPct = Math.round(allDone / allTotal * 100) globalProg.pct.textContent = allDone + '/' + allTotal + ' (' + gPct + '%)' globalProg.fill.style.width = gPct + '%' } curEl.textContent = CTRL.currentTitle || '—' if (CTRL.paused) { dot.className = 'pdot paused' statusText.textContent = '⏸ 已暂停' pauseBtn.textContent = '▶ 继续' pauseBtn.className = 'pbtn primary warn' } else { dot.className = 'pdot' statusText.textContent = CTRL.status || '运行中...' pauseBtn.textContent = '⏸ 暂停' pauseBtn.className = 'pbtn primary' } } setInterval(updatePanel, 800) /* ---- Pause / Stop ---- */ pauseBtn.addEventListener('click', function () { CTRL.paused = !CTRL.paused log(CTRL.paused ? '⏸ 已暂停' : '▶ 已恢复') }) stopBtn.addEventListener('click', function () { if (confirm('确定停止刷课?页面将刷新。')) { location.reload() } }) log('控制面板已创建') } /* ========================= 人类行为模拟 ========================= */ function simulateHumanBehavior() { try { window.scrollBy(0, randomInt(50, 300)) var iframe = document.getElementById('iframe') if (iframe) { var doc try { doc = iframe.contentDocument || iframe.contentWindow.document } catch (e) {} if (doc) { doc.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: randomInt(100, 900), clientY: randomInt(100, 500) })) } } } catch (e) {} } /* ========================= 页面导航辅助 ========================= */ async function clickEntry(entry) { if (entry.isActive) return entry.name.click() await randomDelay(2000, 4000) await waitForIframeReady() } async function findVideosWithRetry() { var maxAttempts = 8 for (var attempt = 0; attempt < maxAttempts; attempt++) { var v = findVideos() if (v.length > 0) return v if (attempt < maxAttempts - 1) { log('⏳ 等待视频加载 (' + (attempt + 1) + '/' + (maxAttempts - 1) + ')...') await sleep(2000) } } return [] } function getNextBtn() { var btn = document.getElementById('prevNextFocusNext') return btn && btn.style.display !== 'none' && btn.offsetParent !== null ? btn : null } /* ========================= 进度上报 XHR 监控 ========================= */ var lastProgressTime = Date.now() function setupXHRMonitor() { var origOpen = XMLHttpRequest.prototype.open XMLHttpRequest.prototype.open = function (method, url) { this._xxtUrl = typeof url === 'string' ? url : '' return origOpen.apply(this, arguments) } var origSend = XMLHttpRequest.prototype.send XMLHttpRequest.prototype.send = function (body) { var self = this if (self._xxtUrl && /multimedia|video.*log|playlog|studyLog|userlog/i.test(self._xxtUrl)) { self.addEventListener('load', function () { if (self.status === 200) lastProgressTime = Date.now() }) } return origSend.apply(self, arguments) } } function isProgressStale() { return (Date.now() - lastProgressTime) > 3 * 60 * 1000 } /* ========================= 获取知识点列表 ========================= */ function getEntries() { var tree = document.getElementById('coursetree') if (!tree) return [] var names = tree.querySelectorAll('.posCatalog_level .posCatalog_name') var entries = [] names.forEach(function (name) { var parent = name.closest('.posCatalog_select') if (!parent) return entries.push({ el: parent, name: name, title: name.getAttribute('title') || name.textContent.trim(), isActive: parent.classList.contains('posCatalog_active'), isCompleted: !!parent.querySelector('.icon_Completed'), }) }) return entries } /* ========================= 等待元素出现 ========================= */ function waitForEl(selector, timeout, root) { if (!root) root = document return new Promise(function (resolve, reject) { var found = root.querySelector(selector) if (found) return resolve(found) var obs = new MutationObserver(function () { var el = root.querySelector(selector) if (el) { obs.disconnect(); resolve(el) } }) obs.observe(root, { childList: true, subtree: true }) setTimeout(function () { obs.disconnect() reject(new Error('timeout waiting for ' + selector)) }, timeout || CONFIG.maxWaitForVideo) }) } /* ========================= 课程树变化检测 ========================= */ var treeChanged = false function setupTreeObserver() { var tree = document.getElementById('coursetree') if (!tree) return var obs = new MutationObserver(function () { treeChanged = true }) obs.observe(tree, { childList: true, subtree: true, attributes: true }) } /* ========================= 等待 iframe 内容加载完成 ========================= */ function waitForIframeReady() { return new Promise(function (resolve) { var iframe = document.getElementById('iframe') if (!iframe) { resolve(); return } function check() { try { var doc = iframe.contentDocument || iframe.contentWindow.document if (doc && doc.readyState === 'complete') { resolve() return } } catch (e) { /* cross-origin */ } setTimeout(check, 300) } check() }) } /* ========================= 查找当前页面的视频 ========================= */ function findVideos() { var mainIframe = document.getElementById('iframe') if (!mainIframe) return [] var iframeDoc try { iframeDoc = mainIframe.contentDocument || mainIframe.contentWindow.document } catch (e) { return [] } if (!iframeDoc) return [] var videoFrames = iframeDoc.querySelectorAll('iframe.ans-insertvideo-online') var videos = [] videoFrames.forEach(function (vf) { try { var vDoc = vf.contentDocument || vf.contentWindow.document var video = vDoc && vDoc.querySelector('video#video_html5_api') if (video) videos.push({ frame: vf, video: video, doc: vDoc }) } catch (e) { /* 忽略跨域错误 */ } }) return videos } /* ========================= 播放一组视频 ========================= */ async function playAllVideos(videos) { if (videos.length === 0) return for (var i = 0; i < videos.length; i++) { await checkPause() var v = videos[i].video var frame = videos[i].frame var doc = videos[i].doc CTRL.status = '▶ 播放视频 ' + (i + 1) + '/' + videos.length + ' (' + CONFIG.playbackRate + 'x)' log('▶ 播放第 ' + (i + 1) + '/' + videos.length + ' 个视频 (倍速: ' + CONFIG.playbackRate + 'x)') applyAntiIdle(frame, doc) try { v.muted = true v.playbackRate = CONFIG.playbackRate await v.play() } catch (e) { log('⚠ 自动播放失败: ' + e.message) try { var bigBtn = doc.querySelector('.vjs-big-play-button') if (bigBtn) { bigBtn.click(); await sleep(500) } v.muted = true await v.play() } catch (e2) { /* 忽略 */ } } await monitorVideo(v) log('✅ 第 ' + (i + 1) + '/' + videos.length + ' 个视频完成') } } /* ========================= 监控单个视频进度 ========================= */ function monitorVideo(video) { return new Promise(function (resolve) { var prevTime = video.currentTime var stallStart = 0 var timer = setInterval(function () { try { if (video.ended) { clearInterval(timer) resolve() return } if (video.paused) { video.muted = true video.play().catch(function () {}) } var pct = video.currentTime / video.duration if (pct >= CONFIG.completionThreshold) { clearInterval(timer) resolve() return } var deltaTime = video.currentTime - prevTime var pctInt = Math.floor(pct * 100) if (pctInt % 10 === 0 && pctInt !== Math.floor(prevTime / video.duration * 100)) { CTRL.status = '▶ 视频 ' + pctInt + '%' log(' 进度: ' + pctInt + '%') } var stallThreshold = CONFIG.autoResumeMs / 1000 * 0.5 if (deltaTime < stallThreshold) { if (stallStart === 0) stallStart = Date.now() if (Date.now() - stallStart > 30000) { log('⚠ 进度停滞超过30秒,强制完成') clearInterval(timer) resolve() return } } else { stallStart = 0 } prevTime = video.currentTime } catch (e) { clearInterval(timer) resolve() } }, CONFIG.autoResumeMs) setTimeout(function () { clearInterval(timer) resolve() }, 2 * 60 * 60 * 1000) }) } /* ========================= 防挂机措施 ========================= */ function applyAntiIdle(frame, doc) { if (!doc) return try { Object.defineProperty(doc, 'hidden', { get: function () { return false } }) Object.defineProperty(doc, 'visibilityState', { get: function () { return 'visible' } }) } catch (e) {} try { var videos = doc.querySelectorAll('video') videos.forEach(function (v) { v.addEventListener('mouseleave', function (e) { e.stopPropagation() }, true) }) } catch (e) {} try { var win = frame.contentWindow if (win) { win.addEventListener('blur', function (e) { e.stopImmediatePropagation() }, true) doc.addEventListener('visibilitychange', function (e) { e.stopImmediatePropagation() }, true) } } catch (e) {} try { doc.addEventListener('keydown', function (e) { if (e.key === ' ' || e.key === 'Spacebar' || e.key === 'k') { e.stopPropagation() } }, true) } catch (e) {} } /* ========================= 处理"是否继续"弹窗 ========================= */ function setupPopupHandler() { var obs = new MutationObserver(function () { try { var btns = document.querySelectorAll( '.jb_btn, .btnBlue, .popBottom a, #popok, #hintOk, #toastok, .popMoveDele, .jb_btn_92' ) btns.forEach(function (btn) { if (btn.offsetParent === null) return var text = btn.textContent.trim() if (text === '继续' || text === '确定' || text === '知道了') { log('自动关闭弹窗: "' + text + '"') btn.click() } }) } catch (e) {} }) obs.observe(document.body, { childList: true, subtree: true }) log('弹窗监听已启动') } /* ========================= 覆盖主页面可见性 ========================= */ function overridePageVisibility() { try { Object.defineProperty(document, 'hidden', { get: function () { return false }, configurable: true, }) } catch (e) {} document.addEventListener('visibilitychange', function (e) { e.stopImmediatePropagation() }, true) log('可见性检测已绕过') } /* ========================= 后台运行增强 ========================= */ function setupBackgroundManager() { var wakeLock = null async function requestWakeLock() { try { if ('wakeLock' in navigator) { wakeLock = await navigator.wakeLock.request('screen') wakeLock.addEventListener('release', function () { log('🔋 Wake Lock 释放') }) log('🔋 Wake Lock 已获取') } } catch (e) { log('🔋 Wake Lock:', e.message) } } requestWakeLock() function recoverVideos() { try { var mainIframe = document.getElementById('iframe') if (!mainIframe) return var doc = mainIframe.contentDocument || mainIframe.contentWindow.document if (!doc) return var vids = doc.querySelectorAll('video#video_html5_api') vids.forEach(function (v) { if (v.paused && v.duration > 0 && v.currentTime < v.duration - 0.5) { v.muted = true v.play().catch(function () {}) } }) } catch (e) {} } document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') { log('🔙 切回标签,立即恢复') requestWakeLock() recoverVideos() } }) window.addEventListener('focus', function () { recoverVideos() }) } /* ========================= 获取当前章节的所有知识点 ========================= */ function getPageEntries(all) { if (!all) all = getEntries() if (all.length === 0) return all var activeIdx = all.findIndex(function (e) { return e.isActive }) if (activeIdx === -1) return all var chapterUl = all[activeIdx].el.closest('.posCatalog_level ul') if (!chapterUl) return all return all.filter(function (e) { return chapterUl.contains(e.el) }) } /* ========================= 计算已完成数量 ========================= */ function countCompleted(entries) { var c = 0 for (var i = 0; i < entries.length; i++) { if (entries[i].isCompleted) c++ } return c } /* ========================= 主流程 ========================= */ async function main() { try { log('═══ 开始 ═══') log('模式: ' + (CONFIG.skipCompleted ? '刷课(跳过已学)' : '复习(全部重新播放)')) log('倍速: ' + CONFIG.playbackRate + 'x') log('最低停留: ' + (CONFIG.minStayMs / 1000) + '秒/知识点') CTRL.status = '初始化...' try { initControlPanel() } catch (e) { log('面板初始化失败:', e) } try { setupPopupHandler() } catch (e) { log('弹窗监听失败:', e) } try { overridePageVisibility() } catch (e) { log('可见性绕过失败:', e) } try { setupXHRMonitor() } catch (e) { log('XHR监控失败:', e) } try { setupBackgroundManager() } catch (e) { log('后台管理初始化失败:', e) } await waitForEl('#coursetree', 30000) var treeEl = document.getElementById('coursetree') if (treeEl) { await waitForEl('.posCatalog_active', 20000, treeEl).catch(function () {}) } setupTreeObserver() await sleep(3000) } catch (e) { log('❌ 初始化失败:', e) CTRL.status = '❌ 初始化失败' return } /* 主循环:逐页处理 */ try { var pageCount = 0 var itemsSinceBreak = 0 var stallCount = 0 var lastCompleted = 0 var videoTitles = new Set() while (pageCount < CONFIG.maxPages) { await checkPause() if (treeChanged) { treeChanged = false log('⚠ 检测到课程树变化,重新扫描') continue } pageCount++ log('\n——— 第 ' + pageCount + ' 轮处理 ———') var entries = getEntries() if (entries.length === 0) { var waitRetries = 0 while (entries.length === 0 && waitRetries < 15) { waitRetries++ log('⏳ 等待知识点加载 (' + waitRetries + '/15)...') CTRL.status = '⏳ 等待课程树...' await sleep(2000) entries = getEntries() } if (entries.length === 0) { log('❌ 课程树为空,退出'); break } } var pageEntries = getPageEntries(entries) log('当前页共 ' + pageEntries.length + ' 个知识点') if (pageEntries.length === 0) { var nb = getNextBtn() if (nb) { nb.click() CTRL.status = '⏳ 翻页中...' await randomDelay(5000, 10000) continue } log('✅ 没有更多页面') break } lastCompleted = countCompleted(pageEntries) var startIdx = pageEntries.findIndex(function (e) { return e.isActive }) if (startIdx === -1) startIdx = 0 var processed = 0 for (var i = startIdx; i < pageEntries.length; i++) { await checkPause() if (treeChanged) { treeChanged = false log('⚠ 课程树变化,退出当前页处理') break } var entry = pageEntries[i] CTRL.currentTitle = entry.title log('\n[' + (i + 1) + '/' + pageEntries.length + '] ' + entry.title) /* ---- 刷课模式:已学知识点短暂进入后跳过 ---- */ if (CONFIG.skipCompleted && entry.isCompleted) { log('⏭ 已学,进入停留后继续') CTRL.status = '⏭ 已学: ' + entry.title await clickEntry(entry) await randomDelay(2000, 4000) simulateHumanBehavior() processed++ continue } /* ---- 切换到该知识点 ---- */ CTRL.status = '➡ 加载: ' + entry.title await clickEntry(entry) await randomDelay(3000, 5000) var entryStartTime = Date.now() /* ---- 查找并播放视频 ---- */ var videos = await findVideosWithRetry() if (videos.length > 0) { if (!CONFIG.skipCompleted && !videoTitles.has(entry.title)) { videoTitles.add(entry.title) } await playAllVideos(videos) /* ---- 最低停留时间(仅视频任务) ---- */ var elapsed = Date.now() - entryStartTime if (elapsed < CONFIG.minStayMs) { var remaining = CONFIG.minStayMs - elapsed log('⏳ 最低停留剩余 ' + Math.floor(remaining / 1000) + ' 秒...') CTRL.status = '⏳ 停留中 ' + Math.floor(remaining / 1000) + 's' await sleep(remaining) } /* ---- 等待进度上报 ---- */ log('⏳ 等待进度上报...') CTRL.status = '⏳ 上报进度...' await randomDelay(5000, 10000) } else { log('📄 文档/附件,短停留后跳过') CTRL.status = '📄 文件: ' + entry.title /* 自动点击文件链接触发任务点 */ try { var iframe = document.getElementById('iframe') if (iframe) { var doc = iframe.contentDocument || iframe.contentWindow.document if (doc) { /* 查找可打开的文件链接 */ var fileBtns = doc.querySelectorAll( 'a[href*="view"], a[href*="open"], a[href*="preview"], ' + 'a[href*=".pdf"], a[href*=".doc"], a[href*=".ppt"], a[href*=".xls"], a[href*=".txt"]' ) fileBtns.forEach(function (el) { el.click() }) /* 查找文字按钮 */ doc.querySelectorAll('a, button, [class*="btn"], [class*="link"], [class*="down"]').forEach(function (el) { var t = (el.textContent || '').trim() if (t === '点击查看' || t === '查看' || t === '查看文件' || t === '打开' || t === '预览' || t === '下载') { el.click() } }) /* 滚动到底部 */ if (doc.body) doc.body.scrollTop = doc.body.scrollHeight } } } catch (e) {} await randomDelay(6000, 10000) } simulateHumanBehavior() processed++ /* ---- 进度停滞检测 ---- */ var freshPage = getPageEntries() var freshCount = countCompleted(freshPage) var madeProgress = freshCount > lastCompleted || !isProgressStale() if (freshCount > lastCompleted) { lastCompleted = freshCount } if (madeProgress) { stallCount = 0 } else { stallCount++ log('⚠ 无进度增长 (' + stallCount + '/' + CONFIG.stallThreshold + ')') CTRL.status = '⚠ 进度停滞 ' + stallCount + '/' + CONFIG.stallThreshold } if (stallCount >= CONFIG.stallThreshold) { log('⚠ 检测到进度停滞,刷新会话...') CTRL.status = '⚠ 刷新会话...' var pause = randomInt(30000, 60000) await sleep(pause) location.reload() return } /* ---- 长间隔休息 ---- */ itemsSinceBreak++ if (itemsSinceBreak >= CONFIG.breakInterval) { var breakDuration = randomInt(30000, 60000) log('⏸ 长休息 ' + Math.floor(breakDuration / 1000) + ' 秒...') CTRL.status = '⏸ 休息 ' + Math.floor(breakDuration / 1000) + 's' await sleep(breakDuration) simulateHumanBehavior() itemsSinceBreak = 0 } } /* ---- 当前页处理完毕,翻页 ---- */ log('\n📄 当前页处理完毕(' + processed + ' 个知识点)') CTRL.status = '📄 翻页中...' var nb = getNextBtn() if (nb) { nb.click() await randomDelay(6000, 12000) } else { break } } /* ---- 复习模式:视频循环 ---- */ if (!CONFIG.skipCompleted && videoTitles.size > 0) { log('\n═══ 进入视频循环模式(' + videoTitles.size + ' 个视频知识点)═══') var loopCount = 0 while (true) { await checkPause() loopCount++ var allEntries = getEntries() var videoEntries = allEntries.filter(function (e) { return videoTitles.has(e.title) }) if (videoEntries.length === 0) { log('⚠ 未找到视频知识点,退出') break } itemsSinceBreak = 0 var aborted = false for (var vi = 0; vi < videoEntries.length; vi++) { await checkPause() if (treeChanged) { treeChanged = false aborted = true break } var ve = videoEntries[vi] CTRL.currentTitle = ve.title CTRL.status = '🔄 [' + loopCount + '轮] ' + ve.title + ' (' + (vi + 1) + '/' + videoEntries.length + ')' log('[' + (vi + 1) + '/' + videoEntries.length + '] ' + ve.title) await clickEntry(ve) await randomDelay(3000, 5000) var vids = await findVideosWithRetry() if (vids.length > 0) { await playAllVideos(vids) } simulateHumanBehavior() itemsSinceBreak++ if (itemsSinceBreak >= CONFIG.breakInterval) { var breakDuration = randomInt(30000, 60000) log('⏸ 长休息 ' + Math.floor(breakDuration / 1000) + ' 秒...') CTRL.status = '⏸ 休息 ' + Math.floor(breakDuration / 1000) + 's' await sleep(breakDuration) simulateHumanBehavior() itemsSinceBreak = 0 } } if (!aborted) { log('🔄 第 ' + loopCount + ' 轮视频循环完成') } } } log('\n═══ 全部完成 ═══') CTRL.status = '✅ 全部完成' } catch (e) { log('❌ 运行异常:', e) CTRL.status = '❌ 异常: ' + (e.message || e) } } /* ========================= 启动 ========================= */ if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { setTimeout(main, 2000) }) } else { setTimeout(main, 2000) } })()