// ==UserScript== // @name MOOC 自动完成助手 // @namespace icourse-auto // @version 1.1.2 // @tag free // @description 自动完成课程任务点 // @match *://www.icourse163.org/learn/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_addStyle // @grant unsafeWindow // @connect www.icourse163.org // @connect api.deepseek.com // @connect api.moonshot.cn // @connect api.openai.com // @connect api.mimo.com // @connect dashscope.aliyuncs.com // @connect open.bigmodel.cn // @run-at document-end // ==/UserScript== (function () { "use strict"; // ─── 配置 ─────────────────────────────────────────────────────────────────── const DEFAULT_CONFIG = { videoSpeed: 1, docWaitTime: 4, taskInterval: 2, skipTypes: [], autoStart: true, includeDone: false, loopVideos: false, skipVideo: false, quizOnly: false, }; const DEFAULT_LLM_CONFIG = { enabled: false, provider: "deepseek", baseUrl: "https://api.deepseek.com/v1/chat/completions", model: "deepseek-chat", apiKey: "", temperature: 0.7, timeout: 30000, }; const LLM_PROVIDER_PRESETS = { openai: { label: "OpenAI", baseUrl: "https://api.openai.com/v1/chat/completions", model: "gpt-4o-mini" }, deepseek: { label: "DeepSeek", baseUrl: "https://api.deepseek.com/v1/chat/completions", model: "deepseek-chat" }, glm: { label: "智谱 GLM", baseUrl: "https://open.bigmodel.cn/api/paas/v4/chat/completions", model: "glm-4-flash" }, qwen: { label: "通义千问", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions", model: "qwen-turbo" }, kimi: { label: "Kimi (月之暗面)", baseUrl: "https://api.moonshot.cn/v1/chat/completions", model: "moonshot-v1-8k" }, mimo: { label: "Mimo", baseUrl: "https://api.mimo.com/v1/chat/completions", model: "mimo-default" }, custom: { label: "自定义", baseUrl: "", model: "" }, }; function encryptApiKey(k) { return k ? btoa(unescape(encodeURIComponent(k))).split("").reverse().join("") : ""; } function decryptApiKey(k) { if (!k) return ""; try { return decodeURIComponent(escape(atob(k.split("").reverse().join("")))); } catch (e) { return ""; } } function getLlmConfig() { const saved = GM_getValue("icourse_llm_config", null); const cfg = saved ? Object.assign({}, DEFAULT_LLM_CONFIG, saved) : Object.assign({}, DEFAULT_LLM_CONFIG); cfg.apiKey = decryptApiKey(cfg.apiKey); return cfg; } function saveLlmConfig(cfg) { const out = Object.assign({}, cfg); out.apiKey = encryptApiKey(out.apiKey || ""); GM_setValue("icourse_llm_config", out); } function getConfig() { const saved = GM_getValue("icourse_config", null); return saved ? Object.assign({}, DEFAULT_CONFIG, saved) : { ...DEFAULT_CONFIG }; } function saveConfig(cfg) { GM_setValue("icourse_config", cfg); } // ─── 工具函数 ──────────────────────────────────────────────────────────────── function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function getCsrfKey() { // icourse163 把 csrfKey 存在 NTESSTUDYSI 这个 cookie 里 const m = document.cookie.match(/(?:^|;\s*)NTESSTUDYSI=([^;]+)/); if (m) return decodeURIComponent(m[1]); // 兜底:从 script 标签里找 const scripts = document.querySelectorAll("script"); for (const s of scripts) { const sm = s.textContent.match(/csrfKey\s*[=:]\s*["']([^"']+)["']/); if (sm) return sm[1]; } return ""; } function rpcRequest(action, body) { return new Promise((resolve, reject) => { const csrfKey = getCsrfKey(); GM_xmlhttpRequest({ method: "POST", url: "https://www.icourse163.org/web/j/" + action + "?csrfKey=" + csrfKey, headers: { "Content-Type": "application/x-www-form-urlencoded", "Referer": location.href, }, data: body, onload(res) { try { resolve(JSON.parse(res.responseText)); } catch (e) { reject(new Error("JSON parse error: " + res.responseText.slice(0, 100))); } }, onerror(err) { reject(err); }, }); }); } function fetchTaskList(termId) { return rpcRequest( "courseBean.getLastLearnedMocTermDto.rpc", "termId=" + termId ).then(data => { const chapters = (data && data.result && data.result.mocTermDto && data.result.mocTermDto.chapters) || []; const units = []; for (const ch of chapters) { for (const lesson of (ch.lessons || [])) { for (const unit of (lesson.units || [])) { unit._chapterName = ch.name || ""; unit._lessonName = lesson.name || ""; units.push(unit); } } } return units; }); } // ─── 导航 ──────────────────────────────────────────────────────────────────── function buildTaskUrl(unit) { const courseDto = unsafeWindow.courseDto || {}; const termDto = unsafeWindow.termDto || {}; const shortName = courseDto.shortName || ""; const courseId = courseDto.id || ""; const termId = termDto.id || ""; return "/learn/" + shortName + "-" + courseId + "?tid=" + termId + "#/learn/content?type=detail&id=" + unit.lessonId + "&cid=" + unit.id; } function navigateToUnit(unit) { const url = buildTaskUrl(unit); const hashPart = url.split("#")[1]; if (hashPart) { location.hash = "#" + hashPart; } else { location.href = url; } } function waitForElement(selector, timeout) { timeout = timeout || 15000; return new Promise((resolve, reject) => { const el = document.querySelector(selector); if (el) return resolve(el); const timer = setTimeout(function () { observer.disconnect(); reject(new Error("Timeout waiting for " + selector)); }, timeout); const observer = new MutationObserver(function () { const found = document.querySelector(selector); if (found) { clearTimeout(timer); observer.disconnect(); resolve(found); } }); observer.observe(document.body, { childList: true, subtree: true }); }); } function waitForContentLoad(unit) { if (unit.contentType === 1) { return waitForElement("video", 20000); } return waitForElement(".unit-study-box, .textbook-box, .rich-text-box, #j-unit-study-box", 15000) .catch(function () { return sleep(3000); }); } // ─── 任务处理器 ────────────────────────────────────────────────────────────── function handleVideo(unit, config, onDone) { waitForElement("video", 20000).then(function (video) { video.playbackRate = config.videoSpeed; video.muted = true; video.volume = 0; const duration = unit.durationInSeconds || 600; const timeoutMs = (duration / config.videoSpeed + 30) * 1000; const timeoutId = setTimeout(function () { log("视频超时,强制切换"); quizObserver.disconnect(); video.removeEventListener("pause", onPause); onDone(); }, timeoutMs); video.addEventListener("ended", function () { clearTimeout(timeoutId); quizObserver.disconnect(); video.removeEventListener("pause", onPause); onDone(); }, { once: true }); let pauseDebounce = 0; function onPause() { if (video.ended) return; clearTimeout(pauseDebounce); pauseDebounce = setTimeout(function () { if (video.paused && !video.ended) { log("视频被暂停 t=" + Math.round(video.currentTime) + ",恢复播放"); video.play().catch(function () { }); } }, 500); } video.addEventListener("pause", onPause); const quizObserver = new MutationObserver(function () { const container = document.querySelector("#j-anchorContainer"); if (!container || !container.offsetParent) return; const submitBtn = container.querySelector("a.j-submit"); const contBtn = container.querySelector("a.j-continue"); if (contBtn && contBtn.offsetParent) { contBtn.click(); log("anchor 题:点击继续学习"); return; } if (submitBtn && submitBtn.offsetParent) { const radios = container.querySelectorAll("input[type=radio]"); const checkboxes = container.querySelectorAll("input[type=checkbox]"); if (radios.length && ![...radios].some(r => r.checked)) { radios[0].click(); log("anchor 题:选择 A 选项"); } else if (checkboxes.length && ![...checkboxes].some(c => c.checked)) { checkboxes[0].click(); log("anchor 题:勾选第一项"); } submitBtn.click(); log("anchor 题:点击提交"); } }); quizObserver.observe(document.body, { childList: true, subtree: true }); // 循环模式:每次进入视频强制从 0 开始(否则平台会把上次播完的视频 currentTime 留在末尾, // 加载即 ended,导致 onDone 立刻触发、视频时长无法累计) const startFromZero = function () { try { video.currentTime = 0; } catch (e) { /* 部分浏览器需 metadata 就绪 */ } video.play().catch(function () { }); }; if (config.loopVideos) { if (video.readyState >= 1) { startFromZero(); } else { video.addEventListener("loadedmetadata", startFromZero, { once: true }); video.load(); } } else { video.play().catch(function () { }); } }).catch(function (err) { log("等待视频元素失败: " + err.message); onDone(); }); } function handleDocument(unit, config, onDone) { waitForContentLoad(unit).then(function () { return sleep(config.docWaitTime * 1000); }).then(function () { onDone(); }).catch(function () { sleep(config.docWaitTime * 1000).then(onDone); }); } function handleRichText(unit, config, onDone) { handleDocument(unit, config, onDone); } // ─── 测试题解析 ────────────────────────────────────────────────────────────── // 已根据真实抓取(测试题示例.txt 单选变体)校准。 // 单选/多选共享 `.u-questionItem`;判断/填空/简答未来若结构不同再细分扩展。 // type 编码:'0' 单选 / '1' 多选 / '2' 填空 / '3' 判断 / '4' 简答 const QUIZ_SELECTORS = { questionItem: ".u-questionItem", questionTitle: ".qaDescription .j-richTxt, .j-title .j-richTxt, .j-richTxt", radioOption: "input[type=radio].u-tbi, input[type=radio]", checkboxOption: "input[type=checkbox].u-tbi, input[type=checkbox]", optionText: ".optionCnt.f-richEditorText, .optionCnt", optionLabel: "label.u-tbl, label", blankInput: "input[type=text].j-blank, input[type=text]", textarea: "textarea.j-textarea, textarea, [contenteditable=true].ql-editor", submitBtn: "a.j-submit", }; function isJudgeOption(optEl) { return !!(optEl && (optEl.querySelector(".u-icon-correct") || optEl.querySelector(".u-icon-wrong"))); } function detectQuestionType(itemEl) { const textareas = itemEl.querySelectorAll(QUIZ_SELECTORS.textarea); const checkboxes = itemEl.querySelectorAll(QUIZ_SELECTORS.checkboxOption); const radios = itemEl.querySelectorAll(QUIZ_SELECTORS.radioOption); const blanks = itemEl.querySelectorAll(QUIZ_SELECTORS.blankInput); if (textareas.length && !radios.length && !checkboxes.length) return "4"; if (checkboxes.length) return "1"; if (radios.length) { const opts = Array.from(itemEl.querySelectorAll(QUIZ_SELECTORS.optionText)); // icourse163 把判断题与单选同属 m-choiceQuestion,靠选项里的 u-icon-correct/u-icon-wrong 图标区分 if (opts.length === 2 && opts.every(isJudgeOption)) return "3"; const optTexts = opts.map(function (e) { return (e.innerText || "").replace(/[\s ​ ]+/g, "").toLowerCase(); }); const isJudgeByText = optTexts.length === 2 && optTexts.every(function (t) { return /^(对|错|正确|错误|是|否|true|false)$/.test(t); }); if (isJudgeByText) return "3"; return "0"; } if (blanks.length) return "2"; return null; } function extractOptionTexts(itemEl, type) { const opts = Array.from(itemEl.querySelectorAll(QUIZ_SELECTORS.optionText)); if (type === "3") { return opts.map(function (e) { if (e.querySelector(".u-icon-correct")) return "对"; if (e.querySelector(".u-icon-wrong")) return "错"; return (e.innerText || "").trim(); }); } return opts.map(function (e) { return (e.innerText || "").trim(); }); } function parseQuiz() { const items = document.querySelectorAll(QUIZ_SELECTORS.questionItem); const questions = []; items.forEach(function (item, idx) { const titleEl = item.querySelector(QUIZ_SELECTORS.questionTitle); const text = titleEl ? (titleEl.innerText || "").trim() : ""; if (!text) return; const type = detectQuestionType(item); if (!type) return; const options = extractOptionTexts(item, type); questions.push({ index: idx, el: item, text: text, type: type, options: options }); }); return questions; } // ─── 测试题填答 ────────────────────────────────────────────────────────────── function triggerInput(el) { el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); } function getOptionItems(itemEl) { // icourse163 选项结构:.j-choicebox > ul.choices > li > input + label // 早先用 "li > label" 会把题干里的 .scoreLabel 也扫进来;改成先锁 li 容器再在 li 内找 let lis = itemEl.querySelectorAll(".j-choicebox ul.choices > li"); if (!lis.length) lis = itemEl.querySelectorAll("ul.choices > li"); if (!lis.length) lis = itemEl.querySelectorAll(".j-choicebox > ul > li, .j-choicebox li"); return lis; } function fillQuizAnswer(q, answer) { const item = q.el; if (q.type === "0") { const letter = (answer.match(/[A-Z]/i) || ["A"])[0].toUpperCase(); const idx = letter.charCodeAt(0) - 65; const lis = getOptionItems(item); const li = lis[idx]; if (!li) return false; const label = li.querySelector("label.u-tbl, label"); const radio = li.querySelector("input[type=radio]"); if (label) { label.click(); return true; } if (radio) { radio.click(); return true; } return false; } if (q.type === "1") { const letters = (answer.toUpperCase().match(/[A-Z]/g) || []); if (!letters.length) return false; const lis = getOptionItems(item); let hit = 0; letters.forEach(function (L) { const i = L.charCodeAt(0) - 65; const li = lis[i]; if (!li) return; const box = li.querySelector("input[type=checkbox]"); if (box && !box.checked) { const label = li.querySelector("label.u-tbl, label"); if (label) label.click(); else box.click(); hit++; } }); return hit > 0; } if (q.type === "2") { const parts = answer.split("###").map(function (s) { return s.trim(); }); const blanks = item.querySelectorAll(QUIZ_SELECTORS.blankInput); let hit = 0; blanks.forEach(function (b, i) { if (parts[i]) { b.value = parts[i]; triggerInput(b); hit++; } }); return hit > 0; } if (q.type === "3") { const lis = getOptionItems(item); const wantTrue = /(对|正确|是|true)/i.test(answer); for (let i = 0; i < lis.length; i++) { const li = lis[i]; const optCnt = li.querySelector(QUIZ_SELECTORS.optionText) || li; const isTrueOpt = !!optCnt.querySelector(".u-icon-correct") || /^(对|正确|是|true)$/i.test((optCnt.innerText || "").trim()); const isWrongOpt = !!optCnt.querySelector(".u-icon-wrong") || /^(错|错误|否|false)$/i.test((optCnt.innerText || "").trim()); const match = wantTrue ? isTrueOpt : isWrongOpt; if (match) { const label = li.querySelector("label.u-tbl, label"); const radio = li.querySelector("input[type=radio]"); if (label) label.click(); else if (radio) radio.click(); else return false; return true; } } return false; } if (q.type === "4" || q.type === "5" || q.type === "6" || q.type === "7") { const ta = item.querySelector(QUIZ_SELECTORS.textarea); if (!ta) return false; if (ta.tagName === "TEXTAREA") { ta.value = answer; triggerInput(ta); } else { ta.innerHTML = "

