// ==UserScript== // @name CX 课程联播 // @namespace cxq.queue // @version 1.0.2 // @description 超星学习通:按队列依次自动学习多门课程。一门课的可自动任务点全部完成后,自动跳转下一门;答题类任务点会被跳过并记入报表。需与 OCS 网课助手配合使用。 // @author cxq // @match *://*.chaoxing.com/* // @grant GM_getValue // @grant GM_setValue // @grant GM_notification // @grant GM_setClipboard // @grant unsafeWindow // @run-at document-idle // ==/UserScript== (function () { 'use strict'; // ============ 常量 ============ var KEY = 'cxq.state.v1'; var TICK_MS = 2000; var MIN_STAY_MS = 20 * 1000; // 进入课程后至少待 20 秒才允许判定 var QUIET_MS = 90 * 1000; // 无变化静默阈值(兜底通道) var QUIZ_QUIET_MS = 25 * 1000; // 已确认"只剩答题"时的缩短阈值 var NAV_DELAY_MIN = 2000; // 课程之间随机间隔下限 var NAV_DELAY_MAX = 10000; // 上限 var NAV_MAX_TRIES = 3; // 同一门课最多尝试进入几次 var STALL_LIMIT = 90; // 连续 90 次 tick 读不到目录 → 判失败 var FRAME_IDLE_MAX = 60000; // 内嵌页 1 分钟无心跳 → 视为空课程/卡死 var FRAME_BEAT_MS = 10000; // 子帧心跳间隔(必须明显小于上面的闲置阈值) var QUIZ_MODULES = ['work', 'exam']; // ============ 小工具 ============ function $(s, r) { try { return (r || document).querySelector(s); } catch (e) { return null; } } function $$(s, r) { try { return Array.prototype.slice.call((r || document).querySelectorAll(s)); } catch (e) { return []; } } function eachDoc(cb, doc, depth) { doc = doc || document; depth = depth || 0; if (depth > 4) return; try { cb(doc, depth); } catch (e) { /* 单帧失败不影响整体 */ } $$('iframe', doc).forEach(function (f) { var d = null; try { d = f.contentDocument; } catch (e) { d = null; } if (d) eachDoc(cb, d, depth + 1); }); } function hhmmss(ms) { if (!ms || ms < 0) return '0分'; var s = Math.floor(ms / 1000); var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60); return h ? (h + '小时' + m + '分') : (m + '分'); } function clock() { var d = new Date(); return [d.getHours(), d.getMinutes(), d.getSeconds()].map(function (n) { return String(n).padStart(2, '0'); }).join(':'); } function esc(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } // ============ 存储 ============ var hasGM = (typeof GM_getValue === 'function' && typeof GM_setValue === 'function'); function blank() { return { v: 1, running: false, cursor: 0, queue: [], lastNav: null, navGuard: null, incDone: false, logs: [], updatedAt: 0 }; } function readState() { var s = blank(); try { var raw = hasGM ? GM_getValue(KEY, '') : localStorage.getItem(KEY); if (!raw) return s; var o = (typeof raw === 'string') ? JSON.parse(raw) : raw; if (o && typeof o === 'object') { s.running = !!o.running; s.cursor = Number(o.cursor) || 0; s.queue = Array.isArray(o.queue) ? o.queue : []; s.lastNav = o.lastNav || null; s.navGuard = o.navGuard || null; s.incDone = !!o.incDone; s.logs = Array.isArray(o.logs) ? o.logs : []; s.updatedAt = Number(o.updatedAt) || 0; } } catch (e) { /* 存储损坏则回退到空状态 */ } return s; } function writeState(s) { s.updatedAt = Date.now(); try { var v = JSON.stringify(s); if (hasGM) GM_setValue(KEY, v); else localStorage.setItem(KEY, v); } catch (e) { /* 忽略写失败 */ } } var S = readState(); function save() { writeState(S); } function log(msg) { S.logs.push(clock() + ' ' + msg); if (S.logs.length > 120) S.logs = S.logs.slice(-120); save(); // 立刻落盘:跳转后内存里的日志会丢 console.log('[cxq] ' + msg); } function notify(text) { try { if (typeof GM_notification === 'function') GM_notification({ title: 'CX 课程联播', text: text, timeout: 8000 }); } catch (e) { /* 忽略 */ } } // ============ 页面探测 ============ function urlCourseId() { try { var p = new URLSearchParams(location.search); return p.get('courseId') || p.get('courseid') || p.get('courseID') || null; } catch (e) { return null; } } function isStudyPage() { return /\/mycourse\/studentstudy/.test(location.pathname); } function readChapters() { return $$('[onclick^="getTeacherAjax"]').map(function (el, i) { var row = el.parentElement; var cnt = row ? $('input.jobUnfinishCount', row) : null; var m = (el.getAttribute('onclick') || '').match(/\('(.*?)','(.*?)','(.*?)'\)/); return { i: i, chapterId: m ? m[3] : null, unFinish: cnt ? (Number(cnt.value) || 0) : 0, hasCount: !!cnt, active: row ? row.classList.contains('posCatalog_active') : false, text: (el.textContent || '').replace(/\s+/g, ' ').trim() }; }); } function anyMediaPlaying() { var playing = false; eachDoc(function (doc) { $$('video,audio', doc).forEach(function (m) { if (!m.paused && !m.ended && m.duration > 0) playing = true; }); }); return playing; } // 当前章节的任务点类型(来自卡片 iframe 的 window.attachments) function currentJobModules(chapterId) { if (!chapterId) return null; var found = []; eachDoc(function (doc) { var a = null; try { a = doc.defaultView && doc.defaultView.attachments; } catch (e) { a = null; } if (Array.isArray(a) && a.length) found = found.concat(a); }); if (!found.length) return null; var mine = found.filter(function (x) { return String((x && x.otherInfo) || '').indexOf('nodeId_' + chapterId) >= 0; }); if (!mine.length) return null; return mine.map(function (x) { return (x.property && x.property.module) || x.type || '?'; }); } // 挡路弹窗:需要人工点「同意」才能进入的页面(诚信承诺书等) var BLOCKERS = [ ['a.commitBtn.agreeStart', '需要先同意「诚信承诺书」'], ['.commitment-content-dialog', '需要先同意「诚信承诺书」'], ['.commitBtn.agreeStart', '需要先同意「诚信承诺书」'], ['.popDiv.course-pop', '有弹窗挡住页面'] ]; function isVisible(el) { if (!el) return false; var r = el.getBoundingClientRect(); if (r.width <= 0 || r.height <= 0) return false; try { var st = el.ownerDocument.defaultView.getComputedStyle(el); if (st.display === 'none' || st.visibility === 'hidden' || st.opacity === '0') return false; } catch (e) { /* 忽略 */ } return true; } function detectBlocker() { for (var i = 0; i < BLOCKERS.length; i++) { var els = $$(BLOCKERS[i][0]); for (var j = 0; j < els.length; j++) { if (isVisible(els[j])) return BLOCKERS[i][1]; } } return ''; } function probe() { var chapters = readChapters(); if (!chapters.length) return { state: 'empty' }; var tabs = $$('.prev_ul li'); var inFinalTab = tabs.length === 0 ? true : tabs[tabs.length - 1].classList.contains('active'); var activeIdx = chapters.findIndex(function (c) { return c.active; }); if (activeIdx < 0) return { state: 'noactive', chapters: chapters }; var cur = chapters[activeIdx]; var after = chapters.slice(activeIdx + 1).filter(function (c) { return c.hasCount && c.unFinish > 0; }); var total = chapters.reduce(function (a, c) { return a + (c.hasCount ? c.unFinish : 0); }, 0); var playing = anyMediaPlaying(); var mods = currentJobModules(cur.chapterId); var quizOnly = !!(mods && mods.length && mods.every(function (m) { return QUIZ_MODULES.indexOf(m) >= 0; })); return { state: 'ok', chapters: chapters, total: total, activeIdx: activeIdx, activeText: cur.text, activeId: cur.chapterId, inFinalTab: inFinalTab, afterCount: after.length, playing: playing, modules: mods, quizOnly: quizOnly, structOk: inFinalTab && after.length === 0 }; } // ============ 调度 ============ var startedAt = Date.now(); var lastChangeAt = Date.now(); var lastSig = ''; var stall = 0; var navPending = false; // 已经安排了一次跳转,跳走之前不再做任何调度决策 var navTimer = null; // 延迟跳转定时器,暂停/清空/手动跳课时必须取消 var navToken = 0; // 防止已进入事件队列的旧回调在取消后仍执行 var nameTries = 0; // 本页已尝试补正课名的次数(最多 12 次 ≈ 24 秒) var blockerTicks = 0; // 挡路弹窗连续出现次数 var entryTries = 0; // 入口解析尝试次数 var navTry = 0; // 已尝试点击左侧「章节」的次数 var entryClickIdx = 0; // 下一个尝试点击的章节条目序号 var lastAutoIdx = -1; // 上次自动选中的小节序号(避免连点同一个) var lastEntryClickAt = 0; // 上次点击章节条目的时间 var frameWait = false; // 是否已转入"等待内嵌页回报"模式 var frameWaitAt = 0; // 进入该模式的时间 var lastFrameLogAt = 0; // 已消费的子帧日志时间戳 function findCourseIndexByUrl() { var cid = urlCourseId(); if (!cid) return -1; for (var i = 0; i < S.queue.length; i++) { if (String(S.queue[i].courseId) === String(cid)) return i; } return -1; } function normUrl(u) { try { var x = new URL(u); x.hash = ''; return x.href; } catch (e) { return String(u); } } function onTargetPage(courseId) { return isStudyPage() && String(urlCourseId()) === String(courseId); } // 是不是"这门课"的任意页面(课程主页 / 学习页都算) function onCoursePage(courseId) { return !!courseId && String(urlCourseId()) === String(courseId); } function findVisible(sel, textRe) { var els = $$(sel); for (var i = 0; i < els.length; i++) { if (textRe && !textRe.test(String(els[i].textContent || '').replace(/\s+/g, ''))) continue; if (isVisible(els[i])) return els[i]; } return null; } // 跨同源 iframe 找第一个可见元素 function pick(sel, textRe) { var hit = null; eachDoc(function (doc) { if (hit) return; var els = $$(sel, doc); for (var i = 0; i < els.length; i++) { if (textRe && !textRe.test(String(els[i].textContent || '').replace(/\s+/g, ''))) continue; if (isVisible(els[i])) { hit = els[i]; return; } } }); return hit; } function pageWin() { try { return (typeof unsafeWindow !== 'undefined' && unsafeWindow) || window; } catch (e) { return window; } } // 跨同源 iframe 收集所有可见元素 function pickAll(sel) { var out = []; eachDoc(function (doc) { var els = $$(sel, doc); for (var i = 0; i < els.length; i++) { if (isVisible(els[i])) out.push(els[i]); } }); return out; } // 章节条目里,"1.1" 这种带小数点的是具体小节(点了进学习页); // 只有 "1" 的多半是章目录,点了只会展开/收起。返回应点击的下标,找不到返回 -1 function pickChapterIndex(items) { for (var i = 0; i < items.length; i++) { var bar = items[i].querySelector('.catalog_sbar, .catalog_sbar2, em, i, .num'); if (bar) { var t = String(bar.textContent || '').replace(/\s+/g, ''); if (/^\d+(\.\d+)+$/.test(t)) return i; continue; } var txt = String(items[i].textContent || '').replace(/\s+/g, ' ').trim(); if (/^\d+(\.\d+)+\s/.test(txt)) return i; } return -1; } function clickByText(txt) { var best = null, bestScore = 1e9; eachDoc(function (doc) { var els = doc.querySelectorAll('a, span, li, div, dt, dd, em, i, b'); for (var i = 0; i < els.length; i++) { var el = els[i]; if (!isVisible(el)) continue; var t = String(el.textContent || '').replace(/\s+/g, ''); if (t !== txt && t !== txt + 'NEW') continue; var score = el.querySelectorAll('*').length; if (score < bestScore) { bestScore = score; best = el; } } }); return best; } // 从课程主页进入学习页:直达链接 → 页面自身的 getTeacherAjax → 目录节点 → 点左侧【章节】 function resolveEntry(cur) { entryTries++; if (!frameWait && entryTries > 25) { cur.status = 'failed'; cur.reason = '找不到章节入口(可能是空课程)'; save(); log('跳过课程:' + cur.name + '(没找到任何章节入口,可能是空课程)'); notify(cur.name + ':找不到章节入口,已跳过'); goNext(); return; } // 1) 页面上已经有直达学习页的链接 var direct = pick('a[href*="studentstudy"]') || pick('a[href*="knowledge/cards"]'); if (direct) { log('进入学习页:' + (direct.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 20)); location.href = direct.href; return; } // 2) onclick 里藏着 learning 地址 var ocEl = pick('[onclick*="studentstudy"]'); if (ocEl) { var m = String(ocEl.getAttribute('onclick') || '').match(/['"]([^'"]*studentstudy[^'"]*)['"]/); if (m) { log('从 onclick 取到学习页地址'); location.href = m[1]; return; } } // 3) 章节目录节点:优先直接调页面自己的 getTeacherAjax(不新开标签页) var node = pick('[onclick*="getTeacherAjax"]'); if (node) { var args = String(node.getAttribute('onclick') || '').match(/getTeacherAjax\(([^)]*)\)/); var fn = pageWin().getTeacherAjax; if (args && typeof fn === 'function') { var parts = args[1].split(',').map(function (s) { return s.trim().replace(/^['"]|['"]$/g, ''); }); if (parts.length >= 3) { log('调用 getTeacherAjax 进入章节:' + parts[2]); fn(parts[0], parts[1], parts[2]); return; } } log('点击章节目录节点'); node.click(); return; } // 4) 超星课程主页的章节条目:1.1 直流稳压电源(无 href / onclick,纯 JS 绑定) var items = pickAll('a.clicktitle') || []; if (items.length) { if (Date.now() - lastEntryClickAt < 5000) { render(false, null); return; } var auto = pickChapterIndex(items); if (auto >= 0 && auto === lastAutoIdx) auto = -1; // 同一节点连点无效,改走递进 var idx = auto >= 0 ? auto : Math.min(entryClickIdx, items.length - 1); if (auto >= 0) lastAutoIdx = auto; entryClickIdx++; lastEntryClickAt = Date.now(); var label = String(items[idx].textContent || '').replace(/\s+/g, ' ').trim().slice(0, 20); log('点击章节条目:' + label + '(第 ' + (idx + 1) + ' 个' + (auto >= 0 ? '·小节' : '·递进') + ')'); items[idx].click(); return; } // 5) 目录容器 var cat = pick('.posCatalog_select a') || pick('.posCatalog_select'); if (cat) { log('点击目录项'); cat.click(); return; } // 6) 左侧【章节】导航:事件常绑在 span.nav_content 这类最内层元素上,点外层 无效 if (navTry < 2 && (navTry === 0 || Date.now() - lastEntryClickAt > 6000)) { var nav = navTry === 0 ? clickByText('章节') : pick('a', /章节/); if (nav) { navTry++; lastEntryClickAt = Date.now(); var ncls = String(nav.className || '').trim(); log('点击左侧「章节」(第 ' + navTry + ' 次 · ' + nav.tagName.toLowerCase() + (ncls ? '.' + ncls.split(/\s+/).join('.') : '') + ')'); nav.click(); return; } } // 章节在跨域 iframe 里:点完「章节」后顶层文档不会变,继续点也没有意义 if (!frameWait && navTry >= 2 && $$('iframe').length) { frameWait = true; frameWaitAt = Date.now(); log('章节在内嵌框里,顶层点不到,改为等待子页面回报'); } if (frameWait && Date.now() - Math.max(frameWaitAt, lastFrameLogAt) > FRAME_IDLE_MAX) { cur.status = 'failed'; cur.reason = '页面没有可进入的章节'; save(); log('跳过课程:' + cur.name + '(内嵌页 1 分钟无心跳,可能是空课程或页面卡住)'); notify(cur.name + ':没有可进入的章节,已跳过'); goNext(); return; } render(false, null); } function clearNavGuard() { if (S.navGuard) { S.navGuard = null; save(); } } // 取消尚未执行的课程跳转。递增 token,确保旧定时器回调失效。 function cancelPendingNavigation() { navToken++; if (navTimer !== null) { clearTimeout(navTimer); navTimer = null; } navPending = false; } function resetCourseRuntime() { startedAt = Date.now(); lastChangeAt = Date.now(); lastSig = ''; stall = 0; blockerTicks = 0; entryTries = 0; navTry = 0; entryClickIdx = 0; lastAutoIdx = -1; lastEntryClickAt = 0; frameWait = false; frameWaitAt = 0; lastFrameLogAt = 0; } function navigate(url, reason) { if (navPending) return; // 已经安排跳转了,避免 tick 重复触发 if (normUrl(url) === normUrl(location.href)) return; // 已经在目标页,交给接管逻辑 var g = S.navGuard; if (!g || g.url !== url) g = { url: url, n: 0 }; g.n += 1; S.navGuard = g; if (g.n > NAV_MAX_TRIES) { S.running = false; S.navGuard = null; log('「' + reason + '」连续 ' + g.n + ' 次跳转都没能进入,已停机。该课程链接可能已失效,请重新收录'); save(); notify('多次跳转未成功,联播已暂停'); render(true); return; } navPending = true; clearSignal(); clearFrameLog(); // 课程之间随机等待,避免出现"固定 2 秒一跳"的机械节奏 var wait = NAV_DELAY_MIN + Math.floor(Math.random() * (NAV_DELAY_MAX - NAV_DELAY_MIN + 1)); var thisToken = ++navToken; log('跳转:' + reason + '(第 ' + g.n + ' 次,' + (wait / 1000).toFixed(1) + ' 秒后)'); save(); render(true); navTimer = setTimeout(function () { navTimer = null; // 定时器到期时再次确认队列仍在运行,且该回调没有被更新/取消。 if (thisToken !== navToken || !navPending || !S.running) return; navPending = false; location.replace(url); }, wait); } function startCourse(idx, opts) { var c = S.queue[idx]; if (!c) { finishQueue(); return; } S.cursor = idx; c.status = 'running'; clearSignal(); clearFrameLog(); c.startedAt = c.startedAt || Date.now(); c.reason = ''; save(); log('开始课程:' + c.name); if (onTargetPage(c.courseId)) { clearNavGuard(); resetCourseRuntime(); render(true); } else if (onCoursePage(c.courseId)) { resetCourseRuntime(); render(true); } else { navigate(c.url, c.name); } } function completeCourse(reason) { var c = S.queue[S.cursor]; if (!c) return; c.status = 'done'; c.finishedAt = Date.now(); c.reason = reason || '完成'; c.leftover = (typeof c._total === 'number') ? c._total : null; delete c._total; save(); log('课程收工:' + c.name + '(' + c.reason + (c.leftover ? ',剩余 ' + c.leftover + ' 个答题任务点' : '') + ')'); goNext(); } function goNext() { var i = S.cursor + 1; while (i < S.queue.length && S.queue[i].status === 'done') i++; if (i >= S.queue.length) { finishQueue(); return; } startCourse(i); } function finishQueue() { S.running = false; cancelPendingNavigation(); S.navGuard = null; save(); var done = S.queue.filter(function (c) { return c.status === 'done'; }).length; var msg = '联播结束:' + done + ' / ' + S.queue.length + ' 门课完成'; log(msg); render(true); notify(msg); } function tick() { if (navPending) return; var p = null; try { p = isStudyPage() ? probe() : null; } catch (e) { p = null; } // 进到某门课就补一次课名;页面可能还没渲染完,因此在 24 秒内最多重试 12 次 if (nameTries < 12 && isStudyPage()) { var ci = findCourseIndexByUrl(); if (ci >= 0 && isAutoName(S.queue[ci])) { nameTries++; if (refreshNameIfNeeded(S.queue[ci])) nameTries = 99; } else { nameTries = 99; } } if (!S.running) { render(false, p); return; } var cur = S.queue[S.cursor]; if (!cur) { finishQueue(); return; } // 子帧的动作通过独立通道回报,显示在面板日志里 var fl = readFrameLog(); if (fl && fl.at > lastFrameLogAt) { lastFrameLogAt = fl.at; // 心跳只续命,不写进日志 if (fl.kind !== 'beat') log('[帧] ' + fl.msg); } // iframe 内的学习页学完了会写信号过来,由顶层负责切课 var sg = readSignal(); if (sg && String(sg.courseId) === String(cur.courseId) && sg.at > startedAt) { clearSignal(); completeCourse('内嵌学习页判定完成'); return; } var hereCid = String(urlCourseId() || ''); if (hereCid !== String(cur.courseId)) { navigate(cur.url, '进入课程'); return; } if (!isStudyPage()) { // 已经在这门课的页面上,但还没到学习页 → 解析入口 resolveEntry(cur); return; } clearNavGuard(); // 被弹窗挡住的课既读不到目录也学不了:连续 3 次(约 6 秒)确认后跳过,不拖住整条队列 var block = detectBlocker(); if (block) { blockerTicks++; if (blockerTicks >= 3) { blockerTicks = 0; cur.status = 'failed'; cur.reason = block; save(); log('跳过课程:' + cur.name + '(' + block + ')'); notify(cur.name + ':' + block + ',已跳过'); goNext(); return; } render(false, p); return; } blockerTicks = 0; if (!p || p.state === 'empty') { stall++; if (stall >= STALL_LIMIT) { cur.status = 'failed'; cur.reason = '3 分钟内读不到章节目录'; save(); log('课程失败:' + cur.name + ',读不到章节目录'); goNext(); } else { render(false, p); } return; } if (p.state !== 'ok') { render(false, p); return; } stall = 0; cur._total = p.total; var sig = [p.total, p.activeIdx, p.inFinalTab, p.afterCount, p.playing, p.quizOnly].join('|'); if (sig !== lastSig) { lastSig = sig; lastChangeAt = Date.now(); } var quiet = Date.now() - lastChangeAt; var stayed = Date.now() - startedAt; var quietNeed = p.quizOnly ? QUIZ_QUIET_MS : QUIET_MS; // 当前章节只剩答题时,章节里不可能还有视频任务点,此时任何播放都不该阻塞判定 var noMediaBlock = p.quizOnly || !p.playing; var done = p.structOk && noMediaBlock && stayed > MIN_STAY_MS && quiet > quietNeed; render(false, p); if (done) { completeCourse('后面无未完成任务点' + (p.quizOnly ? ',当前章节仅剩答题' : '')); } } // ============ 跨 frame 协作 ============ // 课程主页的章节常放在跨域 iframe 里,顶层点不到;让脚本在 iframe 内也跑一份, // 由子页面判断"学完了",写一个信号,顶层读到后切下一门。 var SIGNAL_KEY = 'cxq.signal.v1'; var FLOG_KEY = 'cxq.framelog.v1'; // 子帧不能直接改主状态(会和顶层抢写),单独写一个"最近动作"让顶层去取 // kind: 'act' 有意义的动作(会显示到面板日志);'beat' 纯心跳(只用来续命,不显示) function writeFrameLog(msg, kind) { try { var v = JSON.stringify({ msg: String(msg).slice(0, 140), kind: kind || 'act', at: Date.now() }); if (hasGM) GM_setValue(FLOG_KEY, v); else localStorage.setItem(FLOG_KEY, v); } catch (e) { /* 忽略 */ } } function readFrameLog() { try { var raw = hasGM ? GM_getValue(FLOG_KEY, '') : localStorage.getItem(FLOG_KEY); return raw ? JSON.parse(raw) : null; } catch (e) { return null; } } function clearFrameLog() { try { if (hasGM) GM_setValue(FLOG_KEY, ''); else localStorage.removeItem(FLOG_KEY); } catch (e) { /* 忽略 */ } } function writeSignal(courseId, kind) { try { var v = JSON.stringify({ courseId: String(courseId), kind: kind, at: Date.now() }); if (hasGM) GM_setValue(SIGNAL_KEY, v); else localStorage.setItem(SIGNAL_KEY, v); } catch (e) { /* 忽略 */ } } function readSignal() { try { var raw = hasGM ? GM_getValue(SIGNAL_KEY, '') : localStorage.getItem(SIGNAL_KEY); return raw ? JSON.parse(raw) : null; } catch (e) { return null; } } function clearSignal() { try { if (hasGM) GM_setValue(SIGNAL_KEY, ''); else localStorage.removeItem(SIGNAL_KEY); } catch (e) { /* 忽略 */ } } function startFrameWatcher() { var fStarted = Date.now(); var fSig = '', fChange = Date.now(), sent = false; var clickIdx = 0, lastClick = 0, lastAutoIdx = -1, lastBeat = 0; // 目录点击后如果 DOM 没变化,最多对同一份目录尝试两次,避免每 6 秒重复点击 var lastItemsSig = '', sameSigAttempts = 0, gaveUpLogged = false; console.log('[cxq] 子页面接管:' + location.href.slice(0, 80)); setInterval(function () { try { if (sent) return; var st = readState(); if (!st.running) return; var cur = st.queue[st.cursor]; if (!cur) return; // 阶段一:这个 frame 里就是章节目录 → 由它自己点进章节(顶层点不到跨域 iframe 内部) if (!isStudyPage()) { if (!document.querySelector('a.clicktitle')) return; var items = pickAll('a.clicktitle'); if (!items.length) return; if (Date.now() - lastClick < 6000) return; // 用目录条目文本构造稳定签名;内容没变就别反复点 var itemsSig = items.map(function (el) { return String(el.textContent || '').replace(/\s+/g, ' ').trim(); }).join('||'); if (itemsSig !== lastItemsSig) { lastItemsSig = itemsSig; sameSigAttempts = 0; gaveUpLogged = false; } if (sameSigAttempts >= 2) { if (!gaveUpLogged) { gaveUpLogged = true; writeFrameLog('目录点了 2 次没变化,停止点击(可能不是可跳转的章节条目)'); } return; } var auto = pickChapterIndex(items); if (auto >= 0 && auto === lastAutoIdx) auto = -1; var idx = auto >= 0 ? auto : Math.min(clickIdx, items.length - 1); if (auto >= 0) lastAutoIdx = auto; clickIdx++; sameSigAttempts++; lastClick = Date.now(); var label = String(items[idx].textContent || '').replace(/\s+/g, ' ').trim().slice(0, 20); writeFrameLog('子页面点击章节:' + label + '(第 ' + (idx + 1) + ' 个' + (auto >= 0 ? '·小节' : '·递进') + ')'); items[idx].click(); return; } // 阶段二:已经是学习页 → 判定是否学完 var p = probe(); // 心跳:不论目录是否可读都要发。否则"切章节/加载中"那几秒会被顶层误判成空课程 if (Date.now() - lastBeat > FRAME_BEAT_MS) { lastBeat = Date.now(); writeFrameLog('存活·' + (p.state === 'ok' ? ('剩余 ' + p.total + ' 个任务点') : '目录加载中'), 'beat'); } if (p.state !== 'ok') return; var sig = [p.total, p.activeIdx, p.inFinalTab, p.afterCount, p.playing, p.quizOnly].join('|'); if (sig !== fSig) { fSig = sig; fChange = Date.now(); } var quiet = Date.now() - fChange; var need = p.quizOnly ? 25000 : 90000; if (p.structOk && (p.quizOnly || !p.playing) && (Date.now() - fStarted) > 20000 && quiet > need) { sent = true; writeSignal(cur.courseId, 'done'); writeFrameLog('子页面判定完成:' + cur.name); } } catch (e) { /* 忽略 */ } }, 2000); } // ============ 面板 ============ // 非顶层 frame:只做"学完回报",不建面板、不调度 var IS_TOP = (function () { try { return window.top === window.self; } catch (e) { return false; } })(); if (!IS_TOP) { try { startFrameWatcher(); } catch (e) { console.error('[cxq] frame watcher', e); } return; } var host = document.createElement('div'); host.id = 'cxq-host'; host.style.cssText = 'all:initial;position:fixed;right:18px;bottom:18px;z-index:2147483646;'; var root = host.attachShadow({ mode: 'open' }); root.innerHTML = [ '', '', ' ', ' CX', ' CX 课程联播课程队列控制台', ' 待机−', ' ', ' ', ' ', ' 开始联播', ' 收录当前', ' 生成报表', ' ', '' ].join(''); document.body.appendChild(host); var elBody = root.getElementById('body'); var elBadge = root.getElementById('badge'); var elDot = root.getElementById('dot'); var elToggle = root.getElementById('btn-toggle'); var lastRender = ''; var lastGrab = ''; var lastScan = ''; var uiTab = 'queue'; function statusText(s) { return ({ pending: '待学', running: '进行中', done: '完成', failed: '失败' })[s] || s; } function render(force, p) { var doneCount = S.queue.filter(function (c) { return c.status === 'done'; }).length; var failedCount = S.queue.filter(function (c) { return c.status === 'failed'; }).length; var activeCount = S.queue.filter(function (c) { return c.status === 'pending' || c.status === 'running'; }).length; var progress = S.queue.length ? Math.round(doneCount / S.queue.length * 100) : 0; var cur = S.queue[S.cursor] || null; var sig = [ S.running, S.cursor, S.queue.length, S.logs.length, uiTab, S.queue.map(function (c) { return c.status + (c.leftover || '') + c.name; }).join(','), p ? [p.total, p.activeIdx, p.structOk, p.playing, p.quizOnly].join('|') : 'x' ].join('#'); if (!force && sig === lastRender) return; lastRender = sig; elToggle.textContent = S.running ? '暂停联播' : '开始联播'; elDot.className = 'dot' + (S.running ? ' on' : ''); elBadge.textContent = S.running ? (p && p.structOk ? '运行中' : '正在处理') : '待机'; elBadge.className = 'badge' + (S.running ? ' on' : ''); var h = []; h.push('' + '课程队列' + '工具与日志' + ''); if (uiTab === 'queue') { h.push('' + '' + doneCount + '已完成' + '' + activeCount + '待处理' + '' + failedCount + '失败' + ''); h.push('队列整体进度' + doneCount + ' / ' + S.queue.length + ' · ' + progress + '%' + ''); if (cur) { var currentTitle = esc(cur.name || '未命名课程'); var detail = S.running ? (p && p.state === 'ok' ? '正在处理课程章节与任务点' : (isStudyPage() ? '正在读取章节目录…' : '正在进入课程页面…')) : '队列尚未启动'; h.push('● 当前课程' + '' + currentTitle + '' + '' + detail + ''); if (p && p.state === 'ok') { h.push('' + '当前章节' + (p.activeIdx + 1) + ' / ' + p.chapters.length + '' + '剩余任务点' + p.total + '' + '后续有活章节' + p.afterCount + '' + '媒体播放' + (p.playing ? '检测到播放' : '未检测到播放') + '' + '仅剩答题' + (p.quizOnly ? '是' : (p.modules ? '否' : '未知')) + '' + '结构判据' + (p.structOk ? '成立' : '未成立') + '' + ''); } h.push(''); } else { h.push('队列还是空的。可以先扫描课程列表,或进入一门课程后点击「收录当前」。'); } h.push('课程列表' + S.queue.length + ' 门课程' + (S.queue.length ? '清空队列' : '') + ''); if (!S.queue.length) { h.push('暂无课程'); } else { h.push(''); S.queue.forEach(function (c, i) { var cls = 'course' + (i === S.cursor && S.running ? ' cur' : ''); var st = c.status || 'pending'; h.push('' + '' + (i + 1) + '' + '' + '' + esc(c.name) + '' + '' + statusText(st) + '' + (c.leftover ? '剩余答题 ' + c.leftover + '' : '') + (c.reason ? '· ' + esc(c.reason).slice(0, 18) + '' : '') + '' + (isAutoName(c) ? '名' : '') + ((c.status === 'done' || c.status === 'failed') ? '↺' : '跳') + '×' + ''); }); h.push(''); } } else { if (!isStudyPage()) { h.push('课程发现' + '在「我的课程」等课程列表页面扫描可识别的课程,并导入当前队列。' + '扫描本页全部课程' + ' 包含「已完成」的课程' + (lastScan ? '' + esc(lastScan) + '' : '') + ''); h.push('页面诊断' + '用于排查课程入口、章节目录及课程名称识别问题。抓取结果会自动复制到剪贴板。' + '抓取章节入口元素'); } else { h.push('当前页面当前处于课程学习页面。可使用下方操作查看运行日志或生成报表。'); } if (lastGrab) { h.push('最近一次抓取结果' + '结果已尝试复制到剪贴板;如复制失败,可从这里手动查看。' + '' + esc(lastGrab) + ''); } h.push('运行日志最近 ' + Math.min(20, S.logs.length) + ' 条'); if (S.logs.length) { h.push(''); S.logs.slice(-20).forEach(function (line) { h.push('' + esc(line) + ''); }); h.push(''); } else { h.push('暂无运行日志'); } } var keep = elBody.scrollTop; elBody.innerHTML = h.join(''); elBody.scrollTop = keep; } // ============ 事件 ============ var NAME_SELECTORS = [ 'dd.textHidden', '.courseName', '.course-name', '.courseTitle', '.course-title', '#courseName', '#courseTitle', '.posCatalog_title', '.catalog_title', '.Mooc2Head .title', '.Mooc2Head h1', '.ml_title', '.course_title', '.courseInfo .name', 'h1' ]; var NAME_BAD = /学生学习界面|学习通|超星|我的课程|课程学习|学习页面|章节学习|^首页$/; var ACTIVE_CLS = ['active', 'cur', 'current', 'selected', 'on', 'check']; // 某些选择器天然就是课名容器,允许 2 字短名(例如「测量」「制图」) var NAME_MINLEN = { 'dd.textHidden': 2, '.courseName': 2, '.course-name': 2, '#courseName': 2 }; function okName(t, min) { if (!t) return false; min = min || 3; if (t.length < min || t.length > 50) return false; return !NAME_BAD.test(t); } // 课程名优先取 title 属性:超星用 .textHidden 做视觉截断,textContent 可能不完整 function readName(el) { return String(el.getAttribute('title') || el.textContent || '').replace(/\s+/g, ' ').trim(); } function pickActive(list) { for (var i = 0; i < list.length; i++) { var cls = String(list[i].className || '').toLowerCase().split(/\s+/); for (var j = 0; j < ACTIVE_CLS.length; j++) { if (cls.indexOf(ACTIVE_CLS[j]) >= 0) return list[i]; } } return null; } function guessCourseName() { for (var i = 0; i < NAME_SELECTORS.length; i++) { var sel = NAME_SELECTORS[i]; var min = NAME_MINLEN[sel] || 3; var els = $$(sel).filter(function (el) { return okName(readName(el), min); }); if (!els.length) continue; if (els.length > 1) { var act = pickActive(els); if (act) return readName(act); } return readName(els[0]); } var dt = (document.title || '').replace(/\s*[-—_|]\s*.*$/, '').trim(); if (okName(dt)) return dt; console.warn('[cxq] 没找到课程名候选,已用 courseId 代替,可在面板点名字改名'); return ''; } function isAutoName(c) { if (!c) return false; if (typeof c.nameAuto === 'boolean') return !c.nameAuto; var n = String(c.name || '').trim(); if (!n) return true; if (NAME_BAD.test(n)) return true; // 旧版用 document.title 写进来的"学生学习界面"等 return /^课程 \d+$/.test(n); } // 名字是兜底生成(或旧版本写入)时,进到该课程页再尝试识别一次;成功返回 true function refreshNameIfNeeded(c) { if (!isAutoName(c)) return false; var g = guessCourseName(); if (!g || g === c.name) return false; c.name = g; c.nameAuto = true; save(); log('自动补正课程名:' + g); render(true); return true; } // 登录区(用户名/头像/导航)标记出来,避免把真实姓名当成课程名 var GRAB_SKIP = /(^|\s)(loginAfter|headRight|headLeft|headBottom|footer|nav|topBar)(\s|$)/; function insideSkipped(el) { var p = el; for (var i = 0; i < 6 && p; i++) { if (GRAB_SKIP.test(String(p.className || ''))) return true; p = p.parentElement; } return false; } // 全页转储"短文本叶子节点",按纵向位置排序;隐藏元素、有 title 的、在登录区的都会标记 // 用途:当自动识别失败时,人工找出课程名到底挂在哪个元素上 function collectNameCandidates() { var out = [], seen = {}; var all = document.querySelectorAll('body *'); for (var i = 0; i < all.length; i++) { var el = all[i]; if (el.children.length) continue; var raw = String(el.getAttribute('title') || el.textContent || '').replace(/\s+/g, ' ').trim(); if (!raw || raw.length < 2 || raw.length > 40) continue; var ec = String(el.className || '').trim(); var key = raw + '@' + el.tagName + '.' + ec; if (seen[key]) continue; seen[key] = 1; var r = el.getBoundingClientRect(); var hidden = (r.width <= 0 || r.height <= 0) ? ' [隐藏]' : ''; var hasTitle = el.getAttribute('title') ? ' [有title]' : ''; var inHead = insideSkipped(el) ? ' [登录区]' : ''; var chain = [], p = el.parentElement, n = 0; while (p && n < 3) { var pc = String(p.className || '').trim(); chain.push(p.tagName.toLowerCase() + (pc ? '.' + pc.split(/\s+/).join('.') : '')); p = p.parentElement; n++; } out.push({ top: Math.round(r.top), line: 'y=' + Math.round(r.top) + '\t' + el.tagName.toLowerCase() + (ec ? '.' + ec.split(/\s+/).join('.') : '') + (el.id ? '#' + el.id : '') + hidden + hasTitle + inHead + ' :: "' + raw + '" <- ' + chain.join(' < ') }); } out.sort(function (a, b) { return a.top - b.top; }); return out.slice(0, 60).map(function (x) { return x.line; }); } // ---------- 课程列表扫描 ---------- function parseParams(str) { var out = {}; var re = /(?:^|[?&;'"\s\\])(courseid|clazzid|cpi|enc|openc)=([^&'"\s>]+)/gi; var m; while ((m = re.exec(str))) { var k = m[1].toLowerCase(); if (!out[k]) out[k] = m[2]; } return out; } function cardInfo(el) { var p = el, n = 0, best = null; while (p && n < 6) { var cls = String(p.className || '').toLowerCase(); if (p !== el && /(^|\s|-|_)(course|card|item)(\s|-|_|$)/.test(cls)) { best = p; break; } p = p.parentElement; n++; } if (!best) { try { best = el.closest('li,dl,dd,article,section') || el; } catch (e) { best = el; } } var txt = String(best.textContent || '').replace(/\s+/g, ' ').trim(); return { cls: String(best.className || '').slice(0, 60), text: txt.slice(0, 90) }; } function parseProgress(txt) { var t = String(txt || ''); var m = t.match(/任务点进度[::]?\s*(\d+)\s*\/\s*(\d+)/); if (m) return { done: Number(m[1]), total: Number(m[2]) }; if (/已完成\s*100\s*%/.test(t)) return { done: 1, total: 1 }; return null; } // 课名通常在卡片的 h3 / .course-name 里,cover 层只有按钮和进度 function cardName(el) { var base = el.getBoundingClientRect(); var node = el, n = 0; while (node && n < 4) { var cands = node.querySelectorAll('h1,h2,h3,h4,h5,.course-name,.courseName,.course_title,dt'); for (var i = 0; i < cands.length; i++) { var rc = cands[i].getBoundingClientRect(); // 距离锚点太远的候选多半是隔壁卡片的课名,跳过 if (rc.width > 0 && Math.abs(rc.top - base.top) > 300) continue; var t = String(cands[i].getAttribute('title') || cands[i].textContent || '').replace(/\s+/g, ' ').trim(); if (t.length >= 2 && t.length <= 40 && !/任务点进度|移动到|退课|置顶|课程已结束/.test(t)) return t; } node = node.parentElement; n++; } return ''; } function scanCourses() { var found = []; eachDoc(function (doc) { var els = doc.querySelectorAll('a[href], [onclick]'); for (var i = 0; i < els.length; i++) { var el = els[i]; var sig = String(el.getAttribute('href') || '') + ' ' + String(el.getAttribute('onclick') || ''); if (!/courseid=/i.test(sig)) continue; var pr = parseParams(sig); var cid = pr.courseid; if (!cid) continue; var card = cardInfo(el); var wide = card.text; if (!/任务点进度|已完成/.test(wide)) { try { var outer = el.closest('li,dl,article,section,tr'); if (outer) wide += ' ' + String(outer.textContent || '').replace(/\s+/g, ' ').trim(); } catch (e) { /* 忽略 */ } } found.push({ courseId: cid, clazzid: pr.clazzid || '', cpi: pr.cpi || '', enc: pr.enc || '', openc: pr.openc || '', name: cardName(el), progress: parseProgress(wide), ended: /课程已结束/.test(wide), titleAttr: String(el.getAttribute('title') || '').trim(), cardCls: card.cls, cardText: wide.slice(0, 120), href: String(el.getAttribute('href') || el.getAttribute('onclick') || '').slice(0, 200) }); } }); var map = {}, out = []; found.forEach(function (x) { var k = x.courseId + '_' + x.clazzid; if (map[k]) return; map[k] = 1; out.push(x); }); return out; } // 转储课程页上所有可点击元素,用于排查"点不进章节" function dumpEntryCandidates() { var rows = []; var ptrCount = 0; // 头部自检:一眼看出章节是否存在、iframe 是否跨域 var head = []; head.push('URL ' + location.href.slice(0, 160)); head.push('a.clicktitle=' + document.querySelectorAll('a.clicktitle').length + ' .posCatalog=' + document.querySelectorAll('.posCatalog, .posCatalog_select').length + ' 可见章节文字=' + (function () { var n = 0; $$('body *').forEach(function (el) { if (el.children.length) return; if (/^(\d+\.)+\d+\s*\S/.test(String(el.textContent || '').trim()) && isVisible(el)) n++; }); return n; })()); $$('iframe').forEach(function (f) { var ok = false; try { ok = !!f.contentDocument; } catch (e) { ok = false; } head.push(' iframe ' + (ok ? '[可读] ' : '[跨域] ') + String(f.src || f.id || '(无src)').slice(0, 90)); }); eachDoc(function (doc, depth) { var els = doc.querySelectorAll('a, [onclick], li, div, span'); for (var i = 0; i < els.length && rows.length < 100; i++) { var el = els[i]; if (!isVisible(el)) continue; var t = String(el.textContent || '').replace(/\s+/g, ' ').trim(); if (!t || t.length > 30) continue; var href = el.getAttribute('href') || ''; var oc = el.getAttribute('onclick') || ''; var cls = String(el.className || '').trim(); var ptr = false; try { ptr = doc.defaultView.getComputedStyle(el).cursor === 'pointer'; } catch (e) { ptr = false; } if (ptr) ptrCount++; // 没有 href/onclick 但鼠标是手型、或类名像章节的,也一并列出 if (!href && !oc && !ptr && !/posCatalog|catalog|chapter|clicktitle/i.test(cls)) continue; rows.push('d' + depth + ' ' + el.tagName.toLowerCase() + (cls ? '.' + cls.split(/\s+/).join('.') : '') + ' :: "' + t.slice(0, 28) + '"' + (href ? ' href=' + href.slice(0, 70) : '') + (oc ? ' onclick=' + oc.slice(0, 90) : '') + (ptr && !oc && !href ? ' [点击区]' : '')); } }); head.push('可点击元素 ' + ptrCount + ' 个,列出 ' + rows.length + ' 条'); head.push('-----'); return head.concat(rows.slice(0, 60)); } function importScanned(list) { var added = 0, skipEnded = 0, skipDone = 0, dup = 0; list.forEach(function (x) { if (x.ended) { skipEnded++; return; } if (!S.incDone && x.progress && x.progress.total > 0 && x.progress.done >= x.progress.total) { skipDone++; return; } var exists = S.queue.some(function (c) { return String(c.courseId) === String(x.courseId) && String(c.clazzid) === String(x.clazzid); }); if (exists) { dup++; return; } S.queue.push({ id: x.courseId + '_' + x.clazzid, courseId: x.courseId, clazzid: x.clazzid, cpi: x.cpi, name: x.name || ('课程 ' + String(x.courseId).slice(-6)), nameAuto: !!x.name, url: x.href, status: 'pending', startedAt: 0, finishedAt: 0, leftover: null, reason: '', progress: x.progress || null }); added++; }); if (added) save(); return { added: added, skipEnded: skipEnded, skipDone: skipDone, dup: dup }; } function collectCurrent() { if (!isStudyPage()) { notify('请先进入课程学习页再收录'); return; } var p = new URLSearchParams(location.search); var courseId = urlCourseId(); if (!courseId) { notify('当前 URL 缺少 courseId'); return; } var exists = S.queue.some(function (c) { return String(c.courseId) === String(courseId); }); if (exists) { notify('这门课已经在队列里了'); return; } if (S.running) { S.running = false; if (S.queue[S.cursor]) S.queue[S.cursor].status = 'pending'; log('收录新课程,队列已暂停'); } var guess = guessCourseName(); var name = guess || ('课程 ' + String(courseId).slice(-6)); S.queue.push({ id: courseId + '_' + (p.get('clazzid') || ''), courseId: courseId, clazzid: p.get('clazzid') || '', name: name, nameAuto: !!guess, url: location.href, status: 'pending', startedAt: 0, finishedAt: 0, leftover: null, reason: '' }); save(); log('收录课程:' + name); render(true); } function startQueue() { cancelPendingNavigation(); if (!S.queue.length) { notify('队列为空'); return; } var pending = S.queue.findIndex(function (c) { return c.status !== 'done'; }); if (pending < 0) { S.queue.forEach(function (c) { c.status = 'pending'; c.leftover = null; c.reason = ''; }); pending = 0; } S.running = true; S.lastNav = null; S.navGuard = null; save(); startCourse(pending); } function pauseQueue() { S.running = false; cancelPendingNavigation(); S.navGuard = null; if (S.queue[S.cursor]) S.queue[S.cursor].status = 'pending'; save(); log('已暂停'); render(true); } function buildReport() { var lines = []; var done = S.queue.filter(function (c) { return c.status === 'done'; }).length; lines.push('CX 课程联播报表'); lines.push('生成时间:' + new Date().toLocaleString()); lines.push('完成 ' + done + ' / ' + S.queue.length + ' 门'); lines.push(''); S.queue.forEach(function (c, i) { var dur = (c.startedAt && c.finishedAt) ? hhmmss(c.finishedAt - c.startedAt) : '-'; lines.push((i + 1) + '. ' + c.name); lines.push(' 状态:' + statusText(c.status) + ' 耗时:' + dur + (c.leftover ? (' 剩余答题:' + c.leftover + ' 个') : '') + (c.reason ? (' 说明:' + c.reason) : '')); }); return lines.join('\n'); } root.addEventListener('click', function (e) { var t = e.target; if (t && t.closest) t = t.closest('[data-act]'); var act = t && t.getAttribute && t.getAttribute('data-act'); if (!act) return; if (act === 'tab') { uiTab = t.getAttribute('data-tab') === 'tools' ? 'tools' : 'queue'; render(true); } else if (act === 'toggle') { S.running ? pauseQueue() : startQueue(); } else if (act === 'add') { collectCurrent(); } else if (act === 'min') { var p = root.querySelector('.panel'); var min = p.classList.toggle('min'); root.getElementById('btn-min').textContent = min ? '+' : '−'; } else if (act === 'report') { var text = buildReport(); try { if (typeof GM_setClipboard === 'function') GM_setClipboard(text, 'text'); else navigator.clipboard.writeText(text); } catch (err) { /* 忽略 */ } alert(text); } else if (act === 'scan') { var list = scanCourses(); if (!list.length) { lastScan = '本页没有找到课程链接。\n可能不在「我的课程」页面,或列表还没渲染完(刷新后再试)。'; } else { var r = importScanned(list); lastScan = '本页共 ' + list.length + ' 门课程,新增 ' + r.added + ' 门'; if (r.skipEnded) lastScan += '\n跳过「课程已结束」' + r.skipEnded + ' 门'; if (r.skipDone) lastScan += '\n跳过「已完成」' + r.skipDone + ' 门'; if (r.dup) lastScan += '\n已在队列中 ' + r.dup + ' 门'; log('扫描课程:新增 ' + r.added + ' 门(共 ' + list.length + ' 门)'); try { var dump = JSON.stringify(list, null, 1); if (typeof GM_setClipboard === 'function') GM_setClipboard(dump, 'text'); else navigator.clipboard.writeText(dump); } catch (err) { /* 忽略 */ } } render(true); } else if (act === 'entdump') { var rows = dumpEntryCandidates(); lastGrab = rows.length ? rows.join('\n') : '没抓到可点击元素'; try { if (typeof GM_setClipboard === 'function') GM_setClipboard(lastGrab, 'text'); else navigator.clipboard.writeText(lastGrab); } catch (err) { /* 忽略 */ } log('已抓取入口候选 ' + rows.length + ' 条'); render(true); } else if (act === 'grabname') { var list = collectNameCandidates(); lastGrab = list.length ? list.join('\n') : '没抓到候选元素'; try { if (typeof GM_setClipboard === 'function') GM_setClipboard(lastGrab, 'text'); else navigator.clipboard.writeText(lastGrab); } catch (err) { /* 忽略 */ } log('已抓取课名候选 ' + list.length + ' 条'); render(true); } else if (act === 'restore') { var rs = Number(t.getAttribute('data-i')); var rsc = S.queue[rs]; if (rsc) { rsc.status = 'pending'; rsc.reason = ''; rsc.leftover = null; rsc.finishedAt = 0; save(); log('恢复为待学:' + rsc.name); render(true); } } else if (act === 'incdone') { S.incDone = !!t.checked; save(); render(true); } else if (act === 'clearq') { if (!S.queue.length) return; if (!window.confirm('清空整个队列?此操作不可撤销。')) return; S.queue = []; S.cursor = 0; S.running = false; cancelPendingNavigation(); S.navGuard = null; save(); log('已清空队列'); render(true); } else if (act === 'rename') { var ri = Number(t.getAttribute('data-i')); var rc = S.queue[ri]; if (!rc) return; var nv = window.prompt('修改课程名称', rc.name); if (nv && nv.trim()) { rc.name = nv.trim(); rc.nameAuto = true; save(); render(true); } } else if (act === 'skip') { var i = Number(t.getAttribute('data-i')); if (S.queue[i]) { S.queue[i].status = 'done'; S.queue[i].reason = '手动跳过'; if (i === S.cursor && S.running) { cancelPendingNavigation(); save(); goNext(); return; } save(); render(true); } } else if (act === 'del') { var j = Number(t.getAttribute('data-i')); S.queue.splice(j, 1); if (S.cursor >= S.queue.length) S.cursor = Math.max(0, S.queue.length - 1); save(); render(true); } }); // ============ 启动 ============ function boot() { render(true); if (!S.running) return; var cur = S.queue[S.cursor]; if (!cur) { finishQueue(); return; } if (onTargetPage(cur.courseId)) { clearNavGuard(); resetCourseRuntime(); log('接管课程:' + cur.name); render(true); } else if (onCoursePage(cur.courseId)) { resetCourseRuntime(); render(true); } else { navigate(cur.url, '进入队列课程'); } } try { boot(); setInterval(function () { try { tick(); } catch (e) { console.error('[cxq]', e); } }, TICK_MS); } catch (e) { console.error('[cxq] 初始化失败', e); } })();