// ==UserScript== // @name 实验室安全习题集(自建题库 · 独立版) // @namespace https://labsafety.local/sqb // @version 1.1.7 // @description 把课程树里所有视频题目按章节重绘成习题集(一章一张小卷),人工作答后由脚本代填/代交;考试场景整卷重绘并能代为交卷 // @author 智能学习助手 // @license MIT // @match https://sa.ycit.edu.cn/* // @tag 学习辅助 // @tag 题库 // @tag 效率工具 // @run-at document-start // @grant GM_getValue // @grant GM_setValue // @grant GM_addStyle // @noframes // ==/UserScript== /* * 实验室安全习题集 —— 把课程树里的视频题目重绘成按章节分类的习题集。 * * 能做什么: * 1. 一键采集整棵课程树(会自动展开章节、自动切下一节)的全部题目,按章节分桶存成本地题库; * 2. 主面板按章节生成数字按钮,点开就是这一章的小卷(卡片全展开),直接作答; * 3. 每章可单独「提交本章」,也可在面板上「全部提交」;提交前的作答会写回平台; * 4. 考试场景:开考后自动识别整张试卷并重绘,作答后由脚本代填并代为交卷,交卷得分会回读到面板日志; * 5. 视频控制(倍速 / 自动续播 / 不许暂停 / 播完切下一节)默认全关,需要时在「设置」里开。 * * 数据:题库与作答存在本地(GM 存储),刷新不丢;「全部清空」会把题库一起清掉。 * 定位:辅助学习工具,人工作答,脚本只负责采集、代填和代提交,不推断答案。 * 已知限制:平台命中前端缓存时不发请求,该节可能取不到 courseId 而被跳过;接口不下发正确答案,因此只区分已答/未答,不判对错。 */ (function () { 'use strict'; // ============================ 0. 常量 ============================ const SCRIPT_INFO = { name: '实验室安全习题集', version: '1.1.7' }; const NS = 'sqb_'; // GM 键前缀(与旧脚本隔离) const Z = 2147483000; // 浮层 z-index(压过平台弹窗) const API_QUESTION = '/api/jeecg-boot/jcedutec/courseSource/queryCourseQuestionRelaByMainId'; const API_SUBMIT = '/api/jeecg-boot/jcedutec/courseSource/submitAnswer'; const KIND_TEXT = { '1': '判断题', '2': '单选题', '3': '多选题' }; const LETTERS = ['A', 'B', 'C', 'D', 'E', 'F']; // ============================ 1. 存储层 ============================ function gmGet(key, def) { try { if (typeof GM_getValue === 'function') { const v = GM_getValue(key, undefined); if (v !== undefined && v !== null) return v; } } catch (e) { /* 回落 localStorage */ } try { const raw = localStorage.getItem(NS + key); return raw === null ? def : JSON.parse(raw); } catch (e) { return def; } } function gmSet(key, val) { try { if (typeof GM_setValue === 'function') { GM_setValue(key, val); return; } } catch (e) { /* 回落 localStorage */ } try { localStorage.setItem(NS + key, JSON.stringify(val)); } catch (e) { /* 配额满时静默 */ } } // ============================ 2. 配置 ============================ // 采集默认参数,均可在「设置」里调 const DEFAULTS = { expandChapters: true, // 采集前自动展开所有章节 autoNext: true, // 采完一课自动切下一课 stepDelay: 600, // 切课后的等待(毫秒) idWait: 9000, // 等待课程 id 变化的上限(毫秒) // 视频控制:全部默认关闭,避免与其它脚本抢 video videoSpeed: 1, autoPlayVideo: false, autoSwitchVideo: false, noPause: false, // 提交 submitDelay: 180 }; let cfg = Object.assign({}, DEFAULTS, gmGet('cfg', {})); function saveCfg() { gmSet('cfg', cfg); } // ============================ 3. 运行时状态 ============================ const S = { token: null, // x-access-token courseId: null, // 当前视频对应的课程 id courseIdAt: 0, // 最近一次写入 courseId 的时间戳(兜底判据用) graphId: null, // 章节树根 id running: false, stopFlag: false, courseIndex: -1, totalCourses: 0, examSink: null, // startExam 响应回调 examSubmitSink: null, // submitExam 判分响应回调 _lines: [] }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function log(msg) { try { console.log('[SQB] ' + msg); } catch (e) { /* 控制台不可用时忽略 */ } const line = '[' + new Date().toTimeString().slice(0, 8) + '] ' + msg; S._lines.push(line); if (S._lines.length > 300) S._lines.shift(); const el = document.getElementById('sqb-log'); if (el) { el.textContent = S._lines.slice(-8).join('\n'); el.scrollTop = el.scrollHeight; } } function toast(msg, ms) { let box = document.getElementById('sqb-toast'); if (!box) { box = document.createElement('div'); box.id = 'sqb-toast'; document.body.appendChild(box); } box.textContent = msg; box.classList.add('on'); clearTimeout(box._timer); box._timer = setTimeout(() => box.classList.remove('on'), ms || 2600); } // ============================ 4. 网络钩子 ============================ // 平台切课是 SPA,URL 不变;只能从 courseSource 请求里认出当前课程 id。 function installHook() { const pickId = (url) => { if (!url || url.indexOf('courseSource') < 0) return; const m = String(url).match(/[?&]id=(\d{6,})/); if (m) { if (m[1] !== S.courseId) S.courseId = m[1]; S.courseIdAt = Date.now(); } const g = String(url).match(/[?&]graphId=(\d+)/); if (g && g[1]) S.graphId = g[1]; }; try { const origFetch = window.fetch; if (typeof origFetch === 'function') { window.fetch = function (input, init) { let url = ''; try { url = typeof input === 'string' ? input : (input && input.url) || ''; if (init && init.headers) { const h = init.headers; const v = typeof h.get === 'function' ? h.get('x-access-token') : h['x-access-token']; if (v) S.token = v; } pickId(url); } catch (e) { /* 忽略 */ } const p = origFetch.apply(this, arguments); // 考试整卷 / 交卷判分:旁挂一份响应副本,不改动原响应 if (p && typeof p.then === 'function') { const kind = url.indexOf('/jcedutec/exam/startExam') >= 0 ? 'start' : url.indexOf('/jcedutec/exam/submitExam') >= 0 ? 'submit' : ''; if (kind) { p.then((res) => { try { res.clone().json().then((data) => { if (kind === 'start' && typeof S.examSink === 'function') S.examSink(data); if (kind === 'submit' && typeof S.examSubmitSink === 'function') S.examSubmitSink(data); }).catch(() => {}); } catch (e) { /* 忽略 */ } }).catch(() => {}); } } return p; }; } } catch (e) { /* 忽略 */ } try { const origOpen = XMLHttpRequest.prototype.open; const origSend = XMLHttpRequest.prototype.send; const origSet = XMLHttpRequest.prototype.setRequestHeader; XMLHttpRequest.prototype.open = function (m, u) { try { this.__sqbUrl = u; pickId(u); } catch (e) { /* 忽略 */ } return origOpen.apply(this, arguments); }; XMLHttpRequest.prototype.setRequestHeader = function (k, v) { try { if (String(k).toLowerCase() === 'x-access-token' && v) S.token = v; pickId(this.__sqbUrl); } catch (e) { /* 忽略 */ } return origSet.apply(this, arguments); }; XMLHttpRequest.prototype.send = function () { try { const u = String(this.__sqbUrl || ''); const kind = u.indexOf('/jcedutec/exam/startExam') >= 0 ? 'start' : u.indexOf('/jcedutec/exam/submitExam') >= 0 ? 'submit' : ''; if (kind) { this.addEventListener('load', () => { let data = this.response; if (typeof data === 'string') { try { data = JSON.parse(data); } catch (e) { data = null; } } if (kind === 'start' && data && typeof S.examSink === 'function') S.examSink(data); if (kind === 'submit' && data && typeof S.examSubmitSink === 'function') S.examSubmitSink(data); }, { once: true }); } } catch (e) { /* 忽略 */ } return origSend.apply(this, arguments); }; } catch (e) { /* 忽略 */ } // token 兜底:Vuex 持久化在 localStorage try { const raw = localStorage.getItem('pro__Access-Token'); if (raw) { const o = JSON.parse(raw); if (o && o.value) S.token = o.value; } } catch (e) { /* 忽略 */ } // graphId 兜底:当前 URL 查询参数 try { const p = new URL(location.href).searchParams; if (p.get('graphId')) S.graphId = p.get('graphId'); } catch (e) { /* 忽略 */ } } // ============================ 5. 接口 ============================ async function apiGet(path) { const headers = {}; if (S.token) headers['x-access-token'] = S.token; const sep = path.indexOf('?') >= 0 ? '&' : '?'; const r = await fetch(path + sep + '_t=' + Date.now(), { headers, credentials: 'include' }); return r.json().catch(() => ({})); } async function apiPost(path, body) { const headers = { 'Content-Type': 'application/json' }; if (S.token) headers['x-access-token'] = S.token; const r = await fetch(path, { method: 'POST', headers, body: JSON.stringify(body), credentials: 'include' }); return r.json().catch(() => ({})); } async function fetchQuestions(courseId, graphId) { const j = await apiGet(API_QUESTION + '?id=' + courseId + '&graphId=' + graphId); return Array.isArray(j.result) ? j.result : []; } // ============================ 6. 题目归一化 ============================ // 平台字段:id / kind / stem / optiona..f / analysis / type_dictText function normalize(q, seq) { const options = []; ['a', 'b', 'c', 'd', 'e', 'f'].forEach((ch, i) => { const v = q['option' + ch]; if (v !== null && v !== undefined && String(v).trim() !== '') { options.push({ letter: LETTERS[i], text: String(v).replace(/\s+/g, ' ').trim() }); } }); const kind = String(q.kind !== undefined && q.kind !== null ? q.kind : '2'); return { id: String(q.id), seq: seq, kind: kind, kindText: KIND_TEXT[kind] || ('类型' + kind), stem: String(q.stem || q.question || '').replace(/\s+/g, ' ').trim(), options: options, analysis: q.analysis ? String(q.analysis).replace(/\s+/g, ' ').trim() : '' }; } // ============================ 7. 课程树 ============================ function getCourseNodes() { return Array.prototype.slice.call(document.querySelectorAll('.ant-tree-child-tree li')); } function nodeTitle(li) { const w = li.querySelector('.ant-tree-node-content-wrapper'); return ((w && (w.getAttribute('title') || w.textContent)) || '').replace(/\s+/g, ' ').trim(); } function isSelected(li) { return li.classList.contains('ant-tree-treenode-selected'); } async function expandAllChapters() { const closed = document.querySelectorAll('.ant-tree > .ant-tree-treenode-switcher-close'); if (!closed.length) { log('章节已全部展开'); return; } log('展开 ' + closed.length + ' 个章节…'); for (let i = 0; i < closed.length; i++) { const sw = closed[i].querySelector('.ant-tree-switcher'); if (sw) { sw.click(); await sleep(350); } } await sleep(800); log('展开完成,课程节点 ' + getCourseNodes().length + ' 个'); } // 点击节点并等待课程 id 变化 async function gotoCourse(li) { const prev = S.courseId; const prevAt = S.courseIdAt; const w = li.querySelector('.ant-tree-node-content-wrapper'); if (!w) return null; w.click(); const deadline = Date.now() + (Number(cfg.idWait) || 9000); while (Date.now() < deadline) { await sleep(250); if (S.courseId && S.courseId !== prev) return S.courseId; } // 兜底:id 没变但确实发过请求(同一课被重新请求) if (S.courseId && S.courseIdAt > prevAt) return S.courseId; return null; // 超时:可能该课已被缓存、没发请求 } // 【实测结构,2026-11-03 真机 courseDetail 页】 // .ant-tree > li = 章节(13 个,**没有单独的根节点**) // 章节内 .ant-tree-child-tree > li = 小节(合计 121 个) // 章节 li 的 class 是 ant-tree-treenode-switcher-open;小节是叶子,不带 childTree // ⇒ 归属关系只能靠「章节 li → 其 childTree 的直接 li」,不能用节点深度判定。 function chapterLessons(chLi) { const ct = chLi.querySelector('.ant-tree-child-tree'); if (!ct) return []; return Array.prototype.slice.call(ct.children).filter((el) => el.tagName === 'LI'); } function groupChapters() { return Array.prototype.slice.call(document.querySelectorAll('.ant-tree > li')) .map((ch) => ({ li: ch, title: nodeTitle(ch), lessons: chapterLessons(ch) })) .filter((g) => g.lessons.length); // 未展开的空章丢掉 } // 章节按钮上的数字:取章节名第一段数字("01安全概述" → "1",去掉前导零),取不到用序号 function chapterNumber(title, fallback) { const m = String(title || '').match(/\d+/); if (!m) return String(fallback); return m[0].replace(/^0+(?=\d)/, ''); } // ============================ 8. 题库 ============================ // { v, at, graphId, chapters: [ { num, title, lessons: [ { name, courseId, questions } ] } ] } const Bank = { data: null, load() { const raw = gmGet('bank', null); this.data = raw && raw.chapters ? raw : { v: 1, at: 0, graphId: '', chapters: [] }; this.normalize(); return this.data; }, // 章号归一:去掉前导零("02" → "2")并合并同号章节 // 必要性:早期版本存的是 "02",后来的版本算出来是 "2",不归一会并存成两个按钮 normalize() { const map = new Map(); (this.data.chapters || []).forEach((c) => { const num = String(c.num === null || c.num === undefined ? '' : c.num).replace(/^0+(?=\d)/, '') || '0'; if (!map.has(num)) map.set(num, { num: num, title: c.title || ('第 ' + num + ' 章'), lessons: [] }); const target = map.get(num); (c.lessons || []).forEach((l) => { if (!target.lessons.some((x) => String(x.courseId) === String(l.courseId))) target.lessons.push(l); }); }); this.data.chapters = Array.from(map.values()).sort((a, b) => (Number(a.num) || 0) - (Number(b.num) || 0)); this.save(); }, save() { this.data.at = Date.now(); gmSet('bank', this.data); }, reset() { this.data = { v: 1, at: Date.now(), graphId: S.graphId || '', chapters: [] }; this.save(); }, chapter(num) { const key = String(num === null || num === undefined ? '' : num).replace(/^0+(?=\d)/, ''); return this.data.chapters.find((c) => String(c.num).replace(/^0+(?=\d)/, '') === key) || null; }, upsertChapter(bucket) { // 章号归一后再比对,避免 "02" 与 "2" 并存 const num = String(bucket.num || '').replace(/^0+(?=\d)/, '') || '0'; const target = Object.assign({}, bucket, { num: num }); const idx = this.data.chapters.findIndex((c) => String(c.num).replace(/^0+(?=\d)/, '') === num); if (idx >= 0) this.data.chapters[idx] = target; else this.data.chapters.push(target); this.data.chapters.sort((a, b) => (Number(a.num) || 0) - (Number(b.num) || 0)); this.data.graphId = this.data.graphId || S.graphId || ''; this.save(); }, questionsOf(chapter) { const seen = new Set(); const out = []; (chapter.lessons || []).forEach((lesson) => { (lesson.questions || []).forEach((q) => { if (seen.has(q.id)) return; seen.add(q.id); out.push({ q: q, lesson: lesson }); }); }); return out; }, stats() { let lessons = 0, questions = 0; this.data.chapters.forEach((c) => { lessons += (c.lessons || []).length; questions += this.questionsOf(c).length; }); return { chapters: this.data.chapters.length, lessons: lessons, questions: questions }; } }; // ============================ 9. 作答记录 ============================ // { [题目id]: { letters: ['A'], submitted: bool, at: 时间戳 } } const Answers = { data: null, timer: null, load() { this.data = gmGet('answers', {}) || {}; }, save() { clearTimeout(this.timer); this.timer = setTimeout(() => gmSet('answers', this.data), 220); }, saveNow() { clearTimeout(this.timer); gmSet('answers', this.data); }, get(id) { return this.data[String(id)] || null; }, letters(id) { const rec = this.get(id); return rec && Array.isArray(rec.letters) ? rec.letters : []; }, toggle(q, letter) { const id = String(q.id); const rec = this.data[id] || { letters: [], submitted: false, at: 0 }; if (String(q.kind) === '3') { const i = rec.letters.indexOf(letter); if (i >= 0) rec.letters.splice(i, 1); else rec.letters.push(letter); rec.letters.sort(); } else { rec.letters = rec.letters[0] === letter ? [] : [letter]; } rec.at = Date.now(); rec.submitted = false; this.data[id] = rec; this.save(); return rec.letters; }, markSubmitted(id) { const rec = this.data[String(id)]; if (rec) { rec.submitted = true; rec.at = Date.now(); } else this.data[String(id)] = { letters: [], submitted: true, at: Date.now() }; this.save(); }, countOf(chapter) { const list = Bank.questionsOf(chapter); let answered = 0, submitted = 0; list.forEach((item) => { const rec = this.get(item.q.id); if (rec && rec.letters && rec.letters.length) { answered++; if (rec.submitted) submitted++; } }); return { total: list.length, answered: answered, submitted: submitted }; }, clearChapter(chapter) { Bank.questionsOf(chapter).forEach((item) => { delete this.data[item.q.id]; }); this.saveNow(); }, clearAll() { this.data = {}; this.saveNow(); } }; // ============================ 10. 采集器 ============================ const seen = new Set(); // 已采集的课程名,避免重复 // 【改动】产出写进 Bank,而不是 push 到内存数组 async function collectOne(li, index, bucket) { const name = nodeTitle(li); if (!name) return null; if (seen.has(name)) { log('跳过(已采集):' + name); return null; } log('→ (' + (index + 1) + ') ' + name); const cid = await gotoCourse(li); if (!cid) { log(' ✗ 未捕获到课程 id,跳过'); return null; } await sleep(Number(cfg.stepDelay) || 600); let raw = []; try { raw = await fetchQuestions(cid, S.graphId); } catch (e) { log(' ✗ 拉题失败:' + (e && e.message)); } const questions = raw.map((q, i) => normalize(q, i + 1)); const item = { name: name, courseId: cid, questions: questions }; seen.add(name); bucket.lessons.push(item); log(' ✓ ' + questions.length + ' 题'); return item; } async function startCollect() { if (S.running) return; if (!S.token) log('未捕获到 x-access-token,将直接带 cookie 请求(若失败请刷新一次页面)'); if (!S.graphId) { log('未取到 graphId|根节点=' + document.querySelectorAll('.ant-tree > li').length + '|childTree节点=' + getCourseNodes().length); toast('未取到 graphId:请进入「我的课程 → 某门课」详情页(地址带 graphId=)再采集'); return; } S.running = true; S.stopFlag = false; UI.syncCollectButtons(); log('开始采集|graphId=' + S.graphId); try { if (cfg.expandChapters) await expandAllChapters(); const groups = groupChapters(); S.totalCourses = groups.reduce((n, g) => n + g.lessons.length, 0); if (!groups.length) { log('✗ 未找到章节节点|childTree节点=' + getCourseNodes().length + '|antTree节点=' + document.querySelectorAll('.ant-tree li').length); toast('未找到课程节点,请看面板日志'); return; } log('解析到 ' + groups.length + ' 章 / ' + S.totalCourses + ' 节'); let done = 0; for (let ci = 0; ci < groups.length; ci++) { if (S.stopFlag) { log('已手动停止'); break; } const g = groupChapters()[ci] || groups[ci]; if (!g) continue; const num = chapterNumber(g.title, ci + 1); const bucket = { num: num, title: g.title, lessons: [] }; log('—— 第 ' + num + ' 章|' + g.title + '|' + g.lessons.length + ' 节'); for (let i = 0; i < g.lessons.length; i++) { if (S.stopFlag) { log('已手动停止'); break; } const name = nodeTitle(g.lessons[i]); // 节点可能在切课后被重渲染:在当前 DOM 同章里按名字重新定位 const nowG = groupChapters()[ci]; const pool = nowG ? nowG.lessons : []; const li = pool.find((x) => nodeTitle(x) === name) || g.lessons[i]; if (!li || !document.contains(li)) { log(' ✗ 节点已失效,跳过:' + name); continue; } S.courseIndex = done; done++; UI.setProgress(done, S.totalCourses, name); await collectOne(li, i, bucket); if (!cfg.autoNext) { log('已关闭自动切换,停在当前课'); break; } } // 逐章落盘:中途停止也不丢已采内容 if (bucket.lessons.length) Bank.upsertChapter(bucket); UI.renderGrid(); UI.renderStats(); // 关掉「自动切下一节」时,采完当前章就整体停下 if (!cfg.autoNext) break; } const st = Bank.stats(); log('采集结束:' + st.chapters + ' 章 / ' + st.lessons + ' 节 / ' + st.questions + ' 题'); toast('采集结束:' + st.lessons + ' 节,' + st.questions + ' 题'); } catch (e) { log('✗ 异常:' + (e && e.message)); } finally { S.running = false; UI.syncCollectButtons(); UI.renderGrid(); UI.renderStats(); } } const Collector = { start() { startCollect(); }, stop() { S.stopFlag = true; log('收到停止指令…'); }, get running() { return S.running; } }; // ============================ 11. 提交 ============================ async function submitItems(items) { const graphId = Bank.data.graphId || S.graphId || ''; let ok = 0, fail = 0, skip = 0; for (const item of items) { const letters = Array.isArray(item.letters) ? item.letters : []; if (!letters.length) { skip++; continue; } const option = String(item.q.kind) === '3' ? letters.slice() : letters[0]; let res = null; try { res = await apiPost(API_SUBMIT, { questionId: item.q.id, id: item.courseId, graphId: graphId, option: option }); } catch (e) { res = null; } if (res && (res.success === true || res.code === 200)) { ok++; Answers.markSubmitted(item.q.id); } else fail++; await sleep(Number(cfg.submitDelay) || 180); } return { ok: ok, fail: fail, skip: skip }; } function itemsOfChapter(chapter) { return Bank.questionsOf(chapter).map((item) => ({ q: item.q, letters: Answers.letters(item.q.id), courseId: item.lesson.courseId })); } const Submitter = { async chapter(chapter) { const items = itemsOfChapter(chapter).filter((it) => it.letters.length); if (!items.length) { toast('本章还没有作答,无法提交'); return; } toast('提交第 ' + chapter.num + ' 章:' + items.length + ' 题…', 6000); const r = await submitItems(items); log('第 ' + chapter.num + ' 章提交:成功 ' + r.ok + ' / 失败 ' + r.fail); toast('第 ' + chapter.num + ' 章:成功 ' + r.ok + ',失败 ' + r.fail); UI.renderGrid(); UI.renderStats(); if (UI.overlayChapter) UI.renderOverlayBody(); }, async all() { const chapters = Bank.data.chapters.slice(); if (!chapters.length) { toast('题库为空,先采集'); return; } const all = []; chapters.forEach((c) => { all.push.apply(all, itemsOfChapter(c).filter((it) => it.letters.length)); }); if (!all.length) { toast('还没有任何作答,无法提交'); return; } toast('全部提交:' + all.length + ' 题…', 8000); const r = await submitItems(all); log('全部提交:成功 ' + r.ok + ' / 失败 ' + r.fail); toast('全部提交完成:成功 ' + r.ok + ',失败 ' + r.fail); UI.renderGrid(); UI.renderStats(); } }; // ============================ 12. 答案提供者插槽 ============================ // v1 恒 null。后续接 AI 或错题弹窗只改这里。 const AnswerProvider = { async provide(_question) { return null; } }; // ============================ 13. 考试模块 ============================ const Exam = { records: [], recordId: '', active: false, capture(data) { try { const result = (data && data.result) || null; const records = result && result.records; if (!Array.isArray(records) || !records.length) return; if (this.recordId && this.recordId === String(result.id || '') && this.active) return; this.recordId = String(result.id || ''); this.records = records.map((r, i) => normalize(r, i + 1)); this.active = true; log('捕获考试卷:' + this.records.length + ' 题'); UI.renderExamPane(); UI.openExamOverlay(); } catch (e) { log('整卷解析失败:' + (e && e.message)); } }, // 交卷判分回执(submitExam 响应旁挂)。拿到后把纸面收掉,避免误二次提交。 onResult(data) { try { const r = (data && data.result) || null; if (!data || data.success !== true || !r) return; if (this._resultSeen === String(r.id || '')) return; this._resultSeen = String(r.id || ''); const score = r.score; const pass = String(r.isQualified) === '1'; if (score === null || score === undefined) { log('交卷完成:等待评分'); toast('已交卷,等待评分结果', 5000); } else { log('交卷完成:得分 ' + score + (r.isQualified !== undefined && r.isQualified !== null ? '(' + (pass ? '合格' : '未合格') + ')' : '')); toast('交卷完成:得分 ' + score, 6000); } // 纸面已失效:清空卷面并收起浮层,避免对着旧卷再点一次提交 this.records = []; this.active = false; UI.closeOverlay(); UI.renderExamPane(); } catch (e) { /* 忽略 */ } }, findVm() { const root = document.querySelector('#app') && document.querySelector('#app').__vue__; const stack = root ? [root] : []; const seenVm = new Set(); while (stack.length) { const vm = stack.pop(); if (!vm || seenVm.has(vm)) continue; seenVm.add(vm); if (Array.isArray(vm.records) && vm.records.length && vm.param && typeof vm.submit === 'function') return vm; const kids = vm.$children || []; for (let i = 0; i < kids.length; i++) stack.push(kids[i]); } return null; }, pullFromVm() { const vm = this.findVm(); if (!vm) return false; this.records = vm.records.map((r, i) => normalize(r, i + 1)); this.recordId = String(vm.recordId || ''); this.active = true; log('从页面实例补全考卷:' + this.records.length + ' 题'); UI.renderExamPane(); return true; }, fill(vm) { let n = 0; this.records.forEach((q) => { const letters = Answers.letters(q.id); if (!letters.length) return; const value = String(q.kind) === '3' ? letters.slice() : letters[0]; if (typeof vm.$set === 'function') vm.$set(vm.param, q.id, value); else vm.param[q.id] = value; n++; }); return n; }, // 页面「当前显示题」用的是另一套绑定(vm.model + vm.option/vm.options), // 只写 vm.param 不动它 ⇒ 点提交时页面会拿旧绑定覆盖当前题,白丢一题。 // 否则提交时页面会拿旧绑定覆盖当前题。 syncDisplayed(vm) { if (!vm || !vm.model) return; const curId = String(vm.model.id || ''); const value = vm.param ? vm.param[curId] : null; if (String(vm.model.kind) === '3') { vm.options = Array.isArray(value) ? value.slice() : []; vm.option = ''; } else { vm.option = typeof value === 'string' ? value : ''; vm.options = []; } }, confirmDialog() { return new Promise((resolve) => { let settled = false; const finish = (ok) => { if (settled) return; settled = true; observer.disconnect(); clearTimeout(timer); resolve(ok); }; const tryConfirm = () => { const dialog = Array.prototype.slice.call(document.querySelectorAll('.ant-modal-confirm')) .reverse() .find((el) => el.getClientRects().length > 0 && /是否提交|确认提交试卷/.test(el.textContent || '')); if (!dialog) return; const buttons = Array.prototype.slice.call(dialog.querySelectorAll('button')); const btn = buttons.find((b) => !b.disabled && /^(确定|确认|OK)$/i.test(b.textContent.trim().replace(/\s+/g, ''))) || dialog.querySelector('button.ant-btn-primary:not([disabled])'); if (!btn) return; btn.click(); finish(true); }; const observer = new MutationObserver(tryConfirm); observer.observe(document.body, { childList: true, subtree: true }); const timer = setTimeout(() => finish(false), 2500); tryConfirm(); }); }, async submit() { if (!this.records.length && !this.pullFromVm()) { toast('未找到正在进行的考试'); return; } const vm = this.findVm(); if (!vm) { toast('无法访问考试页面,请保持在考试页后重试'); return; } const filled = this.fill(vm); if (!filled) { toast('还没有作答,无法提交'); return; } // 跳到最后一题并把当前题的绑定值拉齐,再交卷 const last = this.records[this.records.length - 1]; if (last) { const n = this.records.length; vm.index = n - 1; vm.seria = '第' + n + '题'; vm.model = Object.assign({}, last); vm.model.kind_text = String(last.kind) === '1' ? '判断题' : String(last.kind) === '3' ? '多选题' : '单选题'; this.syncDisplayed(vm); if (typeof vm.$nextTick === 'function') await new Promise((r) => vm.$nextTick(r)); } await sleep(120); if (typeof vm.submit !== 'function') { toast('页面没有提交入口,请手动交卷'); return; } const confirmation = this.confirmDialog(); try { vm.submit(); } catch (e) { log('提交异常:' + (e && e.message)); } const ok = await confirmation; toast(ok ? '已提交,等待评分结果' : '提交已发起,请页面上确认', 5000); log('考试提交:填入 ' + filled + ' 题|确认 ' + (ok ? '成功' : '未捕捉到')); } }; // ============================ 14. 视频控制(自有版移植,默认全关) ============================ const Video = { timer: null, pick() { return document.querySelector('video') || document.querySelector('#cc_F7B76307B77D90F213358DE27D943A99'); }, tick() { const v = this.pick(); if (!v) return; const speed = Number(cfg.videoSpeed) || 1; if (speed !== 1 && Math.abs(v.playbackRate - speed) > 0.001) v.playbackRate = speed; if (cfg.noPause && v.paused && !v.ended) v.play().catch(() => {}); else if (cfg.autoPlayVideo && v.paused && !v.ended) v.play().catch(() => {}); }, start() { this.stop(); const need = (Number(cfg.videoSpeed) || 1) !== 1 || cfg.noPause || cfg.autoPlayVideo; if (!need) return; this.timer = setInterval(() => this.tick(), 600); log('视频控制已启动(倍速 ' + (Number(cfg.videoSpeed) || 1) + 'x)'); }, stop() { if (this.timer) { clearInterval(this.timer); this.timer = null; } }, async onEnded() { if (!cfg.autoSwitchVideo) return; const cur = document.querySelector('.ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected'); const li = cur && cur.closest('li'); if (!li) return; const ul = li.parentElement; const ch = ul && ul.tagName === 'UL' ? ul.parentElement.closest('li') : null; if (!ch) return; let kids = ch.querySelector(':scope > ul'); const list = kids ? Array.prototype.slice.call(kids.children).filter((x) => x.tagName === 'LI') : []; const idx = list.indexOf(li); if (idx < 0 || idx >= list.length - 1) { log('本章已到末节,不自动切换'); return; } const w = list[idx + 1].querySelector('.ant-tree-node-content-wrapper'); if (w) { w.click(); log('自动切下一节:' + nodeTitle(list[idx + 1])); } }, bind() { document.addEventListener('ended', (e) => { if (e && e.target && e.target.tagName === 'VIDEO') this.onEnded(); }, true); } }; // ============================ 15. 样式 ============================ const CSS = ` #sqb-panel, #sqb-overlay, #sqb-toast { box-sizing: border-box; } #sqb-panel button, #sqb-overlay button, #sqb-panel input, #sqb-panel select { font-family: inherit; } #sqb-overlay .sqb-of .meta { color: #8a9099; font-size: 12px; } #sqb-panel { --sqb-radius: 20px; --sqb-accent: #1677ff; position: fixed; top: 120px; right: 20px; width: 360px; background: #fff; color: #1f2329; border: 1px solid rgba(0,0,0,.08); border-radius: var(--sqb-radius); box-shadow: 0 12px 40px rgba(15,23,42,.16); z-index: ${Z}; font: 13px/1.55 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; overflow: hidden; user-select: none; } #sqb-panel.min { width: 168px; } #sqb-panel.min .sqb-tabs, #sqb-panel.min .sqb-body { display: none; } #sqb-panel .sqb-head { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; border-bottom: 1px solid rgba(0,0,0,.06); cursor: move; } #sqb-panel .sqb-title { font-weight: 500; font-size: 13.5px; } #sqb-panel .sqb-ver { color: #8a9099; font-size: 11px; margin-left: 6px; } #sqb-panel .sqb-min { border: 0; background: #f2f3f5; color: #4e5969; width: 22px; height: 22px; border-radius: 7px; cursor: pointer; line-height: 1; } #sqb-panel .sqb-tabs { display: flex; gap: 4px; padding: 8px 10px; border-bottom: 1px solid rgba(0,0,0,.06); } #sqb-panel .sqb-tabs button { flex: 1; border: 0; background: transparent; color: #4e5969; padding: 6px 0; border-radius: 8px; cursor: pointer; font-size: 12.5px; } #sqb-panel .sqb-tabs button.on { background: rgba(22,119,255,.1); color: var(--sqb-accent); font-weight: 500; } #sqb-panel .sqb-body { padding: 12px 14px 14px; max-height: 70vh; overflow: auto; } #sqb-panel .sqb-pane { display: none; } #sqb-panel .sqb-pane.on { display: block; } #sqb-panel .sqb-stats { display: flex; gap: 6px; margin-bottom: 10px; } #sqb-panel .sqb-stats div { flex: 1; background: #f7f8fa; border-radius: 10px; padding: 7px 6px; text-align: center; } #sqb-panel .sqb-stats b { display: block; font-size: 15px; color: var(--sqb-accent); font-weight: 500; } #sqb-panel .sqb-stats i { font-style: normal; color: #8a9099; font-size: 10.5px; } #sqb-panel .sqb-row { display: flex; gap: 6px; margin-bottom: 8px; } #sqb-panel .sqb-btn { flex: 1; border: 1px solid rgba(0,0,0,.1); background: #fff; color: #1f2329; padding: 7px 0; border-radius: 9px; cursor: pointer; font-size: 12.5px; } #sqb-panel .sqb-btn.pri { background: var(--sqb-accent); border-color: var(--sqb-accent); color: #fff; } #sqb-panel .sqb-btn.warn { color: #d4380d; border-color: rgba(212,56,13,.3); } #sqb-panel .sqb-btn:disabled { opacity: .5; cursor: not-allowed; } #sqb-panel .sqb-grid { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 10px; } #sqb-panel .sqb-chip { width: 40px; height: 38px; border-radius: 10px; cursor: pointer; border: 1px solid rgba(0,0,0,.1); background: #fff; color: #4e5969; font-size: 13px; font-weight: 500; position: relative; padding: 0 0 10px; box-sizing: border-box; } #sqb-panel .sqb-chip.empty { padding-bottom: 0; } #sqb-panel .sqb-chip.empty { border-style: dashed; color: #b8bcc4; } #sqb-panel .sqb-chip.has { border-color: rgba(22,119,255,.45); color: var(--sqb-accent); } #sqb-panel .sqb-chip.done { background: var(--sqb-accent); border-color: var(--sqb-accent); color: #fff; } /* 当前正在看的那一章:实线蓝边 + 淡蓝底 + 外发光,与「有作答」(半透明蓝边) 区分开 */ #sqb-panel .sqb-chip.sel { border-color: var(--sqb-accent); background: rgba(22,119,255,.1); box-shadow: 0 0 0 2px rgba(22,119,255,.25); color: var(--sqb-accent); font-weight: 600; } #sqb-panel .sqb-chip.done.sel { background: var(--sqb-accent); color: #fff; box-shadow: 0 0 0 2px rgba(22,119,255,.4); } #sqb-panel .sqb-chip em { position: absolute; left: 0; right: 0; bottom: 2px; text-align: center; font-style: normal; font-size: 9px; line-height: 1; letter-spacing: -.2px; opacity: .68; } #sqb-panel .sqb-sw { display: flex; align-items: center; justify-content: space-between; padding: 5px 0; color: #4e5969; font-size: 12.5px; } #sqb-panel .sqb-sw input[type=checkbox] { accent-color: var(--sqb-accent); } #sqb-panel .sqb-sw input[type=number], #sqb-panel .sqb-sw select { width: 78px; border: 1px solid rgba(0,0,0,.12); border-radius: 7px; padding: 3px 6px; font-size: 12px; color: #1f2329; background: #fff; } #sqb-panel .sqb-log { margin-top: 8px; max-height: 108px; overflow: auto; background: #f7f8fa; border-radius: 9px; padding: 7px 8px; color: #6b7280; font: 11px/1.6 ui-monospace, Consolas, monospace; white-space: pre-wrap; word-break: break-all; } #sqb-panel .sqb-hint { color: #8a9099; font-size: 11.5px; line-height: 1.6; } #sqb-toast { position: fixed; left: 50%; bottom: 36px; transform: translate(-50%, 12px); background: rgba(31,35,41,.92); color: #fff; padding: 9px 16px; border-radius: 10px; font: 13px/1.5 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; z-index: ${Z + 1}; opacity: 0; pointer-events: none; transition: opacity .18s ease, transform .18s ease; max-width: 70vw; } #sqb-toast.on { opacity: 1; transform: translate(-50%, 0); } #sqb-overlay { position: fixed; inset: 0; background: rgba(15,23,42,.42); z-index: ${Z - 1}; display: none; align-items: center; justify-content: center; padding: 24px; font: 13.5px/1.6 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; } #sqb-overlay.on { display: flex; } #sqb-overlay .sqb-card { width: 100%; max-width: 1000px; height: 88vh; background: #fff; border-radius: 20px; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 24px 80px rgba(15,23,42,.32); } #sqb-overlay .sqb-oh { padding: 16px 20px; border-bottom: 1px solid rgba(0,0,0,.07); display: flex; align-items: center; gap: 10px; } #sqb-overlay .sqb-oh h3 { margin: 0; font-size: 15px; font-weight: 500; } #sqb-overlay .sqb-oh .meta { color: #8a9099; font-size: 12px; } #sqb-overlay .sqb-oh .spacer { flex: 1; } #sqb-overlay .sqb-oh button { border: 1px solid rgba(0,0,0,.1); background: #fff; color: #4e5969; border-radius: 9px; padding: 6px 12px; cursor: pointer; font-size: 12.5px; } #sqb-overlay .sqb-ob { flex: 1; overflow: auto; padding: 16px 20px; background: #fbfbfc; } #sqb-overlay .sqb-of { padding: 12px 20px; border-top: 1px solid rgba(0,0,0,.07); display: flex; gap: 8px; align-items: center; background: #fff; } #sqb-overlay .sqb-of .spacer { flex: 1; } #sqb-overlay .sqb-of button { border: 1px solid rgba(0,0,0,.1); background: #fff; color: #1f2329; border-radius: 10px; padding: 8px 16px; cursor: pointer; font-size: 13px; } #sqb-overlay .sqb-of button.pri { background: #1677ff; border-color: #1677ff; color: #fff; } #sqb-overlay .sqb-of button.warn { color: #d4380d; border-color: rgba(212,56,13,.3); } #sqb-overlay .sqb-of button:disabled { opacity: .4; cursor: not-allowed; } #sqb-overlay .sqb-q { background: #fff; border: 1px solid rgba(0,0,0,.07); border-radius: 14px; padding: 14px 16px; margin-bottom: 10px; } #sqb-overlay .sqb-qh { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } #sqb-overlay .sqb-no { width: 24px; height: 24px; border-radius: 8px; background: rgba(22,119,255,.1); color: #1677ff; font-size: 12px; font-weight: 500; display: flex; align-items: center; justify-content: center; } #sqb-overlay .sqb-kind { font-size: 11.5px; color: #8a9099; background: #f2f3f5; border-radius: 6px; padding: 1px 7px; } #sqb-overlay .sqb-flag { font-size: 11.5px; color: #0f9d58; margin-left: auto; } #sqb-overlay .sqb-stem { color: #1f2329; margin-bottom: 10px; white-space: pre-wrap; } #sqb-overlay .sqb-opts { display: flex; flex-direction: column; gap: 6px; } #sqb-overlay .sqb-opt { display: flex; gap: 9px; align-items: flex-start; text-align: left; border: 1px solid rgba(0,0,0,.1); background: #fff; border-radius: 10px; padding: 9px 11px; cursor: pointer; font: inherit; color: #1f2329; } #sqb-overlay .sqb-opt:hover { border-color: rgba(22,119,255,.45); } #sqb-overlay .sqb-opt.on { border-color: #1677ff; background: rgba(22,119,255,.06); } #sqb-overlay .sqb-lt { width: 20px; height: 20px; flex: 0 0 20px; border-radius: 6px; background: #f2f3f5; color: #4e5969; font-size: 12px; display: flex; align-items: center; justify-content: center; } #sqb-overlay .sqb-opt.on .sqb-lt { background: #1677ff; color: #fff; } #sqb-overlay .sqb-an { margin-top: 9px; color: #6b7280; font-size: 12.5px; background: #f7f8fa; border-radius: 9px; padding: 8px 10px; } `; function esc(s) { return String(s === null || s === undefined ? '' : s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } // ============================ 16. UI ============================ const UI = { overlayChapter: null, overlayMode: '', buildPanel() { if (document.getElementById('sqb-panel')) return; const el = document.createElement('div'); el.id = 'sqb-panel'; el.innerHTML = [ '