// ==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 内嵌在
QQ群 加入交流群:1121609533点击复制群号,交流使用技巧
页面-
视频 / 倍速0 / 2.0x
任务点-
AI 调用 / 缓存命中0 / 0
AI
`; const panel = root.getElementById('panel'); const fab = root.getElementById('fab'); let fabDragged = false; root.getElementById('btn-min').addEventListener('click', () => panel.classList.add('hidden')); root.getElementById('community').addEventListener('click', async () => { try { await navigator.clipboard.writeText('1121609533'); log('QQ群号已复制:1121609533', 'ok'); const tip = root.querySelector('.qq-copy'); if (tip) { const old = tip.textContent; tip.textContent = '已复制,可在 QQ 中搜索加入'; setTimeout(() => { tip.textContent = old; }, 2200); } } catch (e) { log('QQ群号:1121609533', 'info'); } }); fab.addEventListener('click', (e) => { if (fabDragged) { fabDragged = false; e.stopPropagation(); e.preventDefault(); return; } panel.classList.toggle('hidden'); }); root.querySelectorAll('.tabs button').forEach((b) => { b.addEventListener('click', () => { UI.tab = b.dataset.tab; root.querySelectorAll('.tabs button').forEach((x) => x.classList.toggle('on', x === b)); root.querySelectorAll('.sec').forEach((s) => s.classList.toggle('on', s.id === 'sec-' + UI.tab)); }); }); root.getElementById('btn-scan').addEventListener('click', () => { scanVideos(); broadcastCmd('scan'); log('已重新扫描视频', 'info'); }); root.getElementById('btn-answer').addEventListener('click', () => { runAnswerFlow(true); broadcastCmd('answer'); }); root.getElementById('btn-next').addEventListener('click', () => { void nextChapterClick(); }); root.getElementById('btn-submit').addEventListener('click', () => { if (submitWork()) { log('已触发提交', 'ok'); void confirmSubmitDialog(); } else { log('未找到提交按钮', 'warn'); } broadcastCmd('submit'); }); const donateImg = root.getElementById('donate-qr'); if (donateImg) donateImg.src = DONATION_QR; // 拖拽:标题栏和右下角 AI 方块都能拖,整个悬浮组件一起移动 (function drag() { const head = root.getElementById('head'); const wrap = root.querySelector('.wrap'); let sx = 0, sy = 0, ox = 0, oy = 0, moving = false, moved = false; const start = (e) => { if (e.button !== undefined && e.button !== 0) return; const r = wrap.getBoundingClientRect(); wrap.style.right = 'auto'; wrap.style.bottom = 'auto'; wrap.style.left = r.left + 'px'; wrap.style.top = r.top + 'px'; sx = e.clientX; sy = e.clientY; ox = r.left; oy = r.top; moving = true; moved = false; try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ } }; const move = (e) => { if (!moving) return; const dx = e.clientX - sx; const dy = e.clientY - sy; if (Math.abs(dx) + Math.abs(dy) > 6) { moved = true; if (e.currentTarget === fab) fabDragged = true; } wrap.style.left = clamp(ox + dx, 4, innerWidth - 80) + 'px'; wrap.style.top = clamp(oy + dy, 4, innerHeight - 60) + 'px'; }; const end = (e) => { moving = false; if (e && e.currentTarget === fab && !moved) fabDragged = false; }; [head, fab].forEach((el) => { el.addEventListener('pointerdown', start); el.addEventListener('pointermove', move); el.addEventListener('pointerup', end); el.addEventListener('pointercancel', end); }); })(); buildSettings(root); logSinks.push((entry) => appendLogLine(root, entry)); state.logs.forEach((entry) => appendLogLine(root, entry)); } function appendLogLine(root, entry) { const box = root.getElementById('logbox'); if (!box) return; const div = document.createElement('div'); div.className = entry.type || 'info'; div.innerHTML = '' + fmtTime(entry.t) + '' + esc(entry.msg); box.appendChild(div); while (box.childNodes.length > LOG_MAX) box.removeChild(box.firstChild); box.scrollTop = box.scrollHeight; } const SCHEMA = [ { k: 'apiKey', label: 'DeepSeek API Key', type: 'password', ph: 'sk-xxxxxxxx' }, { k: 'baseUrl', label: '接口地址', type: 'text', ph: 'https://api.deepseek.com/v1' }, { k: 'model', label: '模型', type: 'select', options: ['deepseek-chat', 'deepseek-reasoner'] }, { k: 'rate', label: '视频倍速(最高 2.0,避免任务点卡住)', type: 'range', min: 1, max: 2, step: 0.25 }, { k: 'autoPlay', label: '自动播放视频', type: 'switch' }, { k: 'mute', label: '静音播放', type: 'switch' }, { k: 'resumeOnPause', label: '暂停后自动续播', type: 'switch' }, { k: 'stallRecovery', label: '播放停滞自动恢复', type: 'switch' }, { k: 'quizAI', label: 'AI 处理视频弹题', type: 'switch' }, { k: 'autoNext', label: '任务点完成后自动下一节', type: 'switch' }, { k: 'autoQuizTab', label: '自动进入章节测验并作答', type: 'switch' }, { k: 'autoAnswer', label: '页面加载后自动答题', type: 'switch' }, { k: 'autoSubmitWork', label: '作业/章节测验自动提交', type: 'switch' }, { k: 'autoSubmitExam', label: '考试自动提交(慎用)', type: 'switch' }, { k: 'answerShort', label: '简答题也交给 AI(更长等待)', type: 'switch' }, { k: 'skipAnswered', label: '跳过已作答的题', type: 'switch' }, { k: 'decryptFont', label: '尝试解密切题干字体', type: 'switch' }, { k: 'batchSize', label: '每次请求题目数', type: 'number', min: 1, max: 8 }, ]; function buildSettings(root) { const box = root.getElementById('sec-cfg'); box.innerHTML = SCHEMA.map((f) => { if (f.type === 'switch') { return `
${esc(f.label)}
`; } if (f.type === 'select') { return `
`; } if (f.type === 'range') { return `
`; } if (f.type === 'number') { return `
`; } return `
`; }).join('') + `
API Key 只保存在本机油猴存储中,不会上传到任何第三方。填写后自动保存,点上面按钮可验证连通性。
`; const tester = box.querySelector('#btn-test'); if (tester) { tester.addEventListener('click', async () => { if (!CFG.apiKey) { log('请先填写 API Key', 'warn'); return; } tester.textContent = '测试中…'; try { const r = await callDeepSeek([{ role: 'user', content: '只回复两个字:可用' }], { maxTokens: 16, temperature: 0 }); log('API 连接正常:' + String(r).replace(/\s+/g, ' ').slice(0, 40), 'ok'); tester.textContent = '连接正常'; } catch (e) { log('API 连接失败:' + e.message, 'error'); tester.textContent = '连接失败(见日志)'; } setTimeout(() => { tester.textContent = '测试 API 连接'; }, 3500); }); } const handler = (e) => { const el = e.target.closest('[data-k]'); if (!el) return; const k = el.dataset.k; let v; if (el.type === 'checkbox') v = el.checked; else if (el.type === 'range' || el.type === 'number') v = Number(el.value); else v = el.value; CFG[k] = v; saveCfg(); if (k === 'rate') { const lab = root.getElementById('rate-val'); if (lab) lab.textContent = v; VIDEOS.forEach(enforceRate); } updateStatus(); }; box.addEventListener('input', handler); box.addEventListener('change', handler); } function renderQuestions() { if (!UI.root) return; const list = UI.root.getElementById('qlist'); const head = UI.root.getElementById('q-head'); if (!list) return; const qs = state.questions; head.style.display = qs.length ? 'block' : 'none'; head.textContent = '本页题目(' + qs.length + ')'; list.innerHTML = qs.map((q) => { const ans = q.answer && q.answer.length ? q.answer.join(' / ') : (q.ok ? '已作答' : '待作答'); return `
${esc((q.stem || '(无题干)').slice(0, 90))}${TYPE_LABEL[q.type] || q.type}
${esc(String(ans).slice(0, 120))}
`; }).join(''); } function updateStatus() { if (!UI.root) return; const set = (id, v) => { const el = UI.root.getElementById(id); if (el) el.textContent = v; }; let page = '其他'; if (isChapterPage()) page = '章节学习'; else if (isExamPage()) page = '考试'; else if (document.querySelector('.questionLi')) page = '作业'; else if (document.querySelector('.TiMu')) page = '章节测验'; set('st-page', page); set('st-video', countVideosDeep(window, 4) + ' / ' + (Number(CFG.rate) || 1).toFixed(2) + 'x'); let jobTxt = '-'; const f = chapterFrame(); if (f) { const doc = getDocSafe(f); if (doc) { const all = jobNodes(doc); const jobs = all.filter((n) => !DOC_JOB_RE.test(String(n.className || ''))); if (jobs.length) { const done = jobs.filter((j) => j.classList.contains('ans-job-finished') || (j.closest && j.closest('.ans-job-finished'))).length; jobTxt = done + ' / ' + jobs.length + (all.length > jobs.length ? '(跳过文档 ' + (all.length - jobs.length) + ')' : ''); } } } set('st-job', jobTxt); try { const allowVideo = jobInfo().videoUnfinished > 0; window.__lcsAllowTaskVideo = allowVideo; Array.from(document.querySelectorAll('iframe')).forEach((f) => { try { if (f.contentWindow) f.contentWindow.postMessage({ __lcsAllowVideo: allowVideo }, '*'); } catch (e) { /* ignore */ } }); } catch (e) { /* ignore */ } set('st-ai', state.stats.asked + ' / ' + state.stats.cacheHit); } /* ====================================================================== * 14. 启动 * ==================================================================== */ function isTargetPage() { if (!/chaoxing\.com$/.test(location.hostname) && !/\.edu\.cn$/.test(location.hostname) && !/nbdlib\.cn$/.test(location.hostname) && !/hnsyu\.net$/.test(location.hostname) && !/gdhkmooc\.com$/.test(location.hostname)) return false; return true; } function boot() { if (!isTargetPage()) return; window.addEventListener('message', (e) => { const d = e.data; if (!d) return; if (typeof d.__lcsAllowVideo === 'boolean') { window.__lcsAllowTaskVideo = d.__lcsAllowVideo; try { Array.from(document.querySelectorAll('iframe')).forEach((f) => { try { if (f.contentWindow) f.contentWindow.postMessage(d, '*'); } catch (e) { /* ignore */ } }); } catch (e) { /* ignore */ } return; } if (d.__lcsQuizDone) { quizKeys().forEach((id) => quizSubmitted.add(id)); log('收到章节测验完成信号', 'ok'); return; } if (!d.__lcsCmd) return; if (d.cmd === 'answer') runAnswerFlow(true); else if (d.cmd === 'submit') { if (submitWork()) void confirmSubmitDialog(); } else if (d.cmd === 'scan') scanVideos(); try { Array.from(document.querySelectorAll('iframe')).forEach((f) => { try { if (f.contentWindow) f.contentWindow.postMessage(d, '*'); } catch (err) { /* ignore */ } }); } catch (err) { /* ignore */ } }); if (IS_TOP) { buildUI(); window.addEventListener('message', (e) => { const d = e.data; if (!d || d.__lcsLog !== 1 || !d.entry) return; const host = String(e.origin || '').replace(/^https?:\/\//, ''); if (!/(^|\.)chaoxing\.com$/.test(host) && !/\.edu\.cn$/.test(host) && !/\.(nbdlib\.cn|hnsyu\.net|gdhkmooc\.com)$/.test(host)) return; state.logs.push(d.entry); if (state.logs.length > LOG_MAX) state.logs.shift(); logSinks.forEach((fn) => { try { fn(d.entry); } catch (err) { /* ignore */ } }); }); try { if (typeof GM_registerMenuCommand === 'function') { GM_registerMenuCommand('打开学习通 AI 助手', () => { const p = UI.root && UI.root.getElementById('panel'); if (p) p.classList.remove('hidden'); }); } } catch (e) { /* ignore */ } } scanVideos(); startStallRecovery(); setInterval(scanVideos, 1500); setInterval(checkQuiz, 1000); if (IS_TOP) { setInterval(goNextChapter, 3000); setInterval(() => { reloadCfg(); updateStatus(); }, 1500); } else { setInterval(reloadCfg, 2000); } const tryAnswer = () => { if (!CFG.autoAnswer) return; const n = document.querySelectorAll('.TiMu, .questionLi, [qid]').length; if (n > 0) runAnswerFlow(false); }; setTimeout(tryAnswer, 2200); setInterval(() => { if (!CFG.autoAnswer) return; if (state.answering) return; const n = document.querySelectorAll('.TiMu, .questionLi, [qid]').length; if (n > 0) tryAnswer(); }, 4000); if (IS_TOP) log('脚本已启动 v' + VERSION, 'ok'); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot, { once: true }); } else { boot(); } })();