// ==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 += "" +
"| " + stateLbl + " | " +
"" + escapeHtml(typeLbl) + " | " +
"" + escapeHtml(u.name || "") + " | " +
"" +
"" +
" | " +
"
";
});
bodyHtml += "
";
});
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(); });
}
})();