" + escapeHtml(answer).replace(/\n/g, "
") + "

"; triggerInput(ta); } return true; } return false; } function waitForReplayButton(timeoutMs, isStale) { const start = Date.now(); return new Promise(function (resolve) { (function poll() { if (isStale && isStale()) { resolve(false); return; } const replay = document.querySelector("a.j-replay"); const visible = replay && replay.offsetParent !== null && getComputedStyle(replay).display !== "none"; if (visible) { resolve(true); return; } if (Date.now() - start > timeoutMs) { resolve(false); return; } setTimeout(poll, 500); })(); }); } function doQuiz(unit, onDone, isStale) { isStale = isStale || function () { return false; }; const questions = parseQuiz(); log("[测试题] " + unit.name + " — 解析到 " + questions.length + " 道题"); if (!questions.length) { log("[测试题] 解析失败,跳过"); onDone(); return; } let filled = 0; let index = 0; function next() { if (isStale()) { log("[测试题] 已切到其他任务,停止处理 " + unit.name); return; } if (index >= questions.length) { if (filled === 0) { log("[测试题] 全部题目 LLM 失败或填答失败,不提交"); onDone(); return; } const btn = document.querySelector(QUIZ_SELECTORS.submitBtn); if (!btn) { log("[测试题] 未找到提交按钮,跳过"); onDone(); return; } log("[测试题] 已填 " + filled + "/" + questions.length + " 道,点击提交"); btn.click(); waitForReplayButton(15000, isStale).then(function (ok) { if (isStale()) return; log(ok ? "[测试题] 提交完成(重做按钮已出现)" : "[测试题] 提交后 15s 内未确认,继续下一项"); onDone(); }); return; } const q = questions[index++]; askLLM(q.text, q.type, q.options).then(function (ans) { if (isStale()) return; // detached 防御:q.el 已不在文档树则放弃此题 if (!document.contains(q.el)) { log(" Q" + index + " 节点已 detached,放弃"); sleep(300).then(next); return; } const ok = fillQuizAnswer(q, ans); if (ok) { filled++; log(" Q" + index + " (type=" + q.type + ") → " + ans.slice(0, 40)); } else { log(" Q" + index + " 填答失败:" + ans.slice(0, 40)); } sleep(800).then(next); }, function (err) { if (isStale()) return; log(" Q" + index + " LLM 失败:" + err.message); sleep(500).then(next); }); } next(); } // 是否已有"重做"按钮(提示该测试题已被提交过 / 平台已记完成) function hasReplayButton() { const replay = document.querySelector("a.j-replay"); return !!(replay && replay.offsetParent !== null && getComputedStyle(replay).display !== "none"); } // 测试题专项:仅做单选/多选/判断(0/1/3),填空与简答放过 function doQuizChoiceOnly(unit, onDone, isStale) { isStale = isStale || function () { return false; }; const all = parseQuiz(); const questions = all.filter(function (q) { return q.type === "0" || q.type === "1" || q.type === "3"; }); const skipped = all.length - questions.length; log("[测试题专项] " + unit.name + " — 解析 " + all.length + " 题,做 " + questions.length + " 题" + (skipped ? "(跳过 " + skipped + " 道填空/简答)" : "")); if (!questions.length) { log("[测试题专项] 无可做题目,跳过"); onDone(); return; } let filled = 0; let index = 0; function next() { if (isStale()) { log("[测试题专项] 已中断,停止处理 " + unit.name); return; } if (index >= questions.length) { if (filled === 0) { log("[测试题专项] 全部 LLM 失败或填答失败,不提交"); onDone(); return; } const btn = document.querySelector(QUIZ_SELECTORS.submitBtn); if (!btn) { log("[测试题专项] 未找到提交按钮,跳过"); onDone(); return; } log("[测试题专项] 已填 " + filled + "/" + questions.length + " 道,点击提交"); btn.click(); waitForReplayButton(15000, isStale).then(function (ok) { if (isStale()) return; log(ok ? "[测试题专项] 提交完成" : "[测试题专项] 提交后 15s 内未确认,继续下一项"); onDone(); }); return; } const q = questions[index++]; askLLM(q.text, q.type, q.options).then(function (ans) { if (isStale()) return; if (!document.contains(q.el)) { log(" Q" + index + " 节点已 detached,放弃"); sleep(300).then(next); return; } const ok = fillQuizAnswer(q, ans); if (ok) { filled++; log(" Q" + index + " (type=" + q.type + ") → " + ans.slice(0, 40)); } else { log(" Q" + index + " 填答失败:" + ans.slice(0, 40)); } sleep(800).then(next); }, function (err) { if (isStale()) return; log(" Q" + index + " LLM 失败:" + err.message); sleep(500).then(next); }); } next(); } // ─── 测试题(contentType=5) ──────────────────────────────────────────────── // 记录上一次解析到的第一题文本,跳到新测试题时校验"文本变了"才认为新 DOM 已加载 let __lastQuizFirstText = ""; function getFirstQuestionText() { const first = document.querySelector(QUIZ_SELECTORS.questionItem); if (!first) return ""; const t = first.querySelector(QUIZ_SELECTORS.questionTitle); return t ? (t.innerText || "").trim() : ""; } function waitForNewQuiz(prevText, timeoutMs) { const start = Date.now(); return new Promise(function (resolve) { (function poll() { const cur = getFirstQuestionText(); if (cur && cur !== prevText) { resolve(cur); return; } if (Date.now() - start > timeoutMs) { resolve(cur); return; } setTimeout(poll, 400); })(); }); } function handleQuiz(unit, config, onDone, sid, mooc) { const isStale = function () { return mooc && mooc.isStale && mooc.isStale(sid); }; waitForContentLoad(unit).then(function () { if (isStale()) return null; // 等到新题目 DOM 真出现(文本不等于上次的题目),最多 8 秒 return waitForNewQuiz(__lastQuizFirstText, 8000); }).then(function (curText) { if (isStale()) return; if (curText) __lastQuizFirstText = curText; // quizOnly 模式:已有重做按钮的跳过;其余仅做单选/多选/判断 if (config.quizOnly) { if (hasReplayButton()) { log("[仅做测试题] " + unit.name + " — 已有重做按钮,跳过"); onDone(); return; } doQuizChoiceOnly(unit, onDone, isStale); } else { doQuiz(unit, onDone, isStale); } }).catch(function (err) { if (isStale()) return; log("[测试题] 异常:" + (err && err.message || err)); onDone(); }); } // ─── 讨论题解析与回帖 ──────────────────────────────────────────────────────── // 已根据真实抓取(讨论题示例.txt + 候选编辑器.txt + 候选提交.txt)校准。 // 注意:editor 和 submitBtn 只有在用户/脚本点了 `a.replyBtn.j-replyBtn` 之后才会渲染出来。 const DISCUSS_SELECTORS = { topicContainer: ".j-post", topicTitle: ".j-post h3.j-title.title, .j-post .j-title", topicBody: ".j-post .content.j-content.f-richEditorText", replyList: ".j-reply-all .j-data-list", replyItem: ".m-detailInfoItem", replyContent: ".m-detailInfoItem .j-content.f-richEditorText", replyAuthor: ".m-detailInfoItem .j-name", replyBtn: "a.replyBtn.j-replyBtn", editor: ".rich-editor .ql-editor[contenteditable=true]", submitBtn: "a.j-edit-btn.editbtn", }; const REPLY_TAILS = [ ",我也是这样想的。", ",受教了。", ",深有同感。", ",学习了。", ",赞同这个观点。", ",思路很清晰。", ",确实如此。", ",分析得很到位。", ",谢谢分享。", ",很有启发。", ]; function pickTail() { return REPLY_TAILS[Math.floor(Math.random() * REPLY_TAILS.length)]; } function stripTrailingPunct(s) { return s.replace(/[。!?!?\.\s]+$/g, ""); } function parseDiscussTopic() { const titleEl = document.querySelector(DISCUSS_SELECTORS.topicTitle); const bodyEl = document.querySelector(DISCUSS_SELECTORS.topicBody); const title = titleEl ? (titleEl.innerText || "").trim() : ""; const body = bodyEl ? (bodyEl.innerText || "").trim() : ""; if (!title && !body) return ""; return (title + "\n" + body).trim(); } function findLastReply() { const list = document.querySelector(DISCUSS_SELECTORS.replyList); if (!list) return null; const items = list.querySelectorAll(DISCUSS_SELECTORS.replyItem); if (!items.length) return null; for (let i = items.length - 1; i >= 0; i--) { const c = items[i].querySelector(DISCUSS_SELECTORS.replyContent); const text = c ? (c.innerText || "").trim() : ""; if (text && text.length > 4) return text; } return null; } function openReplyEditor() { if (document.querySelector(DISCUSS_SELECTORS.editor)) return Promise.resolve(true); const replyBtn = document.querySelector(DISCUSS_SELECTORS.replyBtn); if (!replyBtn) return Promise.resolve(false); replyBtn.click(); return waitForElement(DISCUSS_SELECTORS.editor, 5000).then( function () { return true; }, function () { return false; }, ); } function fillEditor(text) { const ed = document.querySelector(DISCUSS_SELECTORS.editor); if (!ed) return false; if (ed.tagName === "TEXTAREA") { ed.value = text; } else { ed.innerHTML = "

