// ==UserScript==
// @name WUT网上党校 全能助手
// @namespace https://gitee.com/fieldlu/party-member-treasury
// @version 1.3.3
// @description 全自动学习+云端题库+多Provider AI答题(DeepSeek/Kimi/ChatGPT/Claude/Gemini/智谱/千问):视频断点续播/智能跳课、云端查答案/自动答题/强制捕获上传
// @author FieldLu
// @license MIT
// @match *://wsdx.whut.edu.cn/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @run-at document-idle
// @connect gitee.com
// @connect giteeusercontent.com
// @connect whut-qbank-worker.tianye0126.workers.dev
// @connect api.deepseek.com
// @connect api.moonshot.cn
// @connect api.openai.com
// @connect open.bigmodel.cn
// @connect dashscope.aliyuncs.com
// @connect api.anthropic.com
// @connect generativelanguage.googleapis.com
// @icon data:image/png;base64,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
// ==/UserScript==
(function () {
'use strict';
// ==================== 云端题库配置 ====================
const CLOUD = {
rawBase: 'https://gitee.com/fieldlu/party-member-treasury/raw/master/qbank',
apiBase: 'https://gitee.com/api/v5/repos/fieldlu/party-member-treasury/contents/qbank',
workerBase: 'https://whut-qbank-worker.tianye0126.workers.dev',
giteeToken: (() => {
const stored = GM_getValue('whut_gitee_token', '');
return stored || '61f7ff8b288155e4360f96609b844f12';
})(),
autoAnswerEnabled: GM_getValue('whut_autoAnswerEnabled', false),
aiEnabled: GM_getValue('whut_aiEnabled', false),
aiProvider: GM_getValue('whut_aiProvider', 'deepseek'),
aiModel: GM_getValue('whut_aiModel', 'deepseek-chat'),
aiKeys: {}, // { providerId: key } 本地缓存(仅从本地 GM_setValue 加载,绝不从云端读取)
aiProviders: null // 从云端 ai-config.json 加载
};
let autoAnswerTimer = null;
const SCRIPT_VERSION = typeof GM_info !== 'undefined' ? GM_info.script.version : '1.0.0';
const CFG = { checkInterval: 2000, coursePageTimeout: 900 };
const hacked = new WeakSet();
let stats = { completed: 0, total: 0, startTime: 0, scriptDone: 0 };
let timers = [];
let stopped = false;
let navigating = false;
let courseList = [];
let currentCourse = null;
let xmId = '';
let videoDuration = 0;
let finishedDuration = 0;
let lastVidUpdate = 0;
let currentVidTime = 0;
let courseRecoveryTimer = null;
let watchdogProgressTime = Date.now();
let watchdogLastCurrentTime = -1;
const WATCHDOG_STUCK_LIMIT = 180000; // 3分钟无进展则强刷
// ==================== 持久化存储(断点续播 + 后台重载恢复) ====================
function saveState() {
try {
GM_setValue('whut_xmId', xmId);
GM_setValue('whut_stopped', stopped);
GM_setValue('whut_completed', stats.completed);
GM_setValue('whut_total', stats.total);
GM_setValue('whut_scriptDone', stats.scriptDone);
} catch(e) {}
}
// 重载前完整保存(含 courseList、进度、当前课程)
function saveStateForReload() {
try {
saveState();
if (courseList.length > 0) {
sessionStorage.setItem('whut_courseList', JSON.stringify(courseList));
}
if (currentCourse) {
sessionStorage.setItem('whut_currentCourse', JSON.stringify(currentCourse));
}
if (finishedDuration > 0) {
sessionStorage.setItem('whut_finishedDuration', finishedDuration);
}
// 保存完整课程状态用于跨重载渲染
const fullInfo = { total: stats.total, completed: stats.completed, scriptDone: stats.scriptDone };
sessionStorage.setItem('whut_courseInfo', JSON.stringify(fullInfo));
sessionStorage.setItem('whut_reload_reason', 'auto_recovery');
} catch(e) {}
}
function renderCourseList() {
if (!listEl) return;
let html = '';
const full = sessionStorage.getItem('whut_fullList');
if (full) {
try {
JSON.parse(full).forEach(c => {
const mode = c.studyMode || '';
const prog = c.progress != null ? parseFloat(c.progress)
: (c.finishedDuration >= c.duration ? 1 : 0);
const cls = prog >= 1 ? 'done' : (prog > 0 ? 'half' : (mode !== 'WLXX' && mode !== '' ? 'skip' : 'todo'));
html += '' + (prog >= 1 ? '✔' : (prog > 0 ? '◐' : '○')) + ' ' + (c.courseName || c.name || '') + ' ' + Math.round(prog * 100) + '%\n';
});
} catch(e) {}
}
if (!html && courseList.length > 0) {
courseList.forEach(c => {
html += '○ ' + (c.courseName || c.name) + ' 0%\n';
});
}
if (html) listEl.innerHTML = html;
}
function loadState() {
try {
xmId = GM_getValue('whut_xmId', '');
const wasStopped = GM_getValue('whut_stopped', true);
stats.completed = GM_getValue('whut_completed', 0) | 0;
stats.total = GM_getValue('whut_total', 0) | 0;
stats.scriptDone = GM_getValue('whut_scriptDone', 0) | 0;
// 尝试恢复课程列表
const saved = sessionStorage.getItem('whut_courseList');
if (saved) {
try { courseList = JSON.parse(saved); } catch(e) {}
}
const savedCourse = sessionStorage.getItem('whut_currentCourse');
if (savedCourse) {
try { currentCourse = JSON.parse(savedCourse); } catch(e) {}
}
const savedFd = sessionStorage.getItem('whut_finishedDuration');
if (savedFd) finishedDuration = parseFloat(savedFd) || 0;
// 检查是否从重载中恢复
const reloadReason = sessionStorage.getItem('whut_reload_reason');
if (reloadReason) {
sessionStorage.removeItem('whut_reload_reason');
// 清理一次性恢复标记
sessionStorage.removeItem('whut_finishedDuration');
return 'reload_recovery';
}
if (xmId && wasStopped === false) {
stopped = false;
return 'session_restore';
}
} catch(e) {}
return false;
}
const $ = (s, p) => (p || document).querySelector(s);
const $$ = (s, p) => [...(p || document).querySelectorAll(s)];
function isDetailPage() { return location.href.includes('/myTrain/detail'); }
function isCoursePage() { return location.href.includes('/myTrain/course'); }
// ==================== ID ====================
function getXmId() {
const qs = (location.hash || '').split('?')[1] || '';
if (!qs) return '';
const p = new URLSearchParams(qs);
if (isCoursePage()) return p.get('xmId') || '';
if (isDetailPage()) return p.get('id') || '';
return p.get('xmId') || p.get('id') || '';
}
function getResourceId() {
const qs = (location.hash || '').split('?')[1] || '';
if (!qs || !isCoursePage()) return '';
return new URLSearchParams(qs).get('id') || '';
}
// ==================== API ====================
const API = {
getXm: () => fetch('/api/student/xm/getById', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
body: 'id=' + xmId,
}).then(r => r.json()),
getCourse: (rid) =>
fetch('/api/student/recommendCourse/getById?id=' + rid + '&xmId=' + xmId).then(r => r.json()),
};
// ==================== Toast 通知 ====================
function toast(msg, duration) {
const el = document.createElement('div');
el.style.cssText = 'position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:999999;'
+ 'background:#1e1e2e;color:#a6e3a1;padding:10px 24px;border-radius:20px;'
+ 'font:13px "Microsoft YaHei",sans-serif;box-shadow:0 4px 20px rgba(0,0,0,.5);'
+ 'animation:fadeInOut ' + (duration || 3) + 's ease both;pointer-events:none;border:1px solid rgba(166,227,161,.3)';
el.textContent = msg;
document.body.appendChild(el);
setTimeout(() => el.remove(), (duration || 3) * 1000);
}
// ==================== UI 面板 ====================
let logEl, statusEl, progressEl, courseNameEl, listEl;
let vidBarEl, vidTimeEl, vidTotalEl, vidEtaEl, bannerEl;
function createPanel() {
if (document.getElementById('__whut_panel')) return;
// 注入动画
const style = document.createElement('style');
style.textContent = '@keyframes fadeInOut{0%{opacity:0;transform:translateX(-50%) translateY(-10px)}15%{opacity:1;transform:translateX(-50%) translateY(0)}85%{opacity:1}100%{opacity:0}}';
document.head.appendChild(style);
const d = document.createElement('div');
d.id = '__whut_panel';
d.innerHTML =
''
// HTML
+ '
'
+ ''
+ ''
// 状态块
+ '
'
// 视频进度
+ '
'
// 课程进度
+ '
'
// 课程列表
+ '
课程状态
'
// 日志
+ '
运行日志
'
// 按钮
+ '
'
+ ''
+ ''
+ ''
+ '
';
document.body.appendChild(d);
// 绑定引用
logEl = $('#__whut_log'); statusEl = $('#__whut_status');
progressEl = $('#__whut_progress'); courseNameEl = $('#__whut_substatus');
listEl = $('#__whut_courselist');
vidBarEl = $('#__whut_vidBar'); vidTimeEl = $('#__whut_vidCur');
vidTotalEl = $('#__whut_vidTot'); vidEtaEl = $('#__whut_vidEta');
bannerEl = $('#__whut_banner');
// 事件
$('#__whut_start').addEventListener('click', start);
$('#__whut_stop').addEventListener('click', stop);
$('#__whut_scan').addEventListener('click', () => scanAndStart());
$('#__whut_hide').addEventListener('click', () => d.classList.toggle('collapsed'));
makeDraggable(d, $('#__whut_hd'));
}
function makeDraggable(el, hd) {
let ox, oy, dx, dy, mv, up;
hd.addEventListener('mousedown', e => {
ox = e.clientX; oy = e.clientY;
const r = el.getBoundingClientRect(); dx = r.left; dy = r.top;
mv = ev => { el.style.left = (dx + ev.clientX - ox) + 'px'; el.style.top = (dy + ev.clientY - oy) + 'px'; el.style.right = 'auto'; };
up = () => { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); mv = up = null; };
document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up);
// 防止在窗口外松开鼠标导致事件监听泄漏
document.addEventListener('mouseleave', up, { once: true });
});
}
// ==================== UI 更新 ====================
function setState(st) {
const dot = $('#__whut_dot');
if (dot) dot.className = 'stat-dot ' + st;
}
function setStatus(s, sub) {
if (statusEl) statusEl.textContent = s;
if (courseNameEl && sub !== undefined) courseNameEl.textContent = sub;
}
function setBanner(msg) {
if (bannerEl) { bannerEl.textContent = msg; bannerEl.style.display = msg ? 'block' : 'none'; }
}
function updateCourseProgress() {
const actual = (stats.completed|0) + (stats.scriptDone|0);
if (progressEl) progressEl.textContent = actual + ' / ' + (stats.total|0);
const bar = $('#__whut_crsBar');
if (bar && stats.total > 0) bar.style.width = Math.min(100, (actual / (stats.total|0)) * 100) + '%';
}
function updateVidProgress() {
const v = $('video');
if (v && v.duration && !v.ended) {
currentVidTime = v.currentTime;
const total = v.duration; // 使用真实 video.duration,不依赖 API 推算
const blk = $('#__whut_vidBlk');
if (blk) blk.style.display = 'block';
const pct = Math.min(100, (currentVidTime / total) * 100);
if (vidBarEl) vidBarEl.style.width = pct + '%';
if (vidTimeEl) vidTimeEl.textContent = fmtTime(currentVidTime);
if (vidTotalEl) vidTotalEl.textContent = fmtTime(total);
if (vidEtaEl) {
const rem = total - currentVidTime;
vidEtaEl.textContent = rem > 0 ? '剩余 ' + fmtTime(rem) : '';
}
}
}
function fmtTime(s) {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return m + ':' + (sec < 10 ? '0' : '') + sec;
}
function log(msg, type) {
const t = new Date().toLocaleTimeString();
const el = document.createElement('div');
el.className = 'l' + (type ? ' ' + type : '');
el.textContent = t + ' ' + msg;
if (logEl) { logEl.appendChild(el); logEl.scrollTop = logEl.scrollHeight; }
console.log('%c[学习]', 'color:#cba6f7', msg);
// 重要事件 toast
if (type === 'ok' && (msg.includes('完成') || msg.includes('进入'))) toast(msg, 2);
if (type === 'err') toast(msg, 4);
}
function showXmIdInput() {
let box = $('#__whut_xmIdBox');
if (!box) {
box = document.createElement('div');
box.id = '__whut_xmIdBox';
box.style.cssText = 'margin-top:8px;display:flex;gap:4px';
box.innerHTML = '';
const body = $('#__whut_body');
if (body) {
const acts = body.querySelector('.acts');
if (acts) body.insertBefore(box, acts);
else body.appendChild(box);
}
$('#__whut_xmIdBtn').addEventListener('click', () => {
const v = $('#__whut_xmIdInput').value.trim();
if (v) { xmId = v; log('手动ID: ' + v, 'ok'); box.remove(); scanAndStart(); }
});
}
box.style.display = 'flex';
}
// ==================== 视频劫持 ====================
function hackVideo(video) {
if (hacked.has(video)) return;
hacked.add(video);
video.muted = true;
video.playbackRate = 1;
// 智能计算跳播时长:处理 API 返回秒/分钟单位不一致
let seekTo = finishedDuration;
let seekDone = false;
let seekRetries = 0;
const MAX_SEEK_RETRIES = 20; // 10秒内每500ms重试
const doSeek = () => {
if (seekDone || seekRetries >= MAX_SEEK_RETRIES) return;
if (!video.duration || video.duration <= 0) return;
seekRetries++;
// 单位修正:如果 finishedDuration 远大于实际时长,可能是重复乘了60
if (seekTo > video.duration * 1.5) {
const fixed = seekTo / 60;
if (fixed > 0 && fixed < video.duration - 5) {
log('单位修正: ' + fmtTime(seekTo) + ' → ' + fmtTime(fixed), 'warn');
seekTo = fixed;
}
}
if (seekTo > 0 && seekTo < video.duration - 5) {
seekDone = true;
video.currentTime = seekTo;
watchdogLastCurrentTime = seekTo;
watchdogProgressTime = Date.now();
log('跳过已看 ' + fmtTime(seekTo) + ',继续播放', 'info');
}
};
// 事件驱动 + 定时兜底,防 SPA 不触发原生事件
video.addEventListener('loadedmetadata', doSeek);
video.addEventListener('canplay', () => { doSeek(); }, { once: false });
if (video.readyState >= 2 && video.duration) doSeek();
const forceTimer = setInterval(() => {
if (video.ended) { clearInterval(forceTimer); return; }
if (!seekDone) doSeek(); // 定时重试跳播
if (video.paused) {
video.play().catch(() => { });
} else {
// 看门狗:视频在播放但 currentTime 变了才算有进展
if (Math.abs(video.currentTime - watchdogLastCurrentTime) > 0.2) {
watchdogLastCurrentTime = video.currentTime;
watchdogProgressTime = Date.now();
}
}
updateVidProgress();
}, 500);
video.play().catch(() => { });
video.addEventListener('ended', () => {
clearInterval(forceTimer);
updateVidProgress();
log('视频播放完毕 ✔', 'ok');
toast('本视频播放完毕,自动进入下一节', 2);
onVideoEnded();
});
video.addEventListener('emptied', () => { clearInterval(forceTimer); hacked.delete(video); });
video.addEventListener('abort', () => { clearInterval(forceTimer); hacked.delete(video); });
const vShow = fmtTime(video.duration || videoDuration);
log('已接管 · 总长 ' + vShow, 'info');
updateVidProgress();
}
function hackAllVideos() { $$('video').forEach(hackVideo); return $$('video').length; }
// ==================== 结束处理 ====================
let videoEndedHandled = false; // 防止 ended 事件和循环检测重复触发导航
function onVideoEnded() {
if (stopped || videoEndedHandled) return;
videoEndedHandled = true;
setTimeout(() => { tryGoNextOrBack(); videoEndedHandled = false; }, 1500);
}
function tryGoNextOrBack() {
if (stopped || navigating) return;
const kws = ['下一节', '下一章', '下一个', '继续学习', '继续'];
for (const kw of kws) {
for (const el of $$('button, a, span, div[class*=btn]')) {
if (!el.offsetParent) continue;
if ((el.textContent || '').trim().includes(kw)) {
log('→ ' + kw, 'ok');
navigating = true;
el.click();
setTimeout(() => { navigating = false; }, 2500);
return;
}
}
}
const items = $$('.el-menu-item,[class*=chapter] [class*=item],[class*=lesson],[class*=catalog] li,[role=treeitem]').filter(e => e.offsetParent);
for (let i = 0; i < items.length - 1; i++) {
if ((items[i].className || '').match(/\b(active|is-active|current)\b/)) {
log('→ 目录下一项', 'ok');
navigating = true;
items[i + 1].click();
setTimeout(() => { navigating = false; }, 2500);
return;
}
}
if (courseList.length > 1) {
log('本节完成 → 返回列表重扫', 'ok');
stats.scriptDone = (stats.scriptDone | 0) + 1;
updateCourseProgress();
goBackAndContinue();
} else {
log('全部完成 → 返回列表', 'ok');
stats.scriptDone = 0;
stats.completed = stats.total | 0;
updateCourseProgress();
goBackAndContinue();
}
}
function goBackAndContinue() {
cancelCourseRecovery();
sessionStorage.setItem('whut_goto_detail', xmId);
saveStateForReload();
location.reload();
}
// ==================== 扫描 ====================
// 静默拉取课程列表和进度(不导航,只填充数据供课程页初始化用)
async function fetchCourseStats() {
try {
const resp = await API.getXm();
if (resp.code !== 0 || !resp.data) return;
const list = resp.data.xmCourseSetting.chooseCourseList;
setBanner(resp.data.name);
courseList = list
.filter(c => {
const mode = c.studyMode || '';
if (mode !== 'WLXX' && mode !== '') return false;
if (!c.resourceId) return false;
const prog = c.progress != null ? parseFloat(c.progress) : 0;
return prog < 1;
})
.sort((a, b) => (a.sort || 0) - (b.sort || 0));
// 过滤掉当前正在播放的课程,避免重复进入
const resId = getResourceId();
if (resId) courseList = courseList.filter(c => String(c.resourceId) !== resId);
const totalOnline = list.filter(c => c.resourceId).length;
stats.total = totalOnline | 0;
stats.completed = (totalOnline - list.filter(c => {
const mode = c.studyMode || '';
if (mode !== 'WLXX' && mode !== '') return false;
const prog = c.progress != null ? parseFloat(c.progress) : 0;
return prog < 1;
}).length) | 0;
updateCourseProgress();
// 渲染课程列表
let html = '';
list.forEach(c => {
const mode = c.studyMode || '';
const prog = c.progress != null ? parseFloat(c.progress) : (c.finishedDuration >= c.duration ? 1 : 0);
const cls = prog >= 1 ? 'done' : (prog > 0 ? 'half' : (mode !== 'WLXX' && mode !== '' ? 'skip' : 'todo'));
html += '' + (prog >= 1 ? '✔' : (prog > 0 ? '◐' : '○')) + ' ' + (c.courseName || c.name) + ' ' + Math.round(prog * 100) + '%\n';
});
if (listEl) listEl.innerHTML = html;
} catch(e) {}
}
// 静默扫描:只拉API渲染课程状态,不启动自动学习
async function scanAndRender() {
try {
const resp = await API.getXm();
if (resp.code !== 0 || !resp.data) return;
const list = resp.data.xmCourseSetting.chooseCourseList;
sessionStorage.setItem('whut_fullList', JSON.stringify(list));
setBanner(resp.data.name);
// 渲染全部课程状态
let html = '';
list.forEach(c => {
const mode = c.studyMode || '';
const prog = c.progress != null ? parseFloat(c.progress)
: (c.finishedDuration >= c.duration ? 1 : 0);
const cls = prog >= 1 ? 'done' : (prog > 0 ? 'half' : (mode !== 'WLXX' && mode !== '' ? 'skip' : 'todo'));
html += '' + (prog >= 1 ? '✔' : (prog > 0 ? '◐' : '○')) + ' ' + (c.courseName || c.name) + ' ' + Math.round(prog * 100) + '%\n';
});
if (listEl) listEl.innerHTML = html;
// 填充 courseList 和 stats
courseList = list
.filter(c => {
const mode = c.studyMode || '';
if (mode !== 'WLXX' && mode !== '') return false;
if (!c.resourceId) return false;
return (c.progress != null ? parseFloat(c.progress) : 0) < 1;
})
.sort((a, b) => (a.sort || 0) - (b.sort || 0));
const resId = getResourceId();
if (resId) courseList = courseList.filter(c => String(c.resourceId) !== resId);
stats.total = list.filter(c => c.resourceId).length | 0;
stats.completed = (stats.total - courseList.length + (resId ? 1 : 0)) | 0;
updateCourseProgress();
} catch(e) {}
}
async function scanAndStart() {
xmId = getXmId();
if (!xmId) {
setStatus('请先进入培训班详情页', '路径: 我的培训 → 选择培训班 → 点击进入');
setState('err');
log('未检测到培训班ID,请进入培训班详情页', 'err');
log('当前页面: ' + location.href, 'warn');
log('正确地址示例: /#/myTrain/detail?id=培训班ID', 'warn');
showXmIdInput();
return;
}
setStatus('正在获取课程列表...', '请稍候');
setState('scan');
setBanner('');
let data;
try {
const resp = await API.getXm();
if (resp.code !== 0) throw new Error('服务器返回 code=' + resp.code);
data = resp.data;
} catch (e) {
setStatus('获取失败', e.message);
setState('err');
log('API 请求失败: ' + e.message, 'err');
showXmIdInput();
return;
}
setBanner(data.name);
log('培训班: ' + data.name, 'ok');
const list = data.xmCourseSetting.chooseCourseList;
sessionStorage.setItem('whut_fullList', JSON.stringify(list));
// 筛选 WLXX 未完成
courseList = list
.filter(c => {
const mode = c.studyMode || '';
if (mode !== 'WLXX' && mode !== '') return false;
if (!c.resourceId) return false;
const prog = c.progress != null ? parseFloat(c.progress) : 0;
return prog < 1;
})
.sort((a, b) => (a.sort || 0) - (b.sort || 0));
// 渲染课程列表(带颜色)
let html = '';
list.forEach(c => {
const mode = c.studyMode || '';
const prog = c.progress != null ? parseFloat(c.progress)
: (c.finishedDuration >= c.duration ? 1 : 0);
const cls = prog >= 1 ? 'done' : (prog > 0 ? 'half' : (mode !== 'WLXX' && mode !== '' ? 'skip' : 'todo'));
const icon = prog >= 1 ? '✔' : (prog > 0 ? '◐' : '○');
const pct = Math.round(prog * 100) + '%';
html += '' + icon + ' ' + (c.courseName || c.name) + ' ' + pct + '\n';
});
if (listEl) listEl.innerHTML = html;
const totalOnline = list.filter(c => c.resourceId).length;
stats.total = totalOnline | 0;
stats.completed = (totalOnline - courseList.length) | 0;
updateCourseProgress();
if (courseList.length === 0) {
setStatus('全部完成!', totalOnline + ' 门课程已全部通过');
setState('done');
toast('所有课程已完成!', 3);
navigating = false;
return;
}
setStatus('准备开始', '共 ' + totalOnline + ' 门,剩 ' + courseList.length + ' 门待完成');
setState('play');
toast('开始自动学习 · 剩余 ' + courseList.length + ' 门', 2);
enterNextCourse();
}
async function enterNextCourse() {
if (stopped || courseList.length === 0) {
setStatus('全部完成!', stats.total + ' 门课程已全部通过');
setState('done');
toast('全部课程已完成!', 3);
navigating = false;
return;
}
navigating = true;
currentCourse = courseList.shift();
const name = currentCourse.courseName || currentCourse.name;
setStatus('进入课程', name);
setState('play');
updateCourseProgress();
videoDuration = 0;
finishedDuration = 0;
try {
const resp = await API.getCourse(currentCourse.resourceId);
if (resp.code === 0 && resp.data) {
videoDuration = (resp.data.duration || 0) * 60;
const fd = resp.data.finishDruation != null ? resp.data.finishDruation
: (resp.data.finishedDuration != null ? resp.data.finishedDuration : 0);
finishedDuration = (fd || 0) * 60;
if (finishedDuration > 0) {
log('已看 ' + fmtTime(finishedDuration) + ',将跳过', 'info');
}
}
} catch (e) { /* 静默 */ }
// 真刷新进课:保存已完成进度 + 跳播位置 + 目标课程
saveStateForReload();
sessionStorage.setItem('whut_finishedDuration', finishedDuration);
sessionStorage.setItem('whut_goto_course', JSON.stringify({
resourceId: currentCourse.resourceId,
xmId: xmId
}));
location.reload();
// 如果 reload 被浏览器拦截未生效,恢复课程到列表头部防止丢课
courseList.unshift(currentCourse);
navigating = false;
}
// ==================== 重载恢复 ====================
function scheduleCourseRecovery() {
cancelCourseRecovery();
courseRecoveryTimer = setTimeout(() => {
if (!stopped && isCoursePage() && $$('video').length === 0 && !navigating) {
log('超时无视频,强制刷新恢复...', 'warn');
saveStateForReload(); location.reload();
}
}, 45000);
}
function cancelCourseRecovery() {
if (courseRecoveryTimer) { clearTimeout(courseRecoveryTimer); courseRecoveryTimer = null; }
}
// ==================== 课程循环 ====================
function startCourseLoop() {
stats.startTime = Date.now();
navigating = false;
videoEndedHandled = false;
watchdogProgressTime = Date.now();
watchdogLastCurrentTime = -1;
let watchdogStuckStart = 0;
const timer = setInterval(() => {
if (stopped) { clearInterval(timer); return; }
if (!isCoursePage()) { clearInterval(timer); return; }
const vids = $$('video');
if (vids.length > 0) { cancelCourseRecovery(); }
if (vids.length === 0) {
window.scrollTo(0, document.body.scrollHeight);
if (Date.now() - stats.startTime > CFG.coursePageTimeout * 1000) {
log('超时无视频,返回列表', 'warn');
clearInterval(timer);
goBackAndContinue();
}
} else {
hackAllVideos();
// 🐕 看门狗:3分钟无播放进展则强刷
if (Date.now() - watchdogProgressTime > WATCHDOG_STUCK_LIMIT) {
log('看门狗:视频卡死超过3分钟,强制刷新恢复', 'err');
setTimeout(() => { saveStateForReload(); location.reload(); }, 500);
clearInterval(timer);
return;
}
// 🐕 子检测:连续卡在0:00超过30秒则强刷(以首次检测到卡死时间为准,避免慢网误判)
const allPaused = vids.every(v => v.paused && !v.ended);
const stuckAtZero = allPaused && vids.some(v => v.currentTime < 0.5);
if (stuckAtZero) {
if (!watchdogStuckStart) watchdogStuckStart = Date.now();
if (Date.now() - watchdogStuckStart > 30000) {
log('视频卡死在0:00持续30秒,强制刷新恢复', 'err');
setTimeout(() => { saveStateForReload(); location.reload(); }, 500);
clearInterval(timer);
return;
}
} else {
watchdogStuckStart = 0;
}
const allDone = vids.every(v => v.ended || (isFinite(v.duration) && v.duration > 0 && v.currentTime >= v.duration - 1));
if (allDone && !navigating) {
clearInterval(timer);
setTimeout(() => tryGoNextOrBack(), 1500);
}
}
}, CFG.checkInterval);
timers.push(timer);
}
// ==================== 启动/停止 ====================
function start() {
stopped = false; navigating = false;
stats = { completed: 0, total: 0, startTime: 0, scriptDone: 0 };
courseList = []; currentCourse = null;
timers.forEach(clearInterval); timers = [];
cancelCourseRecovery();
updateCourseProgress();
log('▶ 全自动学习启动', 'ok');
saveState();
scanAndStart();
}
function stop() {
stopped = true; navigating = false;
timers.forEach(clearInterval); timers = [];
cancelCourseRecovery();
if (videoObserver) { videoObserver.disconnect(); videoObserver = null; }
// 清理 sessionStorage,防止手动刷新后误恢复
sessionStorage.removeItem('whut_reload_reason');
sessionStorage.removeItem('whut_courseList');
sessionStorage.removeItem('whut_currentCourse');
sessionStorage.removeItem('whut_finishedDuration');
courseList = [];
setStatus('已停止', '点击"开始学习"继续');
setState('idle');
const vBlk = $('#__whut_vidBlk');
if (vBlk) vBlk.style.display = 'none';
log('已停止 · 状态已保存,下次打开可继续', 'warn');
saveState();
}
// ==================== URL 监听 ====================
let lastUrl = location.href;
let urlWatcher = setInterval(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
if (!stopped) {
if (isCoursePage()) {
navigating = false;
// 从 loadState 恢复的数据渲染课程状态(不调 API,避免覆盖 scriptDone)
renderCourseList();
updateCourseProgress();
setStatus('播放中...', currentCourse ? (currentCourse.courseName || currentCourse.name) : '');
setState('play');
let waited = 0;
const w = setInterval(() => {
waited++;
if ($$('video').length > 0 || waited > 15) { clearInterval(w); startCourseLoop(); hackAllVideos(); }
}, 1000);
} else if (isDetailPage()) {
navigating = false;
setTimeout(() => { if (!stopped && isDetailPage()) scanAndStart(); }, 1500);
}
}
}
}, 1000);
// ==================== 视频进度轮询 ====================
let vidProgressTimer = setInterval(() => {
if (!stopped && isCoursePage()) updateVidProgress();
}, 2000);
// ==================== 初始化 ====================
let videoObserver = null;
function init() {
createPanel();
videoObserver = new MutationObserver(mutations => {
for (const m of mutations)
for (const node of m.addedNodes) {
if (node.nodeName === 'VIDEO') hackVideo(node);
if (node.querySelectorAll) node.querySelectorAll('video').forEach(hackVideo);
}
}).observe(document.body || document.documentElement, { childList: true, subtree: true });
// 从课程页回详情页的真刷新标记
const gotoDetail = sessionStorage.getItem('whut_goto_detail');
if (gotoDetail) {
sessionStorage.removeItem('whut_goto_detail');
stopped = false;
xmId = gotoDetail;
loadState(); // 恢复进度/课程列表等
location.href = '#/myTrain/detail?id=' + xmId;
return;
}
const gotoCourse = sessionStorage.getItem('whut_goto_course');
if (gotoCourse) {
sessionStorage.removeItem('whut_goto_course');
try {
const c = JSON.parse(gotoCourse);
stopped = false;
xmId = c.xmId;
loadState(); // 恢复 finishedDuration、进度、currentCourse、courseList
location.href = '#/myTrain/course?id=' + c.resourceId + '&xmId=' + c.xmId;
} catch(e) {}
return;
}
// 尝试恢复上次状态
const restored = loadState();
if (restored === 'reload_recovery') {
log('后台重载恢复 · 培训班: ' + xmId, 'ok');
log('恢复进度: ' + stats.completed + '/' + stats.total, 'info');
if (courseList.length > 0) log('待完成: ' + courseList.length + ' 门', 'info');
stopped = false;
} else if (restored === 'session_restore') {
log('已恢复上次会话 · 培训班ID: ' + xmId, 'ok');
log('上次进度: ' + stats.completed + '/' + stats.total, 'info');
}
if (isCoursePage() && !stopped) {
xmId = getXmId() || xmId;
if (xmId) {
const v = $('video');
if (v && v.currentTime > 0) finishedDuration = v.currentTime;
// 如果是重载恢复且已有 courseList,直接接管;否则异步拉取
if (restored !== 'reload_recovery' || courseList.length === 0) {
fetchCourseStats();
} else {
updateCourseProgress();
}
log('已检测到课程页面,自动接管', 'ok');
setStatus('后台运行中', currentCourse ? (currentCourse.courseName || currentCourse.name) : '');
setState('play');
startCourseLoop();
}
} else if (isDetailPage()) {
xmId = getXmId() || xmId;
if (!xmId) {
setStatus('请进入培训班', '点击左侧「我的培训」→ 选择培训班');
setState('idle');
return;
}
// 始终拉取并渲染全部课程状态
scanAndRender();
if (restored === 'reload_recovery' && !stopped && courseList.length > 0) {
log('重载恢复:自动进入下一门课...', 'ok');
setTimeout(() => { navigating = true; enterNextCourse(); }, 800);
} else if (restored === 'session_restore' && !stopped) {
log('检测到未完成任务,自动恢复...', 'ok');
setTimeout(() => { if (isDetailPage()) scanAndStart(); }, 1000);
} else {
setStatus('准备就绪', '点击「全自动学习」开始');
setState('idle');
}
} else {
setStatus('请进入培训班', '点击左侧「我的培训」→ 选择培训班');
setState('idle');
log('请进入培训班详情页后使用', 'warn');
}
}
if (document.readyState === 'complete') init();
else window.addEventListener('load', init);
// ==================== 题库模块 ====================
// ==========================================
// ☁️ 云端题库配置
// ==========================================
// ==========================================
// 1. 本地数据库模块
// ==========================================
const DB_KEY = 'whut_qbank_db_v1';
function getDB() {
try { return JSON.parse(GM_getValue(DB_KEY, '[]')); } catch (e) { return []; }
}
function saveToDB(newQuestions) {
let db = getDB();
let addedCount = 0;
let addedItems = [];
let existingIds = new Set(db.map(q => q.content));
for (let raw of newQuestions) {
let q = normalizeQuestion(raw);
if (!q || !q.content) continue;
if (!existingIds.has(q.content)) {
db.push(q);
existingIds.add(q.content);
addedItems.push(q);
addedCount++;
}
}
if (addedCount > 0) {
GM_setValue(DB_KEY, JSON.stringify(db));
updateStats(db.length);
// 自动上传到云端(AI答案不可靠,不自动上传)
const verifiedItems = addedItems.filter(q => q.source !== 'ai');
if (verifiedItems.length > 0) {
contributeToCloud(verifiedItems, true); // 静默上传,失败不打扰
}
if (addedItems.length > verifiedItems.length) {
qlog(`[入库] ${addedItems.length - verifiedItems.length} 题来自AI未验证,已保存本地但不上传云端`, 'warn');
}
}
return addedCount;
}
function clearDB() {
if(confirm("确定清空本地题库吗?清空后无法恢复!(云端题库不受影响)")) {
GM_deleteValue(DB_KEY);
updateStats(0);
qlog("本地数据库已清空。");
}
}
// ==========================================
// 2. 数据清洗与规范化
// ==========================================
function normalizeQuestion(raw) {
let q = raw.dataJson || raw;
let typeStr = String(q.type || raw.type || raw.realType || '');
let content = q.content || raw.content || '';
let answer = q.answer || raw.answer || '';
let options = q.options || raw.options || [];
if (!content) return null;
let realType = "未知题型";
if (typeStr.includes('SINGLE')) realType = "单选题";
else if (typeStr.includes('MULTIPLE')) realType = "多选题";
else if (typeStr.includes('JUDGMENT')) realType = "判断题";
else if (typeStr.includes('BLANKFILL')) realType = "填空题";
else if (raw.realType) realType = raw.realType;
content = content.replace(/<[^>]+>/g, '').trim();
content = content.replace(/\[BlankArea\d*\]/gi, '______');
let normOptions = [];
if (Array.isArray(options)) {
normOptions = options.map(opt => ({
alias: opt.alisa || opt.alias || '',
text: (opt.text || '').replace(/<[^>]+>/g, '').trim()
}));
}
if (realType === "判断题") {
if (answer === 'Y' || answer === 'true') answer = "正确";
else if (answer === 'N' || answer === 'false') answer = "错误";
} else if (realType === "填空题" && raw.blanks && Array.isArray(raw.blanks)) {
answer = raw.blanks.map(b => b.value).join(" ; ");
}
return { type: realType, content, options: normOptions, answer,
blanks: (raw.blanks || q.blanks || []),
source: raw.source || q.source || 'import',
verified: raw.verified || q.verified || false
};
}
function extractQuestionsFromData(obj) {
let questions = [];
let cache = new Set();
function search(item) {
if (!item || typeof item !== 'object' || cache.has(item)) return;
cache.add(item);
if ((item.type || item.realType) && item.content) {
const typeStr = typeof item.type === 'string' ? item.type : '';
const realTypeStr = typeof item.realType === 'string' ? item.realType : '';
if ((typeStr || realTypeStr) && ['SINGLE', 'MULTIPLE', 'JUDGMENT', 'BLANKFILL', 'ESSAY'].some(t => typeStr.includes(t) || realTypeStr.includes(t))) {
const qid = item.id || item.questionId || item.itemId || item.uuid || '';
questions.push({ ...item, id: qid || item.id });
// 填空题找到后跳过子对象搜索,避免把每个空当作独立题目
if (typeStr.includes('BLANKFILL') || realTypeStr.includes('BLANKFILL')) return;
}
}
for (let key in item) {
if (Object.prototype.hasOwnProperty.call(item, key)) search(item[key]);
}
}
search(obj);
return questions;
}
// ==========================================
// ☁️ 云端题库引擎 — 无课程分组,纯题目存储,逐题比对
// ==========================================
let cloudBank = null;
// GM_xmlhttpRequest 封装 — 优先用 responseText,兼容性最好
function gmFetch(url, opts = {}) {
const method = opts.method || 'GET';
return new Promise((resolve) => {
GM_xmlhttpRequest({
method, url, timeout: 15000,
headers: Object.assign({ 'Accept': 'application/json' }, opts.headers || {}),
data: opts.body || undefined,
onload: (r) => {
const status = r.status || 0;
const ok = status >= 200 && status < 300;
const raw = r.responseText || '';
let _parsed = undefined;
let _parsedDone = false;
resolve({
ok, status,
rawText: raw,
json: () => {
if (_parsedDone) return Promise.resolve(_parsed);
_parsedDone = true;
if (!raw) { _parsed = null; return Promise.resolve(null); }
try { _parsed = JSON.parse(raw); } catch(e) { _parsed = null; }
return Promise.resolve(_parsed);
},
text: () => Promise.resolve(raw)
});
},
onerror: () => resolve({ ok: false, status: 0, rawText: '', json: () => Promise.resolve(null), text: () => Promise.resolve('') }),
ontimeout: () => resolve({ ok: false, status: 0, rawText: '', json: () => Promise.resolve(null), text: () => Promise.resolve('') })
});
});
}
// 加载云端全量题库:优先合并文件 → 回退逐课加载
async function loadCloudBank(forceRefresh = false) {
if (cloudBank && !forceRefresh) return cloudBank;
// 尝试加载已合并的 qbank.json
const qres = await gmFetch(`${CLOUD.rawBase}/qbank.json?t=${Date.now()}`);
if (qres.ok) {
const data = await qres.json().catch(() => null);
if (Array.isArray(data) && data.length > 0) {
cloudBank = data;
qlog(`☁️ 云端题库已加载:${cloudBank.length} 题`, 'ok');
updateCloudUI(cloudBank.length);
return cloudBank;
}
}
// 回退:逐课加载
const idxRes = await gmFetch(`${CLOUD.rawBase}/index.json?t=${Date.now()}`);
qlog(`[云端] index.json → ${idxRes.status}`, idxRes.ok ? 'ok' : 'warn');
if (!idxRes.ok) {
qlog('⚠️ 云端题库暂不可用');
updateCloudUI(0);
return [];
}
const index = await idxRes.json().catch(() => ({}));
const courses = index.courses || [];
if (!courses.length) { qlog('⚠️ 云端无课程数据'); updateCloudUI(0); return []; }
qlog(`☁️ 加载 ${courses.length} 门课程...`);
const results = await Promise.all(courses.map(async (c) => {
const r = await gmFetch(`${CLOUD.rawBase}/${encodeURIComponent(c)}.json?t=${Date.now()}`);
return r.ok ? (await r.json().catch(() => []) || []) : [];
}));
cloudBank = [];
const seen = new Set();
for (const bank of results) {
if (!Array.isArray(bank)) continue;
for (const q of bank) {
const k = (q.content || '').replace(/\s+/g, '').substring(0, 30);
if (!seen.has(k)) { seen.add(k); cloudBank.push(q); }
}
}
qlog(`☁️ 云端题库已加载:${cloudBank.length} 题`, 'ok');
updateCloudUI(cloudBank.length);
// 异步合并推送
if (CLOUD.giteeToken && cloudBank.length > 0) {
setTimeout(() => mergeAndPushQbank(cloudBank), 100);
}
return cloudBank;
}
async function mergeAndPushQbank(questions) {
try {
let sha = null;
const cr = await gmFetch(`${CLOUD.apiBase}/qbank.json?access_token=${CLOUD.giteeToken}&t=${Date.now()}`);
if (cr.ok) {
const info = await cr.json();
if (info && typeof info === 'object' && !Array.isArray(info) && info.sha) {
sha = info.sha;
}
}
const body = { access_token: CLOUD.giteeToken, content: toBase64(JSON.stringify(questions, null, 2)), message: `自动合并题库:${questions.length} 题`, branch: 'master' };
if (sha) body.sha = sha;
const method = sha ? 'PUT' : 'POST';
const ur = await gmFetch(`${CLOUD.apiBase}/qbank.json`, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (ur.ok || ur.status === 201) qlog(`☁️ qbank.json 已同步`, 'ok');
} catch(e) {}
}
// 从云端查询单个答案(逐题比对)
async function queryCloudAnswer(questionContent) {
const bank = await loadCloudBank();
if (!bank.length) return null;
const normalizedQuery = questionContent.replace(/<[^>]+>/g, '').trim();
const queryKeywords = extractKeywords(normalizedQuery);
const found = bank.find(q => {
let dbContent = (q.content || '').replace(/<[^>]+>/g, '').trim();
const dbKeywords = extractKeywords(dbContent);
const overlap = queryKeywords.filter(k => dbKeywords.includes(k)).length;
return overlap >= Math.min(queryKeywords.length, 3);
});
return found || null;
}
// 从云端批量查询答案(逐题比对)
async function queryCloudBatch(questions) {
const bank = await loadCloudBank();
if (!bank.length) return questions.map(() => null);
return questions.map(q => {
const normalizedQuery = (q.content || '').replace(/<[^>]+>/g, '').trim();
const queryKeywords = extractKeywords(normalizedQuery);
const found = bank.find(bq => {
let dbContent = (bq.content || '').replace(/<[^>]+>/g, '').trim();
const dbKeywords = extractKeywords(dbContent);
const overlap = queryKeywords.filter(k => dbKeywords.includes(k)).length;
return overlap >= Math.min(queryKeywords.length, 3);
});
return found || null;
});
}
// 提取关键词(去标点 + 停用词)
function extractKeywords(text) {
return text
.replace(/[,。、;:?!""''()()《》【】\[\]{}.,;:!?\"\'\(\)\[\]{}<>\/\\|@#$%^&*+=~`_-]/g, ' ')
.split(/\s+/)
.filter(w => w.length >= 2)
.filter(w => !['以下','选项','的是','正确','错误','关于','下列','不是','属于'].includes(w));
}
// 贡献到云端(Gitee API → Worker 回退,失败不阻塞)
function toBase64(str) {
// 用 TextEncoder 实现可靠的 UTF-8 → base64
const bytes = new TextEncoder().encode(str);
let bin = '';
bytes.forEach(b => bin += String.fromCharCode(b));
return btoa(bin);
}
async function contributeToCloud(questions, silent = false) {
if (!questions || questions.length === 0) return false;
const cleanQuestions = questions.map(q => ({
type: q.type, content: q.content, options: q.options, answer: q.answer
}));
if (!CLOUD.giteeToken) { if (!silent) qlog('⚠️ 未配置 Gitee Token'); return false; }
// 构建最终内容(新题 + 本地全库合并去重)
const allLocal = getDB().filter(q => q.source !== 'ai');
const merged = [...allLocal, ...cleanQuestions];
const seen = new Set();
const unique = merged.filter(q => {
const k = (q.content || '').replace(/\s+/g, '').substring(0, 30);
if (seen.has(k)) return false;
seen.add(k); return true;
});
const jsonStr = JSON.stringify(unique, null, 2);
// 先 GET 确认文件是否存在,获取 sha
const getUrl = `${CLOUD.apiBase}/qbank.json?access_token=${CLOUD.giteeToken}&t=${Date.now()}`;
const cr = await gmFetch(getUrl);
if (!silent) qlog(`[Gitee GET] 状态=${cr.status}`, cr.ok ? 'ok' : 'warn');
let sha = null;
if (cr.ok) {
const info = await cr.json();
// Gitee 对不存在的文件可能返回 [],需要排除
if (info && typeof info === 'object' && !Array.isArray(info) && info.sha) {
sha = info.sha;
if (!silent) qlog(`[Gitee] 文件已存在 sha=${sha.substring(0, 8)}...`, 'ok');
} else {
if (!silent) qlog('[Gitee] 文件不存在,将创建新文件');
}
} else if (cr.status === 0) {
if (!silent) qlog('⚠️ Gitee API 网络不通,检查 @connect 或网络', 'warn');
return false;
}
// 404 = 文件不存在,sha 保持 null
const body = {
access_token: CLOUD.giteeToken,
content: toBase64(jsonStr),
message: `题库更新:${unique.length} 题 [${new Date().toLocaleDateString()}]`,
branch: 'master'
};
if (sha) body.sha = sha;
// 有 sha → 更新用 PUT;无 sha → 新建用 POST
const method = sha ? 'PUT' : 'POST';
const uploadUrl = `${CLOUD.apiBase}/qbank.json`;
const ur = await gmFetch(uploadUrl, {
method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
});
if (!silent) qlog(`[Gitee ${method}] 状态=${ur.status}`, (ur.ok || ur.status === 201) ? 'ok' : 'warn');
if (ur.ok || ur.status === 201) {
if (!silent) qlog(`☁️ 上传成功!云端共 ${unique.length} 题`, 'ok');
cloudBank = null; return true;
}
const errText = await ur.text().catch(() => '');
if (!silent) qlog(`⚠️ Gitee 上传失败 (${ur.status}): ${errText.substring(0, 100)}`, 'warn');
return false;
}
// 一键上传本地全部题库到云端
async function uploadAllToCloud() {
const db = getDB();
if (db.length === 0) { qlog('[上传] 本地题库为空', 'warn'); return; }
const verified = db.filter(q => q.source !== 'ai');
const aiCount = db.length - verified.length;
let msg = `准备上传全部 ${db.length} 题到云端`;
if (aiCount > 0) msg += ` (含 ${aiCount} 题AI未验证将跳过)`;
if (!confirm(msg + ',确定?')) return;
qlog(`[上传] 正在上传 ${verified.length} 题...`);
await contributeToCloud(verified);
updateStats(db.length);
}
// ==========================================
// 🎯 自动答题引擎
// ==========================================
function parseCurrentPageQuestions(course) {
let found = [];
let paperId = '';
const allElements = document.querySelectorAll('*');
for (let el of allElements) {
if (el.__vue__) {
try {
let vueData = JSON.parse(JSON.stringify(el.__vue__.$data || {}));
if (!paperId) {
const pid = findPaperId(vueData);
if (pid) paperId = pid;
}
let extracted = extractQuestionsFromData(vueData);
extracted = extracted.map(q => ({ ...q, _el: el, paperId: paperId || (q.paperId || '') }));
found = found.concat(extracted);
} catch(e) {}
}
if (el.__vue_app__) {
try {
const appData = JSON.parse(JSON.stringify(el.__vue_app__));
let extracted = extractQuestionsFromData(appData);
extracted = extracted.map(q => ({ ...q, _el: el, paperId: paperId || (q.paperId || '') }));
found = found.concat(extracted);
} catch(e) {}
}
}
if (found.length === 0) {
const questionEls = document.querySelectorAll('[class*="question"], [class*="topic"], [class*="subject"], .q-title, .q-content');
questionEls.forEach(el => {
const text = el.textContent.trim();
if (text.length > 10) {
found.push({ content: text, type: '未知题型', options: [], answer: '', _el: el, paperId: '' });
}
});
}
return found;
}
// 递归搜索 paperId
function findPaperId(obj) {
if (!obj || typeof obj !== 'object') return '';
if (obj.paperId && typeof obj.paperId === 'string' && obj.paperId.length > 20) return obj.paperId;
if (obj.paperId && obj.paperId.id) return obj.paperId.id;
const cache = new Set();
function search(item) {
if (!item || typeof item !== 'object' || cache.has(item)) return '';
cache.add(item);
if (item.paperId && typeof item.paperId === 'string' && item.paperId.includes('-')) return item.paperId;
if (item.id && typeof item.id === 'string' && item.id.includes('-') && item.id.length > 20 && item.type) return ''; // skip question ids
for (const key in item) {
if (Object.prototype.hasOwnProperty.call(item, key)) {
const r = search(item[key]);
if (r) return r;
}
}
return '';
}
return search(obj);
}
// 通过 API 提交答案(POST /api/student/paper/saveData)
async function submitAnswersAPI(answerCacheItems) {
if (!answerCacheItems || answerCacheItems.length === 0) return 0;
// 找到 paperId
let paperId = '';
for (const item of answerCacheItems) {
if (item.pq.paperId) { paperId = item.pq.paperId; break; }
if (item.match && item.match.paperId) { paperId = item.match.paperId; break; }
if (item.pq.id && item.pq.id.includes('-') && item.pq.id.length > 20) {
// 从页面重新扫描获取 paperId
}
}
if (!paperId) {
// 从全部 Vue 实例找 paperId
const allEls = document.querySelectorAll('*');
for (const el of allEls) {
if (el.__vue__) {
try {
const data = JSON.parse(JSON.stringify(el.__vue__.$data || {}));
paperId = findPaperId(data);
if (paperId) break;
} catch(e) {}
}
if (el.__vue_app__) {
try {
const appData = JSON.parse(JSON.stringify(el.__vue_app__));
paperId = findPaperId(appData);
if (paperId) break;
} catch(e) {}
}
}
}
if (!paperId) {
qlog('[API提交] 未找到 paperId,尝试从页面数据提取...', 'warn');
// 从 URL hash 找
const hash = location.hash || '';
const m = hash.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
if (m) paperId = m[0];
// 从 document 全文找
if (!paperId) {
const txt = document.body.textContent || '';
const m2 = txt.match(/paperId[=:]\s*["']?([0-9a-f-]{30,})/i);
if (m2) paperId = m2[1];
}
}
if (!paperId) { qlog('[API提交] 无法找到 paperId', 'err'); return 0; }
// 构建 form body
let body = 'paperId=' + encodeURIComponent(paperId);
let count = 0;
const seen = new Set();
for (const item of answerCacheItems) {
if (!item.match || !item.match.answer) continue;
const pq = item.pq || {};
const m = item.match || {};
const qid = pq.id || pq.questionId || pq.itemId || m.id || m.questionId || '';
if (!qid || qid.length < 3 || seen.has(qid)) continue;
seen.add(qid);
const ans = String(item.match.answer).trim();
// 答案标准化为选项字母(处理 多选用逗号/分号/空格分隔 的情况)
let ansLetters = [];
if (ans === '正确' || ans === 'Y' || ans === 'true') ansLetters = ['Y'];
else if (ans === '错误' || ans === 'N' || ans === 'false') ansLetters = ['N'];
else if (ans.includes(',') || ans.includes(',') || ans.includes(';') || ans.includes(';') || (ans.length > 1 && /^[A-F\s,,;;]+$/i.test(ans))) {
// 多选:拆分 "A,B,C" → ["A","B","C"],每个添加独立的 Q-uuid 条目
ansLetters = ans.split(/[,,;;\s]+/).filter(a => /^[A-FYNP]$/i.test(a)).map(a => a.toUpperCase());
} else {
// 单选:单字母或单答案
if (/^[A-FYNP]$/i.test(ans)) ansLetters = [ans.toUpperCase()];
else ansLetters = [ans]; // 填空题等非字母答案
}
for (const letter of ansLetters) {
body += '&Q-' + encodeURIComponent(qid) + '=' + encodeURIComponent(letter);
}
count += ansLetters.length;
}
if (count === 0) { qlog('[API提交] 没有有效题目可提交', 'warn'); return 0; }
qlog(`[API提交] 正在提交 ${count} 题... paperId=${paperId.substring(0,8)}...`);
try {
const resp = await fetch('/api/student/paper/saveData', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
body: body
});
const json = await resp.json();
if (json.code === 0) {
qlog(`[API提交] 成功提交 ${count} 题`, 'ok');
return count;
}
qlog(`[API提交] 服务器返回 code=${json.code}`, 'err');
return 0;
} catch(e) {
qlog(`[API提交] 网络异常: ${e.message}`, 'err');
return 0;
}
}
// ==========================================
// 🤖 多Provider AI 答题引擎 (DeepSeek/Kimi/OpenAI/Claude/Gemini/智谱/千问/自定义)
// ==========================================
const BUILTIN_AI_PROVIDERS = [
{id:'deepseek',name:'DeepSeek',apiType:'openai-compatible',endpoint:'https://api.deepseek.com/v1/chat/completions',models:['deepseek-chat','deepseek-reasoner'],defaultModel:'deepseek-chat'},
{id:'kimi',name:'Kimi (月之暗面)',apiType:'openai-compatible',endpoint:'https://api.moonshot.cn/v1/chat/completions',models:['moonshot-v1-8k','moonshot-v1-32k','moonshot-v1-128k'],defaultModel:'moonshot-v1-8k'},
{id:'openai',name:'ChatGPT (OpenAI)',apiType:'openai-compatible',endpoint:'https://api.openai.com/v1/chat/completions',models:['gpt-4o','gpt-4o-mini','gpt-3.5-turbo'],defaultModel:'gpt-4o-mini'},
{id:'claude',name:'Claude (Anthropic)',apiType:'anthropic',endpoint:'https://api.anthropic.com/v1/messages',models:['claude-sonnet-4-6','claude-haiku-4-5'],defaultModel:'claude-haiku-4-5'},
{id:'gemini',name:'Gemini (Google)',apiType:'google',endpoint:'https://generativelanguage.googleapis.com/v1beta/models',models:['gemini-2.5-flash','gemini-2.5-pro'],defaultModel:'gemini-2.5-flash'},
{id:'zhipu',name:'智谱 GLM',apiType:'openai-compatible',endpoint:'https://open.bigmodel.cn/api/paas/v4/chat/completions',models:['glm-4-plus','glm-4-flash'],defaultModel:'glm-4-flash'},
{id:'qwen',name:'通义千问',apiType:'openai-compatible',endpoint:'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',models:['qwen-turbo','qwen-plus','qwen-max'],defaultModel:'qwen-turbo'},
{id:'custom',name:'自定义 (OpenAI兼容)',apiType:'openai-compatible',endpoint:'',models:[],defaultModel:''}
];
function getLocalKey(providerId) {
if (CLOUD.aiKeys[providerId]) return CLOUD.aiKeys[providerId];
const key = GM_getValue('whut_aiKey_' + providerId, '');
if (key) CLOUD.aiKeys[providerId] = key;
return key;
}
function setLocalKey(providerId, key) {
CLOUD.aiKeys[providerId] = key;
GM_setValue('whut_aiKey_' + providerId, key);
}
async function loadAIProviders() {
if (CLOUD.aiProviders) return CLOUD.aiProviders;
try {
const res = await gmFetch(`${CLOUD.rawBase}/ai-config.json?t=${Date.now()}`);
if (res.ok) {
const data = await res.json();
if (data && Array.isArray(data.providers) && data.providers.length > 0) {
CLOUD.aiProviders = data.providers;
return data.providers;
}
}
} catch(e) {}
CLOUD.aiProviders = BUILTIN_AI_PROVIDERS;
return BUILTIN_AI_PROVIDERS;
}
// 上传密钥到 Gitee 云端(所有用户共享密钥池)
async function saveAIKeysToCloud(providerId, key) {
if (!CLOUD.giteeToken) return;
try {
const getUrl = `${CLOUD.apiBase}/ai-config.json?access_token=${CLOUD.giteeToken}&t=${Date.now()}`;
const cr = await gmFetch(getUrl);
if (!cr.ok) return;
const info = await cr.json();
if (!info || !info.sha) return;
const content = atob(info.content || '');
let config = JSON.parse(content);
if (!config.keys) config.keys = {};
config.keys[providerId] = key;
const newContent = toBase64(JSON.stringify(config, null, 2));
const body = {
access_token: CLOUD.giteeToken, content: newContent,
message: `更新 ${providerId} 密钥`, branch: 'master', sha: info.sha
};
await gmFetch(`${CLOUD.apiBase}/ai-config.json`, {
method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body)
});
} catch(e) {}
}
function getCurrentProvider() {
const providers = CLOUD.aiProviders || BUILTIN_AI_PROVIDERS;
return providers.find(p => p.id === CLOUD.aiProvider) || providers[0];
}
function getCurrentEndpoint() {
const p = getCurrentProvider();
return p.endpoint || GM_getValue('whut_aiCustomEndpoint', '');
}
function getCurrentModel() {
if (CLOUD.aiModel) return CLOUD.aiModel;
const p = getCurrentProvider();
return p.defaultModel || (p.models && p.models[0]) || '';
}
function ensureAIKey() {
const pid = CLOUD.aiProvider;
if (getLocalKey(pid)) return true;
const p = getCurrentProvider();
const hint = p.apiType === 'google' ? '(Google API Key,从 aistudio.google.com 获取)'
: p.apiType === 'anthropic' ? '(Anthropic API Key,从 console.anthropic.com 获取)'
: '(API Key,格式通常为 sk-...)';
const key = prompt(`请输入 ${p.name} 的 API 密钥:\n${hint}\n\n密钥保存在本地浏览器`);
if (key && key.trim()) {
setLocalKey(pid, key.trim());
saveAIKeysToCloud(pid, key.trim());
qlog(`🔑 ${p.name} 密钥已保存(仅本地)`, 'ok');
return true;
}
return false;
}
// 构建 AI 请求并解析响应(兼容 OpenAI/Anthropic/Google 三种 API)
async function queryAI(questionContent, questionType, options) {
if (!CLOUD.aiEnabled) return null;
if (!ensureAIKey()) { qlog('🤖 未配置 AI 密钥,跳过', 'warn'); return null; }
if (!questionContent || !String(questionContent).trim()) return null;
await loadAIProviders();
const provider = getCurrentProvider();
const endpoint = getCurrentEndpoint();
if (!endpoint) { qlog('🤖 自定义端点未设置', 'err'); return null; }
const model = getCurrentModel();
if (!model) { qlog('🤖 未指定模型', 'err'); return null; }
const apiKey = getLocalKey(CLOUD.aiProvider) || '';
const apiType = provider.apiType || 'openai-compatible';
const qType = String(questionType || '未知题型');
let typeDesc = '';
if (qType.includes('单选') || qType.includes('SINGLE')) typeDesc = '单选题(只有一个正确答案)';
else if (qType.includes('多选') || qType.includes('MULTIPLE')) typeDesc = '多选题(可能有一个或多个正确答案)';
else if (qType.includes('判断') || qType.includes('JUDGMENT')) typeDesc = '判断题(答案只能是"正确"或"错误")';
else if (qType.includes('填空') || qType.includes('BLANKFILL')) typeDesc = '填空题';
else typeDesc = '未知题型';
let optionsText = '';
if (options && options.length > 0) {
optionsText = '\n选项:\n' + options.filter(Boolean).map(o => ((o.alias || o.alisa || '') + '. ' + (o.text || ''))).join('\n');
}
const prompt = `你是一个党校考试答题助手。请回答以下${typeDesc}。
题目:${questionContent}${optionsText}
请严格按以下格式输出答案(只输出一行,不要任何解释):
- 单选题:【答案】选项字母
- 多选题:【答案】选项字母(多个用逗号分隔,如 A,B,C)
- 判断题:【答案】正确 或 【答案】错误
- 填空题:【答案】填空内容(多个空用分号;分隔)
现在请输出答案:`;
const systemPrompt = '你是一个精准的党校考试答题助手。只输出答案,不输出解释。';
try {
const result = await new Promise((resolve) => {
let reqUrl, reqHeaders, reqData;
if (apiType === 'anthropic') {
// Anthropic Messages API
reqUrl = endpoint;
reqHeaders = {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01'
};
reqData = JSON.stringify({
model: model,
max_tokens: 512,
system: systemPrompt,
messages: [{ role: 'user', content: prompt }]
});
} else if (apiType === 'google') {
// Google Gemini API
reqUrl = `${endpoint}/${model}:generateContent?key=${encodeURIComponent(apiKey)}`;
reqHeaders = { 'Content-Type': 'application/json' };
reqData = JSON.stringify({
contents: [{ role: 'user', parts: [{ text: systemPrompt + '\n\n' + prompt }] }],
generationConfig: { maxOutputTokens: 512, temperature: 0.1 }
});
} else {
// OpenAI-compatible
reqUrl = endpoint;
reqHeaders = {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + apiKey
};
reqData = JSON.stringify({
model: model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: prompt }
],
max_tokens: 512, temperature: 0.1, stream: false
});
}
GM_xmlhttpRequest({
method: 'POST', url: reqUrl, headers: reqHeaders, data: reqData,
timeout: 15000,
onload: (r) => { try { resolve(JSON.parse(r.responseText)); } catch(e) { resolve(null); } },
onerror: () => { qlog(`🤖 ${provider.name} 网络错误`, 'err'); resolve(null); },
ontimeout: () => { qlog(`🤖 ${provider.name} 请求超时`, 'err'); resolve(null); }
});
});
if (!result) return null;
// 提取文本响应(三种 API 格式不同)
let aiText = '';
if (apiType === 'anthropic') {
aiText = result.content && result.content[0] ? (result.content[0].text || '') : '';
} else if (apiType === 'google') {
aiText = result.candidates && result.candidates[0] && result.candidates[0].content
? result.candidates[0].content.parts.map(p => p.text || '').join('') : '';
} else {
aiText = result.choices && result.choices[0] ? (result.choices[0].message && result.choices[0].message.content || '') : '';
}
if (!aiText) return null;
aiText = aiText.trim();
qlog(`🤖 ${provider.name} 返回: ${aiText}`);
const m = aiText.match(/【答案】\s*(.+)/);
if (m) {
let rawAnswer = m[1].replace(/^[\s ]+|[\s ]+$/g, '');
if (qType.includes('判断') || qType.includes('JUDGMENT')) {
if (/正确|对|✓|✔|true|Y|yes/i.test(rawAnswer)) rawAnswer = '正确';
else if (/错误|错|✗|✘|false|N|no/i.test(rawAnswer)) rawAnswer = '错误';
}
return { type: qType, content: questionContent, options: options || [], answer: rawAnswer, source: 'ai', verified: false };
}
if (aiText.length < 50) {
return { type: qType, content: questionContent, options: options || [], answer: aiText, source: 'ai', verified: false };
}
return null;
} catch(e) {
qlog(`🤖 AI 调用异常: ${e.message}`, 'err');
return null;
}
}
// ==========================================
// 📋 答案暂存区(获取 → 填充 → 提交 三步流程)
// ==========================================
let answerCache = [];
let fetchRunning = false;
// 三级级联搜索(本地→云端→AI),供 fetchAnswers 和 doAutoAnswer 共享
async function cascadeSearch(pageQuestions) {
const db = getDB();
const results = [];
const remaining = [];
// Step 1: 本地库关键词匹配
for (const pq of pageQuestions) {
const cleanContent = pq.content.replace(/<[^>]+>/g, '').trim();
const queryKeywords = extractKeywords(cleanContent);
const match = db.find(q => {
const dbc = (q.content || '').replace(/<[^>]+>/g, '').trim();
const dbk = extractKeywords(dbc);
return queryKeywords.length > 0 && dbk.length > 0 && queryKeywords.filter(k => dbk.includes(k)).length >= Math.min(queryKeywords.length, 3);
});
if (match && match.answer) {
results.push({ pq, cleanContent, match, source: 'local' });
} else {
remaining.push({ pq, cleanContent });
}
}
if (remaining.length === 0) return results;
// Step 2: 云端批量查询
const cloudResults = await queryCloudBatch(remaining.map(r => r.pq));
const stillRemaining = [];
for (let i = 0; i < remaining.length; i++) {
const { pq, cleanContent } = remaining[i];
const match = cloudResults[i];
if (match && match.answer) {
match.source = match.source || 'cloud';
if (pq.type && pq.type !== '未知题型' && match.type && match.type !== pq.type) {
match.type = pq.type;
}
results.push({ pq, cleanContent, match, source: 'cloud' });
saveToDB([match]);
} else {
stillRemaining.push({ pq, cleanContent });
}
}
if (stillRemaining.length === 0) return results;
// Step 3: AI 逐题兜底
for (const { pq, cleanContent } of stillRemaining) {
if (!CLOUD.aiEnabled) {
results.push({ pq, cleanContent, match: null, source: null });
continue;
}
if (!ensureAIKey()) {
results.push({ pq, cleanContent, match: null, source: null });
continue;
}
const pqType = String(pq.type || '未知题型');
const pqOptions = pq.options || [];
const match = await queryAI(cleanContent, pqType, pqOptions);
if (match && match.answer) {
results.push({ pq, cleanContent, match, source: 'ai' });
saveToDB([match]);
} else {
results.push({ pq, cleanContent, match: null, source: null });
}
}
return results;
}
async function fetchAnswers(course) {
if (fetchRunning) { qlog('⚠️ 正在查询中,请稍候...', 'warn'); return; }
fetchRunning = true;
answerCache = [];
try {
qlog('🔍 开始获取答案(本地→云端→AI)...');
const pageQuestions = parseCurrentPageQuestions(course);
if (pageQuestions.length === 0) { qlog('⚠️ 未在页面检测到题目'); return; }
qlog(`📋 检测到 ${pageQuestions.length} 道题目`);
const results = await cascadeSearch(pageQuestions);
let foundCount = 0;
for (const { pq, cleanContent, match, source } of results) {
answerCache.push({ pq, match, cleanContent });
if (match && match.answer) {
foundCount++;
if (source === 'cloud') {
qlog(`[云端] ${cleanContent.substring(0, 30)}... -> ${match.answer}`, 'ok');
} else if (source === 'ai') {
qlog(`[AI ?] ${cleanContent.substring(0, 30)}... -> ${match.answer} (未验证,请人工确认)`, 'warn');
} else {
const srcTag = match.verified ? '[本地 已验证]' : '[本地]';
qlog(`${srcTag} ${cleanContent.substring(0, 30)}... -> ${match.answer}`, 'ok');
}
} else {
qlog(`❓ [未命中] ${cleanContent.substring(0, 40)}... → 未找到答案`, 'warn');
}
}
const aiCount = answerCache.filter(a => a.match && a.match.source === 'ai').length;
qlog(`查询完成: ${foundCount}/${pageQuestions.length} 题 (已验证 ${foundCount-aiCount} + AI未验证 ${aiCount}),请检查后点击「填充」`);
} catch(e) {
qlog(`❌ 查询异常: ${e.message}`, 'err');
} finally {
fetchRunning = false;
}
}
async function fillAnswers() {
if (fetchRunning) { qlog('⚠️ 正在查询答案中,请稍候再填充', 'warn'); return; }
if (answerCache.length === 0) { qlog('⚠️ 请先点击「获取答案」', 'warn'); return; }
const unfilled = answerCache.filter(a => a.match && a.match.answer);
if (unfilled.length === 0) { qlog('⚠️ 没有可填充的答案', 'warn'); return; }
// 优先使用 API 提交(POST /api/student/paper/saveData)
qlog(`✍ 正在通过API提交 ${unfilled.length} 道题目...`);
const apiCount = await submitAnswersAPI(unfilled);
if (apiCount > 0) {
qlog(`✍ API提交完成:${apiCount}/${unfilled.length} 题已填入页面。网络拦截器将在交卷后自动捕获正确答案并上传云端。`);
return;
}
// API 失败则回退 DOM 点击
qlog('API提交失败,回退到DOM点击填充...', 'warn');
let filled = 0;
for (const { pq, match, cleanContent } of unfilled) {
const ok = fillAnswerToPage(pq, match);
if (ok) {
filled++;
qlog(`✅ [${pq.type || '?'}] ${cleanContent.substring(0, 30)}... -> ${match.answer}`, 'ok');
} else {
qlog(`⚠️ [${pq.type || '?'}] 填充失败`, 'warn');
}
}
qlog(`✍ DOM填充完成:${filled}/${unfilled.length} 题`);
}
async function aiDirectAnswer() {
if (!CLOUD.aiEnabled) { qlog('[AI直答] 请先开启 AI 辅助答题开关', 'warn'); return; }
if (!ensureAIKey()) { qlog('[AI直答] 未配置 AI 密钥,已取消', 'warn'); return; }
if (fetchRunning) { qlog('[AI直答] 正在查询中,请稍候', 'warn'); return; }
fetchRunning = true;
try {
const pageQuestions = parseCurrentPageQuestions('');
if (pageQuestions.length === 0) { qlog('[AI直答] 未在页面检测到题目'); return; }
qlog(`[AI直答] 检测到 ${pageQuestions.length} 题,全部使用 AI 查询...`);
let hitCount = 0;
answerCache = [];
for (const pq of pageQuestions) {
const cleanContent = pq.content.replace(/<[^>]+>/g, '').trim();
const pqType = String(pq.type || '未知题型');
const pqOptions = pq.options || [];
qlog(`[AI直答] 查询: ${cleanContent.substring(0, 40)}...`);
const match = await queryAI(cleanContent, pqType, pqOptions);
if (match && match.answer) {
answerCache.push({ pq, match, cleanContent });
hitCount++;
qlog(`[AI直答] ${cleanContent.substring(0, 30)}... -> ${match.answer}`, 'ok');
saveToDB([match]);
} else {
answerCache.push({ pq, match: null, cleanContent });
qlog(`[AI直答] ${cleanContent.substring(0, 30)}... -> 未识别`, 'warn');
}
}
qlog(`[AI直答] 完成: ${hitCount}/${pageQuestions.length} 题 (未验证,请人工确认)`);
document.getElementById('tk-cache-status').textContent = '暂存 ' + answerCache.filter(a => a.match).length + ' 题 (AI)';
} finally {
fetchRunning = false;
}
}
function submitAnswers() {
const allBtns = document.querySelectorAll('button, a, span[class*="btn"], div[class*="btn"], input[type="button"], input[type="submit"]');
const keywords = ['提交', '交卷', 'submit', '下一题', '下一节', '确认', '确定'];
let submitBtn = null;
for (const kw of keywords) {
for (const el of allBtns) {
if (!el.offsetParent) continue;
const txt = (el.textContent || el.value || '').trim();
if (txt.includes(kw)) { submitBtn = el; break; }
}
if (submitBtn) break;
}
if (submitBtn) {
const txt = (submitBtn.textContent || submitBtn.value || '').trim();
if (confirm(`确定要点击「${txt}」提交吗?\n\n提交后会自动捕获正确答案并上传云端。`)) {
submitBtn.click();
qlog(`[提交] 已点击「${txt}」,网络拦截器将自动捕获正确答案...`, 'ok');
}
} else {
qlog('⚠️ 未找到提交按钮,请手动提交', 'warn');
const visibleBtns = [...allBtns].filter(b => b.offsetParent).map(b => (b.textContent || b.value || '').trim()).filter(t => t.length > 0 && t.length < 20);
if (visibleBtns.length > 0) qlog('页面可见按钮: ' + [...new Set(visibleBtns)].join(', '));
}
}
// 执行自动答题(保留兼容:仅当用户手动勾选自动答题时使用)
let doAutoAnswerRunning = false;
async function doAutoAnswer(course) {
if (!CLOUD.autoAnswerEnabled) return;
if (doAutoAnswerRunning) { qlog('⚠️ 自动答题已在运行中,跳过重复调用', 'warn'); return; }
doAutoAnswerRunning = true;
try {
qlog('🎯 自动答题引擎启动,正在识别页面题目...');
const pageQuestions = parseCurrentPageQuestions(course);
if (pageQuestions.length === 0) { qlog('⚠️ 未在页面检测到题目'); return; }
qlog(`📋 检测到 ${pageQuestions.length} 道题目,正在查询答案...`);
const results = await cascadeSearch(pageQuestions);
let answeredCount = 0;
for (const { pq, cleanContent, match, source } of results) {
if (!match || !match.answer) {
qlog(`❓ [${pq.type || '?'}] ${cleanContent.substring(0, 30)}... → 未找到答案`, 'warn');
continue;
}
const filled = fillAnswerToPage(pq, match);
if (filled) {
answeredCount++;
const tag = source === 'ai' ? '[AI ?]' : source === 'cloud' ? '[云端]' : '[本地]';
qlog(`✅ ${tag} [${pq.type || '?'}] ${cleanContent.substring(0, 30)}... -> ${match.answer}`, source === 'ai' ? 'warn' : 'ok');
} else {
qlog(`⚠️ [${pq.type || '?'}] 答案存在但无法填入页面,跳过`, 'warn');
}
}
qlog(`🎯 自动答题完成:${answeredCount}/${pageQuestions.length} 题已填入答案`);
} finally {
doAutoAnswerRunning = false;
}
}
// 将答案填入页面控件(通过 Vue 实例直接操作)
function fillAnswerToPage(question, match) {
const answer = match.answer;
const type = match.type || question.type;
const el = question._el; // parseCurrentPageQuestions 附上的 DOM 引用
let filled = false;
// 通过附带的 DOM 元素操作 Vue 实例
if (el && el.__vue__) {
try {
const vm = el.__vue__;
const data = vm.$data || vm;
// 尝试常见 Vue 数据字段名
const answerFields = ['answer', 'userAnswer', 'selectedAnswer', 'selected', 'value', 'result'];
const optionsFields = ['options', 'optionList', 'choices', 'items', 'questionOptions'];
const typeFields = ['type', 'questionType', 'realType'];
// 直接设置 answer
for (const f of answerFields) {
if (data[f] !== undefined) {
const old = data[f];
if (type.includes('判断') || type.includes('JUDGMENT')) {
data[f] = (answer === '正确' || answer === 'Y' || answer === 'true') ? 'Y' : 'N';
} else {
data[f] = answer;
}
if (data[f] !== old && vm.$forceUpdate) vm.$forceUpdate();
filled = true;
break;
}
}
// 操作选项列表
if (!filled) {
for (const of of optionsFields) {
const opts = data[of];
if (Array.isArray(opts) && opts.length > 0) {
for (const opt of opts) {
const alias = opt.alias || opt.key || opt.value || opt.label || '';
if (type.includes('单选') || type.includes('SINGLE') || type.includes('判断') || type.includes('JUDGMENT')) {
if (alias === answer || String(alias) === String(answer)) {
if (opt.checked !== undefined) opt.checked = true;
if (opt.selected !== undefined) opt.selected = true;
if (opt.isSelected !== undefined) opt.isSelected = true;
filled = true;
} else {
if (opt.checked !== undefined) opt.checked = false;
if (opt.selected !== undefined) opt.selected = false;
}
} else if (type.includes('多选') || type.includes('MULTIPLE')) {
const answers = answer.split(/[,,\s;;]+/).filter(Boolean);
if (answers.includes(alias) || answers.includes(String(alias))) {
opt.checked = true; opt.selected = true;
filled = true;
}
}
}
if (vm.$forceUpdate) vm.$forceUpdate();
break;
}
}
}
} catch(e) {}
}
// 如果 Vue 操作无效,触发元素点击
if (filled && el) {
el.click();
el.dispatchEvent(new Event('click', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
// 兜底:暴力匹配页面元素 + MouseEvent 模拟
if (!filled) {
if (type.includes('判断') || type.includes('JUDGMENT')) {
const txt = (answer === '正确' || answer === 'Y' || answer === 'true') ? '正确' : '错误';
const all = document.querySelectorAll('*');
for (const e of all) {
const t = (e.textContent || '').trim();
if ((t === txt || t === (txt+'。')) && e.offsetParent) {
e.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
e.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
e.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
if (e.parentElement) e.parentElement.dispatchEvent(new MouseEvent('click', { bubbles: true }));
filled = true; break;
}
}
} else if (type.includes('多选') || type.includes('MULTIPLE')) {
const answers = answer.split(/[,,\s;;]+/).filter(Boolean);
if (answers.length > 1) {
// 多选题分别点击每个选项
let multiFilled = 0;
const all = document.querySelectorAll('*');
for (const ans of answers) {
for (const e of all) {
if (!e.offsetParent) continue;
const t = (e.textContent || '').trim();
if (t === ans || t.startsWith(ans + '.') || t.startsWith(ans + '、')) {
const target = e.closest('button, label, a, [class*="option"], [class*="choice"], [class*="item"]') || e;
target.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
if (target.parentElement) target.parentElement.dispatchEvent(new MouseEvent('click', { bubbles: true }));
multiFilled++;
break;
}
}
}
filled = multiFilled >= answers.length;
} else {
// 单答案 fallthrough 到通用逻辑
const all2 = document.querySelectorAll('*');
for (const e of all2) {
if (!e.offsetParent) continue;
const t = (e.textContent || '').trim();
if (t === answer || t.startsWith(answer + '.') || t.startsWith(answer + '、')) {
const target = e.closest('button, label, a, [class*="option"], [class*="choice"], [class*="item"]') || e;
target.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
if (target.parentElement) target.parentElement.dispatchEvent(new MouseEvent('click', { bubbles: true }));
filled = true; break;
}
}
}
} else {
const all = document.querySelectorAll('*');
for (const e of all) {
if (!e.offsetParent) continue;
const t = (e.textContent || '').trim();
if (t === answer || t.startsWith(answer + '.') || t.startsWith(answer + '、')) {
const target = e.closest('button, label, a, [class*="option"], [class*="choice"], [class*="item"]') || e;
target.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
if (target.parentElement) target.parentElement.dispatchEvent(new MouseEvent('click', { bubbles: true }));
filled = true; break;
}
}
}
}
if (!filled) {
qlog(`⚠️ 无法填入: ${type}`, 'warn');
return false;
}
return true;
}
// 页面监控:检测到题目页面时自动触发答题
function watchForQuestions(course) {
if (autoAnswerTimer) clearInterval(autoAnswerTimer);
autoAnswerTimer = setInterval(() => {
if (!CLOUD.autoAnswerEnabled) return;
const questions = parseCurrentPageQuestions(course);
if (questions.length > 0) {
clearInterval(autoAnswerTimer);
doAutoAnswer(course);
}
}, 2000);
// 30秒后停止扫描
setTimeout(() => { if (autoAnswerTimer) clearInterval(autoAnswerTimer); }, 30000);
}
// ==========================================
// 3. UI 界面构建
// ==========================================
let logArea, countSpan;
function initPanel() {
const panel = document.createElement('div');
panel.id = '__whut_qbank';
panel.style.cssText = `
position:fixed;top:80px;left:10px;width:250px;max-height:88vh;overflow-y:auto;
background:rgba(15,18,28,.92);backdrop-filter:blur(16px) saturate(140%);-webkit-backdrop-filter:blur(16px) saturate(140%);
color:#e2e8f0;border-radius:14px;box-shadow:0 4px 30px rgba(0,0,0,.5),0 0 0 1px rgba(255,255,255,.06);
z-index:99998;font:12px/1.5 'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif;user-select:none;
`;
panel.innerHTML = `
☁ 云端题库
--
答题
暂存 0 题
AI 辅助答题
自动模式
`;
document.body.appendChild(panel);
logArea = document.getElementById('tk-log');
countSpan = document.getElementById('tk-count');
document.getElementById('btn-export-txt').onclick = exportTXT;
document.getElementById('btn-export-json').onclick = exportJSON;
document.getElementById('btn-clear').onclick = clearDB;
document.getElementById('btn-pull-errors').onclick = autoPullErrors;
document.getElementById('btn-scan-dom').onclick = scanDOMForQuestions;
document.getElementById('btn-cloud-refresh').onclick = () => loadCloudBank(true);
document.getElementById('btn-fetch-answers').onclick = () => {
fetchAnswers('').then(() => {
document.getElementById('tk-cache-status').textContent = '暂存 ' + answerCache.filter(a => a.match).length + ' 题';
});
};
document.getElementById('btn-fill-answers').onclick = () => {
fillAnswers();
document.getElementById('tk-cache-status').textContent = '暂存 ' + answerCache.filter(a => a.match).length + ' 题';
};
document.getElementById('btn-submit-answers').onclick = () => submitAnswers();
document.getElementById('btn-ai-direct').onclick = () => aiDirectAnswer();
document.getElementById('tk-auto-answer').onchange = (e) => {
CLOUD.autoAnswerEnabled = e.target.checked;
GM_setValue('whut_autoAnswerEnabled', e.target.checked);
qlog('自动模式: ' + (CLOUD.autoAnswerEnabled ? 'ON' : 'OFF'));
};
// --- Provider / Model / Key 选择器初始化 ---
const providerSel = document.getElementById('tk-ai-provider');
const modelSel = document.getElementById('tk-ai-model');
const customEp = document.getElementById('tk-custom-endpoint');
const keyLink = document.getElementById('btn-set-key');
const keyStat = document.getElementById('tk-key-status');
function updateKeyUI() {
const pid = CLOUD.aiProvider;
const hasKey = !!getLocalKey(pid);
keyStat.textContent = hasKey ? '已设置 (更换)' : '设置密钥';
keyStat.style.color = hasKey ? '#a5b4fc' : '#475569';
}
function renderProviderOptions() {
const providers = CLOUD.aiProviders || BUILTIN_AI_PROVIDERS;
providerSel.innerHTML = providers.map(p => ``).join('');
updateModelOptions();
}
function updateModelOptions() {
const providers = CLOUD.aiProviders || BUILTIN_AI_PROVIDERS;
const p = providers.find(p => p.id === CLOUD.aiProvider) || providers[0];
if (p.id === 'custom') {
modelSel.style.display = 'none';
customEp.style.display = 'inline-block';
customEp.value = GM_getValue('whut_aiCustomEndpoint', '');
} else {
modelSel.style.display = 'inline-block';
customEp.style.display = 'none';
const models = p.models || [];
if (models.length === 0) models.push(p.defaultModel || 'default');
modelSel.innerHTML = models.map(m => ``).join('');
}
updateKeyUI();
}
providerSel.onchange = () => {
CLOUD.aiProvider = providerSel.value;
GM_setValue('whut_aiProvider', CLOUD.aiProvider);
const providers = CLOUD.aiProviders || BUILTIN_AI_PROVIDERS;
const p = providers.find(p => p.id === CLOUD.aiProvider);
if (p && p.defaultModel) {
CLOUD.aiModel = p.defaultModel;
GM_setValue('whut_aiModel', CLOUD.aiModel);
}
updateModelOptions();
qlog('AI Provider: ' + CLOUD.aiProvider);
};
modelSel.onchange = () => {
CLOUD.aiModel = modelSel.value;
GM_setValue('whut_aiModel', CLOUD.aiModel);
qlog('AI Model: ' + CLOUD.aiModel);
};
customEp.onchange = () => {
GM_setValue('whut_aiCustomEndpoint', customEp.value.trim());
qlog('自定义端点已更新');
};
keyLink.onclick = () => {
const p = getCurrentProvider();
const pid = CLOUD.aiProvider;
const current = getLocalKey(pid);
const hint = p.apiType === 'google' ? '(Google API Key,自 aistudio.google.com)'
: p.apiType === 'anthropic' ? '(Anthropic API Key,自 console.anthropic.com)'
: '(API Key,格式通常 sk-...)';
const key = prompt(`${p.name} API 密钥:\n${hint}\n\n当前:${current ? current.substring(0,8)+'...' : '未设置'}\n\n密钥保存在本地`, current);
if (key !== null && key.trim()) {
setLocalKey(pid, key.trim());
saveAIKeysToCloud(pid, key.trim());
qlog(`🔑 ${p.name} 密钥已保存(仅本地)`, 'ok');
} else if (key !== null && !key.trim()) {
setLocalKey(pid, '');
CLOUD.aiKeys[pid] = '';
GM_setValue('whut_aiKey_' + pid, '');
saveAIKeysToCloud(pid, '');
qlog(`🔑 ${p.name} 密钥已清空`, 'warn');
}
updateKeyUI();
};
// 初始加载 AI Providers(含云端密钥)
loadAIProviders().then(() => renderProviderOptions());
document.getElementById('tk-ai-answer').onchange = (e) => {
CLOUD.aiEnabled = e.target.checked;
GM_setValue('whut_aiEnabled', e.target.checked);
qlog('AI 辅助: ' + (CLOUD.aiEnabled ? 'ON' : 'OFF'));
};
document.getElementById('btn-minimize').onclick = (e) => {
e.stopPropagation();
panel.classList.toggle('collapsed');
document.getElementById('btn-minimize').textContent = panel.classList.contains('collapsed') ? '+' : '−';
};
updateStats(getDB().length);
loadCloudBank().then(() => updateCloudUI(cloudBank ? cloudBank.length : 0));
// 拖拽
const header = document.getElementById('tk-header');
let isDragging = false, startX, startY, initialX, initialY;
header.onmousedown = (e) => {
isDragging = true; startX = e.clientX; startY = e.clientY;
initialX = panel.offsetLeft; initialY = panel.offsetTop;
const onMove = (ev) => {
panel.style.left = (initialX + ev.clientX - startX) + 'px';
panel.style.top = (initialY + ev.clientY - startY) + 'px';
panel.style.right = 'auto';
};
const onUp = () => {
isDragging = false;
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.removeEventListener('mouseleave', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
document.addEventListener('mouseleave', onUp, { once: true });
};
// 初始启动题目监控
setTimeout(() => {
watchForQuestions('');
}, 3000);
}
function updateCloudUI(count) {
const el = document.getElementById('tk-cloud-courses');
if (!el) return;
if (count != null && count > 0) {
el.textContent = `${count} 题`;
} else if (count === 0) {
el.textContent = '题库为空';
} else {
el.textContent = '连接失败';
}
}
function qlog(msg, type) {
if(!logArea) return;
const prefix = type ? `[${type.toUpperCase()}] ` : '';
logArea.value += `[${new Date().toLocaleTimeString()}] ${prefix}${msg}\n`;
logArea.scrollTop = logArea.scrollHeight;
}
function updateStats(count) {
if(countSpan) countSpan.innerText = count;
}
// ==========================================
// 4. 导出逻辑
// ==========================================
function exportTXT() {
const db = getDB();
if(db.length === 0) return alert("题库为空!");
let txtContent = "";
db.forEach((q) => {
txtContent += `【${q.type}】题目:${q.content}\n`;
if ((q.type === "单选题" || q.type === "多选题") && q.options && q.options.length > 0) {
q.options.forEach(opt => {
if (opt.alias && opt.text) txtContent += `${opt.alias}. ${opt.text}\n`;
});
}
txtContent += `答案:${q.answer}\n\n`;
});
const blob = new Blob([txtContent], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.setAttribute("href", url);
link.setAttribute("download", `党校题库_${db.length}题.txt`);
document.body.appendChild(link);
link.click();
setTimeout(() => URL.revokeObjectURL(url), 100);
document.body.removeChild(link);
qlog(`导出 TXT 成功。`);
}
function exportJSON() {
const db = getDB();
if(db.length === 0) return alert("题库为空!");
const blob = new Blob([JSON.stringify(db, null, 4)], { type: 'application/json;charset=utf-8' });
const link = document.createElement("a");
const jsonUrl = URL.createObjectURL(blob);
link.href = jsonUrl;
link.download = `党校题库_${db.length}题.json`;
link.click();
setTimeout(() => URL.revokeObjectURL(jsonUrl), 100);
qlog(`导出 JSON 成功。`);
}
// 5. DOM 启发式扫描器
// ==========================================
function scanDOMForQuestions() {
qlog("开始深度扫描当前网页元素...");
let foundQuestions = [];
const allElements = document.querySelectorAll('*');
let rawDataSources = [];
for (let el of allElements) {
if (el.__vue__) {
try {
let vueData = JSON.parse(JSON.stringify(el.__vue__.$data || {}));
rawDataSources.push(vueData);
} catch(e){}
}
if (el.__vue_app__) {
try {
const appData = JSON.parse(JSON.stringify(el.__vue_app__));
rawDataSources.push(appData);
} catch(e) {}
}
}
if (rawDataSources.length > 0) {
rawDataSources.forEach(data => {
let extracted = extractQuestionsFromData(data);
foundQuestions = foundQuestions.concat(extracted);
});
}
if (foundQuestions.length > 0) {
let added = saveToDB(foundQuestions);
qlog(`DOM底层数据扫描完成,找到有效题目,新入库 ${added} 题!`);
// 已由 saveToDB 自动上传云端
} else {
qlog("未能从当前页面的底层数据中扫描到规范题库。");
qlog("提示:如果页面显示了题目,建议通过『高级API接口』重新请求该页面的数据源。");
}
}
// ==========================================
// 6. 高级 API 挖掘器
// ==========================================
// 7. 错题本自动拉取
// ==========================================
async function autoPullErrors() {
qlog("开始全量拉取错题本...");
let current = 1; let totalPages = 1; let allRecords = [];
while (current <= totalPages) {
try {
let res = await fetch("/api/student/test/myErrorQuestion/list", {
method: 'POST',
headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json" },
body: `current=${current}&size=100&keyword=`
});
let resJson = await res.json();
if (resJson.code === 0 && resJson.data) {
totalPages = resJson.data.pages || 1;
let records = resJson.data.records || [];
if(records.length > 0){
allRecords = allRecords.concat(records);
let added = saveToDB(records);
qlog(`错题本第${current}页:新入库 ${added} 题。`);
} else break;
} else break;
current++;
await new Promise(r => setTimeout(r, 600));
} catch (e) { break; }
}
qlog("错题本全量拉取完成!(已由 saveToDB 自动上传云端)");
}
// ==========================================
// 8. 网络拦截双引擎 — 抓取数据 + 强制捕获正确答案 + 自动上传
// ==========================================
let _forcedCaptureTimer = null;
let _forcedCaptureDone = false;
function _onSubmissionDetected(source) {
if (_forcedCaptureDone) return;
_forcedCaptureDone = true;
qlog(`[强制捕获] ${source} 拦截到提交请求,将在批改完成后自动捕获正确答案...`, 'ok');
toast('检测到提交!批改完成后将自动捕获正确答案并上传云端', 4);
// 启动 MutationObserver 监控结果页
if (_forcedCaptureTimer) clearTimeout(_forcedCaptureTimer);
const watcher = new MutationObserver(() => {
const indicators = document.querySelectorAll(
'[class*="score"], [class*="grade"], [class*="result"], [class*="correct"], [class*="right"], a[href*="download"]'
);
if (indicators.length === 0) return;
watcher.disconnect();
_forcedCaptureTimer = null;
qlog('[强制捕获] 检测到结果页,1.5秒后开始提取正确答案...', 'ok');
toast('批改完成,正在提取正确答案...', 3);
setTimeout(async () => {
const added = _forceCaptureAndUpload();
if (added === 0) {
// 第一次没抓到,再等2秒重试
await new Promise(r => setTimeout(r, 2000));
const retryAdded = _forceCaptureAndUpload();
if (retryAdded === 0) {
qlog('[强制捕获] 未能自动提取,请手动使用「扫描页面」', 'warn');
toast('未能自动提取正确答案,请手动扫描页面入库', 4);
}
}
}, 1500);
});
watcher.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
// 120秒超时
_forcedCaptureTimer = setTimeout(() => {
watcher.disconnect();
_forcedCaptureTimer = null;
_forcedCaptureDone = false;
qlog('[强制捕获] 等待超时,本次不捕获', 'warn');
}, 120000);
}
function _forceCaptureAndUpload() {
qlog('[强制捕获] 正在提取正确答案...');
let totalAdded = 0;
const seen = new Set();
const db = getDB();
db.forEach(q => seen.add((q.content || '').replace(/\s+/g, '').substring(0, 40)));
// 方法1: Vue 实例深挖
let found = [];
const allElements = document.querySelectorAll('*');
for (let el of allElements) {
if (el.__vue__) {
try { found = found.concat(extractQuestionsFromData(JSON.parse(JSON.stringify(el.__vue__.$data || {})))); } catch(e) {}
}
if (el.__vue_app__) {
try { found = found.concat(extractQuestionsFromData(JSON.parse(JSON.stringify(el.__vue_app__)))); } catch(e) {}
}
}
const withAnswer = found.filter(q => q.answer && String(q.answer).trim());
if (withAnswer.length > 0) {
const items = [];
for (const q of withAnswer) {
const key = (q.content || '').replace(/\s+/g, '').substring(0, 40);
if (!seen.has(key)) { seen.add(key); items.push({ ...q, source: 'platform', verified: true }); }
}
if (items.length > 0) {
const added = saveToDB(items);
totalAdded += added;
qlog(`[强制捕获 ✓] Vue提取 ${items.length} 题,入库 ${added} 题`, 'ok');
}
}
// 方法2: DOM 结果块扫描
if (totalAdded === 0) {
const correctMarks = document.querySelectorAll('[class*="correct"], [class*="right"]');
const items = [];
for (const mark of correctMarks) {
const container = mark.closest('div, li, tr, section');
if (!container) continue;
const txt = (container.textContent || '').replace(/\s+/g, ' ').trim();
if (txt.length < 10 || txt.length > 2000) continue;
const ansText = (mark.textContent || '').trim();
const key = txt.substring(0, 40);
if (!seen.has(key)) { seen.add(key); items.push({ content: txt, type: '未知题型', options: [], answer: ansText, source: 'platform', verified: true }); }
}
if (items.length > 0) { const added = saveToDB(items); totalAdded += added; qlog(`[强制捕获 ✓] DOM扫描 ${items.length} 题,入库 ${added} 题`, 'ok'); }
}
if (totalAdded > 0) {
qlog(`[强制捕获 🎉] 完成!共入库 ${totalAdded} 题已验证答案,已自动上传云端`, 'ok');
toast(`已捕获 ${totalAdded} 题正确答案并上传云端!`, 4);
}
_forcedCaptureDone = false;
return totalAdded;
}
// XHR 拦截
const originalXHR = window.XMLHttpRequest;
window.XMLHttpRequest = function() {
const xhr = new originalXHR();
const origOpen = xhr.open;
let _url = '';
xhr.open = function(method, url) { _url = url; return origOpen.apply(this, arguments); };
xhr.addEventListener('load', function() {
try {
const url = this.responseURL || _url || '';
if (url.includes('saveData')) {
_onSubmissionDetected('XHR');
}
if (url.includes('/api/')) {
const resData = this.responseType === 'json' ? this.response : JSON.parse(this.responseText);
if (resData) { const added = saveToDB(extractQuestionsFromData(resData)); if (added > 0) qlog(`[XHR] 抓取 ${added} 题`, 'ok'); }
}
} catch (e) {}
});
return xhr;
};
// Fetch 拦截
const originalFetch = window.fetch;
window.fetch = async function(...args) {
const response = await originalFetch.apply(this, args);
const cloneRes = response.clone();
const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url ? args[0].url : '');
if (url.includes('saveData')) {
_onSubmissionDetected('Fetch');
}
cloneRes.json().then(resData => {
try {
if (url.includes('/api/')) {
const added = saveToDB(extractQuestionsFromData(resData));
if (added > 0) qlog(`[Fetch] 抓取 ${added} 题`, 'ok');
}
} catch(e) {}
}).catch(() => {});
return response;
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPanel);
} else {
initPanel();
}
})();