// ==UserScript== // @name NeoMooc|中国大学Mooc|AI答题|云端刷课|挂机刷课|自带题库 // @namespace http://neomooc.click/ // @version 1.5.1 // @description 中国大学Mooc答题、云端代看。自带题库、AI智能分析、本地挂机模拟、定向选择代看、一键互评功能。非认证学习也可显示测验成绩,便于学习和练习。本脚本仅供个人研究学习使用,请勿用于非法用途,产生一切法律责任用户自行承担。 // @author neomooc // @antifeature payment // @antifeature tracking // @match https://www.icourse163.org/learn/* // @match http://www.icourse163.org/learn/* // @match http://www.icourse163.org/spoc/learn/* // @match https://www.icourse163.org/spoc/learn/* // @match https://www.icourse163.org/mooc/* // @grant unsafeWindow // @grant GM_xmlhttpRequest // @grant GM.xmlHttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_info // @grant GM_cookie // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @connect * // @license Proprietary // @require https://scriptcat.org/lib/637/1.4.8/ajaxHooker.js#sha256=dTF50feumqJW36kBpbf6+LguSLAtLr7CEs3oPmyfbiM= // @run-at document-start // @icon http://neomooc.click/static/logo.png // @tag 云端带看 // @tag 隐蔽模式答题 // @tag 免费挂机刷课 // @tag 免费批量互评 // ==/UserScript== (function () { "use strict"; const CONFIG = { debug: false, }; function lockShouldHiddenScore() { try { Object.defineProperty(unsafeWindow, "shouldHiddenScore", { configurable: false, enumerable: true, get() { return false; }, set() { return false; }, }); } catch (e) { try { unsafeWindow.shouldHiddenScore = false; } catch (_) { } } } lockShouldHiddenScore(); function compareVersion(v1, v2) { const p1 = String(v1).split(".").map(Number); const p2 = String(v2).split(".").map(Number); const len = Math.max(p1.length, p2.length); for (let i = 0; i < len; i++) { const n1 = p1[i] || 0; const n2 = p2[i] || 0; if (n1 > n2) return 1; if (n1 < n2) return -1; } return 0; } const CURRENT_VERSION = GM_info.script.version; const existingVersion = unsafeWindow.__NEOMOOC_VERSION__; if (existingVersion) { const cmp = compareVersion(CURRENT_VERSION, existingVersion); if (cmp <= 0) { return; } if (typeof unsafeWindow.__NEOMOOC_CLEANUP__ === 'function') { try { unsafeWindow.__NEOMOOC_CLEANUP__(); } catch (e) { console.warn('[NeoMooc] 旧实例清理出错:', e); } } } unsafeWindow.__NEOMOOC_VERSION__ = CURRENT_VERSION; // 试卷响应统一捕获入口。三类接口结构不同,但核心字段一致: // getOpenQuizPaperDto / getOpenHomeworkPaperDto / mocExamBean.getPaper // 均返回 { code, result: { aid, tid, tname, examId?, objectiveQList?, subjectiveQList? } } function capturePaperResult(result) { if (!result || !result.aid) return; STATE.aid = String(result.aid); STATE.tid = String(result.tid || STATE.termId); STATE.testName = result.tname || ""; console.log( "[NeoMooc] 捕获试卷:", "aid=" + STATE.aid, "tid=" + STATE.tid, "name=" + STATE.testName, ); // 只有响应明确带 examId 时才改判考试/作业, // 避免无 examId 的接口(如 mocExamBean.getPaper)把已判定的考试覆盖成作业 if (Object.prototype.hasOwnProperty.call(result, "examId")) { STATE.isExam = !!result.examId && result.examId !== -1; } // 同一份卷可能被多个接口重复返回:只在「换了试卷」或「这次带了题目列表」时替换, // 避免后到的无题响应(如 mocExamBean.getPaper 的 objectiveQList: null)把题目清空 const samePaper = STATE.paperDto && String(STATE.paperDto.aid) === String(result.aid); const hasQuestions = Array.isArray(result.objectiveQList) || Array.isArray(result.subjectiveQList); if (!samePaper || hasQuestions) STATE.paperDto = result; UI.updateAnswerCost(STATE.answerCostHomework, STATE.answerCostExam, STATE.isExam); const statusEl = document.getElementById("im-status"); if (statusEl) { statusEl.innerHTML = `状态 📋 已捕获测验内容: ${STATE.testName || "当前页面"}`; } } ajaxHooker.hook((request) => { if ( request.url.includes("getOpenQuizPaperDto") || request.url.includes("getOpenHomeworkPaperDto") || request.url.includes("mocExamBean.getPaper") ) { request.response = (res) => { try { const json = JSON.parse(res.responseText); capturePaperResult(json.result); } catch (e) { } }; } if (request.url.includes("getLastLearnedMocTermDto")) { request.response = (res) => { try { const json = JSON.parse(res.responseText); const moc = json?.result?.mocTermDto; if (moc) { STATE.courseTreeData = moc; } } catch (e) { } }; } }); const STATE = { user: null, csrfKey: "", termId: "", courseId: "", courseKey: "", aid: "", tid: "", testName: "", isExam: false, paperDto: null, courseTreeData: null, verified: false, verifyPromise: null, isSlowBrushing: false, isJumping: false, flatUnits: [], privacyActive: false, lastRefresh: 0, qCount: 0, isFetching: false, maxTaskCost: 30, cloudAvailable: true, answerCostHomework: null, answerCostExam: null, }; // 全屏 API 会把全屏元素提升到 top layer,任何 z-index 都盖不住它。 // 解决办法:把所有 fixed 浮层挂进当前全屏元素内部,与全屏内容同层渲染。 const _overlayEls = new Set(); function getOverlayHost() { const fsEl = document.fullscreenElement || document.webkitFullscreenElement || null; // 全屏元素是 iframe 时无法插入其内部(跨文档),保持原挂载点 if (fsEl && fsEl.tagName !== "IFRAME") return fsEl; return document.body || document.documentElement; } function mountOverlay(elm) { if (!elm) return elm; _overlayEls.add(elm); const host = getOverlayHost(); if (elm.parentNode !== host) host.appendChild(elm); return elm; } function syncOverlayHost() { const host = getOverlayHost(); _overlayEls.forEach((elm) => { if (!elm.isConnected) { _overlayEls.delete(elm); return; } if (elm.parentNode !== host) host.appendChild(elm); }); } document.addEventListener("fullscreenchange", syncOverlayHost, true); document.addEventListener("webkitfullscreenchange", syncOverlayHost, true); const Dialog = { fire({ title = "", html = "", onConfirm, onOpen, confirmText = "确定", cancelText = "取消", }) { const panel = document.getElementById("neomooc-panel"); const isPanelVisible = panel && getComputedStyle(panel).display !== "none"; if (!isPanelVisible) return Promise.resolve(null); return new Promise((resolve) => { const overlay = document.createElement("div"); overlay.className = "im-dialog-overlay"; const modal = document.createElement("div"); modal.className = "im-dialog-modal"; const panel = document.getElementById("neomooc-panel"); if (panel && panel.hasAttribute("data-im-theme")) { modal.setAttribute( "data-im-theme", panel.getAttribute("data-im-theme"), ); } modal.innerHTML = `
${title}
${html}
`; overlay.appendChild(modal); mountOverlay(overlay); requestAnimationFrame(() => { overlay.classList.add("show"); modal.classList.add("show"); }); if (onOpen) setTimeout(() => onOpen(modal), 0); const close = (val) => { modal.classList.remove("show"); overlay.classList.remove("show"); setTimeout(() => overlay.remove(), 200); resolve(val); }; overlay.addEventListener("click", (e) => { if (e.target === overlay) close(null); }); const cancelBtn = modal.querySelector(".cancel"); if (cancelBtn) cancelBtn.addEventListener("click", () => close(null)); const confirmBtn = modal.querySelector(".confirm"); if (confirmBtn) { confirmBtn.addEventListener("click", () => { const result = onConfirm ? onConfirm() : true; if (result !== false) close(result); }); } }); }, toast(html, duration = 3000) { let container = document.getElementById("im-toast-container"); if (!container) { container = document.createElement("div"); container.id = "im-toast-container"; container.style.cssText = "position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:2147483002;display:flex;flex-direction:column;align-items:center;gap:10px;pointer-events:none;"; mountOverlay(container); } const toast = document.createElement("div"); toast.className = "im-toast"; if (CONFIG.theme === "light") { toast.setAttribute("data-im-theme", "light"); } toast.innerHTML = html; container.appendChild(toast); // 强制同步渲染 toast.offsetHeight; // 使用双帧等待确保动画触发 requestAnimationFrame(() => { requestAnimationFrame(() => { toast.classList.add("show"); }); }); setTimeout(() => { toast.classList.remove("show"); setTimeout(() => { if (toast.parentNode) toast.remove(); if (container.children.length === 0) container.remove(); }, 400); }, duration); }, }; GM_addStyle(` @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap'); :root { --im-bg: rgba(22, 27, 34, 0.92); --im-text: #e6edf3; --im-text-dim: #8b949e; --im-border: rgba(255, 255, 255, 0.1); --im-accent: linear-gradient(135deg, #58a6ff, #bc8cff); --im-card-bg: rgba(255, 255, 255, 0.04); } [data-im-theme="light"] { --im-bg: rgba(255, 255, 255, 0.95); --im-text: #1f2328; --im-text-dim: #656d76; --im-border: rgba(31, 35, 40, 0.12); --im-card-bg: rgba(31, 35, 40, 0.05); } #neomooc-panel { position: fixed; top: 80px; right: 20px; z-index: 2147483000; width: 320px; font-family: 'Outfit', 'Inter', sans-serif; user-select: none; transition: opacity 0.3s; display: flex; flex-direction: column; align-items: flex-end; } #neomooc-panel.dragging { transition: none !important; } .im-main-frame { width: 100%; box-sizing: border-box; background: var(--im-bg); backdrop-filter: blur(16px); border: 1px solid var(--im-border); border-radius: 20px; color: var(--im-text); overflow: hidden; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); } .im-main-frame.collapsed { width: 52px; height: 52px; border-radius: 26px; cursor: pointer; } .im-main-frame.collapsed .panel-body, .im-main-frame.collapsed .panel-header { display: none; } .im-main-frame.collapsed::after { content: "⚡"; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; font-size: 24px; background: var(--im-accent); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } .im-main-frame.collapsed + .notice-container { display: none !important; } .panel-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--im-border); cursor: move; } .panel-header .title { font-size: 16px; font-weight: 700; background: var(--im-accent); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } .header-actions { display: flex; align-items: center; gap: 12px; } .icon-btn { cursor: pointer; font-size: 18px; opacity: 0.6; transition: all 0.2s; display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; } .icon-btn:hover { opacity: 1; transform: scale(1.1); } .panel-body { padding: 18px 20px; } .user-info { margin-bottom: 16px; } .user-main { display: flex; align-items: center; justify-content: space-between; margin-bottom: 2px; } .user-name { font-size: 15px; font-weight: 600;width: 50%;overflow: hidden; } .user-id { font-size: 11px; color: var(--im-text-dim); opacity: 0.8; margin-bottom: 8px; font-family: monospace; } .vip-badges { display: flex; gap: 6px; margin-left: 8px; } .vip-badge { font-size: 10px; padding: 2px 8px; border-radius: 6px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; border: 1px solid transparent; } .vip-badge.active { background: linear-gradient(120deg, rgba(235,165,3,0.1), rgba(240,113,10,0.1)); border-color: rgba(235,165,3,0.5); color: #EBA503; text-shadow: 0 0 10px rgba(235,165,3,0.4); } .vip-badge.none { background: transparent; border-color: var(--im-border); color: var(--im-text-dim); opacity: 0.5; } .info-row { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; font-size: 13px; color: var(--im-text-dim); } .info-row .val { color: var(--im-text); font-weight: 600; } .score-badge { display: inline-block; padding: 2px 10px; border-radius: 12px; background: linear-gradient(135deg, #238636, #2ea043); color: #fff; font-size: 13px; font-weight: 700; cursor: pointer; } .btn-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; } .btn-grid.row-3 { grid-template-columns: 1fr 1fr 1fr; } .im-btn { padding: 8px 0; border: none; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); text-align: center; color: #fff; } .im-btn:hover { transform: translateY(-2px); filter: brightness(1.1); } .im-btn:active { transform: scale(0.96); } .im-btn.primary { background: linear-gradient(135deg, #238636, #2ea043); } .im-btn.info { background: linear-gradient(135deg, #1f6feb, #388bfd); } .im-btn.warning { background: linear-gradient(135deg, #d29922, #b0801a); } .im-btn.purple { background: linear-gradient(135deg, #8957e5, #6e40c9); } .im-btn.success { background: linear-gradient(135deg, #238636, #2ea043); } .im-btn.danger { background: linear-gradient(135deg, #da3633, #f85149); } .im-btn.full { grid-column: span 2; } .im-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none !important; box-shadow: none !important; } .status-container { margin-top: 16px; padding: 12px; border-radius: 12px; background: var(--im-card-bg); font-size: 12px; color: var(--im-text-dim); line-height: 1.5; border: 1px solid var(--im-border); } .status-container .label { color: #58a6ff; font-weight: 700; margin-right: 6px; } .progress-container { height: 6px; border-radius: 3px; background: var(--im-card-bg); margin-top: 8px; overflow: hidden; display: none; } .progress-fill { height: 100%; border-radius: 3px; background: linear-gradient(90deg, #2ea043, #58a6ff); transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1); } .notice-container { width: 100%; box-sizing: border-box; margin-top: 12px; padding: 14px 16px; border-radius: 16px; background: rgba(255, 150, 0, 0.18); backdrop-filter: blur(16px); border: 1px solid rgba(255, 150, 0, 0.4); font-size: 13px; line-height: 1.6; color: #cc7a00; display: none; position: relative; } .notice-container.show { display: block; animation: im-slide-up 0.4s ease; } @keyframes im-slide-up { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } .notice-container b { color: #ff9d00; font-weight: 700; margin-right: 4px; } .notice-close { position: absolute; top: 6px; right: 10px; cursor: pointer; opacity: 0.6; font-size: 16px; font-weight: bold; } .notice-close:hover { opacity: 1; color: #ff9d00; } .neomooc-answer-block { display: block !important; width: 100%; margin: 8px 0 10px; clear: both; } .neomooc-answer-block .neomooc_answer { margin: 0; flex-wrap: wrap; } .neomooc_answer { margin: 8px 0 2px; padding: 0; background: transparent; font-size: 14px; line-height: 1.6; color: #333; display: inline-flex !important; align-items: center; gap: 10px; max-width: 100%; vertical-align: top; } .neomooc_answer .ans-label { display: inline-flex; align-items: center; justify-content: center; height: 20px; padding: 0 6px; border: 1px solid #bfe6c5; border-radius: 3px; background: #f3fbf1; color: #2f8f46; font-size: 0; font-weight: 600; line-height: 20px; white-space: nowrap; } .neomooc_answer .ans-label::before { content: "\\53C2\\8003\\7B54\\6848"; font-size: 12px; } .neomooc_answer .ans-content { min-width: 0; } .neomooc_answer-subjective { align-items: flex-start; } .neomooc_answer-subjective .ans-label { margin-top: 2px; } .neomooc_answer-subjective .ans-content { display: block; } .neomooc_answer .ans-opt { display: flex; align-items: center; min-height: 24px; line-height: 24px; color: #333; max-width: 100%; } .neomooc_answer .ans-opt-letter { width: 24px; margin-right: 4px; white-space: nowrap; font-weight: 700; color: #666; } .neomooc_answer .ans-opt-text { min-width: 0; } .neomooc_answer .ans-opt-text p { margin: 0; display: inline; } .neomooc_answer .ans-opt-text img { max-width: 100%; height: auto; vertical-align: middle; } .neomooc-answer-card { position: fixed; top: 30%; right: 30%; z-index: 2147483000; background: rgba(255,255,255,0.97); border-radius: 12px; padding: 14px 16px; min-width: 200px; max-width: 300px; max-height: 55vh; overflow-y: auto; font-size: 13px; } .neomooc-answer-card .card-title { font-weight: 700; font-size: 14px; color: #333; margin-bottom: 10px; border-bottom: 1px solid #eee; padding-bottom: 8px; cursor: move; user-select: none; } .neomooc-card-item { display: inline-block; min-width: 42px; text-align: center; padding: 5px 10px; margin: 3px; border-radius: 6px; cursor: pointer; background: #f5f5f5; color: #333; transition: all 0.15s; font-size: 12px; font-weight: 500; } .neomooc-card-item:hover { background: #e8f5e9; color: #2e7d32; } .im-dialog-overlay { position: fixed; inset: 0; z-index: 2147483001; background: rgba(0,0,0,0); backdrop-filter: blur(0px); display: flex; align-items: center; justify-content: center; transition: background 0.25s, backdrop-filter 0.25s; } .im-dialog-overlay.show { background: rgba(0,0,0,0.45); backdrop-filter: blur(4px); } .im-dialog-modal { --dlg-bg: rgba(22, 27, 34, 0.96); --dlg-text: #e6edf3; --dlg-dim: #8b949e; --dlg-border: rgba(255,255,255,0.08); --dlg-input-bg: rgba(255,255,255,0.05); --dlg-hover: rgba(255,255,255,0.08); width: auto; min-width: 420px; max-width: 90vw; font-family: 'Outfit','Inter',sans-serif; background: var(--dlg-bg); color: var(--dlg-text); border: 1px solid var(--dlg-border); border-radius: 16px; opacity: 0; transform: scale(0.92) translateY(12px); transition: opacity 0.25s cubic-bezier(0.4,0,0.2,1), transform 0.25s cubic-bezier(0.4,0,0.2,1); } .im-dialog-modal.show { opacity: 1; transform: scale(1) translateY(0); } .im-dialog-modal[data-im-theme="light"] { --dlg-bg: rgba(255,255,255,0.97); --dlg-text: #1f2328; --dlg-dim: #656d76; --dlg-border: rgba(31,35,40,0.1); --dlg-input-bg: rgba(31,35,40,0.04); --dlg-hover: rgba(31,35,40,0.06); } .im-dialog-title { padding: 20px 24px 0; font-size: 16px; font-weight: 700; background: linear-gradient(135deg, #58a6ff, #bc8cff); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } .im-dialog-body { padding: 16px 24px; font-size: 14px; line-height: 1.7; color: var(--dlg-dim); } .im-dialog-body label { display: block; font-size: 12px; font-weight: 600; color: var(--dlg-text); margin-bottom: 6px; letter-spacing: 0.3px; } .im-dialog-body input[type="text"] { width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--dlg-border); background: var(--dlg-input-bg); color: var(--dlg-text); font-size: 13px; font-family: inherit; outline: none; transition: border-color 0.2s, box-shadow 0.2s; } .im-dialog-body input[type="text"]:focus { border-color: #58a6ff; } .im-dialog-body input[type="text"]::placeholder { color: var(--dlg-dim); opacity: 0.5; } .im-dialog-body .field-group { margin-bottom: 14px; } .im-dialog-body .field-group:last-child { margin-bottom: 0; } .im-dialog-body .option-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 4px; } .im-dialog-body .option-item { padding: 10px 12px; border-radius: 8px; cursor: pointer; border: 1px solid var(--dlg-border); background: var(--dlg-input-bg); font-size: 13px; font-weight: 500; text-align: center; transition: all 0.15s; color: var(--dlg-text); } .im-dialog-body .option-item:hover { background: var(--dlg-hover); border-color: #58a6ff; } .im-dialog-body .option-item.selected { border-color: #58a6ff; background: rgba(88,166,255,0.12); color: #58a6ff; font-weight: 600; } .im-dialog-body .hint { font-size: 11px; color: var(--dlg-dim); margin-top: 8px; opacity: 0.7; } .im-dialog-footer { display: flex; gap: 10px; padding: 0 24px 20px; justify-content: flex-end; } .im-dialog-btn { padding: 8px 20px; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; transition: all 0.15s; } .im-dialog-btn.cancel { background: transparent; color: var(--dlg-dim); border: 1px solid var(--dlg-border); } .im-dialog-btn.cancel:hover { background: var(--dlg-hover); } .im-dialog-btn.confirm { background: linear-gradient(135deg, #58a6ff, #388bfd); color: #fff; } .im-dialog-btn.confirm:hover { filter: brightness(1.1); transform: translateY(-1px); } .ux-modal-fadeIn { display: none !important; } .unit_button, .chapter_button, .lesson_button { display: none; position: absolute; width: 80px; height: 26px; border-radius: 4px; font-size: 11px; font-weight: 600; cursor: pointer; border: 1px solid #7a8cb4; color: #333; transition: all 0.2s; line-height: 26px; text-align: center; padding: 0; top: 50%; transform: translateY(-50%); z-index: 100; } .unit:hover { background-color: #DCF0E4 !important; } .unit:hover .unit_button, .u-learnLesson:hover .lesson_button, .m-learnChapterNormal:hover .chapter_button { display: block; } .unit_button { right: 60%; background: #DCF0E4; } .chapter_button { right: 40%; background: #EEE8A9; } .lesson_button { right: 50%; background: #FFE9FF; top: 20px;transform: translateY(0%);} /* 新版课程目录 hover 浮动按钮:挂在 body 上,不进 React 渲染树 */ #neomooc-outline-float { position: fixed; z-index: 2147483000; display: none; width: 80px; height: 26px; padding: 0; border-radius: 4px; border: 1px solid #7a8cb4; background: #DCF0E4; color: #333; font-size: 11px; font-weight: 600; line-height: 26px; text-align: center; cursor: pointer; white-space: nowrap; transition: all 0.2s; transform: translate(-100%, -50%); } #neomooc-outline-float.show { display: block; } #neomooc-outline-float[data-kind="lesson"] { background: #FFE9FF; } #neomooc-outline-float[data-kind="chapter"] { background: #EEE8A9; } #neomooc-outline-float:hover { filter: brightness(0.95); } #neomooc-outline-float:disabled { opacity: .55; cursor: default; } html, body, div, span, p, pre, code, i, b, strong, th, td, table, section, article { user-select: text !important; -webkit-user-select: text !important; -ms-user-select: text !important; -moz-user-select: text !important; } html.im-privacy-active #neomooc-panel, html.im-privacy-active .unit_button, html.im-privacy-active .chapter_button, html.im-privacy-active .lesson_button, html.im-privacy-active #neomooc-outline-float, html.im-privacy-active .neomooc-answer-card { display: none !important; } .im-data-list { display: flex; gap: 20px; width: 680px; max-width: 90vw; } .im-list-section { flex: 1; min-width: 0; display: flex; flex-direction: column; max-height: 480px; } .im-list-title { font-size: 13px; font-weight: 700; color: var(--dlg-text); margin-bottom: 12px; display: flex; align-items: center; gap: 6px; padding: 4px 0; flex-shrink: 0; } .im-list-title i { color: #58a6ff; font-style: normal; } .im-task-container, .im-log-container { overflow-y: auto; flex: 1; padding-right: 4px; } /* 统一滚动条美化 */ .im-dialog-modal [style*="overflow-y: auto"], .im-task-container, .im-log-container, .neomooc-answer-card { scrollbar-width: thin; scrollbar-color: rgba(139, 148, 158, 0.3) transparent; } .im-dialog-modal ::-webkit-scrollbar, .im-main-frame ::-webkit-scrollbar, .neomooc-answer-card ::-webkit-scrollbar { width: 5px; height: 5px; } .im-dialog-modal ::-webkit-scrollbar-track, .im-main-frame ::-webkit-scrollbar-track, .neomooc-answer-card ::-webkit-scrollbar-track { background: transparent; } .im-dialog-modal ::-webkit-scrollbar-thumb, .im-main-frame ::-webkit-scrollbar-thumb, .neomooc-answer-card ::-webkit-scrollbar-thumb { background: rgba(139, 148, 158, 0.3); border-radius: 10px; } .im-dialog-modal ::-webkit-scrollbar-thumb:hover, .im-main-frame ::-webkit-scrollbar-thumb:hover, .neomooc-answer-card ::-webkit-scrollbar-thumb:hover { background: rgba(139, 148, 158, 0.5); } .im-task-item { background: var(--dlg-input-bg); border-radius: 8px; padding: 10px; margin-bottom: 8px; border: 1px solid var(--dlg-border); } .im-task-header { display: flex; justify-content: space-between; margin-bottom: 6px; font-size: 12px; font-weight: 600; color: var(--dlg-text); } .im-task-status { font-size: 10px; padding: 2px 5px; border-radius: 4px; } .im-status-running { background: rgba(88,166,255,0.15); color: #58a6ff; } .im-status-executed { background: rgba(187, 128, 255, 0.15); color: #bc8cff; border: 1px solid rgba(187, 128, 255, 0.3); } .im-status-completed { background: rgba(46,160,67,0.15); color: #3fb950; } .im-status-failed { background: rgba(248,81,73,0.15); color: #f85149; } .im-status-pending { background: rgba(210,153,34,0.12); color: #d29922; } .im-status-cancelled { background: rgba(139,148,158,0.12); color: #8b949e; } .im-task-progress { height: 4px; background: rgba(255,255,255,0.05); border-radius: 2px; margin: 8px 0; overflow: hidden; } .im-task-progress-bar { height: 100%; background: #58a6ff; border-radius: 2px; } .im-task-info { font-size: 10px; color: var(--dlg-dim); display: flex; justify-content: space-between; } .im-log-item { display: flex; justify-content: space-between; align-items: flex-start; padding: 8px 0; font-size: 11px; border-bottom: 1px solid var(--dlg-border); } .im-log-item:last-child { border-bottom: none; } .im-log-desc { color: var(--dlg-text); line-height: 1.4; word-break: break-all; padding-right: 8px; } .im-log-time { font-size: 10px; color: var(--dlg-dim); margin-top: 2px; } .im-log-amount { font-weight: 700; font-family: monospace; font-size: 12px; white-space: nowrap; } .im-amount-minus { color: #f85149; } .im-amount-plus { color: #3fb950; } .im-quick-submit { position: fixed; left: 50%; bottom: 5%; z-index: 2147483000; padding: 11px 20px; border-radius: 14px; background: var(--im-bg); backdrop-filter: blur(24px); border: 1px solid var(--im-border); color: var(--im-text); font-family: 'Outfit', sans-serif; font-size: 13px; font-weight: 600; display: flex; align-items: center; gap: 10px; cursor: pointer; transition: all 0.4s cubic-bezier(0.19, 1, 0.22, 1); letter-spacing: 0.8px; text-transform: uppercase; } .im-toast-container { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); z-index: 2147483002; display: flex; flex-direction: column; align-items: center; gap: 10px; pointer-events: none; } .im-toast { padding: 12px 24px; border-radius: 12px; background: rgba(22, 27, 34, 0.9); color: #e6edf3; border: 1px solid rgba(255,255,255,0.1); font-size: 14px; font-weight: 500; pointer-events: auto; opacity: 0; transform: translateY(-20px); transition: all 0.4s cubic-bezier(0.19, 1, 0.22, 1); backdrop-filter: blur(12px); white-space: nowrap; } .im-toast.show { opacity: 1; transform: translateY(0); } .im-toast[data-im-theme="light"] { background: rgba(255, 255, 255, 0.9); color: #1f2328; border: 1px solid rgba(31, 35, 40, 0.1); } .im-quick-submit i { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; font-size: 14px; background: var(--im-accent); -webkit-background-clip: text; -webkit-text-fill-color: transparent; font-style: normal; } .im-quick-submit:hover { transform: translateX(-8px); border-color: rgba(88, 166, 255, 0.5); } .im-quick-submit::after { content: ''; position: absolute; left: -1px; top: 25%; height: 50%; width: 3px; background: var(--im-accent); border-radius: 0 4px 4px 0; opacity: 0; transition: all 0.3s ease; } .im-quick-submit:hover::after { opacity: 1; } .im-quick-submit:active { transform: translateX(-8px) scale(0.96); } .im-btn-mini { display: inline-flex; align-items: center; padding: 3px 12px; margin: 0 6px; background: #fff; border: 1.5px solid #ff9d00; border-radius: 20px; color: #ff9d00 !important; font-size: 11px; font-weight: 800; cursor: pointer; text-decoration: none !important; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); vertical-align: middle; box-shadow: 0 2px 4px rgba(255, 157, 0, 0.15); line-height: 1; letter-spacing: 0.3px; } .im-btn-mini:hover { background: #ff9d00; color: #fff !important; transform: translateY(-1px); box-shadow: 0 4px 10px rgba(255, 157, 0, 0.3); } .im-btn-mini:active { transform: translateY(0); box-shadow: 0 1px 2px rgba(255, 157, 0, 0.2); } #neomooc-evaluation { box-sizing: border-box; width: 100%; min-height: 210px; display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; margin: 20px 0 0; padding: 18px; border: 1px solid rgba(88, 166, 255, 0.18); border-radius: 14px; background: rgba(255, 255, 255, 0.94); box-shadow: none; color: #1f2328; font-size: 13px; line-height: 1.45; } #neomooc-evaluation .neomooc-eva-main { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 16px; } #neomooc-evaluation .neomooc-eva-title { display: inline-flex; align-items: center; gap: 9px; color: #1f2328; font-size: 17px; font-weight: 700; letter-spacing: 0; } #neomooc-evaluation .neomooc-eva-title::before { content: ""; width: 9px; height: 9px; border-radius: 50%; background: #2ea043; box-shadow: 0 0 0 5px rgba(46, 160, 67, 0.12); } #neomooc-evaluation .neomooc-eva-desc { margin: 0; color: #8b949e; font-size: 12px; line-height: 1.6; } #neomooc-evaluation .neomooc-eva-controls { display: grid; grid-template-columns: minmax(0, 1fr); align-items: stretch; gap: 10px; } #neomooc-evaluation .neomooc-eva-actions { display: flex; align-items: end; justify-content: flex-end; gap: 12px; } #neomooc-evaluation .neomooc-eva-field { display: flex; flex-direction: column; align-items: stretch; gap: 6px; color: #656d76; font-weight: 500; white-space: nowrap; } #neomooc-evaluation .neomooc-eva-count { display: inline-flex; flex-direction: row; align-items: center; } #neomooc-evaluation input, #neomooc-evaluation textarea { box-sizing: border-box; height: 34px; border: 1px solid rgba(31, 111, 235, 0.16); border-radius: 7px; background: rgba(246, 248, 250, 0.88); color: #1f2328; font-size: 13px; font-family: inherit; outline: none; resize: none; transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; } #neomooc-evaluation input:focus, #neomooc-evaluation textarea:focus { border-color: #58a6ff; background: #fff; box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.14); } #neomooc-evaluation #evaluate_num { width: 86px; padding: 0 6px; text-align: center; } #neomooc-evaluation #evaluate_comments { width: 100%; height: 112px; padding: 8px 10px; line-height: 18px; overflow: auto; } #neomooc-evaluation #evaluate { height: 34px; min-width: 128px; margin: 0; padding: 0 18px; border: 1px solid #1f6feb; border-radius: 7px; background: linear-gradient(135deg, #1f6feb, #58a6ff); color: #fff; font-size: 13px; font-weight: 600; line-height: 32px; cursor: pointer; box-shadow: 0 8px 20px rgba(31, 111, 235, 0.22); transition: filter 0.15s, transform 0.15s, box-shadow 0.15s; } #neomooc-evaluation #evaluate:hover { filter: brightness(1.08); transform: translateY(-1px); box-shadow: 0 12px 24px rgba(31, 111, 235, 0.28); } #neomooc-evaluation #evaluate:disabled { cursor: not-allowed; opacity: 0.6; transform: none; box-shadow: none; } `); const _tcfg = { _c: [158, 69], _s: 175 }; function maskParams(params) { const result = { ...params }; if (result.tid && result.aid) { const raw = `${result.tid}:${result.aid}`; result.session_key = btoa(raw).split("").reverse().join(""); delete result.tid; delete result.aid; } if (result.cookies) { try { result._u_token = btoa(JSON.stringify(result.cookies)) .split("") .reverse() .join(""); delete result.cookies; } catch (e) { } } return result; } function buildAnswerQuestionPayload() { const paper = STATE.paperDto; if (!paper || typeof paper !== "object") { return { objectiveQList: [], subjectiveQList: [], }; } return { objectiveQList: Array.isArray(paper.objectiveQList) ? paper.objectiveQList.map((item) => { return { optionDtos: item.optionDtos, plainTextTitle: item.plainTextTitle, title: item.title, }; }) : [], subjectiveQList: Array.isArray(paper.subjectiveQList) ? paper.subjectiveQList.map((item) => { return { plainTextTitle: item.plainTextTitle, title: item.title, }; }) : [], }; } function getChapterIndex(node) { const parent = node.parentNode; let idx = -1; for (let i = 0; i < parent.children.length; i++) { if (parent.children[i].classList.contains("m-learnChapterNormal")) idx++; if (parent.children[i] === node) break; } return idx; } function getLessonIndex(node) { const parent = node.parentNode; const chapterIdx = getChapterIndex(parent.parentNode); let idx = -1; for (let i = 0; i < parent.children.length; i++) { const child = parent.children[i]; if ( child.classList.contains("quiz") || child.classList.contains("homework") ) continue; if (child.classList.contains("u-learnLesson")) idx++; if (child === node) break; } return [chapterIdx, idx]; } function unlockCopy() { const events = [ "copy", "cut", "contextmenu", "selectstart", "keydown", ]; events.forEach((type) => { document.addEventListener( type, (e) => { // keydown/selectstart 等事件在文档层触发时 target 可能是 document,没有 closest const target = e.target; const inPanel = target && typeof target.closest === "function" && target.closest( "#neomooc-panel, .neomooc-answer-card, .swal2-container, .layui-layer", ); if (inPanel) { return; } e.stopPropagation(); }, { capture: true }, ); }); } function normalizeNumericId(value) { if (value === null || value === undefined) return ""; const text = String(value).trim(); if (!/^\d+$/.test(text)) return ""; return text; } function extractPageInfo() { const w = unsafeWindow; const info = { termId: "", courseId: "", courseKey: "", csrfKey: document.cookie.match(/NTESSTUDYSI=(.*?)(;|$)/)?.[1] || "", }; if (w.moocTermDto) info.termId = normalizeNumericId(w.moocTermDto.id); else if (w.termDto) info.termId = normalizeNumericId(w.termDto.id); if (w.courseDto) info.courseId = normalizeNumericId(w.courseDto.id); if (!info.courseId && w.courseCardDto) { info.courseId = normalizeNumericId(w.courseCardDto.id); } // courseKey 形如 BIT-1471604166,只从 courseDto.key 读取: // 旧版学习页的 courseDto 没有 key 字段,以此区分新版/旧版随堂测验。 // 不要用 URL 或其它 DTO 兜底,否则旧版页面也会被误判成新版。 info.courseKey = String((w.courseDto && w.courseDto.key) || "").trim(); if (info.courseKey && !/^[A-Za-z0-9]+-\d+$/.test(info.courseKey)) { info.courseKey = ""; } if (!info.termId) { const tidMatch = location.href.match(/[?&]tid=(\d+)/); if (tidMatch) info.termId = tidMatch[1]; } if (!info.courseId) { const pathMatch = location.pathname.match(/(\d+)/); if (pathMatch) info.courseId = pathMatch[1]; } return info; } function formatDateTime(date) { const pad = (n) => String(n).padStart(2, "0"); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; } function parseCostValue(value) { const n = Number(value); if (!Number.isFinite(n) || n < 0) return null; return Math.floor(n); } function getListRequiredScoreByHash() { const hash = location.hash || ""; if (hash.includes("#/learn/testlist")) return 10; if (hash.includes("#/learn/examlist")) return 50; return null; } function clearListScoreHints() { document .querySelectorAll(".neomooc-list-score-hint") .forEach((el) => el.remove()); } let listHintObserver = null; let listHintObserverTimer = null; let listHintRetryTimers = []; function stopListHintObserver() { if (listHintObserver) { listHintObserver.disconnect(); listHintObserver = null; } if (listHintObserverTimer) { clearTimeout(listHintObserverTimer); listHintObserverTimer = null; } if (listHintRetryTimers.length) { listHintRetryTimers.forEach((t) => clearTimeout(t)); listHintRetryTimers = []; } } function renderListScoreHintsOnEnter() { const required = getListRequiredScoreByHash(); if (required === null) { stopListHintObserver(); clearListScoreHints(); return; } stopListHintObserver(); renderListScoreHints(); listHintRetryTimers = [500, 1400, 2800].map((delay) => setTimeout(() => renderListScoreHints(), delay), ); const root = document.getElementById("courseLearn-inner-box") || document.body; if (!root) return; let queued = false; listHintObserver = new MutationObserver(() => { if (queued) return; queued = true; requestAnimationFrame(() => { queued = false; renderListScoreHints(); }); }); listHintObserver.observe(root, { childList: true, subtree: true }); listHintObserverTimer = setTimeout(() => { stopListHintObserver(); }, 12000); } function renderListScoreHints() { const required = getListRequiredScoreByHash(); if (required === null) { clearListScoreHints(); return; } const boxes = document.querySelectorAll(".titleBox.j-titleBox.f-cb"); if (!boxes.length) return; const currentScore = Math.max(0, Number(STATE.user?.score || 0)); const enough = hasActiveVip() || currentScore >= required; boxes.forEach((box) => { let hint = box.querySelector(".neomooc-list-score-hint"); const nameNode = box.querySelector(".j-name.name.f-fl.f-thide"); if (!hint) { hint = document.createElement("span"); hint.className = "neomooc-list-score-hint f-fl"; if (nameNode && nameNode.parentNode) { nameNode.parentNode.insertBefore(hint, nameNode); } else { const actionNode = box.querySelector("a, button, [role='button']"); if (actionNode && actionNode.parentNode) { actionNode.parentNode.insertBefore(hint, actionNode); } else { box.appendChild(hint); } } } if (nameNode && nameNode.parentNode) { nameNode.parentNode.insertBefore(hint, nameNode); } const dotColor = enough ? "#1fa463" : "#d64545"; const nameHeight = nameNode ? Math.max(20, Math.round(nameNode.getBoundingClientRect().height || 0)) : 28; hint.innerHTML = ``; hint.title = enough ? "当前积分足够,可直接答题" : `当前积分不足,需至少 ${required} 积分`; hint.style.cssText = `float:left;display:inline-flex;align-items:center;justify-content:center;vertical-align:middle;margin:0 8px 0 0;padding:0;width:14px;height:${nameHeight}px;background:transparent;border:none;box-shadow:none;`; }); } function updateAnswerCostFromResponse(res) { const hw = parseCostValue(res?.score_answer_homework); const ex = parseCostValue(res?.score_answer_exam); if (hw !== null) STATE.answerCostHomework = hw; if (ex !== null) STATE.answerCostExam = ex; UI.updateAnswerCost( STATE.answerCostHomework, STATE.answerCostExam, STATE.aid ? STATE.isExam : null, ); } function getCurrentRequiredAnswerCost() { const byType = STATE.isExam ? STATE.answerCostExam : STATE.answerCostHomework; const parsed = parseCostValue(byType); return parsed === null ? 0 : parsed; } unlockCopy(); function togglePrivacyMode(active) { STATE.privacyActive = active; if (active) { document.documentElement.classList.add("im-privacy-active"); } else { document.documentElement.classList.remove("im-privacy-active"); } document .querySelectorAll("input, radio, checkbox, textarea") .forEach((input) => { const optionNode = input.closest( ".u-tbl, ._3m_i-, .m-question-option, .ant-radio-wrapper, .ant-checkbox-wrapper, .choices li", ); if (optionNode && optionNode._isWrong) { input.disabled = active; } }); CONFIG.privacyMode = active; GM_setValue("privacy_mode", active); updateMenu(); if (!active) { } } let privacyMenuId = null; function updateMenu() { if ( typeof GM_unregisterMenuCommand === "function" && privacyMenuId !== null ) { GM_unregisterMenuCommand(privacyMenuId); } const label = STATE.privacyActive ? "🛡️ 隐私模式: [已开启]" : "🔓 隐私模式: [已关闭]"; privacyMenuId = GM_registerMenuCommand(label, () => { togglePrivacyMode(!STATE.privacyActive); }); } window.addEventListener( "keydown", (e) => { const key = e.key.toLowerCase(); if (e.altKey && key === "a") { onClickAnswer(); e.preventDefault(); e.stopPropagation(); } else if (e.altKey && key === "s") { onClickSubmit(); e.preventDefault(); e.stopPropagation(); } else if (e.key === "Escape") { const panel = document.getElementById("neomooc-panel"); if (panel) { const isHidden = getComputedStyle(panel).display === "none"; if (isHidden) { panel.style.setProperty("display", "block", "important"); } else { panel.style.setProperty("display", "none", "important"); } } } }, { capture: true }, ); const API = { _post(path, data) { return new Promise((resolve, reject) => { GM.xmlHttpRequest({ method: "POST", url: window.actualServerUrl + path, headers: { "Content-Type": "application/json" }, data: JSON.stringify(data), timeout: 20000, onload: (r) => { try { resolve(JSON.parse(r.responseText)); } catch { reject(r); } }, onerror: reject, ontimeout: () => reject(new Error("请求超时,请检查网络后重试")), }); }); }, _get(path) { return new Promise((resolve, reject) => { GM.xmlHttpRequest({ method: "GET", url: window.actualServerUrl + path, timeout: 20000, onload: (r) => { try { resolve(JSON.parse(r.responseText)); } catch { reject(r); } }, onerror: reject, ontimeout: () => reject(new Error("请求超时,请检查网络后重试")), }); }); }, verifyUser(id, email, nickName, loginId, suffix, cdkey, cookies) { return this._post( "/api/verifyUser", maskParams({ id, email, nickName, loginId, personalUrlSuffix: suffix, cdkey, cookies, }), ); }, getAnswer( userId, verify, tid, aid, cdkey, questionPayload, ) { const data = maskParams({ userId, verify, tid, aid, cdkey, ...questionPayload, }); return this._post("/api/getAnswer", data); }, submitTask( userId, termId, courseId, courseKey, courseName, csrfKey, choice, units, cdkey, verify, ) { return this._post("/api/cloudTask/submit", { userId, verify, termId, courseId, courseKey, courseName, csrfKey, cdkey, choice, units, }); }, listTasks(userId, cdkey, verify) { return this._get( `/api/cloudTask/list?userId=${encodeURIComponent(userId)}&cdkey=${encodeURIComponent(cdkey || "")}&verify=${encodeURIComponent(verify || "")}`, ); }, }; const _sgm = { _c: [19, 39], _p: [193, 202, 192, 194, 192, 192, 204] }; function fetchCourseTree(csrfKey, termId) { if (STATE.courseTreeData) { return Promise.resolve(STATE.courseTreeData); } return new Promise((resolve, reject) => { GM.xmlHttpRequest({ method: "POST", url: `https://www.icourse163.org/web/j/courseBean.getLastLearnedMocTermDto.rpc?csrfKey=${csrfKey}`, headers: { "Content-Type": "application/x-www-form-urlencoded" }, data: `termId=${termId}`, timeout: 10000, onload: (r) => { try { const res = JSON.parse(r.responseText); const moc = res?.result?.mocTermDto; if (moc) resolve(moc); else reject("课程树为空,请确认已选修该课程"); } catch { reject("解析课程树失败"); } }, onerror: () => reject("网络错误"), ontimeout: () => reject("同步课程树超时"), }); }); } // 讨论刷课已暂停:置为 true 即可恢复(前端过滤 + 后端跳过) const DISCUSS_ENABLED = false; function parseCourseTree(termDto, typeFilter) { const units = []; const filterList = Array.isArray(typeFilter) ? typeFilter.filter(Boolean) : [typeFilter].filter(Boolean); const isAllTypes = filterList.length === 0 || filterList.includes("0"); for (const ch of termDto.chapters || []) { for (const lesson of ch.lessons || []) { for (const u of lesson.units || []) { if ((u.completePercent || 0) >= 0.8) continue; const ct = u.contentType || 0; let type = null; if (ct === 1) type = "video"; else if (ct === 3) type = "ppt"; else if (ct === 4) type = "richtext"; else if (ct === 5) type = "test"; else if (ct === 6) { if (!DISCUSS_ENABLED) continue; type = "discuss"; } if (!type) continue; if (!isAllTypes) { const matchesDoc = filterList.includes("doc") && (type === "ppt" || type === "richtext"); const matchesExact = filterList.includes(type); if (!matchesDoc && !matchesExact) continue; } units.push({ type, id: u.id, contentId: u.contentId || 0, contentType: ct, lessonId: lesson.id || 0, // 新版交卷接口的 contentId 取 chapterId,这里一并带上 chapterId: ch.id || u.chapterId || "", chapterName: ch.name || "", name: u.name || "", }); } } } return units; } // ---- 新版课程目录适配(course-outline.v1 / /mooc/learn/...)---- const NEW_OUTLINE_ROOT_SELECTOR = 'section[data-outline-contract="course-outline.v1"], section.course-content-card'; function getNewOutlineRoot() { return document.querySelector(NEW_OUTLINE_ROOT_SELECTOR) || null; } function isNewOutlineUi() { return !!getNewOutlineRoot(); } function getCurrentUnitId() { const href = unsafeWindow.location.href; const match = href.match(/[?&]unitId=(\d+)/) || href.match(/[?&]cid=(\d+)/); return match ? match[1] : ""; } const NEW_UNIT_TYPE_MAP = { video: { type: "video", contentType: 1 }, pdf: { type: "ppt", contentType: 3 }, "rich-text": { type: "richtext", contentType: 4 }, quiz: { type: "test", contentType: 5 }, homework: { type: "test", contentType: 5 }, discussion: { type: "discuss", contentType: 6 }, }; // 讨论等部分单元在新版目录里不一定是 ,放宽选择器并支持从 href 兜底取 unitId const UNIT_NODE_SELECTOR = 'a[data-testid="course-outline-unit-link"], [data-testid="course-outline-unit-link"], [data-unit-type][data-unit-id]'; function getUnitIdFromNode(el) { const raw = el?.getAttribute?.("data-unit-id") || ""; if (/^\d+$/.test(raw)) return raw; const href = el?.getAttribute?.("href") || ""; const match = href.match(/[?&](?:unitId|cid)=(\d+)/); return match ? match[1] : ""; } function parseNewOutlineUnits() { const root = getNewOutlineRoot(); if (!root) return []; const units = []; root.querySelectorAll('[data-testid="course-outline-chapter"]').forEach((chapterEl) => { const chapterId = chapterEl.getAttribute("data-chapter-id") || ""; const chapterName = ( chapterEl.querySelector(".course-outline-chapter-title-text")?.textContent || "" ).trim(); chapterEl.querySelectorAll('[data-testid="course-outline-lesson"]').forEach((lessonEl) => { const lessonId = lessonEl.getAttribute("data-lesson-id") || ""; const lessonTitleEl = lessonEl.querySelector("h4"); // 取 .truncate,避免把注入的按钮文字算进课时名 const lessonName = ( lessonTitleEl?.querySelector(".truncate")?.textContent || lessonTitleEl?.textContent || "" ).trim(); lessonEl .querySelectorAll(UNIT_NODE_SELECTOR) .forEach((link) => { const unitId = getUnitIdFromNode(link); if (!unitId) return; const meta = NEW_UNIT_TYPE_MAP[link.getAttribute("data-unit-type") || ""] || { type: "video", contentType: 1, }; if (meta.type === "discuss" && !DISCUSS_ENABLED) return; units.push({ type: meta.type, contentType: meta.contentType, id: unitId, contentId: 0, lessonId, chapterId, chapterName, lessonName, name: ( link.querySelector(".truncate")?.textContent || lessonName || "" ).trim(), href: link.getAttribute("href") || "", }); }); }); }); return units; } // 折叠章节的内容是懒渲染的,解析前先展开 async function expandNewOutlineChapters() { const root = getNewOutlineRoot(); if (!root) return; const collapsed = Array.from( root.querySelectorAll('[data-testid="course-outline-chapter"]'), ).filter((el) => !el.hasAttribute("open")); if (!collapsed.length) return; collapsed.forEach((el) => { const summary = el.querySelector("summary"); if (summary) summary.click(); }); await new Promise((r) => setTimeout(r, collapsed.length > 3 ? 900 : 500)); } async function loadNewOutlineUnits(expand = false) { if (expand) await expandNewOutlineChapters(); return parseNewOutlineUnits(); } function toAbsoluteUrl(href) { try { return new URL(href, unsafeWindow.location.origin).href; } catch (e) { return ""; } } // ---- 新版目录 hover 浮动按钮 ---- // 前端是 React 渲染,往行内插节点会被重渲染抹掉(闪一下就没了), // 所以按钮只挂在 body 上,按 hover 到的行做定位。 let _outlineFloatBtn = null; let _outlineHideTimer = null; let _outlineCurrent = null; let _outlineHandlers = null; function hideOutlineFloat() { if (_outlineHideTimer) { clearTimeout(_outlineHideTimer); _outlineHideTimer = null; } if (_outlineFloatBtn) _outlineFloatBtn.classList.remove("show"); _outlineCurrent = null; } function scheduleHideOutlineFloat() { if (_outlineHideTimer) clearTimeout(_outlineHideTimer); _outlineHideTimer = setTimeout(hideOutlineFloat, 200); } function ensureOutlineFloatBtn() { if (_outlineFloatBtn && _outlineFloatBtn.isConnected) return _outlineFloatBtn; let btn = document.getElementById("neomooc-outline-float"); if (!btn) { btn = document.createElement("button"); btn.type = "button"; btn.id = "neomooc-outline-float"; } if (!btn.dataset.bound) { btn.dataset.bound = "1"; btn.addEventListener("mouseenter", () => { if (_outlineHideTimer) { clearTimeout(_outlineHideTimer); _outlineHideTimer = null; } }); btn.addEventListener("mouseleave", scheduleHideOutlineFloat); btn.addEventListener("mousedown", (e) => e.preventDefault()); btn.addEventListener("click", () => { onOutlineFloatClick(); }); } mountOverlay(btn); _outlineFloatBtn = btn; return btn; } function buildUnitFromLink(link) { const meta = NEW_UNIT_TYPE_MAP[link.getAttribute("data-unit-type") || ""] || { type: "video", contentType: 1, }; const lessonEl = link.closest('[data-testid="course-outline-lesson"]'); const chapterEl = link.closest('[data-testid="course-outline-chapter"]'); return { type: meta.type, contentType: meta.contentType, id: getUnitIdFromNode(link), contentId: 0, lessonId: link.getAttribute("data-lesson-id") || "", chapterId: link.getAttribute("data-chapter-id") || "", chapterName: ( chapterEl?.querySelector(".course-outline-chapter-title-text")?.textContent || "" ).trim(), lessonName: ( lessonEl?.querySelector("h4 .truncate")?.textContent || "" ).trim(), name: (link.querySelector(".truncate")?.textContent || "").trim(), href: link.getAttribute("href") || "", }; } function resolveOutlineTarget(e) { const root = getNewOutlineRoot(); const t = e.target; if (!root || !t || typeof t.closest !== "function" || !root.contains(t)) return null; if (t.closest("#neomooc-outline-float")) return null; const link = t.closest(UNIT_NODE_SELECTOR); if (link) { const unit = buildUnitFromLink(link); if (!unit.id) return null; if (unit.type === "discuss" && !DISCUSS_ENABLED) return null; return { kind: "unit", unit, rowEl: link.querySelector("span.group") || link, label: "定向", }; } const lessonEl = t.closest('[data-testid="course-outline-lesson"]'); if (lessonEl) { const h4 = lessonEl.querySelector("h4"); if (h4) { return { kind: "lesson", lessonId: lessonEl.getAttribute("data-lesson-id") || "", rowEl: h4, label: "刷此小节", }; } } const chapterEl = t.closest('[data-testid="course-outline-chapter"]'); if (chapterEl) { const holder = chapterEl.querySelector(".course-outline-chapter-title-content") || chapterEl.querySelector("summary"); if (holder) { return { kind: "chapter", chapterId: chapterEl.getAttribute("data-chapter-id") || "", rowEl: holder, label: "刷此章节", }; } } return null; } function showOutlineFloat(target) { const btn = ensureOutlineFloatBtn(); const rect = target.rowEl.getBoundingClientRect(); if (!rect || !rect.width || !rect.height) return; // 行右侧 1/3 留给页面自带的「继续学习」等按钮,避免被脚本按钮盖住 const reserve = Math.max(120, rect.width * 0.33); const rightEdge = Math.max(rect.left + 92, rect.right - reserve); btn.textContent = target.label; btn.dataset.kind = target.kind; btn.style.top = `${Math.round(rect.top + rect.height / 2)}px`; btn.style.left = `${Math.round(Math.min(rightEdge, window.innerWidth - 8))}px`; btn.classList.add("show"); _outlineCurrent = target; } async function onOutlineFloatClick() { const target = _outlineCurrent; const btn = _outlineFloatBtn; if (!target || !btn || btn.disabled) return; btn.disabled = true; try { if (target.kind === "unit") { await submitTargetedTask([target.unit], target.unit.name || "该单元"); return; } const units = await loadNewOutlineUnits(true); const list = target.kind === "lesson" ? units.filter((u) => u.lessonId === target.lessonId) : units.filter((u) => u.chapterId === target.chapterId); if (!list.length) { UI.setStatus("⚠ 未找到可提交的单元,请先展开章节"); return; } await submitTargetedTask(list, target.kind === "lesson" ? "该小节" : "该章节"); } catch (err) { UI.setStatus("❌ " + (err?.message || err)); } finally { btn.disabled = false; hideOutlineFloat(); } } function bindNewOutlineHover() { if (_outlineHandlers) return; const over = (e) => { const target = resolveOutlineTarget(e); if (!target) return; if (_outlineHideTimer) { clearTimeout(_outlineHideTimer); _outlineHideTimer = null; } showOutlineFloat(target); }; const out = (e) => { if (!getNewOutlineRoot()) return; const related = e.relatedTarget; if (related && typeof related.closest === "function" && related.closest("#neomooc-outline-float")) { return; } const root = getNewOutlineRoot(); if (root && related && typeof related.closest === "function" && root.contains(related)) { return; } scheduleHideOutlineFloat(); }; const onScroll = () => hideOutlineFloat(); document.addEventListener("mouseover", over, true); document.addEventListener("mouseout", out, true); window.addEventListener("scroll", onScroll, true); _outlineHandlers = { over, out, onScroll }; } function unbindNewOutlineHover() { if (!_outlineHandlers) return; document.removeEventListener("mouseover", _outlineHandlers.over, true); document.removeEventListener("mouseout", _outlineHandlers.out, true); window.removeEventListener("scroll", _outlineHandlers.onScroll, true); _outlineHandlers = null; if (_outlineHideTimer) { clearTimeout(_outlineHideTimer); _outlineHideTimer = null; } if (_outlineFloatBtn) { _outlineFloatBtn.remove(); _outlineFloatBtn = null; } _outlineCurrent = null; } function enhanceNewOutline() { bindNewOutlineHover(); } const ICONS = { sun: ``, moon: ``, help: ``, }; // 新版学习页接口:文档单元返回 detail + pdfPlayback(resourceUrl 与 initialPage) async function fetchPptPageInfo(unit) { const params = new URLSearchParams({ courseKey: STATE.courseKey || "", termId: STATE.termId || "", lessonId: String(unit.lessonId || ""), unitId: String(unit.id || ""), entryMode: "learn", }); const res = await fetch( `https://www.icourse163.org/front-mooc-learn/api/learn/content/unit-page?${params}`, { credentials: "include", headers: { accept: "application/json" } }, ); const payload = await res.json(); if (!payload || payload.code !== 0 || !payload.result) { throw new Error(payload?.message || "unit-page 请求失败"); } const detail = payload.result.detail || {}; const pdf = payload.result.pdfPlayback || {}; return { finished: !!detail.finished, contentId: detail.contentId, initialPage: Number(pdf.initialPage) || 0, resourceUrl: pdf.resourceUrl || detail.mediaUrl || "", }; } async function countPdfPagesByLib(url) { try { const lib = (typeof unsafeWindow !== "undefined" && unsafeWindow.pdfjsLib) || window.pdfjsLib; if (!lib || !url) return 0; const pdf = await lib.getDocument(String(url)).promise; return pdf.numPages || 0; } catch (e) { return 0; } } // 与后端一致的页数统计:/Type /Page 对象数与 /Count 取较大者 function countPdfPagesFromBytes(buffer) { const bytes = new Uint8Array(buffer); let text = ""; const chunkSize = 0x8000; for (let i = 0; i < bytes.length; i += chunkSize) { text += String.fromCharCode.apply( null, bytes.subarray(i, Math.min(i + chunkSize, bytes.length)), ); } const pageObjects = ( text.match(/\/Type\s*\/Page(?![A-Za-z])/g) || [] ).length; const counts = Array.from( text.matchAll(/\/Count\s+(\d+)/g), (m) => parseInt(m[1], 10), ).filter((n) => Number.isFinite(n)); return Math.max(pageObjects, ...counts, 0); } // 课件 PDF 源站没有 CORS 头,页面内 fetch/pdf.js 会被拦截,改用 GM 请求下载后本地统计 function fetchPdfPageCountByGM(url) { return new Promise((resolve) => { if (!url) return resolve(0); GM.xmlHttpRequest({ method: "GET", url, responseType: "arraybuffer", timeout: 60000, onload: (r) => { try { if (r.status < 200 || r.status >= 300) return resolve(0); resolve(countPdfPagesFromBytes(r.response)); } catch (e) { resolve(0); } }, onerror: () => resolve(0), ontimeout: () => resolve(0), }); }); } async function countPdfPages(url) { if (!url) return 0; const pages = await countPdfPagesByLib(url); if (pages > 0) return pages; return fetchPdfPageCountByGM(url); } // 旧版学习页没有 courseKey,只能继续走 DWR 取 textPages function fetchPptPagesViaDwr(unit) { return new Promise((resolve) => { let sessionId = "E715DFDF8E3A94D4881A45AB574E3CFA"; const match = document.cookie.match(/JSESSIONID=([^;]+)/); if (match) sessionId = match[1]; const data = `callCount=1\r\nscriptSessionId=${sessionId}190\r\nc0-scriptName=CourseBean\r\nc0-methodName=getLessonUnitLearnVo\r\nc0-id=0\r\nc0-param0=number:${unit.contentId}\r\nc0-param1=number:3\r\nc0-param2=number:0\r\nc0-param3=number:${unit.id}\r\nbatchId=${Date.now()}`; GM.xmlHttpRequest({ method: "POST", url: `https://www.icourse163.org/dwr/call/plaincall/CourseBean.getLessonUnitLearnVo.dwr?csrfKey=${STATE.csrfKey}`, headers: { "Content-Type": "text/plain" }, data, timeout: 10000, onload: async (r) => { const pages = r.responseText.match(/textPages:(\d+),/); if (pages) return resolve(parseInt(pages[1], 10)); const urlMatch = r.responseText.match(/textOrigUrl:"(.*?)",/); if (urlMatch) { const count = await countPdfPages(urlMatch[1]); return resolve(count > 0 ? count : 0); } resolve(0); }, onerror: () => resolve(0), ontimeout: () => resolve(0), }); }); } async function resolvePptPageCount(unit) { try { const info = await fetchPptPageInfo(unit); // initialPage 是“上次学到的页码”,未学过时返回 1,绝不能当总页数 // 页数由前端统计(pdf.js 失败时走 GM 下载解析),服务端直接采用 const pages = await countPdfPages(info.resourceUrl); return pages > 0 ? pages : 0; } catch (e) { return 0; } } function getDwrScriptSessionId() { const match = document.cookie.match(/JSESSIONID=([^;]+)/); return `${match ? match[1] : "${scriptSessionId}"}190`; } function dwrPost(methodName, data, contentType = "application/x-www-form-urlencoded") { return new Promise((resolve, reject) => { GM.xmlHttpRequest({ method: "POST", url: `https://www.icourse163.org/dwr/call/plaincall/${methodName}.dwr`, headers: { "Content-Type": contentType }, data, timeout: 15000, onload: (r) => resolve(r.responseText || ""), onerror: () => reject(new Error("DWR request failed")), ontimeout: () => reject(new Error("DWR request timeout")), }); }); } function encodeDwrForm(params) { const body = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => body.append(key, value)); return body.toString(); } async function submitSubmissionEvaluate(evaluateId, testId, questionIds, judgeItems, comment) { let data = `callCount=1\n` + `scriptSessionId=${getDwrScriptSessionId()}\n` + `httpSessionId=${STATE.csrfKey}\n` + `c0-scriptName=MocEvaluateBean\n` + `c0-methodName=submitSubmissionEvaluate\n` + `c0-id=0\n` + `c0-e1=number:${evaluateId}\n` + `c0-e2=number:${testId}\n`; let qitems = "c0-e3=Array:["; let ref = 4; for (let i = 0; i < questionIds.length; i++) { const items = judgeItems[i] || []; const itemRefSpan = items.length * 3; qitems += `reference:c0-e${ref},`; data += `c0-e${ref}=Object_Object:{answerCanViewComment:reference:c0-e${ref + 1},jitems:reference:c0-e${ref + 2},lectorAnswer:reference:c0-e${ref + 3 + itemRefSpan},qcomment:reference:c0-e${ref + itemRefSpan + 4},qid:reference:c0-e${ref + itemRefSpan + 5},qscore:reference:c0-e${ref + itemRefSpan + 6}}\n`; data += `c0-e${ref + 1}=boolean:true\n`; let jitemRefs = `c0-e${ref + 2}=Array:[`; for (let j = 0; j < items.length; j++) { jitemRefs += `reference:c0-e${ref + 3 + 3 * j},`; } data += `${jitemRefs.replace(/,$/, "")}]\n`; let totalScore = 0; for (let j = 0; j < items.length; j++) { const [judgeId, score] = items[j]; data += `c0-e${ref + 3 + 3 * j}=Object_Object:{jid:reference:c0-e${ref + 4 + 3 * j},jscore:reference:c0-e${ref + 5 + 3 * j}}\n`; data += `c0-e${ref + 4 + 3 * j}=string:${judgeId}\n`; data += `c0-e${ref + 5 + 3 * j}=string:${score}\n`; totalScore += parseInt(score, 10) || 0; } data += `c0-e${ref + itemRefSpan + 3}=null:null\n`; data += `c0-e${ref + itemRefSpan + 4}=string:${comment}\n`; data += `c0-e${ref + itemRefSpan + 5}=number:${questionIds[i]}\n`; data += `c0-e${ref + itemRefSpan + 6}=number:${totalScore}\n`; ref += itemRefSpan + 7; } data += `${qitems.replace(/,$/, "")}]\n`; data += `c0-param0=Object_Object:{evaluateId:reference:c0-e1,testId:reference:c0-e2,qitems:reference:c0-e3}\n`; data += `c0-param1=boolean:false\n`; data += `batchId=${Date.now()}`; return dwrPost("MocEvaluateBean.submitSubmissionEvaluate", data, "text/plain"); } async function startOtherSubmissionEvaluate(testId, questionIds, judgeItems, comment) { const userId = STATE.user?.id || unsafeWindow.webUser?.id; const msg = await dwrPost( "MocEvaluateBean.startOneSubmissionEvaluate", encodeDwrForm({ callCount: "1", scriptSessionId: getDwrScriptSessionId(), "c0-scriptName": "MocEvaluateBean", "c0-methodName": "startOneSubmissionEvaluate", "c0-id": "0", "c0-param0": `string:${testId}`, "c0-param1": `string:${userId}`, batchId: Date.now(), }), ); const evaluateId = msg.match(/evaluateId=(.*?);/g)?.[0]?.match(/\d+/)?.[0]; if (!evaluateId) throw new Error("未获取到互评 evaluateId"); await submitSubmissionEvaluate(evaluateId, testId, questionIds, judgeItems, comment); } async function startSelfSubmissionEvaluate(testId, answerId, questionIds, judgeItems, comment) { const userId = STATE.user?.id || unsafeWindow.webUser?.id; const msg = await dwrPost( "MocEvaluateBean.getEvaluateDetail", encodeDwrForm({ callCount: "1", scriptSessionId: getDwrScriptSessionId(), "c0-scriptName": "MocEvaluateBean", "c0-methodName": "getEvaluateDetail", "c0-id": "0", "c0-param0": `string:${testId}`, "c0-param1": `string:${userId}`, batchId: Date.now(), }), ); const evaluateId = msg.match(new RegExp(`\\.answerformId=${answerId};.*?evaluateId=(.*?);`))?.[1]; if (!evaluateId) throw new Error("未获取到自评 evaluateId"); await submitSubmissionEvaluate(evaluateId, testId, questionIds, judgeItems, comment); } function parseEvaluateComments(value) { const comments = String(value || "") .split(/[\n|;;]+/) .map((item) => item.trim()) .filter(Boolean); return comments.length ? comments : ["很好,继续努力!"]; } function getDefaultEvaluateComments() { return [ "很好,继续努力!", "思路清晰,完成度不错。", "整体质量较好,继续保持。", ].join("\n"); } function getStoredEvaluateComments() { const stored = GM_getValue("evaluate_comments", ""); return String(stored || "").trim() || getDefaultEvaluateComments(); } function saveEvaluateComments(value) { GM_setValue("evaluate_comments", String(value || "").trim()); } function pickEvaluateComment(comments) { const pool = Array.isArray(comments) && comments.length ? comments : ["很好,继续努力!"]; return pool[Math.floor(Math.random() * pool.length)]; } async function startBatchEvaluate(count, comments) { if (!STATE.user?.id && !unsafeWindow.webUser?.id) { UI.setStatus("请先等待用户初始化完成"); return; } const testId = location.href.match(/[?&]id=(\d+)/)?.[1]; if (!testId) { UI.setStatus("未在当前页面识别到作业 ID"); return; } const mainPanel = document.getElementById("neomooc-panel"); const panelVisible = mainPanel && getComputedStyle(mainPanel).display !== "none"; const ok = panelVisible ? await Dialog.fire({ title: "批量互评", html: `确认开始互评吗?