" + escapeHtml(text).replace(/\n/g, "
") + "

"; } ed.dispatchEvent(new Event("input", { bubbles: true })); ed.dispatchEvent(new Event("change", { bubbles: true })); return true; } // ─── 讨论题(contentType=6) ──────────────────────────────────────────────── function handleDiscuss(unit, config, onDone, sid, mooc) { const isStale = function () { return mooc && mooc.isStale && mooc.isStale(sid); }; waitForContentLoad(unit).then(function () { if (isStale()) return; return sleep(2000); }).then(function () { if (isStale()) return; doDiscuss(unit, onDone, isStale); }).catch(function (err) { if (isStale()) return; log("[讨论题] 异常:" + (err && err.message || err)); onDone(); }); } function doDiscuss(unit, onDone, isStale) { isStale = isStale || function () { return false; }; const topic = parseDiscussTopic(); const prev = findLastReply(); log("[讨论题] " + unit.name + (prev ? " — 复制上一回复" : " — 无回复,调用 LLM")); const buildAndPost = function (body) { if (isStale()) return; const finalBody = stripTrailingPunct(body) + pickTail(); openReplyEditor().then(function (opened) { if (isStale()) return; if (!opened) { log("[讨论题] 无法打开回复编辑器(找不到 .j-replyBtn 或 5s 内未渲染),跳过"); onDone(); return; } return sleep(500).then(function () { if (isStale()) return; const ok = fillEditor(finalBody); if (!ok) { log("[讨论题] 编辑器定位失败,跳过"); onDone(); return; } return sleep(800).then(function () { if (isStale()) return; const btn = document.querySelector(DISCUSS_SELECTORS.submitBtn); if (btn) { btn.click(); log("[讨论题] 已提交:" + finalBody.slice(0, 40)); } else { log("[讨论题] 未找到提交按钮"); } return sleep(3000).then(function () { if (isStale()) return; onDone(); }); }); }); }); }; if (prev) { buildAndPost(prev); } else { if (!topic) { log("[讨论题] 主题为空,跳过"); onDone(); return; } askLLM(topic, "4", []).then(function (ans) { if (isStale()) return; buildAndPost(ans); }, function (err) { if (isStale()) return; log("[讨论题] LLM 失败:" + err.message + ",跳过"); onDone(); }); } } // ─── 日志 ──────────────────────────────────────────────────────────────────── const logLines = []; function log(msg) { const time = new Date().toLocaleTimeString(); const line = "[" + time + "] " + msg; logLines.push(line); if (logLines.length > 30) logLines.shift(); console.log("[慕课助手]", msg); updateUI(); } // ─── LLM 调用 ──────────────────────────────────────────────────────────────── const LLM_PROMPTS = { "0": "你是答题助手。下面是一道单选题,请只回复一个大写字母(A/B/C/D...)作为答案,不要任何解释。", "1": "你是答题助手。下面是一道多选题,请只回复选项字母序列(如 ABD),不要任何解释。", "2": "你是答题助手。下面是一道填空题,可能有多个空。请用 ### 分隔每个空的答案,不要任何解释。例:北京###上海###广州", "3": "你是答题助手。下面是一道判断题,请只回复『对』或『错』,不要任何解释。", "4": "你是答题助手。下面是一道简答题,请给出准确简洁的答案,不超过 200 字,不要列点。", "5": "你是答题助手。下面是一道名词解释,请给出简洁定义,不超过 100 字。", "6": "你是答题助手。下面是一道论述题,请给出条理清晰的回答,不超过 300 字。", "7": "你是答题助手。下面是一道计算题,请给出解题步骤和最终答案。", }; function callLlmRaw(cfg, question, type, options) { if (!cfg.apiKey || !cfg.baseUrl || !cfg.model) return Promise.reject(new Error("LLM 配置不完整")); const systemPrompt = LLM_PROMPTS[type] || LLM_PROMPTS["4"]; let userContent = "题目:" + question; if (options && options.length) { userContent += "\n\n选项:\n" + options.map(function (o, i) { return String.fromCharCode(65 + i) + ". " + o; }).join("\n"); } const payload = JSON.stringify({ model: cfg.model, temperature: cfg.temperature, messages: [ { role: "system", content: systemPrompt }, { role: "user", content: userContent }, ], }); return new Promise(function (resolve, reject) { GM_xmlhttpRequest({ method: "POST", url: cfg.baseUrl, headers: { "Content-Type": "application/json", "Authorization": "Bearer " + cfg.apiKey, }, data: payload, timeout: cfg.timeout, onload: function (resp) { if (resp.status < 200 || resp.status >= 300) { reject(new Error("LLM HTTP " + resp.status + ": " + (resp.responseText || "").slice(0, 200))); return; } try { const data = JSON.parse(resp.responseText); const text = (data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) || (data.content && data.content[0] && data.content[0].text) || ""; if (!text) { reject(new Error("LLM 返回为空")); return; } resolve(text.trim()); } catch (e) { reject(new Error("LLM 响应解析失败:" + e.message)); } }, onerror: function () { reject(new Error("LLM 网络错误")); }, ontimeout: function () { reject(new Error("LLM 超时")); }, }); }); } function askLLM(question, type, options) { const cfg = getLlmConfig(); if (!cfg.enabled) return Promise.reject(new Error("LLM 未启用")); return callLlmRaw(cfg, question, type, options); } // ─── 主控制器 ──────────────────────────────────────────────────────────────── class IcourseMooc { constructor() { this.config = getConfig(); // 自动放开 5/6:旧版默认把它们加入 skipTypes,会导致探测/答题无法触发 if (this.config.skipTypes && (this.config.skipTypes.includes(5) || this.config.skipTypes.includes(6))) { this.config.skipTypes = this.config.skipTypes.filter(function (t) { return t !== 5 && t !== 6; }); saveConfig(this.config); log("已自动迁移 skipTypes:放开测试题/讨论题供探测"); } this.state = "IDLE"; this.tasks = []; this.currentIndex = 0; this.paused = false; this.sessionId = 0; this.pendingJumpTarget = null; this._refreshAfterTask = false; } isStale(sid) { return sid !== this.sessionId || this.paused; } async start() { if (this.state !== "IDLE") return; this.paused = false; this.pendingJumpTarget = null; this._refreshAfterTask = false; this.config = getConfig(); log("开始获取任务列表..."); this.state = "FETCHING"; updateUI(); const termId = unsafeWindow.termDto && unsafeWindow.termDto.id; if (!termId) { log("错误:未找到 termId,请在课程学习页运行"); this.state = "IDLE"; updateUI(); return; } try { const allUnits = await fetchTaskList(termId); this.allUnits = allUnits; const cfg = this.config; // icourse163 的"绿色已完成"判定:只要 learnTime>0 就算已完成。 // quizOnly=true 时只保留测试题(contentType=5),且不过滤已完成(平台bug:未做也可能记完成)。 this.tasks = allUnits.filter(function (u) { if (cfg.skipTypes.includes(u.contentType)) return false; if (cfg.quizOnly) return u.contentType === 5; if ((u.completePercent || 0) >= 1.0) return false; if (u.learnTime && u.learnTime > 0) return false; return true; }); log("找到 " + this.tasks.length + " 个" + (cfg.quizOnly ? "测试题" : "未完成") + "任务(共 " + allUnits.length + " 个)"); this.currentIndex = 0; await this._loop(); } catch (err) { log("获取任务列表失败: " + err.message); this.state = "IDLE"; updateUI(); } } pause() { this.paused = true; this.state = "PAUSED"; log("已暂停"); updateUI(); } resume() { if (this.state !== "PAUSED") return; this.paused = false; if (this.pendingJumpTarget) { const target = this.pendingJumpTarget; this.pendingJumpTarget = null; const idx = this.tasks.findIndex(function (u) { return u.id === target.id; }); if (idx >= 0) { // 目标在队列里 → 把"进行中"指针移到该位置,完成后 currentIndex++ 自然走到它的下一项 this.currentIndex = idx; this._refreshAfterTask = false; log("继续运行:跳到队列第 " + (idx + 1) + " 项「" + target.name + "」,完成后向下继续"); } else { // 目标不在队列(如点了"全部任务"里的已完成项)→ 插队执行,完成后刷新列表再继续 this.tasks.splice(this.currentIndex, 0, target); this._refreshAfterTask = true; log("继续运行:临时执行「" + target.name + "」(不在队列),完成后将刷新任务列表"); } } else { log("继续运行..."); } this._loop(); } stop() { this.paused = true; this.state = "IDLE"; this.pendingJumpTarget = null; this._refreshAfterTask = false; log("已停止"); updateUI(); } jumpTo(unit) { // 跳转 = 导航到目标 + 暂停。让用户检查后再决定是否点 ▶ 继续。 // 续跑时:若目标在队列里 → currentIndex 移过去,完成后接它的下一项继续; // 若不在队列里 → 临时插队执行,完成后刷新列表回到正常未完成流程。 if (!unit) return; // 让正在跑的旧任务的所有异步回调(askLLM/sleep/poll)作废 this.sessionId++; this.pendingJumpTarget = unit; this.paused = true; this.state = "PAUSED"; log("[跳转] " + unit.name + " — 已导航并暂停,请确认后点 ▶ 继续"); try { navigateToUnit(unit); } catch (e) { log("[跳转] 导航失败:" + e.message); } updateUI(); } async _refreshTaskList() { const termId = unsafeWindow.termDto && unsafeWindow.termDto.id; if (!termId) throw new Error("未找到 termId"); const allUnits = await fetchTaskList(termId); this.allUnits = allUnits; const cfg = this.config; this.tasks = allUnits.filter(function (u) { if (cfg.skipTypes.includes(u.contentType)) return false; if (cfg.quizOnly) return u.contentType === 5; if ((u.completePercent || 0) >= 1.0) return false; if (u.learnTime && u.learnTime > 0) return false; return true; }); this.currentIndex = 0; log("[刷新] 剩余 " + this.tasks.length + " 个" + (cfg.quizOnly ? "测试题" : "未完成") + "任务(共 " + allUnits.length + " 个)"); } async _loop() { if (this.paused) return; if (this.currentIndex >= this.tasks.length) { // 列表整体循环:把视频任务重新拼到队列末尾,currentIndex 继续走 if (this.config.loopVideos && !this.config.skipVideo) { const videos = (this.allUnits || []).filter(function (u) { return u.contentType === 1; }); if (videos.length) { this.tasks = this.tasks.concat(videos); log("[循环] 已追加 " + videos.length + " 个视频到队列末尾,继续刷时长"); } else { log("所有任务已完成!"); this.state = "IDLE"; updateUI(); return; } } else { log("所有任务已完成!"); this.state = "IDLE"; updateUI(); return; } } // 每个任务一个全新 sessionId,老任务的异步回调(askLLM/wait/sleep)通过 isStale() 自动作废 const sid = ++this.sessionId; const unit = this.tasks[this.currentIndex]; log("[" + (this.currentIndex + 1) + "/" + this.tasks.length + "] " + unit.name + " (类型 " + unit.contentType + ")"); this.state = "NAVIGATING"; updateUI(); navigateToUnit(unit); await sleep(1500); if (this.isStale(sid)) return; this.state = "WAITING_LOAD"; updateUI(); const self = this; const onDone = async function () { if (self.isStale(sid)) return; // 跳转任务完成:刷新列表 → 切换到"剩余未完成"队列,currentIndex 重置为 0,再 _loop if (self._refreshAfterTask) { self._refreshAfterTask = false; self.state = "FETCHING"; updateUI(); log("[跳转] 任务完成,刷新任务列表..."); try { await self._refreshTaskList(); } catch (e) { log("[跳转] 刷新任务列表失败:" + e.message + ",按现有队列继续"); self.currentIndex++; } await sleep(self.config.taskInterval * 1000); if (self.isStale(sid)) return; self._loop(); return; } self.currentIndex++; await sleep(self.config.taskInterval * 1000); if (self.isStale(sid)) return; self._loop(); }; this.state = "PROCESSING"; updateUI(); if (unit.contentType === 1) { if (this.config.skipVideo) { log("[跳过视频] " + unit.name); onDone(); return; } handleVideo(unit, this.config, onDone); } else if (unit.contentType === 3) { handleDocument(unit, this.config, onDone); } else if (unit.contentType === 4) { handleRichText(unit, this.config, onDone); } else if (unit.contentType === 5) { handleQuiz(unit, this.config, onDone, sid, this); } else if (unit.contentType === 6) { handleDiscuss(unit, this.config, onDone, sid, this); } else { log("跳过类型 " + unit.contentType + ": " + unit.name); onDone(); } } } const mooc = new IcourseMooc(); // ─── UI ────────────────────────────────────────────────────────────────────── GM_addStyle([ "#icourse-btn{", "position:fixed;bottom:20px;right:20px;z-index:99999;", "width:48px;height:48px;border-radius:50%;", "background:#e02020;color:#fff;font-size:20px;", "border:none;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.3);", "display:flex;align-items:center;justify-content:center;", "}", "#icourse-panel{", "position:fixed;left:12px;top:50%;", "z-index:99999;width:260px;background:rgba(20,20,20,.92);", "color:#eee;border-radius:8px;", "font-size:12px;font-family:monospace;line-height:1.6;", "box-shadow:0 4px 16px rgba(0,0,0,.5);", "}", "#icourse-panel .head{", "display:flex;align-items:center;justify-content:space-between;", "padding:8px 10px;cursor:move;user-select:none;", "background:rgba(255,255,255,.06);border-radius:8px 8px 0 0;", "}", "#icourse-panel .head h4{margin:0;color:#fff;font-size:13px;}", "#icourse-panel .head .btns{display:flex;gap:4px;}", "#icourse-panel .head .icon-btn{", "width:20px;height:20px;line-height:18px;text-align:center;", "background:transparent;color:#ccc;border:1px solid #555;", "border-radius:3px;cursor:pointer;font-size:12px;padding:0;", "}", "#icourse-panel .head .icon-btn:hover{background:#333;color:#fff;}", "#icourse-panel .body{padding:10px 12px 12px;}", "#icourse-panel.collapsed .body{display:none;}", "#icourse-panel.collapsed{width:auto;}", "#icourse-panel .progress{color:#4fc;margin-bottom:6px;}", "#icourse-panel .log-box{max-height:160px;overflow-y:auto;}", "#icourse-panel .log-line{border-bottom:1px solid #333;padding:2px 0;}", "#icourse-speed{margin-top:8px;}", "#icourse-speed label{color:#aaa;}", "#icourse-speed select{background:#333;color:#eee;border:1px solid #555;border-radius:3px;}", "#icourse-video-opts{margin-top:6px;display:flex;gap:10px;color:#aaa;font-size:11px;}", "#icourse-video-opts label{cursor:pointer;display:flex;align-items:center;gap:4px;}", "#icourse-video-opts input{margin:0;}", "#icourse-video-opts label.disabled{opacity:.4;cursor:not-allowed;}", "#icourse-popup-viewer{", "position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);", "z-index:100000;width:720px;max-height:80vh;background:#1d1d1d;", "color:#eee;border-radius:8px;font-family:monospace;font-size:12px;", "box-shadow:0 8px 32px rgba(0,0,0,.6);display:flex;flex-direction:column;", "}", "#icourse-popup-viewer .pv-head{", "padding:10px 12px;background:#2a2a2a;border-radius:8px 8px 0 0;", "display:flex;justify-content:space-between;align-items:center;", "}", "#icourse-popup-viewer .pv-body{padding:10px 12px;overflow:auto;flex:1;}", "#icourse-popup-viewer pre{", "background:#000;padding:8px;border-radius:4px;", "white-space:pre-wrap;word-break:break-all;margin:6px 0;", "}", "#icourse-popup-viewer .pv-actions button{", "background:#444;color:#eee;border:none;padding:4px 10px;", "border-radius:3px;cursor:pointer;margin-left:6px;", "}", /* LLM 配置面板专属样式 */ "#icourse-popup-viewer.llm-panel{width:520px;}", "#icourse-popup-viewer.llm-panel .pv-body{padding:16px 18px;font-family:'Segoe UI','PingFang SC',sans-serif;}", ".llm-row{display:grid;grid-template-columns:90px 1fr;align-items:center;gap:10px;margin-bottom:12px;}", ".llm-row label.k{color:#aaa;font-size:12px;}", ".llm-row .v{display:flex;align-items:center;gap:6px;}", ".llm-row .v input[type=text],.llm-row .v input[type=password],.llm-row .v input[type=number],.llm-row .v select{", "flex:1;min-width:0;background:#2a2a2a;color:#eee;border:1px solid #444;", "border-radius:4px;padding:5px 8px;font-size:12px;font-family:inherit;outline:none;", "}", ".llm-row .v input:focus,.llm-row .v select:focus{border-color:#4fc;}", ".llm-row .v input::placeholder{color:#555;}", ".llm-row.dual{grid-template-columns:90px 1fr 90px 1fr;}", ".llm-row .eye{", "background:#333;color:#ccc;border:1px solid #444;border-radius:4px;", "padding:4px 8px;cursor:pointer;font-size:12px;", "}", ".llm-row .eye:hover{background:#4a4a4a;color:#fff;}", /* 启用开关 */ ".llm-switch{", "display:flex;align-items:center;justify-content:space-between;", "background:#262626;padding:10px 14px;border-radius:6px;margin-bottom:14px;", "border-left:3px solid #555;transition:border-color .2s;", "}", ".llm-switch.on{border-left-color:#4f4;}", ".llm-switch .lbl{font-size:13px;color:#eee;}", ".llm-switch .sub{font-size:11px;color:#888;margin-top:2px;}", ".llm-switch input{display:none;}", ".llm-switch .track{", "width:40px;height:22px;background:#444;border-radius:11px;", "position:relative;cursor:pointer;transition:background .2s;", "}", ".llm-switch .track::after{", "content:'';position:absolute;left:2px;top:2px;width:18px;height:18px;", "background:#ddd;border-radius:50%;transition:left .2s,background .2s;", "}", ".llm-switch.on .track{background:#2ea043;}", ".llm-switch.on .track::after{left:20px;background:#fff;}", /* 测试结果 */ "#cf-test-out{margin-top:6px;font-size:12px;min-height:18px;}", "#cf-test-out.ok{color:#4f4;}", "#cf-test-out.err{color:#f66;}", "#cf-test-out.pending{color:#aaa;}", /* toast */ "#cf-toast{", "position:absolute;top:10px;left:50%;transform:translateX(-50%);", "background:#2ea043;color:#fff;padding:6px 14px;border-radius:4px;", "font-size:12px;opacity:0;transition:opacity .2s,top .2s;pointer-events:none;", "}", "#cf-toast.show{opacity:1;top:46px;}", ].join("")); function buildUI() { const btn = document.createElement("button"); btn.id = "icourse-btn"; btn.textContent = "▶"; btn.title = "慕课助手"; document.body.appendChild(btn); const panel = document.createElement("div"); panel.id = "icourse-panel"; panel.innerHTML = [ "
", "

