Files
study-tool/script/reference/dgjy-toolkit.user.js
T
study-tool 1a3347f7f9 feat: 添加账号支持选择学习平台
账号新增 platform 字段,登录/刷新课程/发现课程按平台分发;同时把 JS 脚本收进 script/(目录规范调整)。
2026-10-30 01:13:47 +08:00

1377 lines
53 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==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) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 <Middle-Token>,原生 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(); }
);
}
/* ================= 三、试题:把答案填进页面 ================= */
/* 页面结构:每道题外层 <div class="question-content" id="topic{sort}">,
选项是 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 = `
<style>${CSS}</style>
<div class="card" id="card">
<div class="hd">
<i class="dot" id="dot"></i>
<div class="title" id="title">学习助手<span class="ver">v${VERSION}</span></div>
<button class="close" id="close">×</button>
</div>
<div class="bd">
<div class="st" id="st">正在初始化…</div>
<div id="secLearn">
<div class="stat"><b id="num">0 / 0</b><span>课时</span></div>
<div class="bar"><i id="bar"></i></div>
<div class="logs" id="logs"></div>
<div class="row" id="btns"></div>
<div class="tip" id="tipLearn">考试需自行完成,本工具不处理</div>
</div>
<div id="secFeedback" class="hid">
<div class="row"><button class="btn primary" id="fbGo">提交反馈</button></div>
<div class="logs" id="fbLogs" style="margin-top:10px"></div>
<div class="tip">每颗星 1 分,默认满分</div>
</div>
<div id="secExam" class="hid">
<div class="row" style="margin-bottom:8px">
<button class="btn primary" id="ai">AI 作答</button>
<button class="btn primary" id="fill">填入页面</button>
</div>
<div class="row" style="margin-bottom:10px">
<button class="btn ghost" id="copy">复制答案</button>
<button class="btn ghost" id="set">设置</button>
</div>
<div class="cfg hid" id="cfg">
<label>Base URL(到 /v1 为止)</label>
<input id="baseUrl" placeholder="https://api.deepseek.com/v1">
<label>模型名</label>
<input id="model" placeholder="deepseek-chat">
<label>API Key</label>
<input id="apiKey" type="password" placeholder="sk-...">
<label>temperature(0-1,越小越稳定)</label>
<input id="temperature" placeholder="0.2">
<label>最大输出 tokens(0=不传,20 题建议 4096)</label>
<input id="maxTokens" placeholder="0">
<div class="row">
<button class="btn primary" id="save">保存</button>
<button class="btn ghost" id="test">测试连接</button>
</div>
</div>
<div id="qlist"></div>
<div class="tip">答案仅供参考,请在页面上自行核对并提交,本工具不代替交卷</div>
</div>
</div>
</div>`;
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 = `<button class="btn ghost" id="stop">停止</button>`;
root.getElementById('stop').onclick = () => { state.stop = true; };
} else if (total) {
el.btns.innerHTML = `<button class="btn primary" id="start">开始学习</button>
<button class="btn ghost" id="flow">一键全流程</button>`;
root.getElementById('start').onclick = runLearn;
root.getElementById('flow').onclick = startPipeline;
} else {
el.btns.innerHTML = `<button class="btn primary" id="reload">重新读取课表</button>`;
root.getElementById('reload').onclick = () => { setStatus('正在重新读取…'); ensurePeriods(true); };
}
el.logs.innerHTML = state.logs.map((l) => `<div>${l}</div>`).join('') || '<div>等待中…</div>';
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) => `<div>${l}</div>`).join('') || '<div>等待中…</div>';
}
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 = '<div class="q"><div class="qs" style="color:#9ca3af">尚未读取到题目,若页面已显示题目请刷新一次。</div></div>';
} 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 `<div class="q"><div class="qs"><span class="qt">${q.idx}. ${q.typeName}</span>${escapeHtml(q.subject).slice(0, 120)}</div>
<div class="qa ${a ? '' : 'none'}">${a ? '→ ' + escapeHtml(shown) + (full ? '(' + escapeHtml(full).slice(0, 72) + ')' : '') : '待作答'}</div></div>`;
} catch (e) {
log('渲染第 ' + (i + 1) + ' 题出错', e, q);
return `<div class="q"><div class="qs"><span class="qt">${q.idx}. ${q.typeName}</span>${escapeHtml(q.subject).slice(0, 60)}</div><div class="qa none">渲染出错,见控制台</div></div>`;
}
});
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();
})();