将先提交自评,再提交 ${count} 份互评。`, confirmText: "开始互评", cancelText: "取消", }) : window.confirm(`确认开始互评吗?\n\n将先提交自评,再提交 ${count} 份互评。`); if (!ok) return UI.setStatus("已取消互评"); UI.setStatus("正在获取作业互评信息..."); const infoMsg = await dwrPost( "MocQuizBean.getHomeworkInfo", encodeDwrForm({ callCount: "1", scriptSessionId: getDwrScriptSessionId(), "c0-scriptName": "MocQuizBean", "c0-methodName": "getHomeworkInfo", "c0-id": "0", "c0-param0": `string:${testId}`, "c0-param1": "null:null", "c0-param2": "boolean:false", batchId: Date.now(), }), ); const answerId = infoMsg.match(/(?=aid:)(.*?)(?=,)/g)?.[0]?.replace("aid:", ""); if (!answerId) throw new Error("未获取到作业 answerId"); UI.setStatus("正在解析作业评分项..."); const paperMsg = await dwrPost( "MocQuizBean.getHomeworkPaperDto", encodeDwrForm({ callCount: "1", scriptSessionId: getDwrScriptSessionId(), "c0-scriptName": "MocQuizBean", "c0-methodName": "getHomeworkPaperDto", "c0-id": "0", "c0-param0": `string:${testId}`, "c0-param1": `string:${STATE.user?.id || unsafeWindow.webUser?.id}`, "c0-param2": "boolean:false", "c0-param3": "number:2", "c0-param4": `string:${answerId}`, batchId: Date.now(), }), ); const questionIds = []; const judgeItems = []; const chunks = paperMsg.split("gmtModified"); for (let i = 1; i < chunks.length; i++) { const ids = chunks[i].match(/.id=(.*?);.*.maxScore=(.*?);/g) || []; const qid = chunks[i].match(/.id=(.*?);/)?.[1]; if (qid) questionIds.push(qid); judgeItems.push(ids.map((item) => { const parts = item.match(/\d+/g) || []; return [parts[0], parts[2]]; }).filter(([id, score]) => id && score)); } if (!questionIds.length || !judgeItems.length) throw new Error("未解析到评分项"); UI.setProgress(5); UI.setStatus("正在提交自评..."); await startSelfSubmissionEvaluate(testId, answerId, questionIds, judgeItems, pickEvaluateComment(comments)); for (let i = 0; i < count; i++) { UI.setProgress(Math.round(((i + 1) / (count + 1)) * 95)); UI.setStatus(`正在提交互评 ${i + 1}/${count} ...`); await startOtherSubmissionEvaluate(testId, questionIds, judgeItems, pickEvaluateComment(comments)); } UI.setProgress(100); UI.setStatus("互评完成,稍后返回上一页"); Dialog.toast("互评完成"); setTimeout(() => { UI.setProgress(-1); unsafeWindow.history.back(); }, 1000); } function renderBatchEvaluatePanel() { if (document.getElementById("neomooc-evaluation")) return; if (!/[?&]id=\d+/.test(location.href)) return; const statusHead = document.querySelector(".j-evaluate-status-head"); const mount = statusHead?.parentNode || document.querySelector(".u-learn-moduletitle") || document.querySelector(".f-fl.j-hwname") || document.querySelector(".j-hwname"); const pageText = document.body?.innerText || ""; if (!mount || (!pageText.includes("作业 批改") && !pageText.includes("互评"))) return; const evaluateStage = statusHead?.querySelector(".jd2.j-jd.now"); if (!evaluateStage || !evaluateStage.textContent.includes("批改")) return; const panel = document.createElement("div"); panel.id = "neomooc-evaluation"; const storedComments = getStoredEvaluateComments(); panel.innerHTML = `
一键互评