慕课自动完成助手

", "
", "", "", "", "
", "
", "
", "
就绪
", "
", "", "", "
", "
", "", "", "", "
", "
", "
", ].join(""); document.body.appendChild(panel); const uiState = GM_getValue("icourse_ui", null) || {}; if (typeof uiState.left === "number" && typeof uiState.top === "number") { panel.style.left = uiState.left + "px"; panel.style.top = uiState.top + "px"; } if (uiState.collapsed) { panel.classList.add("collapsed"); const mb = document.getElementById("icourse-min"); if (mb) mb.textContent = "□"; } const head = document.getElementById("icourse-head"); let dragging = false, dragDx = 0, dragDy = 0, didDrag = false; head.addEventListener("mousedown", function (e) { if (e.target.closest(".icon-btn")) return; dragging = true; didDrag = false; const rect = panel.getBoundingClientRect(); dragDx = e.clientX - rect.left; dragDy = e.clientY - rect.top; panel.style.transform = "none"; e.preventDefault(); }); document.addEventListener("mousemove", function (e) { if (!dragging) return; didDrag = true; const maxL = window.innerWidth - panel.offsetWidth; const maxT = window.innerHeight - panel.offsetHeight; const nl = Math.max(0, Math.min(maxL, e.clientX - dragDx)); const nt = Math.max(0, Math.min(maxT, e.clientY - dragDy)); panel.style.left = nl + "px"; panel.style.top = nt + "px"; }); document.addEventListener("mouseup", function () { if (!dragging) return; dragging = false; if (didDrag) { const s = GM_getValue("icourse_ui", null) || {}; s.left = parseInt(panel.style.left, 10); s.top = parseInt(panel.style.top, 10); GM_setValue("icourse_ui", s); } }); const minBtn = document.getElementById("icourse-min"); minBtn.addEventListener("click", function () { const collapsed = panel.classList.toggle("collapsed"); minBtn.textContent = collapsed ? "□" : "—"; const s = GM_getValue("icourse_ui", null) || {}; s.collapsed = collapsed; GM_setValue("icourse_ui", s); }); document.getElementById("icourse-llm").addEventListener("click", showLlmConfigPanel); document.getElementById("icourse-tasks").addEventListener("click", showTaskListPanel); const speedSel = document.getElementById("icourse-speed-sel"); speedSel.value = String(mooc.config.videoSpeed); speedSel.addEventListener("change", function () { mooc.config.videoSpeed = parseFloat(speedSel.value); saveConfig(mooc.config); }); const loopEl = document.getElementById("cf-loopVideos"); const skipEl = document.getElementById("cf-skipVideo"); const quizEl = document.getElementById("cf-quizOnly"); const lblLoop = document.getElementById("lbl-loop"); const lblSkip = document.getElementById("lbl-skip"); const lblQuiz = document.getElementById("lbl-quiz"); const refreshDisable = function () { // 三方互斥:任一勾选 → 另两项禁用 const lk = loopEl.checked, sk = skipEl.checked, qz = quizEl.checked; lblLoop.classList.toggle("disabled", sk || qz); lblSkip.classList.toggle("disabled", lk || qz); lblQuiz.classList.toggle("disabled", lk || sk); loopEl.disabled = sk || qz; skipEl.disabled = lk || qz; quizEl.disabled = lk || sk; }; const hintIfRunning = function () { if (mooc.state !== "IDLE") { log("[提示] 当前任务队列结束后生效;如需立即应用,可刷新页面重新开始"); } }; loopEl.checked = !!mooc.config.loopVideos; skipEl.checked = !!mooc.config.skipVideo; quizEl.checked = !!mooc.config.quizOnly; refreshDisable(); loopEl.addEventListener("change", function () { mooc.config.loopVideos = loopEl.checked; if (loopEl.checked) { skipEl.checked = false; mooc.config.skipVideo = false; quizEl.checked = false; mooc.config.quizOnly = false; } saveConfig(mooc.config); refreshDisable(); log(loopEl.checked ? "[视频循环] 开启 — 队列结束后自动续刷视频或手动刷新页面进行任务" : "[视频循环] 关闭"); hintIfRunning(); }); skipEl.addEventListener("change", function () { mooc.config.skipVideo = skipEl.checked; if (skipEl.checked) { loopEl.checked = false; mooc.config.loopVideos = false; quizEl.checked = false; mooc.config.quizOnly = false; } saveConfig(mooc.config); refreshDisable(); log(skipEl.checked ? "[跳过视频] 开启 — 视频任务直接跳过" : "[跳过视频] 关闭"); hintIfRunning(); }); quizEl.addEventListener("change", function () { mooc.config.quizOnly = quizEl.checked; if (quizEl.checked) { loopEl.checked = false; mooc.config.loopVideos = false; skipEl.checked = false; mooc.config.skipVideo = false; } saveConfig(mooc.config); refreshDisable(); log(quizEl.checked ? "[仅做测试题] 开启 — 只处理无重做按钮的测试题.手动刷新页面后进行" : "[仅做测试题] 关闭"); hintIfRunning(); }); btn.addEventListener("click", function () { if (mooc.state === "IDLE") { mooc.start(); } else if (mooc.state === "PAUSED") { mooc.resume(); } else { mooc.pause(); } }); } function updateUI() { const progress = document.getElementById("icourse-progress"); const logBox = document.getElementById("icourse-log"); const btn = document.getElementById("icourse-btn"); if (!progress) return; const stateLabel = { IDLE: "就绪", FETCHING: "获取任务...", NAVIGATING: "跳转中...", WAITING_LOAD: "等待加载...", PROCESSING: "处理中...", PAUSED: "已暂停" }; const total = mooc.tasks.length; const done = mooc.currentIndex; progress.textContent = total > 0 ? (stateLabel[mooc.state] || mooc.state) + " (" + done + "/" + total + ")" : (stateLabel[mooc.state] || mooc.state); if (btn) { btn.textContent = (mooc.state === "PAUSED" || mooc.state === "IDLE") ? "▶" : "⏸"; } if (logBox) { logBox.innerHTML = logLines.slice(-10).map(function (l) { return "
" + l + "
"; }).join(""); logBox.scrollTop = logBox.scrollHeight; } } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }[c])); } function showLlmConfigPanel() { const old = document.getElementById("icourse-popup-viewer"); if (old) old.remove(); const cfg = getLlmConfig(); const viewer = document.createElement("div"); viewer.id = "icourse-popup-viewer"; viewer.classList.add("llm-panel"); viewer.style.position = "fixed"; const presetOptions = Object.keys(LLM_PROVIDER_PRESETS).map(function (k) { const lbl = LLM_PROVIDER_PRESETS[k].label || k; return ""; }).join(""); viewer.innerHTML = "
" + "LLM 配置" + "
" + "" + "" + "" + "" + "
" + "
" + "
已保存 ✓
" + "
" + // 启用开关 "
" + "
" + "
启用 LLM 答题
" + "
关闭后测试题与讨论题不会调用 LLM
" + "
" + "
" + "" + "
" + "
" + "
" + "" + "
" + "
" + "" + "
" + "
" + "" + "" + "
" + "
" + "
" + "" + "
" + "
" + "" + "
" + "
" + "
" + "
"; document.body.appendChild(viewer); const $ = function (id) { return document.getElementById(id); }; // 启用开关:点开关任意区域都切 const swEl = $("cf-switch"); const enabledEl = $("cf-enabled"); swEl.addEventListener("click", function (e) { if (e.target.tagName === "INPUT") return; enabledEl.checked = !enabledEl.checked; swEl.classList.toggle("on", enabledEl.checked); }); // 预设切换:填 Base URL + Model $("cf-provider").addEventListener("change", function (e) { const p = LLM_PROVIDER_PRESETS[e.target.value]; if (p && p.baseUrl) $("cf-baseUrl").value = p.baseUrl; if (p && p.model) $("cf-model").value = p.model; }); // 密码显隐 $("cf-eye").addEventListener("click", function () { const el = $("cf-apiKey"); el.type = el.type === "password" ? "text" : "password"; }); // 恢复默认(按当前选中预设回填) $("pv-reset").onclick = function () { const p = LLM_PROVIDER_PRESETS[$("cf-provider").value]; if (p) { $("cf-baseUrl").value = p.baseUrl || ""; $("cf-model").value = p.model || ""; } $("cf-temperature").value = DEFAULT_LLM_CONFIG.temperature; $("cf-timeout").value = DEFAULT_LLM_CONFIG.timeout; const out = $("cf-test-out"); out.className = "pending"; out.textContent = "已按预设恢复(未保存)"; }; function readForm() { const tRaw = parseFloat($("cf-temperature").value); const toRaw = parseInt($("cf-timeout").value, 10); return { enabled: $("cf-enabled").checked, provider: $("cf-provider").value, baseUrl: $("cf-baseUrl").value.trim(), model: $("cf-model").value.trim(), apiKey: $("cf-apiKey").value.trim(), temperature: isNaN(tRaw) ? 0.7 : tRaw, timeout: isNaN(toRaw) ? 30000 : toRaw, }; } $("pv-close").onclick = function () { viewer.remove(); }; $("pv-save").onclick = function () { saveLlmConfig(readForm()); log("LLM 配置已保存"); const toast = $("cf-toast"); toast.classList.add("show"); setTimeout(function () { viewer.remove(); }, 700); }; $("pv-test").onclick = function () { const out = $("cf-test-out"); out.className = "pending"; out.textContent = "测试中..."; const tempCfg = Object.assign({}, readForm(), { enabled: true }); const t0 = Date.now(); callLlmRaw(tempCfg, "1+1=?", "0", ["0", "1", "2", "3"]).then( function (ans) { const dt = Date.now() - t0; out.className = "ok"; out.textContent = "✓ 返回:" + ans + " · " + dt + " ms"; }, function (err) { out.className = "err"; out.textContent = "✗ " + err.message; }, ); }; } // ─── 任务列表面板(自由跳转 / 强抓 DOM) ────────────────────────────────────── const CONTENT_TYPE_LABELS = { 1: "视频", 3: "文档", 4: "富文本", 5: "测试题", 6: "讨论题" }; function showTaskListPanel() { const old = document.getElementById("icourse-popup-viewer"); if (old) old.remove(); const viewer = document.createElement("div"); viewer.id = "icourse-popup-viewer"; // 列表视图:默认显示 mooc.tasks(当前队列);勾选"列出已完成任务"切到 mooc.allUnits(全部单元) const renderBody = function () { const curCfg = getConfig(); const showAll = !!curCfg.includeDone; const tasks = mooc.tasks || []; const allUnits = mooc.allUnits || []; let bodyHtml = ""; bodyHtml += "
"; bodyHtml += ""; bodyHtml += "
"; // 空队列:提示并提供"拉取任务列表"按钮(不启动执行) if (!showAll && !tasks.length) { if (!allUnits.length) { bodyHtml += "
暂无任务数据。

