// ==UserScript== // @name 中国大学MOOC 自动完成助手 // @namespace icourse-auto // @version 2.1 // @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 www.baidu.com // @connect www.bing.com // @connect duckduckgo.com // @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, webSearch: false, searchEngine: "baidu", searchMode: "smart", }; 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 yield$() { return new Promise(resolve => setTimeout(resolve, 0)); } 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, "
") + "
" + escapeHtml(text).replace(/\n/g, "
") + "
]*>([\s\S]*?)<\/p>/gi; var match; while ((match = itemRegex.exec(html)) !== null && results.length < count) { var title = match[1].replace(/<[^>]+>/g, "").trim(); var snippet = match[2].replace(/<[^>]+>/g, "").trim(); if (title) results.push({ title: title, snippet: snippet }); } log("[WebSearch] Bing 搜索完成,获取 " + results.length + " 条结果"); resolve(results); } catch (e) { log("[WebSearch] Bing 搜索解析失败"); resolve([]); } }, ontimeout: function () { log("[WebSearch] Bing 搜索超时"); resolve([]); }, onerror: function () { log("[WebSearch] Bing 搜索失败"); resolve([]); } }); }); } function searchDuckDuckGo(query, count) { return new Promise(function (resolve) { var url = "https://duckduckgo.com/html/?q=" + encodeURIComponent(query); GM_xmlhttpRequest({ method: "GET", url: url, headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" }, timeout: 10000, onload: function (res) { try { var html = res.responseText; var results = []; var itemRegex = /
| " + stateLbl + " | " + "" + escapeHtml(typeLbl) + " | " + "" + escapeHtml(u.name || "") + " | " + "" + "" + " | " + "