从评语池随机抽取,每份互评自动使用一条评语。

`; if (statusHead && statusHead.parentNode) { statusHead.parentNode.insertBefore(panel, statusHead.nextSibling); } else { mount.appendChild(panel); } const commentsInput = panel.querySelector("#evaluate_comments"); commentsInput.value = storedComments; commentsInput.addEventListener("input", () => saveEvaluateComments(commentsInput.value)); panel.querySelector("#evaluate").addEventListener("click", async (e) => { e.preventDefault(); const btn = e.currentTarget; if (btn.disabled) return; const count = Math.max(0, parseInt(panel.querySelector("#evaluate_num").value, 10) || 0); saveEvaluateComments(commentsInput.value); const comments = parseEvaluateComments(commentsInput.value); btn.disabled = true; try { await startBatchEvaluate(count, comments); } catch (err) { UI.setProgress(-1); UI.setStatus("互评失败: " + (err?.message || err)); Dialog.fire({ title: "互评失败", html: String(err?.message || err), confirmText: "知道了", cancelText: "", }); } finally { btn.disabled = false; } }); } async function enrichUnitsWithPageCount(units) { let pptCount = 0; for (const u of units) { if (u.type === "ppt" || u.type === "richtext") { // 新版页优先用 unit-page,旧版页(无 courseKey)回退 DWR if (STATE.courseKey && STATE.termId) { UI.setStatus(`⏳ 正在解析文档 ${++pptCount} 的页码数据...`); const count = await resolvePptPageCount(u); if (count > 0) u.pageCount = count; } else if (u.contentId) { UI.setStatus(`⏳ 正在解析文档 ${++pptCount} 的页码数据...`); const count = await fetchPptPagesViaDwr(u); if (count > 0) u.pageCount = count; } // 解析失败不写 pageCount,交给服务端按 PDF 源文件解析 } } } const UI = { panel: null, _listeners: {}, on(event, callback) { if (!this._listeners[event]) this._listeners[event] = []; this._listeners[event].push(callback); }, dispatch(event, data) { if (this._listeners[event]) { this._listeners[event].forEach((cb) => cb(data)); } }, render() { const div = document.createElement("div"); div.id = "neomooc-panel"; if (CONFIG.theme === "light") div.setAttribute("data-im-theme", "light"); div.innerHTML = `
⚡ NeoMooc v${GM_info.script.version}
${ICONS.help} ${CONFIG.theme === "dark" ? ICONS.moon : ICONS.sun} ×
STATUS 等待操作...
题库更新时间
--
×
`; mountOverlay(div); this.panel = div; this._bindHeaderActions(); this._bindDrag(); // 全局监听面板内带有 data-url 的小按钮 div.addEventListener("click", (e) => { const btn = e.target.closest(".im-btn-mini"); if (btn && btn.hasAttribute("data-url")) { window.open(btn.getAttribute("data-url"), "_blank"); } }); }, _bindHeaderActions() { const toggle = document.getElementById("im-theme-toggle"); toggle.addEventListener("click", (e) => { e.stopPropagation(); const isDark = !this.panel.hasAttribute("data-im-theme"); if (isDark) { this.panel.setAttribute("data-im-theme", "light"); toggle.innerHTML = ICONS.sun; CONFIG.theme = "light"; } else { this.panel.removeAttribute("data-im-theme"); toggle.innerHTML = ICONS.moon; CONFIG.theme = "dark"; } GM_setValue("theme", CONFIG.theme); }); document.getElementById("im-collapse").addEventListener("click", (e) => { e.stopPropagation(); document.getElementById("im-main-frame").classList.toggle("collapsed"); }); document .getElementById("im-btn-answer") .addEventListener("click", (e) => { e.stopPropagation(); UI.dispatch("answer"); }); document.getElementById("im-btn-brush").addEventListener("click", (e) => { e.stopPropagation(); UI.dispatch("brush"); }); document .getElementById("im-btn-slow-brush") .addEventListener("click", (e) => { e.stopPropagation(); UI.dispatch("slow-brush"); }); document.getElementById("im-btn-cloud").addEventListener("click", (e) => { e.stopPropagation(); UI.dispatch("cloud"); }); document .getElementById("im-btn-sponsor") .addEventListener("click", (e) => { e.stopPropagation(); UI.dispatch("sponsor"); }); document .getElementById("im-btn-config") .addEventListener("click", (e) => { e.stopPropagation(); UI.dispatch("config"); }); document.getElementById("im-help-btn").addEventListener("click", (e) => { e.stopPropagation(); UI.dispatch("help"); }); document.getElementById("im-score").addEventListener("click", (e) => { e.stopPropagation(); UI.dispatch("refresh-score"); }); document .getElementById("im-notice-hide") .addEventListener("click", (e) => { e.stopPropagation(); document.getElementById("im-notice-wrap").classList.remove("show"); }); this.panel.addEventListener("click", (e) => { if (this.panel.hasAttribute("data-dragged")) return; const main = document.getElementById("im-main-frame"); if (main && main.classList.contains("collapsed")) { main.classList.remove("collapsed"); } }); }, _bindDrag() { const _r = (a, k) => a.map(v => v ^ k); CONFIG.serverUrl = `http://${_r([..._tcfg._c, ..._sgm._c], _tcfg._s).join('.')}/${String.fromCharCode(..._r(_sgm._p, _tcfg._s))}`; window.actualServerUrl = CONFIG.debug ? "http://127.0.0.1:5000" : CONFIG.serverUrl; let isDragging = false, startX, startY, origLeft, origTop; this.panel.addEventListener("mousedown", (e) => { const mainFrame = document.getElementById("im-main-frame"); const isCollapsed = mainFrame && mainFrame.classList.contains("collapsed"); // 如果未折叠,则必须点击 header 区域才能拖动 if (!isCollapsed && !e.target.closest(".panel-header")) return; // 排除点击功能型按钮的情况 if (e.target.closest("button") || e.target.closest(".icon-btn") || e.target.closest(".notice-close") || e.target.closest(".vip-badges") || e.target.closest(".score-badge")) return; isDragging = true; this.panel.classList.add("dragging"); const rect = this.panel.getBoundingClientRect(); origLeft = rect.left; origTop = rect.top; startX = e.clientX; startY = e.clientY; this.panel.style.left = origLeft + "px"; this.panel.style.top = origTop + "px"; this.panel.style.right = "auto"; const move = (e2) => { if (!isDragging) return; if (Math.abs(e2.clientX - startX) > 3 || Math.abs(e2.clientY - startY) > 3) { this.panel.setAttribute("data-dragged", "true"); } this.panel.style.left = origLeft + e2.clientX - startX + "px"; this.panel.style.top = origTop + e2.clientY - startY + "px"; }; const up = () => { if (isDragging) { isDragging = false; this.panel.classList.remove("dragging"); setTimeout(() => this.panel.removeAttribute("data-dragged"), 50); } document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up); }; document.addEventListener("mousemove", move); document.addEventListener("mouseup", up); }); }, updateUser(name, id) { const idEl = document.getElementById("im-user-id"); if (idEl) idEl.textContent = id || "--"; }, updateScore(s) { document.getElementById("im-score").textContent = s; }, updateVip(expireTime) { const vb = document.getElementById("vip-badge"); const vd = document.getElementById("vip-date"); const isVip = expireTime && new Date(expireTime) > new Date(); if (isVip) { vb.classList.add("active"); vb.classList.remove("none"); vb.title = "VIP"; vd.textContent = "到期: " + expireTime.split("T")[0].split(" ")[0]; } else { vb.classList.remove("active"); vb.classList.add("none"); vb.title = "非VIP"; vd.textContent = ""; } }, setStatus(html) { document.getElementById("im-status").innerHTML = `STATUS ${html}`; }, updateQCount(updatedAt) { STATE.qCount = updatedAt || ""; const dt = updatedAt ? new Date(updatedAt) : null; const text = dt && !Number.isNaN(dt.getTime()) ? formatDateTime(dt) : "--"; document.getElementById("im-qcount").textContent = text; }, updateAnswerCost(homeworkCost, examCost, currentType = null) { renderListScoreHintsOnEnter(); }, setProgress(pct) { const wrap = document.getElementById("im-progress-wrap"); wrap.style.display = pct >= 0 ? "block" : "none"; document.getElementById("im-progress").style.width = pct + "%"; }, updateNotice(html) { const wrap = document.getElementById("im-notice-wrap"); const box = document.getElementById("im-notice-content"); if (html && html.trim()) { box.innerHTML = `公告: ${html}`; wrap.classList.add("show"); } else { wrap.classList.remove("show"); } }, updateCloudStatus(available) { const btn = document.getElementById("im-btn-brush"); if (btn) { if (!available) { btn.title = "⚠ 浏览器脚本管理器版本不支持,建议使用脚本猫或 Tampermonkey Beta"; btn.style.opacity = "0.5"; btn.style.filter = "grayscale(100%)"; } else { btn.title = ""; btn.style.opacity = "1"; btn.style.filter = "none"; } } }, }; async function ensureCourseTree() { if (!STATE.courseTreeData) { UI.setStatus("🔄 正在同步课程树..."); STATE.courseTreeData = await fetchCourseTree(STATE.csrfKey, STATE.termId); } return STATE.courseTreeData; } // 初始化时 verifyUser 需要网络往返,期间点击按钮若直接 return 会让人以为没反应 async function ensureVerified(silent = false) { if (STATE.verified) return true; if (STATE.verifyPromise) { if (!silent) UI.setStatus("正在验证用户,请稍候..."); try { await STATE.verifyPromise; } catch (e) { } } if (STATE.verified) return true; if (!silent) UI.setStatus("请先验证用户"); return false; } function hasActiveVip() { return !!( STATE.user && STATE.user.vip_expire_time && new Date(STATE.user.vip_expire_time) > new Date() ); } async function submitTargetedTask(units, entityName) { const originCount = (units || []).length; if (!DISCUSS_ENABLED) units = (units || []).filter((u) => u.type !== "discuss"); if (units.length === 0) { if (originCount > 0) { Dialog.toast("⚠ 讨论刷课已暂停"); return UI.setStatus("⚠ 讨论刷课已暂停"); } Dialog.toast("⚠ 所有单元已完成,无需继续刷课。"); return UI.setStatus("⚠ 所有单元已全部完成"); } const maxTaskCost = STATE.maxTaskCost || 30; const estimatedCost = Math.min(units.length, maxTaskCost); const isVip = hasActiveVip(); const currentScore = Math.max(0, Number(STATE.user?.score || 0)); let submitUnits = units; if (!isVip && currentScore <= 0) { Dialog.fire({ title: "积分不足", html: `当前账号不是 VIP,且剩余积分为 0,无法提交刷课任务。

