// ==UserScript== // @name 东莞教师培训 · 学习助手 // @namespace https://study.dgjy.net/ // @version 2.4.0 // @description 课程页自动完成视频课时;反馈页一键提交评价;答题页提取题目并调用大模型给出参考答案(不代交卷) // @author WorkBuddy // @match https://study.dgjy.net/training/* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant unsafeWindow // @connect * // @run-at document-start // ==/UserScript== (function () { 'use strict'; const W = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; const VERSION = '2.4.0'; /* ================= 配置 ================= */ const CONFIG = { autoStart: true, // 进入课程页后自动开始学习 interval: 1200, // 每节之间的间隔(ms) sendZeroFirst: true, // 先上报 0 再上报完成 feedbackStars: null, // null=满分 pollRounds: 20, pollGap: 1500, }; const LLM_DEFAULTS = { baseUrl: 'https://api.deepseek.com/v1', model: 'deepseek-chat', apiKey: '', temperature: 0.2, maxTokens: 0, // 0 = 不传,由服务端默认;长卷建议 4096 }; const store = { get(k, d) { try { const v = typeof GM_getValue === 'function' ? GM_getValue(k) : localStorage.getItem('dgjy_' + k); if (v === undefined || v === null || v === '') return d; return typeof v === 'string' ? JSON.parse(v) : v; } catch (e) { return d; } }, set(k, v) { try { const s = JSON.stringify(v); if (typeof GM_setValue === 'function') GM_setValue(k, s); else localStorage.setItem('dgjy_' + k, s); } catch (e) {} }, }; let llmCfg = Object.assign({}, LLM_DEFAULTS, store.get('llm', {})); const API = { tree: '/train/course/front/period/list/tree/studyLog', log: '/train/course/front/period/study/log', period: (pid) => `/train/course/front/period/getById?periodId=${pid}`, evalDetail: (eid) => `/train/course/front/evaluation/result/getDetail?evaluationId=${eid}`, evalSubmit: '/train/course/front/evaluation/result', paper: (rid) => `/questions/front/result/period/student/testpaper?testpaperResultId=${rid}`, }; /* ================= 状态 ================= */ const state = { mode: null, // 'learn' | 'feedback' | 'exam' key: '', status: '正在初始化…', // 学习 map: new Map(), periods: [], others: [], doneCount: 0, running: false, stop: false, pendingRun: false, autoRan: false, polling: false, logs: [], // 试题 paper: null, questions: [], answers: {}, busy: false, }; let ordSeq = 0; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const num = (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : 0; }; const log = (...a) => console.log('%c[学习助手]', 'color:#2563eb;font-weight:600', ...a); function strip(html) { if (!html) return ''; const d = document.createElement('div'); d.innerHTML = String(html); return (d.textContent || '').replace(/\s+/g, ' ').trim(); } function escapeHtml(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function pushLog(msg) { const t = new Date().toTimeString().slice(0, 8); if (state.logs[0] && state.logs[0].slice(11) === msg) return; state.logs.unshift(`[${t}] ${msg}`); if (state.logs.length > 5) state.logs.length = 5; render(); } /* ================= 鉴权 ================= */ /* 页面 axios 拦截器给每个请求加 Authorization: Bearer ,原生 fetch 不会,需照做 */ function token() { const m = (document.cookie || '').match(/(?:^|;\s*)Middle-Token=([^;]+)/); return m ? decodeURIComponent(m[1]) : ''; } function authHeaders(extra) { const h = Object.assign({ Accept: 'application/json, text/plain, */*' }, extra || {}); const t = token(); if (t) h.Authorization = 'Bearer ' + t; return h; } async function postJSON(url, data) { const r = await fetch(url, { method: 'POST', credentials: 'include', headers: authHeaders({ 'Content-Type': 'application/json;charset=utf-8' }), body: JSON.stringify(data), }); return r.json(); } async function getJSON(url) { const r = await fetch(url, { credentials: 'include', headers: authHeaders() }); return r.json(); } /* ================= 网络 hook ================= */ function hookNetwork() { const XHR = (W && W.XMLHttpRequest) || XMLHttpRequest; const origOpen = XHR.prototype.open; const origSend = XHR.prototype.send; XHR.prototype.open = function (method, url) { try { this.__dgjyUrl = String(url); } catch (e) {} return origOpen.apply(this, arguments); }; XHR.prototype.send = function () { try { const u = this.__dgjyUrl || ''; if (/tree\/studyLog|questions\/front\/result|testpaper/.test(u)) { this.addEventListener('load', () => { try { handlePayload(u, this.responseText); } catch (e) {} }); } } catch (e) {} return origSend.apply(this, arguments); }; if (W.fetch) { const origFetch = W.fetch; W.fetch = function (input, init) { const url = (typeof input === 'string' ? input : (input && input.url) || '') + ''; const p = origFetch.apply(this, arguments); if (/tree\/studyLog|questions\/front\/result|testpaper/.test(url)) { p.then((r) => r.clone().text().then((t) => handlePayload(url, t)).catch(() => {})).catch(() => {}); } return p; }; } } function handlePayload(url, text) { if (url.indexOf('tree/studyLog') !== -1) { if (considerTree(text)) onTreeReady(); return; } let j; try { j = typeof text === 'string' ? JSON.parse(text) : text; } catch (e) { return; } const d = j && (j.data || j.result); if (d && (d.questions || d.questionList)) applyPaper(d); } /* ================= 一、学习:课表解析 ================= */ function collect(nodes) { (nodes || []).forEach((n) => { if (!n || !n.id) return; const t = String(n.periodType); if (t === '2') { const f = n.file || {}; const dur = num(f.videoDuration) || num(f.fileDuration) || num(f.duration) || num(n.duration) || num(n.videoDuration) || 0; const sl = n.studyLog || {}; const prev = state.map.get(n.id) || {}; state.map.set(n.id, { id: n.id, name: n.name || prev.name || '课时 ' + n.id, duration: dur || prev.duration || 0, done: prev.done || !!(sl.completeTime || Number(sl.progress) === 1), ord: prev.ord !== undefined ? prev.ord : (num(n.sort) ? Number(n.sort) : ++ordSeq), }); } else if (t === '5' || t === '17') { const label = t === '5' ? '测试' : '培训反馈'; if (!state.others.some((x) => x.id === n.id)) { const sl = n.studyLog || {}; state.others.push({ id: n.id, type: t, label, evaluationId: n.evaluationId || null, testpaperId: n.testpaperId || null, testpaperResultId: n.testpaperResultId || null, status: n.status, score: n.score, done: !!(sl.completeTime || n.testpaperResultId || Number(sl.progress) === 1), }); } } if (n.children && n.children.length) collect(n.children); }); } function syncFromMap() { state.periods = [...state.map.values()].sort((a, b) => (a.ord || 0) - (b.ord || 0)); state.doneCount = state.periods.filter((p) => p.done).length; } function considerTree(payload) { let j = payload; if (typeof j === 'string') { try { j = JSON.parse(j); } catch (e) { return 0; } } if (!j || j.code !== 200) return 0; let nodes = j.data; if (!Array.isArray(nodes)) { if (nodes && Array.isArray(nodes.records)) nodes = nodes.records; else if (nodes && Array.isArray(nodes.list)) nodes = nodes.list; else return 0; } const before = state.map.size; collect(nodes); syncFromMap(); const added = state.map.size - before; if (state.map.size && added > 0) { log('课表更新', state.map.size, '节 (+' + added + ')'); if (state.mode === 'learn' && !state.running && !state.pendingRun) { setStatus(`已读取 ${state.periods.length} 节,待学 ${state.periods.length - state.doneCount} 节`); } render(); } return state.map.size; } function onTreeReady() { if (state.mode !== 'learn') return; if (state.pendingRun || (CONFIG.autoStart && !state.autoRan)) { state.autoRan = true; state.pendingRun = false; setStatus(`已读取 ${state.periods.length} 节,待学 ${state.periods.length - state.doneCount} 节`); setTimeout(runLearn, 600); } else { render(); } } function qs() { return new URLSearchParams(location.search); } function courseId() { return qs().get('courseId') || ''; } function trainId() { return qs().get('id') || ''; } function treeUrls() { const tid = trainId(); const cid = courseId() || qs().get('periodId') || ''; const out = []; if (cid && tid) out.push(`${API.tree}?courseId=${cid}&id=${tid}&isFront=1`); if (cid) out.push(`${API.tree}?courseId=${cid}&isFront=1`); if (tid) out.push(`${API.tree}?id=${tid}&isFront=1`); return out; } async function ensurePeriods(force) { if (state.polling) return; if (!force && state.map.size) return; state.polling = true; const urls = treeUrls(); let authFail = false; for (let i = 0; i < CONFIG.pollRounds; i++) { for (const u of urls) { try { const j = await getJSON(u); if (j && (j.code === 401 || j.code === 403)) authFail = true; considerTree(j); } catch (e) {} if (state.map.size) break; } if (state.map.size) break; setStatus(`等待课表…(${i + 1}/${CONFIG.pollRounds})`); await sleep(CONFIG.pollGap); } state.polling = false; if (state.map.size) onTreeReady(); else if (!state.running) { setStatus(authFail ? '登录态失效,请刷新页面重新登录' : '未获取到课表,点「重新读取」或刷新页面'); log('兜底读取失败', { authFail, urls }); } } /* ================= 一、学习:上报 ================= */ async function report(pid, complete, last, extra) { const body = { periodId: pid, isComplete: complete, lastProgressTime: String(last) }; if (extra) Object.assign(body, extra); return postJSON(API.log, body); } async function serverLog(pid) { try { const j = await getJSON(API.period(pid)); return (j && j.data && j.data.studyLog) || null; } catch (e) { return null; } } const isDone = (sl) => !!(sl && (sl.completeTime || Number(sl.progress) === 1)); async function diag(pid) { const p = state.periods.find((x) => String(x.id) === String(pid)); const dur = (p && p.duration) || 60; const before = await serverLog(pid); const r0 = await report(pid, 0, 0); await sleep(400); const r1 = await report(pid, 1, dur); await sleep(600); const after = await serverLog(pid); const out = { pid, dur, before, r0, r1, after, ok: isDone(after) }; log('=== 诊断结果 ===', out); console.log(JSON.stringify(out, null, 1)); return out; } async function runLearn() { if (state.mode !== 'learn') return; if (state.running) return; if (!state.periods.length) { state.pendingRun = true; setStatus('等待课表…'); ensurePeriods(true); return; } state.running = true; state.stop = false; syncFromMap(); const todo = state.periods.filter((p) => !p.done); pushLog(`待学 ${todo.length} 节,已完成 ${state.doneCount} 节`); render(); for (let i = 0; i < todo.length; i++) { if (state.stop) { pushLog('已停止'); break; } const p = todo[i]; try { if (CONFIG.sendZeroFirst) { await report(p.id, 0, 0); await sleep(350); } let r = await report(p.id, 1, p.duration || 1); await sleep(400); let sl = await serverLog(p.id); let ok = isDone(sl); if (!ok && r && r.code === 200) { const variants = [ { studyTime: p.duration }, { courseId: courseId(), trainId: trainId() }, { studyTime: p.duration, courseId: courseId(), trainId: trainId() }, ]; for (const v of variants) { r = await report(p.id, 1, p.duration || 1, v); await sleep(500); sl = await serverLog(p.id); if (isDone(sl)) { ok = true; log('变体生效', v); break; } } if (!ok) log('上报返回200但未落库', { periodId: p.id, resp: r, studyLog: sl, variants }); } if (ok) { p.done = true; state.doneCount++; pushLog(`✓ ${p.name} ${p.duration}s`); } else { log('上报未生效 periodId=' + p.id, { resp: r, studyLog: sl }); if (r && (r.code === 401 || r.code === 403)) pushLog('× 登录态失效,请刷新页面后重试'); else pushLog(`× ${p.name} ${(r && (r.msg || 'code ' + r.code)) || '未生效'}`); pushLog('已暂停:把控制台红字发我可定位'); state.running = false; pipelineClear(); setStatus('上报未生效,已暂停'); render(); return; } } catch (e) { pushLog(`× ${p.name} 网络异常`); } render(); if (i < todo.length - 1) await sleep(CONFIG.interval); } state.running = false; pushLog(`结束:完成 ${state.doneCount}/${state.periods.length}`); setStatus(state.doneCount === state.periods.length ? '全部完成,可刷新查看' : '部分未完成,可再点一次'); render(); if (state.doneCount === state.periods.length) pipelineNext(); else pipelineClear(); } /* ================= 二、培训反馈 ================= */ async function submitFeedback() { const eid = qs().get('id'); const pid = qs().get('periodId'); if (!eid || !pid) { pushLog('未识别到评价参数'); return; } try { const d = await getJSON(API.evalDetail(eid)); const data = d && d.data; if (!data) { pushLog('评价信息读取失败'); return; } if (data.resultId) { pushLog(`已提交过(resultId=${data.resultId})`); return; } const stars = CONFIG.feedbackStars || data.starCount || 10; const items = (data.items || []).map((it) => ({ itemId: it.itemId, answer: stars })); const res = await postJSON(API.evalSubmit, { periodId: Number(pid), evaluationId: Number(eid), items }); if (res && res.code === 200) { pushLog(`✓ 反馈已提交(${items.length} 题 × ${stars} 分)`); setStatus('反馈已提交,刷新页面可查看'); } else { pushLog(`× 提交失败:${(res && res.msg) || ''}`); } } catch (e) { pushLog('× 提交异常:' + e.message); } render(); } /* ================= 全流程:视频 → 反馈 → 测试 ================= */ /* 流程状态存油猴存储,跨页面导航后仍可续上 */ function pipelineGet() { const p = store.get('pipeline', null); if (!p) return null; if (!p.ts || Date.now() - p.ts > 10 * 60 * 1000) { store.set('pipeline', null); return null; } return p; } function pipelineSet(v) { store.set('pipeline', Object.assign({ ts: Date.now() }, v)); } function pipelineClear() { store.set('pipeline', null); } async function submitFeedbackApi(pid, eid) { try { const d = await getJSON(API.evalDetail(eid)); const data = d && d.data; if (!data) { pushLog('× 读取评价失败'); return false; } if (data.resultId) { pushLog('反馈已提交过,跳过'); return true; } const stars = CONFIG.feedbackStars || data.starCount || 10; const items = (data.items || []).map((it) => ({ itemId: it.itemId, answer: stars })); const res = await postJSON(API.evalSubmit, { periodId: Number(pid), evaluationId: Number(eid), items }); if (res && res.code === 200) { pushLog(`✓ 反馈已提交(${items.length} 题 × ${stars} 分)`); return true; } pushLog(`× 反馈提交失败:${(res && res.msg) || ''}`); return false; } catch (e) { pushLog('× 反馈异常:' + e.message); return false; } } /* 视频跑完后继续下一环 */ async function pipelineNext() { let p = pipelineGet(); if (!p) return; if (p.phase === 'learn') { const fb = state.others.find((o) => o.type === '17' && o.evaluationId); if (fb) { setStatus('全流程:正在提交培训反馈…'); render(); const ok = await submitFeedbackApi(fb.id, fb.evaluationId); if (!ok) { pipelineClear(); setStatus('全流程中断:培训反馈提交失败'); return; } } p = Object.assign({}, p, { phase: 'exam' }); pipelineSet(p); } if (p.phase === 'exam') { const ex = state.others.find((o) => o.type === '5'); if (!ex) { pipelineClear(); setStatus('全流程完成:学习与反馈已处理'); render(); return; } if (!ex.testpaperResultId) { pipelineClear(); setStatus('全流程暂停:测试还没开始,请手动点一次「测试」,之后就能接管'); render(); return; } setStatus('全流程:正在打开测试…'); render(); await sleep(600); location.href = `/training/generatingPaper/answerTestPaper?id=${ex.testpaperResultId}`; } } function startPipeline() { if (!state.periods.length) { setStatus('先等课表加载完'); return; } pipelineSet({ phase: 'learn', courseId: courseId() || state.key, trainId: trainId() }); pushLog('全流程启动:视频 → 反馈 → 测试'); runLearn(); } /* 答题页:若处于全流程,自动作答并填入 */ let autoExamRan = false; async function maybeAutoExam() { const p = pipelineGet(); if (!p || p.phase !== 'exam' || autoExamRan) return; if (!state.questions.length) return; autoExamRan = true; setStatus('全流程:正在让模型作答…'); render(); await aiAnswer(); if (!Object.keys(state.answers).length) { setStatus('全流程暂停:模型没有返回答案(检查 API Key / 控制台)'); render(); return; // 保留流程状态,配好 Key 后可手动再点「AI 作答 → 填入页面」 } await sleep(400); await fillPage(); state.status = '全流程 · ' + state.status; pipelineClear(); render(); } /* ================= 三、试题:解析 ================= */ const TYPES = { SINGLECHOICE: '单选题', MULTIPLECHOICE: '多选题', JUDGMENT: '判断题', COMPLETION: '填空题', ESSAYQUSETION: '简答题', }; function typeName(t) { if (TYPES[t]) return TYPES[t]; const n = Number(t); return ({ 1: '单选题', 2: '多选题', 3: '判断题', 4: '填空题', 5: '简答题' })[n] || String(t || '题目'); } /* 真实试卷字段(从答题页 template 倒推): 题干 = stem,选项 = metasDtos,id = questionId / itemResultId 字段名在不同版本间有差异,因此:先按已知名找 → 再全字段自动识别 */ const SUBJECT_KEYS = ['stem', 'subjectName', 'subjectContent', 'subject', 'content', 'questionContent', 'question', 'title']; const OPTION_KEYS = ['metasDtos', 'metaDtos', 'metas', 'optionDtos', 'optionDtoList', 'optionList', 'subjectList', 'options', 'itemOptions', 'choiceList', 'optionVoList']; const SKIP_KEYS = /appendix|file|attach|answer|score|sort|id$|type$|stem|time|date/i; function pickSubject(q) { for (const k of SUBJECT_KEYS) { const v = q[k]; if (typeof v === 'string' && strip(v)) return strip(v); } return ''; } const LABEL_KEYS = ['option', 'label', 'key', 'code', 'optionCode', 'itemKey', 'optionKey', 'name']; const TEXT_KEYS = ['content', 'text', 'value', 'optionContent', 'optionText', 'optionValue', 'title', 'desc', 'name']; function normalizeOption(o) { if (typeof o === 'string') { const m = o.match(/^\s*([A-Za-z])\s*[.、::))]?\s*([\s\S]*)$/); return m && m[2] ? { label: m[1].toUpperCase(), text: strip(m[2]) } : { label: '', text: strip(o) }; } if (!o || typeof o !== 'object') return { label: '', text: '' }; let label = ''; for (const k of LABEL_KEYS) { const v = o[k]; if (typeof v === 'string' && /^[A-Za-z]$/.test(v.trim())) { label = v.trim().toUpperCase(); break; } if (typeof v === 'number' && v >= 0 && v < 10) { label = String.fromCharCode(65 + v); break; } } let text = ''; for (const k of TEXT_KEYS) { const v = o[k]; if (typeof v === 'string') { const s = strip(v); if (!s) continue; if (k === 'name' && /^[A-Za-z]$/.test(s)) continue; // name 是字母就当成 label 用 if (s.length > text.length) text = s; } } if (!label && text) { const m = text.match(/^\s*([A-Za-z])\s*[.、::))]\s*([\s\S]+)$/); if (m) { label = m[1].toUpperCase(); text = m[2].trim(); } } return { label, text }; } function toOptions(arr) { if (!Array.isArray(arr) || arr.length < 2) return []; const mapped = arr.map(normalizeOption).filter((o) => o.text || o.label); const withText = mapped.filter((o) => o.text); if (withText.length < 2) return []; // 至少一半带选项字母,才算选项数组(避免把文件列表、答案列表误当选项) const withLabel = withText.filter((o) => o.label).length; if (withLabel < Math.ceil(withText.length / 2)) { const allLetterish = withLabel === withText.length; if (!allLetterish) return []; } return withText.slice(0, 12); } function pickOptions(q, group) { for (const k of OPTION_KEYS) { const r = toOptions(q && q[k]); if (r.length) return r; } // 全字段扫描:任何「对象数组」都试着当选项解析 const scan = (obj) => { if (!obj || typeof obj !== 'object') return []; for (const k of Object.keys(obj)) { if (SKIP_KEYS.test(k)) continue; const r = toOptions(obj[k]); if (r.length) { log('选项字段自动识别为 →', k); return r; } } return []; }; const fromQ = scan(q); if (fromQ.length) return fromQ; return scan(group); } function parsePaper(data) { if (!data) return []; let groups = data.questions || data.questionList || data.items || []; if (!Array.isArray(groups)) groups = []; const out = []; groups.forEach((g) => { const items = Array.isArray(g.data) ? g.data : (Array.isArray(g.items) ? g.items : [g]); items.forEach((q) => { if (!q || typeof q !== 'object') return; const subject = pickSubject(q); const opts = pickOptions(q, g); if (!subject && !opts.length) return; // 跳过解析不出内容的空壳 out.push({ idx: out.length + 1, type: q.type || g.type, typeName: typeName(q.type || g.type), subject, options: opts, itemId: q.itemResultId != null ? q.itemResultId : (q.questionId != null ? q.questionId : q.itemId), score: q.score, raw: q, }); }); }); return out; } function applyPaper(data) { if (!data || state.questions.length) return false; const qs2 = parsePaper(data); if (!qs2.length) return false; state.paper = data; state.questions = qs2; const blank = qs2.filter((q) => !q.subject).length; if (blank) log('警告:有 ' + blank + ' 题解析不出题干,字段可能又变了,跑 __DGJY.shape() 看结构'); const stat = {}; qs2.forEach((q) => { stat[q.typeName] = (stat[q.typeName] || 0) + 1; }); state.status = `已读取 ${qs2.length} 题(${Object.entries(stat).map(([k, v]) => k + ' ' + v).join(',')})`; log('试卷解析完成', qs2.length, '题', stat); render(); setTimeout(maybeAutoExam, 800); return true; } async function tryFetchPaper() { if (state.mode !== 'exam' || state.questions.length) return; const id = qs().get('id'); if (!id) return; try { const j = await getJSON(API.paper(id)); if (j && (j.data || j.result)) applyPaper(j.data || j.result); if (!state.questions.length) log('兜底拉取返回不可解析', j); } catch (e) { log('兜底拉取失败', e); } } /* ================= 三、试题:大模型 ================= */ function llm(messages, opts) { return new Promise((resolve, reject) => { if (!llmCfg.apiKey) return reject(new Error('未填写 API Key')); const body = { model: llmCfg.model, temperature: Number(llmCfg.temperature) || 0, stream: false, messages, }; const mt = Number(llmCfg.maxTokens); if (mt > 0) body.max_tokens = mt; if (opts && opts.json) body.response_format = { type: 'json_object' }; const o = { method: 'POST', url: llmCfg.baseUrl.replace(/\/+$/, '') + '/chat/completions', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + llmCfg.apiKey }, data: JSON.stringify(body), timeout: 180000, onload(r) { let j; try { j = JSON.parse(r.responseText); } catch (e) { return reject(new Error('返回不是 JSON:' + String(r.responseText).slice(0, 160))); } if (j.error) return reject(new Error(j.error.message || '接口返回错误')); const ch = j.choices && j.choices[0]; const c = ch && ch.message && ch.message.content; if (!c) return reject(new Error('没有 choices[0].message.content')); resolve({ content: c, finish: (ch.finish_reason || ''), usage: j.usage || null }); }, onerror() { reject(new Error('网络请求失败(检查 Base URL / 网络)')); }, ontimeout() { reject(new Error('请求超时')); }, }; if (typeof GM_xmlhttpRequest === 'function') GM_xmlhttpRequest(o); else { fetch(o.url, { method: 'POST', headers: o.headers, body: o.data }) .then((r) => r.text().then((t) => o.onload({ responseText: t }))) .catch(() => o.onerror()); } }); } function buildPrompt(qs2) { const lines = []; qs2.forEach((q) => { lines.push(`【${q.idx}】(${q.typeName}) ${q.subject}`); q.options.forEach((o) => lines.push(` ${o.label}. ${o.text}`)); }); return [ '你是教师继续教育考试的答题助手。请回答以下题目。', '', lines.join('\n'), '', '输出要求:只输出一个 JSON 数组,不要解释、不要 markdown 代码块。格式:', '[{"index":1,"answer":["A"]},{"index":2,"answer":["A","C"]},{"index":3,"answer":["正确"]},{"index":4,"answer":["填空内容"]}]', '规则:单选/多选只填选项字母(多选按字母序,如 ["A","C"]),禁止写选项文字;判断题填 ["正确"] 或 ["错误"];填空/简答填文本。', '注意:答案必须严格来自上面给出的选项,不要凭常识另写内容。', ].join('\n'); } function parseAnswers(text) { let s = String(text).trim(); const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/); if (fence) s = fence[1].trim(); const a = s.indexOf('['), b = s.lastIndexOf(']'); if (a === -1 || b === -1) throw new Error('模型输出里找不到 JSON 数组'); const arr = JSON.parse(s.slice(a, b + 1)); const map = {}; arr.forEach((it) => { const i = Number(it.index || it.i || it.idx); if (!i || !it.answer) return; map[i] = Array.isArray(it.answer) ? it.answer.map(String) : [String(it.answer)]; }); return map; } async function oneShot(qs2) { state.calls = (state.calls || 0) + 1; const r = await llm([ { role: 'system', content: '你是一名严谨的答题助手,只输出 JSON。' }, { role: 'user', content: buildPrompt(qs2) }, ]); return { map: parseAnswers(r.content), finish: r.finish, usage: r.usage }; } async function aiAnswer() { if (state.busy) return; if (!state.questions.length) { setStatus('还没读到题目,刷新页面试试'); return; } if (!state.questions.some((q) => q.subject)) { setStatus('题目内容解析失败(题干为空),先跑一次 __DGJY.shape() 发我'); log('题干全空,已中止请求', state.paper && Object.keys(state.paper)); render(); return; } if (!llmCfg.apiKey) { setStatus('请先在设置里填写 API Key'); openCfg(true); render(); return; } state.busy = true; state.calls = 0; const total = state.questions.length; setStatus(`正在请求模型(1 次请求,共 ${total} 题)…`); render(); try { const r1 = await oneShot(state.questions); state.answers = r1.map; let missing = state.questions.filter((q) => !state.answers[q.idx]); let note = r1.finish === 'length' ? ',输出被截断' : ''; // 只对漏答的题补一次(仍是单次请求,不是逐题请求) if (missing.length && missing.length < total) { setStatus(`首轮答 ${total - missing.length} 题,补答 ${missing.length} 题…`); render(); try { const r2 = await oneShot(missing); Object.assign(state.answers, r2.map); note += `,补答 ${missing.length} 题`; missing = state.questions.filter((q) => !state.answers[q.idx]); } catch (e2) { log('补答失败', e2); } } const answered = total - missing.length; state.status = `${state.calls} 次请求 · ${answered}/${total} 题已答` + (missing.length ? `(缺 ${missing.length} 题)` : '') + note; log('作答完成', { calls: state.calls, answered, finish: r1.finish, usage: r1.usage }); } catch (e) { state.status = '失败:' + e.message; log('调用失败', e); } state.busy = false; render(); } async function testConn() { setStatus('测试连接中…'); render(); try { const r = await llm([{ role: 'user', content: '只回复两个字:正常' }]); setStatus('连接正常,回复:' + String(r.content).slice(0, 20)); } catch (e) { setStatus('连接失败:' + e.message); } render(); } function answerText() { return state.questions.map((q) => { const a = state.answers[q.idx]; return `${q.idx}. ${a ? a.join('') : '(未作答)'}`; }).join('\n'); } function copyAll() { navigator.clipboard.writeText(answerText()).then( () => { setStatus('答案已复制到剪贴板'); render(); }, () => { setStatus('复制失败,可手动选中复制'); render(); } ); } /* ================= 三、试题:把答案填进页面 ================= */ /* 页面结构:每道题外层
, 选项是 Element UI 的 .el-radio / .el-checkbox(内部带原生 input) */ function questionBox(q) { const sort = q.raw && Number.isFinite(Number(q.raw.sort)) ? Number(q.raw.sort) : q.idx - 1; let box = document.getElementById('topic' + sort); if (box) return box; const all = document.querySelectorAll('.question-content'); return all[sort] || all[q.idx - 1] || null; } function optionNodes(box) { let nodes = [...box.querySelectorAll('.el-radio, .el-checkbox')]; if (nodes.length) return nodes; nodes = [...box.querySelectorAll('input[type=radio], input[type=checkbox]')]; return nodes; } function isChecked(node) { return node.classList.contains('is-checked') || !!(node.querySelector && node.querySelector('input:checked')) || (node.checked === true); } function clickNode(node) { node.click(); if (isChecked(node)) return true; const inp = node.querySelector && node.querySelector('input'); if (inp) { inp.click(); if (isChecked(node)) return true; } const label = node.closest && node.closest('label'); if (label) { label.click(); if (isChecked(node)) return true; } return isChecked(node); } /* 把 ['A','C'] / ['正确'] / ['文本'] 映射成 DOM 里的选项下标 */ function mapAnswerToIndex(a, q, count) { const out = new Set(); (a || []).forEach((raw) => { const s = String(raw).trim(); if (!s) return; let i = -1; if (/^[A-Za-z]$/.test(s)) { i = q.options.findIndex((o) => String(o.label).toUpperCase() === s.toUpperCase()); } if (i < 0) { i = q.options.findIndex((o) => o.text && (o.text.includes(s) || s.includes(o.text))); } if (i < 0 && /^(正确|对|是|√|T|true)$/i.test(s)) i = 0; if (i < 0 && /^(错误|错|否|×|F|false)$/i.test(s)) i = 1; if (i >= 0 && i < count) out.add(i); }); return out; } async function fillOne(q) { const a = answerOf(q, q.idx - 1); if (!a || !a.length) return 'noanswer'; const box = questionBox(q); if (!box) return 'nobox'; const nodes = optionNodes(box); if (nodes.length) { const want = mapAnswerToIndex(a, q, nodes.length); if (!want.size) return 'nomatch'; let done = 0; for (const i of want) { if (isChecked(nodes[i])) { done++; continue; } if (clickNode(nodes[i])) done++; await sleep(50); } return done === want.size ? 'ok' : 'partial'; } // 填空 / 简答:写进输入框 const inputs = [...box.querySelectorAll('input[type=text], textarea, .el-textarea__inner, .el-input__inner')]; if (inputs.length) { inputs.forEach((inp, i) => { const v = a[i] != null ? a[i] : a[0]; if (v == null) return; inp.value = v; inp.dispatchEvent(new Event('input', { bubbles: true })); inp.dispatchEvent(new Event('change', { bubbles: true })); }); return 'ok'; } return 'nobox'; } async function fillPage() { if (state.busy) return; if (!state.questions.length) { setStatus('还没读到题目'); return; } if (!Object.keys(state.answers).length) { setStatus('先点「AI 作答」拿到答案再填'); render(); return; } state.busy = true; setStatus('正在填入页面…'); render(); let ok = 0; const fails = []; for (const q of state.questions) { let r = 'err'; try { r = await fillOne(q); } catch (e) { log('填充第 ' + q.idx + ' 题出错', e); } if (r === 'ok') ok++; else if (r !== 'noanswer') fails.push(q.idx + '(' + r + ')'); render(); } state.busy = false; if (!ok) { state.status = '填充失败:没找到题目元素,跑一次 __DGJY.domInfo() 发我'; log('填充失败,页面结构可能不同', domInfo()); } else { state.status = `已填入 ${ok}/${state.questions.length} 题` + (fails.length ? `,${fails.length} 题未填(${fails.slice(0, 6).join(' ')})` : '') + ',请在页面上核对后自行提交'; } render(); } function domInfo() { const all = [...document.querySelectorAll('.question-content')]; const f = all[0]; return { 题目容器数: all.length, 容器id样例: all.slice(0, 4).map((e) => e.id), 首题单选: f ? f.querySelectorAll('.el-radio').length : 0, 首题复选: f ? f.querySelectorAll('.el-checkbox').length : 0, 首题input: f ? f.querySelectorAll('input').length : 0, 题目列表: state.questions.slice(0, 3).map((q) => ({ idx: q.idx, sort: q.raw && q.raw.sort, 有容器: !!questionBox(q) })), }; } /* ================= 界面 ================= */ let host = null, root = null; const el = {}; const CSS = ` :host { all: initial; } * { box-sizing: border-box; font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; } .card { position: fixed; top: 84px; right: 20px; width: 306px; z-index: 2147483000; background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; overflow: hidden; box-shadow: 0 8px 28px rgba(15,23,42,.13); font-size: 13px; color: #1f2937; display: flex; flex-direction: column; max-height: 80vh; } .card.wide { width: 372px; } .hd { display: flex; align-items: center; gap: 8px; padding: 11px 14px; border-bottom: 1px solid #f1f5f9; flex: none; } .dot { width: 7px; height: 7px; border-radius: 50%; background: #2563eb; flex: none; } .dot.run { animation: pulse 1.2s infinite; } @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.25} } .title { font-weight: 600; flex: 1; letter-spacing: .2px; } .ver { font-size: 10px; color: #cbd5e1; margin-left: 3px; } .close { cursor: pointer; color: #9ca3af; font-size: 16px; border: 0; background: none; padding: 2px 4px; } .close:hover { color: #4b5563; } .bd { padding: 12px 14px 14px; overflow: auto; } .stat { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8px; } .stat b { font-size: 15px; font-weight: 600; color: #111827; } .stat span { color: #6b7280; font-size: 12px; } .bar { height: 4px; border-radius: 2px; background: #f1f5f9; overflow: hidden; margin-bottom: 12px; } .bar > i { display: block; height: 100%; width: 0; background: linear-gradient(90deg,#3b82f6,#60a5fa); transition: width .35s ease; } .st { color: #6b7280; font-size: 12px; margin-bottom: 10px; min-height: 16px; line-height: 1.5; } .logs { background: #f8fafc; border: 1px solid #f1f5f9; border-radius: 8px; padding: 8px 10px; min-height: 54px; margin-bottom: 12px; } .logs div { font-size: 11.5px; color: #64748b; line-height: 1.7; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .logs div:first-child { color: #334155; } .btn { border: 0; border-radius: 8px; padding: 9px 0; font-size: 13px; font-weight: 500; cursor: pointer; transition: .15s; flex: 1; } .btn.primary { background: #2563eb; color: #fff; } .btn.primary:hover { background: #1d4ed8; } .btn.ghost { background: #f1f5f9; color: #334155; } .btn.ghost:hover { background: #e2e8f0; } .btn:disabled { opacity: .55; cursor: default; } .row { display: flex; gap: 8px; } .tip { margin-top: 9px; font-size: 11px; color: #9ca3af; line-height: 1.6; } .cfg { border: 1px solid #eef2f7; background: #fafbfe; border-radius: 9px; padding: 10px; margin-bottom: 11px; } .cfg label { display: block; font-size: 11.5px; color: #64748b; margin: 0 0 3px; } .cfg input { width: 100%; border: 1px solid #e2e8f0; border-radius: 6px; padding: 6px 8px; font-size: 12px; margin-bottom: 8px; outline: none; } .cfg input:focus { border-color: #93c5fd; } .q { border-bottom: 1px dashed #eef2f7; padding: 7px 0; } .q:last-child { border-bottom: 0; } .qt { font-size: 11px; color: #94a3b8; margin-right: 4px; } .qs { font-size: 12px; color: #334155; line-height: 1.55; } .qa { font-size: 12px; color: #2563eb; font-weight: 600; margin-top: 3px; word-break: break-all; } .qa.none { color: #cbd5e1; font-weight: 400; } .hid { display: none !important; } `; function removeLegacyPanels() { ['dgjy-exam-ai', 'dgjy-auto-learn'].forEach((id) => { const n = document.getElementById(id); if (n) { n.remove(); log('检测到旧版脚本的面板 #' + id + ',已移除;建议在油猴里禁用旧脚本'); } }); } function buildUI() { const parent = document.documentElement || document.body; if (!parent) { setTimeout(buildUI, 200); return; } removeLegacyPanels(); if (host) host.remove(); host = document.createElement('div'); host.id = 'dgjy-helper'; root = host.attachShadow({ mode: 'open' }); root.innerHTML = `
学习助手v${VERSION}
正在初始化…
0 / 0课时
考试需自行完成,本工具不处理
每颗星 1 分,默认满分
答案仅供参考,请在页面上自行核对并提交,本工具不代替交卷
`; parent.appendChild(host); ['card', 'dot', 'title', 'st', 'secLearn', 'secFeedback', 'secExam', 'num', 'bar', 'logs', 'btns', 'tipLearn', 'fbGo', 'fbLogs', 'ai', 'copy', 'set', 'cfg', 'qlist', 'baseUrl', 'model', 'apiKey', 'temperature', 'maxTokens', 'save', 'test', 'fill'].forEach((k) => { el[k] = root.getElementById(k); }); root.getElementById('close').onclick = () => host.remove(); el.fbGo.onclick = submitFeedback; el.ai.onclick = aiAnswer; el.fill.onclick = fillPage; el.copy.onclick = copyAll; el.set.onclick = () => openCfg(el.cfg.classList.contains('hid')); el.save.onclick = () => { llmCfg = { baseUrl: el.baseUrl.value.trim() || LLM_DEFAULTS.baseUrl, model: el.model.value.trim() || LLM_DEFAULTS.model, apiKey: el.apiKey.value.trim(), temperature: Number(el.temperature.value) || 0, maxTokens: Number(el.maxTokens.value) || 0, }; store.set('llm', llmCfg); openCfg(false); setStatus('配置已保存'); render(); }; el.test.onclick = testConn; fillCfg(); render(); } function fillCfg() { if (!el.baseUrl) return; el.baseUrl.value = llmCfg.baseUrl || ''; el.model.value = llmCfg.model || ''; el.apiKey.value = llmCfg.apiKey || ''; el.temperature.value = String(llmCfg.temperature == null ? 0.2 : llmCfg.temperature); el.maxTokens.value = String(llmCfg.maxTokens == null ? 0 : llmCfg.maxTokens); } function openCfg(show) { if (!el.cfg) return; el.cfg.classList.toggle('hid', !show); if (show) fillCfg(); } function setStatus(t) { state.status = t; if (el.st) el.st.textContent = t; } function render() { try { renderInner(); } catch (e) { log('render 异常', e); } } function renderInner() { if (!root) return; el.st.textContent = state.status; el.dot.className = 'dot' + (state.running || state.busy ? ' run' : ''); el.card.classList.toggle('wide', state.mode === 'exam'); el.secLearn.classList.toggle('hid', state.mode !== 'learn'); el.secFeedback.classList.toggle('hid', state.mode !== 'feedback'); el.secExam.classList.toggle('hid', state.mode !== 'exam'); el.title.firstChild.textContent = state.mode === 'exam' ? '试题 AI 助手' : '学习助手'; if (state.mode === 'learn') { const total = state.periods.length; el.num.textContent = `${state.doneCount} / ${total}`; el.bar.style.width = total ? (state.doneCount / total) * 100 + '%' : '0%'; if (state.running) { el.btns.innerHTML = ``; root.getElementById('stop').onclick = () => { state.stop = true; }; } else if (total) { el.btns.innerHTML = ` `; root.getElementById('start').onclick = runLearn; root.getElementById('flow').onclick = startPipeline; } else { el.btns.innerHTML = ``; root.getElementById('reload').onclick = () => { setStatus('正在重新读取…'); ensurePeriods(true); }; } el.logs.innerHTML = state.logs.map((l) => `
${l}
`).join('') || '
等待中…
'; el.tipLearn.textContent = state.others.length ? `另有 ${state.others.length} 项需手动完成(${[...new Set(state.others.map((o) => o.label))].join('、')})` : '考试需自行完成,本工具不处理'; } if (state.mode === 'feedback') { el.fbLogs.innerHTML = state.logs.map((l) => `
${l}
`).join('') || '
等待中…
'; } if (state.mode === 'exam') { el.ai.disabled = state.busy || !state.questions.length; el.fill.disabled = state.busy || !state.questions.length; el.copy.disabled = !state.questions.length; if (!state.questions.length) { el.qlist.innerHTML = '
尚未读取到题目,若页面已显示题目请刷新一次。
'; } else { const rows = state.questions.map((q, i) => { try { const a = answerOf(q, i); let full = ''; if (a && q.options && q.options.length) { full = q.options.filter((o) => a.some((x) => String(x).toUpperCase() === String(o.label).toUpperCase())) .map((o) => o.label + '. ' + o.text).join(' / '); } const shown = a ? String(a.join('')) : '待作答'; return `
${q.idx}. ${q.typeName}${escapeHtml(q.subject).slice(0, 120)}
${a ? '→ ' + escapeHtml(shown) + (full ? '(' + escapeHtml(full).slice(0, 72) + ')' : '') : '待作答'}
`; } catch (e) { log('渲染第 ' + (i + 1) + ' 题出错', e, q); return `
${q.idx}. ${q.typeName}${escapeHtml(q.subject).slice(0, 60)}
渲染出错,见控制台
`; } }); try { el.qlist.innerHTML = rows.join(''); } catch (e) { log('写入题目列表失败', e); } if (Object.keys(state.answers).length && !state.questions.some((q) => answerOf(q, q.idx - 1))) { log('答案键不匹配', { 题号: state.questions.slice(0, 5).map((q) => q.idx), 答案键: Object.keys(state.answers).slice(0, 5) }); } } } } /* 容错取答案:兼容数字键 / 字符串键 / 0 基索引 */ function answerOf(q, i) { const m = state.answers; if (!m) return null; let v = m[q.idx]; if (v === undefined) v = m[String(q.idx)]; if (v === undefined) v = m[q.idx - 1]; if (v === undefined) v = m[i]; if (v === undefined || v === null) return null; return Array.isArray(v) ? v : [String(v)]; } /* ================= 路由 ================= */ function detectMode() { const p = location.pathname; if (/\/training\/curriculum/.test(p)) return 'learn'; if (/\/training\/comment/.test(p)) return 'feedback'; if (/\/training\/generatingPaper/.test(p)) return 'exam'; return null; } function currentKey() { const q = qs(); return [location.pathname, q.get('id') || '', q.get('courseId') || '', q.get('periodId') || ''].join('|'); } function resetState() { state.map = new Map(); state.periods = []; state.others = []; state.doneCount = 0; ordSeq = 0; state.running = false; state.stop = false; state.pendingRun = false; state.autoRan = false; state.polling = false; state.logs = []; state.paper = null; state.questions = []; state.answers = {}; state.busy = false; } function onRoute() { const mode = detectMode(); if (!mode) return; const key = currentKey(); if (key === state.key && state.mode === mode) { if (!host) buildUI(); else render(); return; } state.key = key; state.mode = mode; resetState(); if (!host) buildUI(); else render(); if (mode === 'learn') { setStatus('正在读取课表…'); ensurePeriods(false); } else if (mode === 'feedback') { setStatus('培训反馈页'); render(); } else { setStatus('正在读取题目…'); render(); setTimeout(tryFetchPaper, 800); setTimeout(tryFetchPaper, 4000); } } /* ================= 调试 ================= */ W.__DGJY = { version: VERSION, state, run: runLearn, diag, startPipeline, pipeline: () => pipelineGet(), aiAnswer, fillPage, domInfo, submitFeedback, render, paper: () => state.paper, questions: () => state.questions, answers: () => state.answers, shape: () => { const p = state.paper || {}; const g = Array.isArray(p.questions) ? p.questions[0] : null; const it = g && Array.isArray(g.data) ? g.data[0] : null; const brief = (v) => { if (Array.isArray(v)) { const e = v[0]; const inner = e && typeof e === 'object' ? ' of {' + Object.keys(e).slice(0, 8).join(',') + '}' : (typeof e === 'string' ? ' of string' : ''); return '[Array ' + v.length + inner + ']'; } if (v && typeof v === 'object') return '[Object ' + Object.keys(v).slice(0, 5) + ']'; return String(v).slice(0, 40); }; return { 顶层键: Object.keys(p), 分组键: g ? Object.keys(g) : [], 题目键: it ? Object.keys(it) : [], 题目样例: it ? Object.fromEntries(Object.entries(it).map(([k, v]) => [k, brief(v)])) : null, 解析结果: { 题数: state.questions.length, 有题干: state.questions.filter((q) => q.subject).length, 有选项: state.questions.filter((q) => q.options.length).length, 首题题干: state.questions[0] && state.questions[0].subject.slice(0, 60), 首题选项: state.questions[0] && state.questions[0].options.slice(0, 4), }, }; }, debug: () => ({ mode: state.mode, 题数: state.questions.length, 题号: state.questions.map((q) => q.idx).slice(0, 25), 答案键: Object.keys(state.answers).slice(0, 25), 答案样例: state.answers[state.questions[0] && state.questions[0].idx], 命中: state.questions.filter((q) => answerOf(q, q.idx - 1)).length, 状态: state.status, }), setLlm: (v) => { llmCfg = Object.assign(llmCfg, v); store.set('llm', llmCfg); return llmCfg; }, feed: (json) => { const n = considerTree(json); syncFromMap(); render(); return n; }, setPeriods(list) { state.map = new Map(list.map((p) => [p.id, p])); state.mode = 'learn'; syncFromMap(); buildUI(); setStatus(`已加载 ${list.length} 节,待学 ${list.length - state.doneCount} 节`); }, dump: () => ({ mode: state.mode, key: state.key, periods: state.periods, others: state.others, done: state.doneCount, questions: state.questions, answers: state.answers, llm: { ...llmCfg, apiKey: llmCfg.apiKey ? '***' : '' }, }), }; function init() { hookNetwork(); let last = ''; setInterval(() => { if (location.href !== last) { last = location.href; onRoute(); } }, 700); if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', onRoute); else onRoute(); log('已加载 v' + VERSION, token() ? '· 已读到 Middle-Token' : '· 未读到 Middle-Token'); } init(); })();