// ==UserScript== // @name 🎓 学习通 AI 助手 · 自动视频 · DeepSeek 答题 // @namespace https://lcs.local/cx-ai // @version 1.0.14 // @description 🔎 当前支持平台:【超星学习通 / 泛雅课程(视频、章节测验、新版作业、旧版作业)】【`.chaoxing.com` 相关系列页面】【部分高校定制域名】。🚀 目前已经具备的主要功能包括:🎬 自动播放未完成任务的课程视频,支持静音播放、暂停后续播、播放停滞自动恢复、视频结束检测以及任务点完成后自动进入下一节;📝 自动识别新版与旧版作业页面,支持单选题、多选题、判断题、填空题和简答题;🤖 调用 DeepSeek 批量生成答案,并根据题目中的真实 A/B/C/D 标签自动选择选项;🧩 支持章节测验与视频弹题处理,减少答题过程中反复中断的问题;🛡️ 对隐藏旧题容器、题目顺序打乱、页面重复加载等常见情况进行了兼容;⚙️ 提供运行、设置、日志和捐赠四个独立面板,可随时查看当前页面、AI 调用次数、任务点状态与错误信息;💾 已作答内容支持本地缓存,减少重复请求;📱 电脑端 Chrome、Edge 等支持 Tampermonkey / 篡改猴的浏览器均可使用。 // @author Little Code Sauce // @license MIT // @match *://*.chaoxing.com/* // @match *://*.edu.cn/* // @match *://*.nbdlib.cn/* // @match *://*.hnsyu.net/* // @match *://*.gdhkmooc.com/* // @resource ttf https://www.forestpolice.org/ttf/2.0/table.json // @grant GM_getResourceText // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant unsafeWindow // @connect api.deepseek.com // @connect * // @run-at document-start // ==/UserScript== (function () { 'use strict'; if (window.__LCS_CX_AI_BOOTED__) return; window.__LCS_CX_AI_BOOTED__ = true; /* ====================================================================== * 0. 基础工具 * ==================================================================== */ const VERSION = '1.0.14'; const CFG_KEY = 'lcs_cx_cfg_v1'; const CACHE_KEY = 'lcs_cx_cache_v1'; const LOG_MAX = 160; const UW = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window; const IS_TOP = (() => { try { return window.top === window.self; } catch (e) { return false; } })(); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const clamp = (v, a, b) => Math.min(b, Math.max(a, v)); const $ = (sel, root) => (root || document).querySelector(sel); const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel)); const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] )); function textOf(el) { if (!el) return ''; const t = el.innerText != null && el.innerText !== '' ? el.innerText : (el.textContent || ''); return String(t) .replace(/\u00a0/g, ' ') .replace(/[\u200b-\u200f\ufeff]/g, '') .replace(/[ \t]+/g, ' ') .replace(/\s*\n\s*/g, '\n') .trim(); } function hashStr(str) { let h1 = 0x811c9dc5, h2 = 0x01000193; for (let i = 0; i < str.length; i++) { const c = str.charCodeAt(i); h1 = (h1 ^ c) * 16777619 >>> 0; h2 = (h2 * 31 + c) >>> 0; } return (h1.toString(36) + h2.toString(36)); } // 2-gram Dice 相似度,用于选项文本匹配 function similarity(a, b) { const x = String(a || '').replace(/[\s\p{P}]/gu, ''); const y = String(b || '').replace(/[\s\p{P}]/gu, ''); if (!x || !y) return 0; if (x === y) return 1; if (x.length < 2 || y.length < 2) return x === y ? 1 : 0; const grams = new Map(); for (let i = 0; i < x.length - 1; i++) { const g = x.slice(i, i + 2); grams.set(g, (grams.get(g) || 0) + 1); } let hit = 0; for (let i = 0; i < y.length - 1; i++) { const g = y.slice(i, i + 2); const n = grams.get(g) || 0; if (n > 0) { grams.set(g, n - 1); hit++; } } return (2 * hit) / ((x.length - 1) + (y.length - 1)); } /* ====================================================================== * 1. 配置 * ==================================================================== */ const DEFAULTS = { apiKey: '', baseUrl: 'https://api.deepseek.com/v1', model: 'deepseek-chat', temperature: 0.1, rate: 2, autoPlay: true, autoNext: true, autoQuizTab: true, mute: true, resumeOnPause: true, stallRecovery: true, quizAI: true, autoAnswer: true, autoSubmitWork: true, autoSubmitExam: false, answerShort: true, batchSize: 4, skipAnswered: true, decryptFont: true, }; function gmGet(key, def) { try { if (typeof GM_getValue === 'function') { const v = GM_getValue(key, null); return v == null ? def : v; } const v = localStorage.getItem(key); return v == null ? def : v; } catch (e) { return def; } } function gmSet(key, val) { try { if (typeof GM_setValue === 'function') return GM_setValue(key, val); localStorage.setItem(key, val); } catch (e) { /* ignore */ } } let CFG = (function loadCfg() { try { const raw = gmGet(CFG_KEY, ''); const obj = raw ? JSON.parse(raw) : {}; return Object.assign({}, DEFAULTS, obj && typeof obj === 'object' ? obj : {}); } catch (e) { return Object.assign({}, DEFAULTS); } })(); // 部分课程会直接拒绝高于 2x 的播放,表现为停在首帧或任务点不增加。 CFG.rate = clamp(Number(CFG.rate) || DEFAULTS.rate, 1, 2); gmSet(CFG_KEY, JSON.stringify(CFG)); let CFG_SIG = JSON.stringify(CFG); function saveCfg() { CFG_SIG = JSON.stringify(CFG); gmSet(CFG_KEY, CFG_SIG); } function reloadCfg() { try { const raw = gmGet(CFG_KEY, ''); if (!raw || raw === CFG_SIG) return false; const obj = JSON.parse(raw); CFG = Object.assign({}, DEFAULTS, obj || {}); CFG.rate = clamp(Number(CFG.rate) || DEFAULTS.rate, 1, 2); CFG_SIG = JSON.stringify(CFG); if (CFG_SIG !== raw) gmSet(CFG_KEY, CFG_SIG); return true; } catch (e) { return false; } } const CACHE = (function () { try { return JSON.parse(gmGet(CACHE_KEY, '{}')) || {}; } catch (e) { return {}; } })(); let cacheDirty = false; function cacheSaveSoon() { if (cacheDirty) return; cacheDirty = true; setTimeout(() => { cacheDirty = false; try { const keys = Object.keys(CACHE); if (keys.length > 3000) keys.slice(0, keys.length - 3000).forEach((k) => delete CACHE[k]); gmSet(CACHE_KEY, JSON.stringify(CACHE)); } catch (e) { /* ignore */ } }, 800); } /* ====================================================================== * 2. 运行状态 & 日志 * ==================================================================== */ const state = { logs: [], questions: [], currentRate: 1, videoCount: 0, answering: false, answeredOnce: new Set(), stats: { asked: 0, filled: 0, failed: 0, cacheHit: 0 }, chapterDoc: null, }; const logSinks = []; function log(msg, type) { const entry = { t: Date.now(), msg: String(msg), type: type || 'info' }; state.logs.push(entry); if (state.logs.length > LOG_MAX) state.logs.shift(); logSinks.forEach((fn) => { try { fn(entry); } catch (e) { /* ignore */ } }); if (!IS_TOP) { try { window.top.postMessage({ __lcsLog: 1, entry: entry }, '*'); } catch (e) { /* ignore */ } } } /* ====================================================================== * 3. DeepSeek 调用 * ==================================================================== */ function gmRequest(opts) { if (typeof GM_xmlhttpRequest !== 'function') { // 极少数环境没有 GM_xmlhttpRequest,退回 fetch(同源且允许 CORS 时可用) return fetch(opts.url, { method: opts.method || 'GET', headers: opts.headers || {}, body: opts.data, }).then(async (r) => ({ status: r.status, responseText: await r.text() })) .catch(() => { throw new Error('网络请求失败'); }); } return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: opts.method || 'GET', url: opts.url, headers: opts.headers || {}, data: opts.data, timeout: opts.timeout || 120000, onload: (res) => resolve(res), onerror: () => reject(new Error('网络请求失败')), ontimeout: () => reject(new Error('请求超时')), onabort: () => reject(new Error('请求已取消')), }); }); } async function callDeepSeek(messages, opt) { const o = opt || {}; if (!CFG.apiKey) throw new Error('未配置 DeepSeek API Key(设置页填写)'); const base = String(CFG.baseUrl || DEFAULTS.baseUrl).replace(/\/+$/, ''); const body = { model: CFG.model || 'deepseek-chat', messages: messages, temperature: typeof CFG.temperature === 'number' ? CFG.temperature : 0.1, max_tokens: o.maxTokens || 2048, stream: false, }; if (o.json && !/reasoner/i.test(body.model)) { body.response_format = { type: 'json_object' }; } const res = await gmRequest({ method: 'POST', url: base + '/chat/completions', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + CFG.apiKey, }, data: JSON.stringify(body), timeout: o.timeout || 120000, }); let data = null; try { data = JSON.parse(res.responseText || '{}'); } catch (e) { throw new Error('接口返回非 JSON(HTTP ' + res.status + ')'); } if (res.status < 200 || res.status >= 300) { const m = (data && data.error && (data.error.message || data.error.type)) || ''; if (res.status === 401) throw new Error('API Key 无效(401)'); if (res.status === 402) throw new Error('账户余额不足(402)'); if (res.status === 429) throw new Error('请求过于频繁(429),稍后重试'); throw new Error('接口错误 HTTP ' + res.status + (m ? ':' + m : '')); } const choice = data && data.choices && data.choices[0]; const content = (choice && choice.message && choice.message.content) || ''; if (!content) throw new Error('模型返回为空'); return content; } const PROMPT_SYSTEM = [ '你是一位严谨的高校课程答题助手。用户会给你一份题目 JSON 数组。', '请逐题作答,并且**只输出一个 JSON 对象**,不要输出 markdown 代码块、不要任何解释文字。', '输出结构:{"answers":[{"id":0,"answer":["B"],"confidence":0.95}]}', '规则:', '1. 单选题(single):answer 为单个选项字母数组,例如 ["B"]。', '2. 多选题(multi):answer 为所有正确选项字母数组,例如 ["A","C","D"]。', '3. 判断题(judge):answer 为 ["对"] 或 ["错"]。', '4. 填空题(blank):answer 为按空格顺序排列的字符串数组,例如 ["光合作用","叶绿体"]。', '5. 简答题(short):answer 为一段完整、可直接提交的中文答案(单个元素)。', '6. 若题目信息不足,仍按你的最佳判断给出答案,绝不留空。', '7. confidence 为 0~1 的小数,表示你的把握程度。', '8. 若某题的 encrypted 为 true,表示题干被平台加密字体保护、无法读取,请仅根据题型与选项语义推断最合理的答案。', ].join('\n'); function extractJson(text) { let s = String(text || '').trim(); s = s.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '').trim(); const a = s.indexOf('{'); const b = s.lastIndexOf('}'); if (a >= 0 && b > a) s = s.slice(a, b + 1); try { return JSON.parse(s); } catch (e) { /* fall through */ } try { return JSON.parse(s.replace(/,\s*([}\]])/g, '$1').replace(/([{,]\s*)'([^']*)'(\s*:)/g, '$1"$2"$3')); } catch (e) { return null; } } /* ====================================================================== * 4. 题目解析 * ==================================================================== */ const TYPE_LABEL = { single: '单选题', multi: '多选题', judge: '判断题', blank: '填空题', short: '简答题', unknown: '未知题型', }; const TYPE_TAG_RE = /[\[【((]\s*(单选题|多选题|判断题|填空题|简答题|论述题|名词解释|计算题|材料题|分析题|连线题|其他)[^\]】))]{0,15}[\]】))]/g; function matchTypeTag(text) { TYPE_TAG_RE.lastIndex = 0; const m = String(text || '').match(TYPE_TAG_RE); if (!m) return ''; const one = m[0].match(/单选题|多选题|判断题|填空题|简答题|论述题|名词解释|计算题|材料题|分析题|连线题|其他/); return one ? one[0] : ''; } function stripTypeTag(text) { return String(text || '') .replace(TYPE_TAG_RE, ' ') .replace(/^\s*[\d一二三四五六七八九十]+[\.、,.]\s*/, '') .replace(/\s*(\s*\d+(\.\d+)?\s*分\s*)\s*$/, '') .replace(/\s+/g, ' ') .trim(); } function stripOptionLabel(text) { return String(text || '') .replace(/^\s*[A-Za-z]\s*[\.、,.::\))]?\s*/, '') .replace(/^\s*[((]\s*[A-Za-z]\s*[))]\s*/, '') .replace(/\s+/g, ' ') .trim(); } function optionLabelOf(text, index) { const t = String(text || '').trim(); const m = t.match(/^\s*([A-Za-z])\s*[\.、,.::\))]/); if (m) return m[1].toUpperCase(); return String.fromCharCode(65 + index); } function optionLabelFromElement(optEl, text, index) { const num = optEl && optEl.querySelector ? optEl.querySelector('.num_option') : null; const numText = textOf(num); const numMatch = numText.match(/[A-Za-z]/); if (numMatch) return numMatch[0].toUpperCase(); const aria = String((optEl && optEl.getAttribute && optEl.getAttribute('aria-label')) || ''); const ariaMatch = aria.match(/^\s*([A-Za-z])(?:\s|[.、,::))]|$)/); if (ariaMatch) return ariaMatch[1].toUpperCase(); return optionLabelOf(text, index); } function detectType(el, options, tag) { const t = tag || ''; if (/多选/.test(t)) return 'multi'; if (/判断/.test(t)) return 'judge'; if (/填空/.test(t)) return 'blank'; if (/简答|论述|名词解释|计算|材料|分析/.test(t)) return 'short'; if (/单选/.test(t)) return 'single'; if (el.querySelector('input[type="checkbox"]')) return 'multi'; if (el.querySelector('input[type="radio"]')) return 'single'; if (options.length === 2) { const texts = options.map((o) => o.text.replace(/\s/g, '')); const isJudge = texts.every((x) => /^(对|错|正确|错误|是|否|√|×|T|F|True|False)$/i.test(x)); if (isJudge) return 'judge'; } if (options.length > 0) return 'single'; if (el.querySelector('textarea') || el.querySelector('[contenteditable="true"]')) return 'short'; if (el.querySelector('input[type="text"], input:not([type])')) return 'blank'; return 'unknown'; } function visible(el) { if (!el) return false; if (el.offsetParent === null && getComputedStyle(el).position !== 'fixed') return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; } function parseTiMu(el, index) { const stemEl = el.querySelector('.fontLabel') || el; const rawStem = textOf(stemEl); const tag = matchTypeTag(rawStem); const optEls = $$('[class*="before-after"]', el).filter((o) => { // 排除包裹整个选项区的容器 return !o.querySelector('[class*="before-after"]'); }); const options = optEls.map((o, i) => { const tEl = o.querySelector('.fl.after') || o.querySelector('.after') || o; const raw = textOf(tEl); return { label: optionLabelFromElement(o, raw, i), text: stripOptionLabel(raw), raw: raw, el: o, input: o.querySelector('input'), }; }).filter((o) => o.text); const type = detectType(el, options, tag); const inputs = $$('input[type="text"], input:not([type]), textarea', el) .filter((x) => !x.disabled && x.type !== 'hidden'); return { index, el, mode: 'timu', type, tag, stem: stripTypeTag(rawStem), options, inputs, answer: [], ok: false, }; } function parseQuestionLi(el, index) { const h3 = el.querySelector('h3.mark_name, h3'); const shallow = el.querySelector('.colorShallow'); let rawStem = textOf(h3 || el); let tag = ''; if (shallow) { tag = stripTypeTag(textOf(shallow)); if (tag) { const pos = rawStem.indexOf(textOf(shallow)); if (pos >= 0) rawStem = rawStem.slice(pos + textOf(shallow).length); } } if (!tag) tag = matchTypeTag(rawStem); let optEls = $$('.stem_answer .answerBg', el); if (!optEls.length) optEls = $$('.answerBg', el); let useUl = false; if (!optEls.length) { optEls = $$('ul.mark_letter > li', el); useUl = true; } const options = optEls.map((o, i) => { const p = useUl ? o : (o.querySelector('.answer_p') || o); const raw = textOf(p); return { label: optionLabelFromElement(o, raw, i), text: stripOptionLabel(raw), raw: raw, el: o, input: o.querySelector('input'), }; }).filter((o) => o.text); const type = detectType(el, options, tag); const inputs = $$('input[type="text"], input:not([type]), textarea', el) .filter((x) => !x.disabled && x.type !== 'hidden'); return { index, el, mode: 'questionLi', type, tag, stem: stripTypeTag(rawStem), options, inputs, answer: [], ok: false, }; } // 新版章节测验:题干是 div.fontLabel,选项是 li[qid](before-after / before-after-checkbox) function collectNewUiQuestions(root) { const labels = $$('.fontLabel', root).filter((el) => { if (textOf(el).length <= 3) return false; return !!el.querySelector('.newZy_TItle') || /【(单选|多选|判断|填空|简答|论述|名词解释)/.test(textOf(el)); }); const optEls = $$('[qid]', root).filter((el) => /^\d+$/.test(String(el.getAttribute('qid') || ''))); if (!labels.length || !optEls.length) return []; const groups = new Map(); optEls.forEach((el) => { const qid = String(el.getAttribute('qid')); if (!groups.has(qid)) groups.set(qid, []); groups.get(qid).push(el); }); const out = []; let index = 0; for (const [, els] of groups) { const first = els[0]; // 找这道题前面最近的 fontLabel 当题干 let labelEl = null; for (const l of labels) { if (l === first) break; if (l.compareDocumentPosition(first) & 4) labelEl = l; // DOCUMENT_POSITION_FOLLOWING } if (!labelEl) continue; const rawStem = textOf(labelEl); const tag = matchTypeTag(rawStem); const options = els.map((el, i) => { const after = el.querySelector('.fl.after, .after') || el; const raw = textOf(after); return { label: optionLabelFromElement(el, raw, i), text: stripOptionLabel(raw), raw, el, input: el.querySelector('input'), }; }).filter((o) => o.text); if (!options.length) continue; const cls = String(els[0].className || ''); const onclick = String(els[0].getAttribute('onclick') || ''); const qtype = String(els[0].getAttribute('qtype') || ''); let type = 'single'; if (qtype === '3' || options.every((o) => /^(对|错|正确|错误)$/.test(o.text))) type = 'judge'; else if (/checkbox/i.test(cls) || /Multiple/i.test(onclick) || qtype === '1') type = 'multi'; out.push({ index: index++, el: labelEl, mode: 'newUi', type, tag, stem: stripTypeTag(rawStem), options, inputs: [], answer: [], ok: false, }); } return out; } function collectQuestions(doc) { const root = doc || document; const groups = []; const timu = $$('.TiMu', root).map((el, i) => parseTiMu(el, i)); const qli = $$('.questionLi', root).map((el, i) => parseQuestionLi(el, i)); groups.push(timu, qli, collectNewUiQuestions(root)); const viable = groups .map((list) => list.filter((q) => q && ( q.options.length > 0 || q.type === 'blank' || q.type === 'short' ))) .sort((a, b) => b.length - a.length); // 同一页面可能混有第三方扩展插入的旧题容器;优先使用有效题目最多的一组。 return viable.find((list) => list.length > 0) || []; } /* ====================================================================== * 5. 字体解密(超星 font-cxsecret) * * 超星把字体以 base64 内嵌在