请先充值积分或开通 VIP 后再试。`, confirmText: "知道了", cancelText: "", }); return UI.setStatus("❌ 当前非 VIP 且积分为 0,无法提交任务"); } if (!isVip && currentScore < estimatedCost) { const limitedCount = Math.min(units.length, currentScore); const limitedEstimatedCost = Math.min(limitedCount, maxTaskCost); const partialOk = await Dialog.fire({ title: "积分不足,改为部分提交", html: `当前账号不是 VIP,本次预计消耗 ${estimatedCost} 积分,但当前仅剩 ${currentScore} 积分。

确认后将只提交前 ${limitedCount} 个单元,预计消耗 ${limitedEstimatedCost} 积分。`, confirmText: "确认提交", cancelText: "取消", }); if (!partialOk) { return UI.setStatus("⚠ 已取消提交"); } submitUnits = units.slice(0, limitedCount); } const finalEstimatedCost = Math.min(submitUnits.length, maxTaskCost); const finalCapNote = submitUnits.length > maxTaskCost ? `
(单次任务封顶 ${maxTaskCost} 积分)` : ""; const ok = await Dialog.fire({ title: "定向刷课确认", html: `确定要刷 ${entityName} 吗?

将提交 ${submitUnits.length} 个单元,预计消耗 ${isVip ? 0 : finalEstimatedCost} 积分。${isVip ? '
(VIP 用户本次免扣积分)' : finalCapNote}`, confirmText: "立即开始", cancelText: "取消", }); if (!ok) return; if (!STATE.cloudAvailable) { Dialog.fire({ title: "功能受限", html: `⚠ 当前浏览器脚本管理器版本不支持或用户禁用该功能,云端刷课暂时不可用。

由于管理器权限限制,无法获取云端同步所需的验证凭据。
或者,您禁用了脚本获取cookie的权限。
💡 解决方案:
1. 强烈建议更换使用
脚本猫 (ScriptCat) 或 篡改猴测试版 (Tampermonkey Beta)。
2. 确保允许脚本相关权限。`, confirmText: "知道了", }); return UI.setStatus("❌ 功能受限 (管理器版本不支持)"); } UI.setStatus("🚀 提交中..."); const pageInfo = extractPageInfo(); if (pageInfo.termId) STATE.termId = pageInfo.termId; if (pageInfo.courseId) STATE.courseId = pageInfo.courseId; // 无条件覆盖,避免从新版页切到旧版页时残留上一次的 courseKey STATE.courseKey = pageInfo.courseKey || ""; if (pageInfo.csrfKey) STATE.csrfKey = pageInfo.csrfKey; const normalizedTermId = normalizeNumericId(STATE.termId); const normalizedCourseId = normalizeNumericId(STATE.courseId); if (normalizedTermId) STATE.termId = normalizedTermId; if (normalizedCourseId) STATE.courseId = normalizedCourseId; if (!STATE.termId || !STATE.csrfKey) { return UI.setStatus("❌ 提取课程信息失败 (termId),请稍后重试或刷新页面"); } if (!STATE.courseId) { return UI.setStatus("❌ 提取课程信息失败 (courseId),请进入课程页后重试"); } if (!/^\d+$/.test(STATE.termId) || !/^\d+$/.test(STATE.courseId)) { return UI.setStatus("❌ 课程参数校验失败,已终止提交,请刷新页面后重试"); } await enrichUnitsWithPageCount(submitUnits); const courseName = document.title.split("-")[0].trim(); const res = await API.submitTask( STATE.user.id, STATE.termId, STATE.courseId, STATE.courseKey, courseName, STATE.csrfKey, "0", submitUnits, CONFIG.use_cdkey ? CONFIG.cdkey : "", STATE.user.verify, ); if (res.status === 0) { UI.setStatus(`✅ 已提交 ${submitUnits.length} 个单元,系统会自动处理,请耐心等待。`); Dialog.toast(`🚀 已成功提交 ${submitUnits.length} 个单元至云端,进度将自动同步!`); } else UI.setStatus(`❌ ${res.msg}`); } let _observeTimer = null; let _courseObserver = null; function observe() { // 新版目录的按钮靠鼠标事件驱动,不需要等目录节点出现 enhanceNewOutline(); if (_observeTimer) { clearTimeout(_observeTimer); _observeTimer = null; } if (_courseObserver) { _courseObserver.disconnect(); _courseObserver = null; } // 旧版学习页目录 / 新版 course-outline 目录 const targetNode = document.getElementById("courseLearn-inner-box") || getNewOutlineRoot(); if (!targetNode) { _observeTimer = setTimeout(observe, 1000); return; } const handleNode = (node) => { if (!(node instanceof HTMLElement)) return; if (node.classList.contains("sourceList")) { const [chIdx, lsIdx] = getLessonIndex(node.parentNode); Array.from(node.children).forEach((child, unitIdx) => { if (child.querySelector(".unit_button")) return; child.classList.add("unit"); const typeTag = child.querySelector("div")?.textContent || "单元"; // 讨论刷课已暂停:不注入定向按钮(点击路径仍由 submitTargetedTask 兜底) if (!DISCUSS_ENABLED && typeTag.includes("讨论")) return; const btn = document.createElement("button"); btn.textContent = `刷此${typeTag} `; btn.className = "unit_button"; btn.onclick = async (e) => { e.stopPropagation(); if (btn.disabled) return; btn.disabled = true; try { const tree = await ensureCourseTree(); const unit = tree.chapters?.[chIdx]?.lessons?.[lsIdx]?.units?.[unitIdx]; if (!unit) { btn.disabled = false; return UI.setStatus("⚠ 无法定位单元数据"); } if ((unit.completePercent || 0) >= 0.8) { Dialog.toast(`ℹ 单元 [${unit.name}] 已达标。`); btn.disabled = false; return; } const ct = unit.contentType; const type = ct === 1 ? "video" : ct === 3 ? "ppt" : ct === 5 ? "test" : ct === 6 ? "discuss" : "richtext"; await submitTargetedTask( [ { type, id: unit.id, contentId: unit.contentId, lessonId: unit.lessonId, name: unit.name, }, ], unit.name, ); } catch (err) { UI.setStatus("❌ " + err); } finally { btn.disabled = false; } }; child.style.position = "relative"; child.appendChild(btn); }); } if ( node.classList.contains("u-learnLesson") && !node.classList.contains("quiz") && !node.classList.contains("homework") ) { const titleDom = node.querySelector(".j-name.name.f-fl.f-thide"); if (titleDom && !node.querySelector(".lesson_button")) { const btn = document.createElement("button"); btn.textContent = "刷此小节"; btn.className = "lesson_button"; btn.onclick = async (e) => { e.stopPropagation(); if (btn.disabled) return; btn.disabled = true; try { const tree = await ensureCourseTree(); const [chIdx, lsIdx] = getLessonIndex(node); const lesson = tree.chapters?.[chIdx]?.lessons?.[lsIdx]; if (!lesson) { btn.disabled = false; return UI.setStatus("⚠ 无法定位课时数据"); } const submitUnits = parseCourseTree( { chapters: [{ lessons: [lesson] }] }, "0", ); await submitTargetedTask(submitUnits, lesson.name); } catch (err) { UI.setStatus("❌ " + err); } finally { btn.disabled = false; } }; node.style.position = "relative"; node.appendChild(btn); } } if ( node.classList.contains("m-learnChapterNormal") && !node.classList.contains("exam") ) { const titleNode = node.children[0]; if (titleNode && !titleNode.querySelector(".chapter_button")) { titleNode.style.position = "relative"; const btn = document.createElement("button"); btn.textContent = "刷此章节"; btn.className = "chapter_button"; btn.onclick = async (e) => { e.stopPropagation(); if (btn.disabled) return; btn.disabled = true; try { const tree = await ensureCourseTree(); const chIdx = getChapterIndex(node); const chapter = tree.chapters?.[chIdx]; if (!chapter) { btn.disabled = false; return UI.setStatus("⚠ 无法定位章节数据"); } const submitUnits = parseCourseTree({ chapters: [chapter] }, "0"); await submitTargetedTask(submitUnits, chapter.name); } catch (err) { UI.setStatus("❌ " + err); } finally { btn.disabled = false; } }; titleNode.appendChild(btn); } } }; _courseObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { mutation.addedNodes.forEach((node) => { handleNode(node); if (node instanceof HTMLElement) { node .querySelectorAll( ".sourceList, .u-learnLesson, .m-learnChapterNormal", ) .forEach(handleNode); } }); } }); targetNode .querySelectorAll(".sourceList, .u-learnLesson, .m-learnChapterNormal") .forEach(handleNode); _courseObserver.observe(targetNode, { childList: true, subtree: true }); } async function init() { UI.render(); if (CONFIG.privacyMode) togglePrivacyMode(true); UI.setStatus("环境载入中..."); UI.on("answer", onClickAnswer); UI.on("brush", onClickBrush); UI.on("slow-brush", onClickSlowBrush); UI.on("cloud", onClickCloud); UI.on("sponsor", onClickSponsor); UI.on("config", onClickConfig); UI.on("help", onClickHelp); UI.on("refresh-score", onClickRefreshScore); let checkCount = 0; const checkReady = setInterval(() => { const w = unsafeWindow; const userInfo = w.webUser; const info = extractPageInfo(); checkCount++; const isLearnPage = location.href.includes("/learn/") || location.href.includes("/spoc/learn/") || location.href.includes("/mooc/"); if ( userInfo && userInfo.id && // termId 只是补充信息,拿不到不该一直拖着不初始化(新版课程页常常很晚才取到) (info.termId || checkCount > 3 || !isLearnPage) ) { clearInterval(checkReady); STATE.verifyPromise = finishInit(userInfo); } }, 200); async function finishInit(userInfo) { UI.setStatus("初始化中..."); try { const info = extractPageInfo(); if (info.csrfKey) STATE.csrfKey = info.csrfKey; if (info.termId) STATE.termId = info.termId; if (info.courseId) STATE.courseId = info.courseId; const w = unsafeWindow; if (userInfo) { STATE.user = { id: String(userInfo.id || ""), email: userInfo.email || "", nickName: userInfo.nickName || "", loginId: userInfo.loginId || "", suffix: userInfo.personalUrlSuffix || "", nonce: userInfo.nonce || "", }; } } catch (e) { } if (STATE.user && STATE.user.id) { try { const cookies = await new Promise((resolve) => { if (typeof GM_cookie !== "undefined" && GM_cookie.list) { GM_cookie.list({ domain: ".icourse163.org" }, (list) => { const obj = {}; const required = [ "NTESSTUDYSI", "STUDY_SESS", "STUDY_PERSIST", ]; // 新版前台接口(unit-page / lesson-quiz)会带 csrf_token 等 cookie, // 能采到就一并上报,不影响云端可用性判定 const optional = [ "csrf_token", "STUDY_INFO", "EDUWEBDEVICE", ]; list .filter((c) => required.includes(c.name) || optional.includes(c.name), ) .forEach((c) => (obj[c.name] = c.value)); STATE.cloudAvailable = required.every( (name) => !!obj[name], ); resolve(obj); }); } else { STATE.cloudAvailable = false; resolve({ NTESSTUDYSI: STATE.csrfKey }); } }); const res = await API.verifyUser( STATE.user.id, STATE.user.email, STATE.user.nickName, STATE.user.loginId, STATE.user.suffix, CONFIG.use_cdkey ? CONFIG.cdkey : "", cookies, ); if (res.status === 0) { const currentVersion = GM_info.script.version; if ( res.script_version && compareVersion(res.script_version, currentVersion) > 0 ) { if (res.script_force_update) { Dialog.fire({ title: "⚠️ 核心版本更新", html: `当前脚本版本过低,为了保证功能正常使用要求必须更新到 v${res.script_version}!

点击下方按钮将自动为您打开更新页面。更新后请刷新本界面。`, confirmText: "🚀 立即强制更新", cancelText: "", }).then(() => { if (res.script_update_url) { const url = res.script_update_url + (res.script_update_url.includes("?") ? "&" : "?") + "t=" + Date.now(); window.open(url, "_blank"); } else { Dialog.fire({ title: "更新地址未配置", html: "管理员尚未配置更新地址,请联系管理员获取最新版本。", confirmText: "知道了", }); } UI.setStatus(`⚠️ 请刷新页面应用 v${res.script_version}`); }); document.querySelectorAll(".im-btn").forEach((btn) => { btn.disabled = true; btn.style.filter = "grayscale(100%)"; }); UI.setStatus(`⚠️ 需要强制更新`); throw new Error( "SCRIPT_FORCE_UPDATE_REQUIRED: 需要强制更新,终止后续脚本。", ); } else { Dialog.fire({ title: "✨ 发现新版本", html: `发现最新版本 v${res.script_version},是否前往更新体验新功能?`, confirmText: "🚀 立即更新", cancelText: "下次一定", }).then((opened) => { if (opened && res.script_update_url) { const url = res.script_update_url + (res.script_update_url.includes("?") ? "&" : "?") + "t=" + Date.now(); window.open(url, "_blank"); } }); } } STATE.verified = true; STATE.user.score = res.score; STATE.user.verify = res.verify; STATE.user.vip_expire_time = res.vip_expire_time; UI.updateUser(STATE.user.nickName || STATE.user.id, res.im_user || STATE.user.id); UI.updateScore(res.score); UI.updateVip(res.vip_expire_time); updateAnswerCostFromResponse(res); if (res.announce) UI.updateNotice(res.announce); if (res.question_updated_at !== undefined) UI.updateQCount(res.question_updated_at); if (res.max_task_cost) STATE.maxTaskCost = res.max_task_cost; UI.updateCloudStatus(STATE.cloudAvailable); UI.setStatus( STATE.cloudAvailable ? "系统就绪 ✓" : "系统就绪 (⚠ 云端受限)", ); } else { Dialog.fire({ title: "验证失败", html: `⚠ ${res.msg}`, confirmText: "知道了", }); UI.setStatus(`验证异常: ${res.msg}`); document.querySelectorAll(".im-btn").forEach((btn) => { btn.disabled = true; btn.style.filter = "grayscale(100%)"; }); } } catch (e) { UI.setStatus("⚠ 无法连接至云端服务"); } } else { UI.updateUser("未检测到登录"); UI.setStatus("请先登录慕课网平台"); } observe(); renderBatchEvaluatePanel(); renderListScoreHintsOnEnter(); if (GM_getValue("first_load_v3", true)) { setTimeout(() => { onClickHelp(); GM_setValue("first_load_v3", false); }, 2000); } } const quickSubmitTimer = setInterval(() => { const quizDoing = document.querySelector(".m-quizDoing"); const examGuard = quizDoing ? quizDoing.querySelector(".j-warnTip.warnTip.f-dn") : null; if ( quizDoing && unsafeWindow.location.href.indexOf("exam") === -1 && !STATE.privacyActive && examGuard ) { if (!document.getElementById("quickSubmit")) { const btn = document.createElement("button"); btn.id = "quickSubmit"; btn.className = "im-quick-submit"; btn.innerHTML = `✦ 一键交卷`; btn.title = "自动组装满分答案并提交"; btn.onclick = (e) => { e.preventDefault(); onClickSubmit(btn); }; mountOverlay(btn); } } else { const btn = document.getElementById("quickSubmit"); if (btn) btn.remove(); } }, 1500); const evaluationPanelTimer = setInterval(renderBatchEvaluatePanel, 1500); if (!window._imListHintHashBound) { window.addEventListener("hashchange", () => { setTimeout(() => renderListScoreHintsOnEnter(), 120); setTimeout(() => renderBatchEvaluatePanel(), 120); // 新版学习页会重建目录节点,需重新挂载观察器 setTimeout(() => observe(), 300); }); window._imListHintHashBound = true; } unsafeWindow.__NEOMOOC_CLEANUP__ = function () { clearInterval(checkReady); clearInterval(quickSubmitTimer); clearInterval(evaluationPanelTimer); stopListHintObserver(); if (slowBrushTimer) clearInterval(slowBrushTimer); if (_observeTimer) clearTimeout(_observeTimer); if (_courseObserver) { _courseObserver.disconnect(); _courseObserver = null; } unbindNewOutlineHover(); document.getElementById('neomooc-panel')?.remove(); document.querySelectorAll('.neomooc-answer-card, .neomooc-answer-block, .neomooc_answer, .neomooc-list-score-hint, #neomooc-required-cost, .im-quick-submit, #quickSubmit, #neomooc-evaluation, .im-dialog-overlay, #neomooc-outline-float').forEach(el => el.remove()); document.querySelectorAll('.unit_button, .lesson_button, .chapter_button').forEach(el => el.remove()); document.documentElement.classList.remove('im-privacy-active'); STATE.verified = false; STATE.isSlowBrushing = false; }; } async function onClickRefreshScore() { if (!(await ensureVerified())) return; const btn = document.getElementById("im-score"); if (btn && btn.hasAttribute("disabled")) return; const now = Date.now(); if (now - STATE.lastRefresh < 5000) { UI.setStatus("⚠ 刷新过于频繁 (5s 冷却)"); return; } STATE.lastRefresh = now; if (btn) btn.setAttribute("disabled", "true"); UI.setStatus("🔄 正在同步云端余额..."); try { const res = await API.verifyUser( STATE.user.id, STATE.user.email, STATE.user.nickName, STATE.user.loginId, STATE.user.suffix, CONFIG.use_cdkey ? CONFIG.cdkey : "", ); if (res.status === 0) { UI.updateScore(res.score); UI.updateVip(res.vip_expire_time); updateAnswerCostFromResponse(res); if (res.question_updated_at !== undefined) UI.updateQCount(res.question_updated_at); UI.setStatus("✅ 积分余额同步成功"); } } catch { UI.setStatus("❌ 同步积分失败"); } finally { if (btn) btn.removeAttribute("disabled"); } } // 考试页的试卷接口可能晚于用户点击返回,这里短暂等待捕获结果 function waitForPaper(timeout = 3000) { return new Promise((resolve) => { if (STATE.aid) return resolve(true); const step = 200; let waited = 0; const timer = setInterval(() => { waited += step; if (STATE.aid) { clearInterval(timer); resolve(true); } else if (waited >= timeout) { clearInterval(timer); resolve(false); } }, step); }); } async function onClickAnswer(silent = false) { if (!(await ensureVerified(silent))) return; if (STATE.isFetching) return; if ( document.querySelectorAll(".neomooc_answer, .neomooc-choice-correct") .length > 0 ) { if (!silent) UI.setStatus("ℹ 答案已在页面显示"); return; } const btn = document.getElementById("im-btn-answer"); const oldText = btn ? btn.innerText : "获取答案"; if (btn && !silent) { btn.disabled = true; btn.innerText = "正在获取中..."; } STATE.isFetching = true; try { if (!STATE.aid) await waitForPaper(3000); let matched = true; const urlAid = (location.href.match(/[?&#]aid=(\d+)/) || [])[1] || ""; const urlPaperId = (location.href.match(/[?&#]id=(\d+)/) || [])[1] || ""; if (!STATE.aid) { if (urlAid) STATE.aid = urlAid; if (urlPaperId) STATE.tid = urlPaperId; } else { // STATE.aid 来自接口捕获,本身就是当前试卷的直接证据。 // 只有 URL 明确指向另一份试卷时才判定不匹配,避免 tid 不在 URL 中导致误报 const paperId = STATE.paperDto && STATE.paperDto.id ? String(STATE.paperDto.id) : ""; if (urlAid) matched = urlAid === String(STATE.aid); else if (urlPaperId && paperId) matched = urlPaperId === paperId; } if (!STATE.aid) { if (!silent) { Dialog.toast("⚠ 当前页面未检测到测验或作业内容"); UI.setStatus("⚠ 未检测到试卷"); } return; } if (!matched) { if (!silent) { Dialog.toast("⚠ 试卷与当前页面不一致,请刷新后重试"); UI.setStatus("⚠ 试卷与当前页面不一致"); } return; } const requiredCost = getCurrentRequiredAnswerCost(); const currentScore = Math.max(0, Number(STATE.user?.score || 0)); UI.updateAnswerCost(STATE.answerCostHomework, STATE.answerCostExam, STATE.isExam); if (!hasActiveVip() && requiredCost > 0 && currentScore < requiredCost) { if (!silent) { Dialog.fire({ title: "积分不足", html: `当前${STATE.isExam ? "考试" : "测验"}预计需要 ${requiredCost} 积分,您当前仅有 ${currentScore} 积分。

请先充值积分或开通 VIP 后再试。`, confirmText: "知道了", }); UI.setStatus(`❌ 当前积分不足,需 ${requiredCost} 积分`); } return; } if (!silent) UI.setStatus(`正在分析测验内容 (${STATE.testName || "请稍候"})...`); const res = await API.getAnswer( STATE.user.id, STATE.user.verify, STATE.tid, STATE.aid, CONFIG.use_cdkey ? CONFIG.cdkey : "", buildAnswerQuestionPayload(), ); if (res.status === 0) { UI.updateScore(res.score); STATE.user.score = res.score; renderAnswers(res.answer); updateAnswerCostFromResponse(res); if (!silent) UI.setStatus("✅ 答案已获取完成"); } else { if (!silent) { Dialog.fire({ title: "获取失败", html: `❌ ${res.msg}`, confirmText: "知道了", }); UI.setStatus(`❌ ${res.msg}`); } } } catch (e) { if (!silent) { Dialog.fire({ title: "获取失败", html: "⚠ 云端获取答案失败,请检查网络或 CDKEY 设置。", confirmText: "知道了", }); UI.setStatus("⚠ 获取答案失败"); } } finally { STATE.isFetching = false; if (btn && !silent) { btn.disabled = false; btn.innerText = oldText; } } } // 交卷后成绩页地址:基础地址不变,hash 换为 #/learn/quizscore?id=&aid=&eid=&examType= function buildQuizScoreUrl() { try { const loc = unsafeWindow.location; const hash = String(loc.hash || ""); const quizId = (hash.match(/[?&]id=(\d+)/) || [])[1] || (STATE.paperDto && STATE.paperDto.id ? String(STATE.paperDto.id) : ""); const aid = String(STATE.aid || (STATE.paperDto && STATE.paperDto.aid) || ""); if (!quizId || !aid) return ""; const paper = STATE.paperDto || {}; const eid = STATE.isExam && paper.examId && paper.examId !== -1 ? String(paper.examId) : "undefined"; const examType = STATE.isExam && paper.examType ? String(paper.examType) : "undefined"; return `${loc.href.split("#")[0]}#/learn/quizscore?id=${quizId}&aid=${aid}&eid=${eid}&examType=${examType}`; } catch (e) { return ""; } } async function onClickSubmit(btnNode) { if (!(await ensureVerified(true))) { Dialog.fire({ title: "未验证", html: "ℹ 未从面板检测到已验证的云端权限,请刷新或确认已配置好助手!", confirmText: "知道了", }); return UI.setStatus("请先验证用户"); } if (!STATE.aid && unsafeWindow.s0 && unsafeWindow.s0.aid) { STATE.aid = unsafeWindow.s0.aid; } if (!STATE.aid) { Dialog.fire({ title: "获取失败", html: "⚠ 测验内容加载失败,请按 F5 刷新网页重试。", confirmText: "知道了", }); return UI.setStatus("⚠ 内容加载失败"); } const confirmed = await Dialog.fire({ title: "确认一键交卷?", html: `此操作将自动拼装满分答案直接交卷。
自动补充答题时间防风控检测,成功后将直接退出页面。`, confirmText: "开始交卷", }); if (!confirmed) return; if (btnNode) { btnNode.disabled = true; const textSpan = btnNode.querySelector("span"); if (textSpan) textSpan.innerText = "正在交卷..."; else btnNode.innerText = "正在交卷..."; } UI.setStatus("正在准备交卷数据..."); try { const res = await API.getAnswer( STATE.user.id, STATE.user.verify, STATE.tid, STATE.aid, CONFIG.use_cdkey ? CONFIG.cdkey : "", buildAnswerQuestionPayload(), ); if (res.status !== 0) { if (btnNode) { btnNode.disabled = false; const textSpan = btnNode.querySelector("span"); if (textSpan) textSpan.innerText = "一键交卷"; else btnNode.innerText = "一键交卷"; } Dialog.fire({ title: "交卷拒绝", html: `❌ 云端获取答案拒绝:${res.msg}`, confirmText: "知道了", }); return UI.setStatus("❌ 云端获取答案拒绝:" + res.msg); } UI.updateScore(res.score); STATE.user.score = res.score; const normalizedAnswer = normalizeAnswerPayload(res.answer); const answerQuestions = (normalizedAnswer?.questions || []).filter( (q) => q && Object.prototype.hasOwnProperty.call(q, "id"), ); if (!normalizedAnswer || answerQuestions.length === 0) { if (btnNode) { btnNode.disabled = false; const textSpan = btnNode.querySelector("span"); if (textSpan) textSpan.innerText = "一键交卷"; else btnNode.innerText = "一键交卷"; } Dialog.fire({ title: "解析失败", html: "⚠ 解析题库格式缺失,云端可能暂未收录此卷。", confirmText: "知道了", }); return UI.setStatus("⚠ 解析题库格式缺失"); } let submitJsonObj = null; if ( unsafeWindow.s0 && unsafeWindow.s0.submitStatus !== undefined && unsafeWindow.s0.objectiveQList ) { submitJsonObj = { paperDto: JSON.parse(JSON.stringify(unsafeWindow.s0)), preview: false, }; } else if (STATE.paperDto) { submitJsonObj = { paperDto: JSON.parse(JSON.stringify(STATE.paperDto)), preview: false, }; } else { Dialog.fire({ title: "无法交卷", html: "ℹ 当前环境下试卷初始化不完整,建议刷新页面后重试。", confirmText: "知道了", }); if (btnNode) { btnNode.disabled = false; const textSpan = btnNode.querySelector("span"); if (textSpan) textSpan.innerText = "一键交卷"; else btnNode.innerText = "一键交卷"; } return UI.setStatus("❌ 找不到试卷基础数据,请刷新重试"); } submitJsonObj.paperDto.submitType = 1; submitJsonObj.paperDto.switchPageCount = null; let submitAnswers = []; let nowTime = Date.now(); const ansQList = answerQuestions; const origQList = submitJsonObj.paperDto.objectiveQList || []; for (let i = 0; i < origQList.length; i++) { let origQ = origQList[i]; let ansQ = ansQList.find((q) => String(q.id) === String(origQ.id)) || ansQList[i]; if (!ansQ) continue; let thinkTime = Math.floor(Math.random() * 30000) + 30000; nowTime += thinkTime; let qtype = origQ.type; let qAnswer = { qid: origQ.id, type: qtype, optIds: [], time: nowTime, }; if (qtype !== 3) { (ansQ.correct_indexes || []).forEach((optIdx) => { const originOpt = origQ.optionDtos?.[optIdx]; if (originOpt && originOpt.id !== undefined) { qAnswer.optIds.push(originOpt.id); } }); } else { let stdAns = ansQ.answer_text || ""; let finalStr = stdAns.split(" (或) ")[0]; qAnswer.content = { content: finalStr }; } submitAnswers.push(qAnswer); } submitJsonObj.paperDto.answers = submitAnswers; UI.setStatus("答题卡组装完成,正在向慕课教务交卷..."); const payloadStr = JSON.stringify(submitJsonObj); GM.xmlHttpRequest({ method: "POST", url: "https://www.icourse163.org/web/j/mocQuizRpcBean.submitAnswers.rpc?csrfKey=" + STATE.csrfKey, headers: { "Content-Type": "application/json;charset=UTF-8", Accept: "application/json", "Access-Control-Allow-Origin": "*", Origin: "null", }, data: payloadStr, onload: function (resp) { if (btnNode) { btnNode.disabled = false; const textSpan = btnNode.querySelector("span"); if (textSpan) textSpan.innerText = "一键交卷"; else btnNode.innerText = "一键交卷"; } try { let r = JSON.parse(resp.responseText); if ((r.result && r.result === 200) || r.code === 0) { UI.setStatus("✅ 一键交卷成功!2秒后跳转成绩页..."); setTimeout(() => { const scoreUrl = buildQuizScoreUrl(); try { // 尽力清掉页面注册的离开确认 unsafeWindow.onbeforeunload = null; } catch (e) { } if (scoreUrl) { unsafeWindow.location.href = scoreUrl; return; } // 拿不到 quizId/aid 时退回历史回退 try { unsafeWindow.history.back(); } catch (e) { } }, 2000); } else { UI.setStatus( `❌ 交卷被打回 (服务端返回:${r.result || r.message || "未知异常"})`, ); } } catch (e) { UI.setStatus("❌ 云端反馈数据异常,建议手动检查"); } }, onerror: function () { if (btnNode) { btnNode.disabled = false; const textSpan = btnNode.querySelector("span"); if (textSpan) textSpan.innerText = "交卷失败重试"; else btnNode.innerText = "交卷失败重试"; } UI.setStatus("❌ 慕课服务器连接超时或网络异常,请检查拦截插件"); }, }); } catch (e) { if (btnNode) { btnNode.disabled = false; const textSpan = btnNode.querySelector("span"); if (textSpan) textSpan.innerText = "一键交卷"; else btnNode.innerText = "一键交卷"; } UI.setStatus("⚠ 本地交卷执行脚本发生崩溃"); } } function normalizeAnswerPayload(answerData) { if (answerData && Array.isArray(answerData.questions)) { return answerData; } return null; } function renderAnswers(answerData) { try { const normalized = normalizeAnswerPayload(answerData); if (!normalized) { Dialog.fire({ title: "格式错误", html: "⚠ 答案数据格式异常,请联系开发者。", confirmText: "知道了", }); return UI.setStatus("⚠ 答案数据格式异常"); } const NEW_EXAM_ROW_SELECTOR = '[class*="moocQuestionContainer"]'; const OPTION_CONTENT_SELECTOR = '[class*="optionContent"], .optionCnt, .m-question-option-content, .f-richEditorText'; const isNewExam = location.href.includes("newExam") || !!document.querySelector(NEW_EXAM_ROW_SELECTOR) || !!document.querySelector('[class*="examMainColumn"]'); const warningHint = "请根据理解作答,勿抄参考答案,以免被检测为使用插件!"; const warningHintHtml = `
⚠ ${warningHint}
`; const questions = Array.isArray(normalized.questions) ? normalized.questions : []; if (questions.length === 0) { Dialog.toast("⚠ 云端未找到匹配的题目数据"); return UI.setStatus("⚠ 未找到题目数据"); } let questionRows = []; if (isNewExam) { questionRows = Array.from( document.querySelectorAll(NEW_EXAM_ROW_SELECTOR), ); if (questionRows.length === 0) { const container = document.querySelector( ".ant-form.ant-form-horizontal", ); questionRows = container ? Array.from(container.children) : []; } } else { const listContainer = document.querySelector(".m-data-lists.f-cb.f-pr.j-data-list") || document.querySelector(".m-homeworkQuestionList"); questionRows = listContainer ? Array.from(listContainer.children) : []; } let answerTargets; if (isNewExam) { answerTargets = document.querySelectorAll('[class*="questionInfo"]'); if (answerTargets.length === 0) answerTargets = document.querySelectorAll( ".index-module__questionInfo__MRpPD", ); if (answerTargets.length === 0) answerTargets = document.querySelectorAll("._2LgP5"); if (answerTargets.length === 0) answerTargets = questionRows; } else { answerTargets = document.querySelectorAll( ".f-richEditorText.j-richTxt", ); if (answerTargets.length === 0) answerTargets = questionRows; } // 新版考试页的类名带构建 hash(如 index-module__questionInfo__MRpPDBJ), // 硬编码必然失效,这里统一用包含匹配并保留旧选择器兜底 const getOptionNodes = (row) => { if (!row) return []; const bodyNodes = row.querySelectorAll('[class*="optionBody"]'); if (bodyNodes.length > 0) return Array.from(bodyNodes); return Array.from( row.querySelectorAll( ".chooses .u-tbl, ._3m_i-, .m-question-option, .ant-radio-group .ant-radio-wrapper, .ant-checkbox-group .ant-checkbox-wrapper, .choices li", ), ); }; const resolveQuestionRow = (q, index) => { const qid = q && q.id != null ? String(q.id) : ""; if (qid) { const el = document.getElementById(qid); if ( el && el.querySelector( '[class*="questionInfo"], .f-richEditorText.j-richTxt', ) ) { return el; } } return questionRows[index] || null; }; const resolveAnswerTarget = (row, index) => { if (row) { const node = row.querySelector( '[class*="questionInfo"], .index-module__questionInfo__MRpPD, ._2LgP5, .f-richEditorText.j-richTxt', ); if (node) return node; } return answerTargets[index] || null; }; const cardItems = []; const getPageOptionHtmls = (row) => { if (!row) return []; return getOptionNodes(row).map((optNode) => { const contentNode = optNode.querySelector( OPTION_CONTENT_SELECTOR, ); if (contentNode) return contentNode.innerHTML.trim(); const clone = optNode.cloneNode(true); clone .querySelectorAll("input, .optionPos, [class*='optionIndex']") .forEach((node) => node.remove()); return clone.innerHTML.trim(); }); }; for (let i = 0; i < questions.length; i++) { const q = questions[i]; const targetRow = resolveQuestionRow(q, i); const isObjective = Object.prototype.hasOwnProperty.call(q, "id"); const isFillBlank = isObjective && !Array.isArray(q.correct_indexes); let answerHtml = ""; let cardLabel = ""; if (isObjective) { if (isFillBlank) { let stdAns = q.answer_text || ""; answerHtml = `
解析:
${stdAns}
`; cardLabel = stdAns.substring(0, 12); } else { const correctIndexes = Array.isArray(q.correct_indexes) ? q.correct_indexes : []; let letters = ""; const options = getPageOptionHtmls(targetRow); const answerParts = []; correctIndexes.forEach((idx) => { const letter = String.fromCharCode(65 + idx); letters += letter; const optionText = options[idx] || ""; answerParts.push( optionText ? `
${letter}.${optionText}
` : letter, ); }); const answerContent = answerParts.join(""); const answerLetters = letters; if (answerContent) letters = answerContent; answerHtml = `
解析:
${letters || "暂无"}
`; cardLabel = letters; cardLabel = answerLetters; } } else { answerHtml = `
解析:
${q.answer_text || "暂无"}
${warningHintHtml}
`; cardLabel = "主观"; } const richText = resolveAnswerTarget(targetRow, i); const answerAnchor = richText || targetRow; if (answerAnchor && !STATE.privacyActive) { const scope = targetRow || answerAnchor.parentElement; if (scope) { scope .querySelectorAll(".neomooc-answer-block, .neomooc_answer") .forEach((node) => node.remove()); } // 统一挂在题干元素的同级下方,避免插进题干文字流导致位置飘 const block = document.createElement("div"); block.className = "neomooc-answer-block"; block.innerHTML = answerHtml; if (answerAnchor !== targetRow) { answerAnchor.insertAdjacentElement("afterend", block); } else { answerAnchor.appendChild(block); } } if (isObjective && !isFillBlank) { const target = targetRow || richText; const correctIndexes = Array.isArray(q.correct_indexes) ? q.correct_indexes : []; if (target) { getOptionNodes(target).forEach((optNode, idx) => { if (correctIndexes.includes(idx)) return; optNode._isWrong = true; if (STATE.privacyActive) { const input = optNode.querySelector("input"); if (input) input.disabled = true; } }); } } if (isFillBlank) { const target = targetRow || richText; const inputs = target ? target.querySelectorAll('input[type="text"], textarea') : []; const stdAns = (q.answer_text || "").split(" (或) ")[0]; inputs.forEach((input) => { let _copyLock = false; const doCopy = () => { if (_copyLock) return; _copyLock = true; setTimeout(() => (_copyLock = false), 300); navigator.clipboard.writeText(stdAns).then(() => { if (!STATE.privacyActive) { UI.setStatus(`已复制答案: ${stdAns}`); } }).catch(() => { }); }; input.addEventListener("focus", doCopy); input.addEventListener("click", doCopy); }); } if (isObjective && !STATE.privacyActive) { cardItems.push({ idx: i + 1, label: cardLabel }); } } if (!window._imAnswerCleanerBound) { window.addEventListener("hashchange", () => { document .querySelectorAll(".neomooc-answer-card, .neomooc-answer-block, .neomooc_answer, .neomooc-list-score-hint, #neomooc-required-cost") .forEach((el) => el.remove()); STATE.aid = ""; STATE.testName = ""; UI.setStatus("请打开作业/测验页面并点击获取答案"); }); window._imAnswerCleanerBound = true; } document .querySelectorAll(".neomooc-answer-card") .forEach((el) => el.remove()); if (cardItems.length === 0) return; const card = document.createElement("div"); card.className = "neomooc-answer-card"; card.innerHTML = `
📋 解析卡 (${questions.length}题)
` + cardItems .map( (c) => `${c.idx}: ${c.label}`, ) .join(""); mountOverlay(card); card.querySelectorAll(".neomooc-card-item").forEach((item) => { item.addEventListener("click", () => { const idx = parseInt(item.dataset.idx) - 1; if (idx < questionRows.length) { questionRows[idx].scrollIntoView({ behavior: "smooth", block: "start", }); } }); }); const cardTitle = card.querySelector(".card-title"); let isDragging = false, startX, startY, origLeft, origTop; const onMove = (e) => { if (!isDragging) return; card.style.left = origLeft + e.clientX - startX + "px"; card.style.top = origTop + e.clientY - startY + "px"; }; const onUp = () => { isDragging = false; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; cardTitle.addEventListener("mousedown", (e) => { isDragging = true; const rect = card.getBoundingClientRect(); origLeft = rect.left; origTop = rect.top; startX = e.clientX; startY = e.clientY; card.style.left = origLeft + "px"; card.style.top = origTop + "px"; card.style.right = "auto"; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }); } catch (e) { UI.setStatus("⚠ 答案渲染失败: " + e.message); } } async function onClickBrush() { if (!(await ensureVerified())) return; const btn = document.getElementById("im-btn-brush"); if (btn) btn.disabled = true; try { const selectionState = { modal: null }; await Dialog.fire({ title: "选择刷课类型", html: `
视频快刷
文档/文章
测验快刷
${DISCUSS_ENABLED ? '
讨论快刷
' : ""}
支持多选;点击“快速全选”可一键勾选全部分类。
`, confirmText: "提交", cancelText: "取消", onOpen: (modal) => { selectionState.modal = modal; const optionItems = modal.querySelectorAll(".option-item[data-type]"); const selectAllBtn = modal.querySelector('[data-action="select-all"]'); const clearAllBtn = modal.querySelector('[data-action="clear-all"]'); optionItems.forEach((item) => { item.addEventListener("click", () => { item.classList.toggle("selected"); }); }); if (selectAllBtn) { selectAllBtn.addEventListener("click", () => { optionItems.forEach((item) => item.classList.add("selected")); }); } if (clearAllBtn) { clearAllBtn.addEventListener("click", () => { optionItems.forEach((item) => item.classList.remove("selected")); }); } }, onConfirm: () => { const modal = selectionState.modal; if (!modal) { Dialog.toast("⚠ 弹窗未初始化完成,请重试"); return false; } const selectedTypes = Array.from( modal.querySelectorAll(".option-item.selected[data-type]"), ).map((item) => item.dataset.type); if (selectedTypes.length === 0) { Dialog.toast("⚠ 请至少选择一种刷课类型"); return false; } const labels = { video: "视频", doc: "文档/文章", test: "测验", discuss: "讨论", }; const optionCount = modal.querySelectorAll(".option-item[data-type]").length; const label = selectedTypes.length === optionCount ? "全部" : selectedTypes.map((type) => labels[type]).join("、"); onClickBrushByType(selectedTypes, label); return true; }, }); } finally { if (btn) btn.disabled = false; } } async function onClickBrushByType(type, label) { UI.setStatus(`正在分析 ${label} ...`); try { let units; if (isNewOutlineUi()) { const all = await loadNewOutlineUnits(true); const picked = Array.isArray(type) ? type : [type]; units = picked.includes("0") || picked.includes("all") ? all : all.filter((u) => picked.some((p) => { if (p === "video") return u.type === "video"; if (p === "doc") return u.type === "ppt" || u.type === "richtext"; return u.type === p; }), ); } else { units = parseCourseTree(await ensureCourseTree(), type); } if (!units.length) { return UI.setStatus("⚠ 未找到可提交的单元,请先展开课程章节"); } await submitTargetedTask(units, label); } catch (e) { UI.setStatus("❌ 处理异常: " + e); } } async function onClickSlowBrush() { if (!(await ensureVerified())) return; const btn = document.getElementById("im-btn-slow-brush"); if (btn && btn.disabled) return; if (STATE.isSlowBrushing) { stopSlowBrush(); return; } if (!isLearnContentUrl(unsafeWindow.location.href)) { Dialog.fire({ title: "无法启动挂机", html: "⚠ 请先进入课程学习详情页(即显示视频、文档的具体播放页面)后再点击此按钮。", confirmText: "知道了", }); return; } if (btn) btn.disabled = true; try { const ok = await Dialog.fire({ title: "挂机刷课", html: "挂机刷课将模拟手动点击与播放,由于受浏览器限制,窗口必须保持在前端。确认开始?", confirmText: "开始挂机", }); if (ok) startSlowBrush(); } finally { if (btn) btn.disabled = false; } } let slowBrushTimer = null; let lastUrl = ""; // 旧版 #/learn/content?type=detail... / 新版 /mooc/learn/{course}/content/{lessonId} function isLearnContentUrl(url) { if (!url) return false; return url.includes("/learn/content") || /\/learn\/[^/?#]+\/content\//.test(url); } async function loadFlatUnits() { if (STATE.flatUnits.length > 0) return STATE.flatUnits; if (isNewOutlineUi()) { const domUnits = await loadNewOutlineUnits(true); if (domUnits.length) { STATE.flatUnits = domUnits; return domUnits; } } try { const tree = await fetchCourseTree(STATE.csrfKey, STATE.termId); const list = []; (tree?.chapters || []).forEach((c) => (c.lessons || []).forEach((l) => (l.units || []).forEach((u) => { list.push({ ...u, lessonId: l.id }); }), ), ); STATE.flatUnits = list; return list; } catch (e) { return []; } } async function startSlowBrush() { STATE.isSlowBrushing = true; const btn = document.getElementById("im-btn-slow-brush"); if (btn) { btn.textContent = "停止挂机"; btn.classList.add("danger"); } UI.setStatus("🚀 挂机刷课中,请勿遮挡浏览器"); lastUrl = ""; await loadFlatUnits(); slowBrushTimer = setInterval(() => { const currentUrl = unsafeWindow.location.href; if (!isLearnContentUrl(currentUrl)) { return stopSlowBrush(); } if (currentUrl !== lastUrl) { lastUrl = currentUrl; STATE.isJumping = false; handleCurrentUnit(); return; } if (STATE.isJumping) return; const video = document.querySelector("video"); if (video) { if (video.paused && !video.ended) video.play().catch(() => { }); video.muted = true; if (!video._imBound) { video._imBound = true; video.addEventListener("pause", () => { if (STATE.isSlowBrushing && !video.ended) video.play().catch(() => { }); }); video.addEventListener("ended", () => { if (STATE.isSlowBrushing) { UI.setStatus("🎬 播放结束,准备跳转..."); setTimeout(gotoNextUnit, 1000); } }); } if (video.ended || document.querySelector(".playEnd.f-f0.f-pa")) { gotoNextUnit(); } } const pptViewer = document.querySelector(".ux-edu-pdfthumbnailviewer"); if (pptViewer) handlePPT(pptViewer); }, 1500); } function stopSlowBrush() { STATE.isSlowBrushing = false; clearInterval(slowBrushTimer); const btn = document.getElementById("im-btn-slow-brush"); if (btn) { btn.textContent = "挂机刷课"; btn.classList.remove("danger"); } UI.setStatus("⏹ 挂机已停止"); } function handleCurrentUnit() { const curId = getCurrentUnitId() || null; let typeStr = "分析中..."; if (curId && STATE.flatUnits.length > 0) { const unit = STATE.flatUnits.find((u) => u.id.toString() === curId); if (unit) { const types = { 1: "视频", 3: "文档", 4: "富文本", 5: "测验", 6: "讨论", }; typeStr = types[unit.contentType] || "未知单元"; } } else { const tab = document.querySelector(".u-learnBCUI .f-cb .current"); if (tab) { typeStr = tab.innerText.trim(); } } UI.setStatus(`正在挂机: ${typeStr}`); if ( STATE.flatUnits.length > 0 && (typeStr === "富文本" || typeStr === "讨论" || typeStr === "测验") ) { UI.setStatus(`${typeStr} 单元,5秒后自动跳过...`); setTimeout(() => { if (getCurrentUnitId() === String(curId)) { gotoNextUnit(); } }, 5000); } } function handlePPT(viewer) { if (STATE._isPptHandling) return; STATE._isPptHandling = true; const links = viewer.querySelectorAll("a"); let currentIdx = 0; const footerInput = document.querySelector( ".ux-h5pdfreader_container_footer_pages_in", ); if (footerInput) { currentIdx = parseInt(footerInput.value) - 1; } async function clickNext(idx) { if (!STATE.isSlowBrushing) { STATE._isPptHandling = false; return; } if (idx >= 0 && idx < links.length && links[idx]) { links[idx].click(); UI.setStatus(`文 档: ${idx + 1}/${links.length}`); const wait = 2500 + Math.random() * 2000; setTimeout(() => clickNext(idx + 1), wait); } else { STATE._isPptHandling = false; if (links.length > 0 && idx >= links.length) { UI.setStatus("文 档已阅读完毕,准备跳转..."); setTimeout(gotoNextUnit, 1000); } else if (links.length === 0) { UI.setStatus("文 档: 正在载入内容..."); } else { } } } clickNext(currentIdx); } function gotoNextUnit() { if (!STATE.isSlowBrushing || STATE.isJumping) return; const loc = unsafeWindow.location.href; const currentItem = document.querySelector(".f-fl.current"); if (!currentItem) { // 新版目录没有 .current 节点,直接按扁平列表顺序跳转 if (getNewOutlineRoot() || getCurrentUnitId()) { STATE.isJumping = true; findAndJump(loc); } return; } STATE.isJumping = true; let next = currentItem.nextElementSibling; if (!next && currentItem.parentElement) { findAndJump(loc); } else if (next) { next.click(); } } async function findAndJump(loc) { if (!STATE.isSlowBrushing) return; UI.setStatus("查找下一单元..."); try { await loadFlatUnits(); const curIdMatch = loc.match(/[?&]unitId=(\d+)/) || loc.match(/[?&]cid=(\d+)/); const curId = curIdMatch ? curIdMatch[1] : null; if (!curId) return UI.setStatus("⚠ 无法识别当前页面位置"); const currentIndex = STATE.flatUnits.findIndex( (u) => u.id.toString() === curId, ); let nextUnit = null; if (currentIndex !== -1 && currentIndex + 1 < STATE.flatUnits.length) { nextUnit = STATE.flatUnits[currentIndex + 1]; } if (!nextUnit) { nextUnit = STATE.flatUnits.find((u) => (u.completePercent || 0) < 0.8); } if (!nextUnit) { stopSlowBrush(); Dialog.toast("🎉 恭喜,当前课程所有单元已刷完!"); return; } const nextUrl = toAbsoluteUrl(nextUnit.href) || loc.split("#")[0] + `#/learn/content?type=detail&id=${nextUnit.lessonId || 0}&cid=${nextUnit.id}`; UI.setStatus(`即将跳转到: ${nextUnit.name}`); setTimeout(() => { if (STATE.isSlowBrushing) unsafeWindow.location.href = nextUrl; }, 1000); } catch (e) { UI.setStatus("⚠ 自动跳转失败: " + (e.message || e)); } } async function onClickCloud() { if (!(await ensureVerified())) return; const btn = document.getElementById("im-btn-cloud"); const oldText = btn ? btn.innerText : "任务列表"; if (btn) { btn.disabled = true; btn.innerText = "获取中..."; } UI.setStatus("🚀 正在获取任务与详细账单..."); try { const res = await API.listTasks( STATE.user.id, CONFIG.use_cdkey ? CONFIG.cdkey : "", STATE.user.verify, ); if (res.status === 0) { if (res.score !== undefined) { UI.updateScore(res.score); STATE.user.score = res.score; } const statusMap = { pending: "排队中", executed: "同步中", completed: "已完成", failed: "已完成", cancelled: "已取消", }; const tasksHtml = res.tasks.length > 0 ? res.tasks .map( (t) => `
${t.courseName || t.termId} ${statusMap[t.status] || "未知"}
进度: ${t.progress || 0}% 已用: ${t.pointUsed || 0}积分
${t.currentUnitName ? `
当前: ${t.currentUnitName}
` : ""}
`, ) .join("") : '

暂无云端任务

'; const logsHtml = res.logs && res.logs.length > 0 ? res.logs .map( (log) => `
${log.reason || "未说明原因"}
${new Date(log.createTime).toLocaleString()}
${log.amount <= 0 ? (log.amount === 0 ? "-0" : log.amount) : "+" + log.amount}
`, ) .join("") : '

暂无积分变动记录

'; Dialog.fire({ title: "云端管理中心", cancelText: "", confirmText: "我知道了", html: `
● 任务概览 (最近5条)
${tasksHtml}
● 积分消费流水
${logsHtml}
`, }); UI.setStatus("✅ 云端数据加载成功"); } else { UI.setStatus("❌ " + (res.msg || "查询失败")); } } catch (e) { Dialog.fire({ title: "查询失败", html: "⚠ 查询任务或积分异常,可能是云端服务暂时不可用。", confirmText: "知道了", }); UI.setStatus("⚠ 查询数据失败"); } finally { if (btn) { btn.disabled = false; btn.innerText = oldText; } } } async function onClickConfig() { const btn = document.getElementById("im-btn-config"); if (btn) btn.disabled = true; try { const result = await Dialog.fire({ title: "功能配置", html: `
`, confirmText: "保存并刷新", onOpen: (modal) => { const check = modal.querySelector("#im-cfg-use-cdkey"); const input = modal.querySelector("#im-cfg-cdkey"); if (check) { check.onchange = () => { input.disabled = !check.checked; }; } }, onConfirm: () => ({ cdkey: document.getElementById("im-cfg-cdkey").value.trim(), use_cdkey: document.getElementById("im-cfg-use-cdkey").checked, privacyMode: document.getElementById("im-cfg-privacy-mode").checked, }), }); if (result) { const cdkeyChanged = CONFIG.cdkey !== result.cdkey || CONFIG.use_cdkey !== result.use_cdkey; CONFIG.cdkey = result.cdkey; CONFIG.use_cdkey = result.use_cdkey; GM_setValue("cdkey", CONFIG.cdkey); GM_setValue("use_cdkey", CONFIG.use_cdkey); if (CONFIG.privacyMode !== result.privacyMode) { togglePrivacyMode(result.privacyMode); } if (cdkeyChanged) { UI.setStatus("配置已保存,正在重新验证权限..."); onClickRefreshScore(); } else { UI.setStatus("✅ 配置已保存"); } } } finally { if (btn) btn.disabled = false; } } async function onClickSponsor() { if (!(await ensureVerified(true)) || !STATE.user?.id) { Dialog.fire({ title: "暂时无法打开充值页", html: "请先等待脚本完成账号验证,再进入充值中心。", confirmText: "知道了", }); return; } const userId = encodeURIComponent(STATE.user.id || ""); const cdkey = encodeURIComponent(CONFIG.cdkey || ""); const version = encodeURIComponent(GM_info.script.version || ""); const payUrl = `${window.actualServerUrl}/pay/recharge?user_id=${userId}&cdkey=${cdkey}&version=${version}`; window.open(payUrl, "_blank"); UI.setStatus("已打开充值中心"); } async function onClickHelp() { const btn = document.getElementById("im-help-btn"); if (btn && btn.getAttribute("disabled")) return; if (btn) btn.setAttribute("disabled", "true"); try { await Dialog.fire({ title: "NeoMooc 助手使用指引", confirmText: "我理解了", cancelText: "", html: `

隐私安全与辅助功能

  • 快捷控制:按 Esc 键可开关脚本主面板。
  • 隐私过滤机制:开启隐私模式后,系统将自动隐藏全部面板与答案;在此模式下,仅允许勾选正确答案。

测验与作业答题辅助

进入测验或作业页面后,点击面板上的「获取答案」或按快捷键 Alt+A:
  • 题目解析显示:系统将自动匹配云端最高评分答案;若当前题目在云端尚无收录,系统将实时调用 AI 模型进行深度分析,并提供逻辑推导出的最优解。
  • 快速填充:针对填空题,系统检测到光标聚焦时,点击输入框即可通过内部剪贴板机制快速粘贴标准答案。
  • 答题卡:页面左侧/右侧会生成悬浮答题卡,标记已捕获的题目序号,支持点击快速跳转定位。

刷课模式对比与说明

本助手提供两种截然不同的刷课方案,用户可按需选择:
  • 云端代看(推荐):由后端服务器接管,支持视频、文档、测验、讨论等全类型单元。提交任务后可立即关闭浏览器,任务将在云端代刷,通过「任务列表」查看积分扣除明细与同步状态。
  • 本地挂机模拟:在播放页启动后,脚本将通过模拟前端操作完成进度。必须保持浏览器视窗常驻前台(不可最小化或被其他窗口遮挡),适用于无需消耗额外积分的本地自动化。
  • 定向范围刷课:将鼠标移动到课程目录树,可看到针对特定章节、小节、甚至单个视频单元的独立控制按钮。

积分体系与计费规则

本助手采用积分制管理云端资源调用,具体规则如下:
  • 获取答案扣费:普通作业或测验每次消耗 10 积分;考试每次消耗 50 积分。
  • 云端刷课扣费:每一个学习单元(视频、文档、讨论等)消耗 1 积分。单次提交任务最高封顶扣除 30 积分,超出部分不再计费。
  • 会员尊享权益:VIP 会员在有效期内享有无限额度,所有云端功能均不消耗积分(不可跨账号使用)。
  • 充值后发放CDKey,配置后可跨账号使用。
`, }); } finally { if (btn) btn.removeAttribute("disabled"); } } Object.assign(CONFIG, { panelWidth: 320, cdkey: GM_getValue("cdkey", ""), use_cdkey: GM_getValue("use_cdkey", true), theme: GM_getValue("theme", "dark"), privacyMode: GM_getValue("privacy_mode", false), }); STATE.privacyActive = CONFIG.privacyMode; togglePrivacyMode(STATE.privacyActive); init(); })();