" + "" + "仅拉取,不开始执行
"; } else { bodyHtml += "
当前队列为空。勾选上方复选框可查看全部任务点。
"; } return bodyHtml; } if (showAll && !allUnits.length) { bodyHtml += "
暂无单元数据。

" + "
"; return bodyHtml; } const list = showAll ? allUnits : tasks; const curIdx = mooc.currentIndex || 0; // 按章节-小节分组 const groups = {}; const groupOrder = []; list.forEach(function (u, listIdx) { const key = (u._chapterName || "(未分组)") + " ▸ " + (u._lessonName || ""); if (!groups[key]) { groups[key] = []; groupOrder.push(key); } groups[key].push({ unit: u, listIdx: listIdx }); }); groupOrder.forEach(function (g) { bodyHtml += "
" + escapeHtml(g) + ""; bodyHtml += ""; groups[g].forEach(function (rec) { const u = rec.unit; const done = (u.completePercent || 0) >= 1.0 || (u.learnTime && u.learnTime > 0); const typeLbl = CONTENT_TYPE_LABELS[u.contentType] || ("类型" + u.contentType); // 队列视图下显示当前位标记(已跑过/进行中/待执行);全部视图下只显示完成状态 let stateLbl; if (showAll) { stateLbl = done ? "✓已完成" : "○未完成"; } else { if (rec.listIdx < curIdx) stateLbl = "✓已跑过"; else if (rec.listIdx === curIdx && mooc.state !== "IDLE") stateLbl = "▶ 进行中"; else stateLbl = "○待执行"; } const rowStyle = (!showAll && rec.listIdx === curIdx && mooc.state !== "IDLE") ? "background:#1a3a3a;" : ""; bodyHtml += "" + "" + "" + "" + "" + ""; }); bodyHtml += "
" + stateLbl + "" + escapeHtml(typeLbl) + "" + escapeHtml(u.name || "") + "" + "" + "
"; }); return bodyHtml; }; const tasksCount = (mooc.tasks || []).length; const allCount = (mooc.allUnits || []).length; viewer.innerHTML = "
" + "任务队列(" + tasksCount + " 待执行 / " + allCount + " 总计)" + "
" + "" + "" + "
" + "
" + "
" + renderBody() + "
"; document.body.appendChild(viewer); const rerender = function () { document.getElementById("tl-body").innerHTML = renderBody(); bind(); // 头部计数也同步 const head = viewer.querySelector(".pv-head span"); if (head) { head.textContent = "任务队列(" + (mooc.tasks || []).length + " 待执行 / " + (mooc.allUnits || []).length + " 总计)"; } }; const bind = function () { const inc = document.getElementById("tl-include"); if (inc) { inc.onchange = function () { const c = getConfig(); c.includeDone = inc.checked; saveConfig(c); mooc.config = c; rerender(); }; } const fetchBtn = document.getElementById("tl-fetch"); if (fetchBtn) { fetchBtn.onclick = function () { fetchBtn.disabled = true; fetchBtn.textContent = "拉取中..."; mooc.config = getConfig(); mooc._refreshTaskList().then(function () { rerender(); log("[任务列表] 已拉取(队列 " + (mooc.tasks || []).length + " 项)"); }, function (err) { log("[任务列表] 拉取失败:" + err.message); fetchBtn.disabled = false; fetchBtn.textContent = "拉取任务列表"; }); }; } viewer.querySelectorAll(".tl-jump").forEach(function (b) { b.onclick = function () { const id = b.getAttribute("data-id"); // 跳转目标既可能来自 tasks 也可能来自 allUnits const pool = (mooc.allUnits && mooc.allUnits.length) ? mooc.allUnits : (mooc.tasks || []); const u = pool.find(function (x) { return String(x.id) === id; }); if (!u) { log("找不到目标单元 id=" + id); return; } viewer.remove(); mooc.jumpTo(u); }; }); }; const refreshBtn = document.getElementById("tl-refresh"); const setRefreshState = function () { if (mooc.state === "IDLE") { refreshBtn.disabled = false; refreshBtn.title = "重新拉取课程任务"; } else { refreshBtn.disabled = true; refreshBtn.title = "运行中无法刷新(避免中断当前进度)"; } }; setRefreshState(); refreshBtn.onclick = function () { if (mooc.state !== "IDLE") return; mooc.config = getConfig(); log("[任务列表] 重新拉取..."); mooc._refreshTaskList().then(function () { rerender(); log("[任务列表] 已刷新(队列 " + (mooc.tasks || []).length + " 项)"); }, function (err) { log("拉取失败:" + err.message); }); }; document.getElementById("pv-close").onclick = function () { viewer.remove(); }; bind(); } // ─── 启动 ──────────────────────────────────────────────────────────────────── buildUI(); if (mooc.config.autoStart) { sleep(2000).then(function () { mooc.start(); }); } })();