585a7a28dd
- settings: study.mode fast|normal and study.parallel 1-5 - normal mode: one headless browser per video, visibility keep-alive, auto-degrade when the platform enforces order - report study/log/detail/save before isComplete; drop lastProgressTime on completion - complete periodType 10/13 container nodes and recompute counters via PUT calFinalScore - capture mode: POST /api/capture records all XHR/fetch including new tabs - UI: study mode in settings, capture / stop-capture menu items
1377 lines
53 KiB
JavaScript
1377 lines
53 KiB
JavaScript
// ==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 <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();
|
||
})();
|