// ==UserScript==
// @name 中国保密在线 · 保密教育助手 v4(学习 + 考试 合体)
// @namespace baomi-helper.local
// @version 4.0.0
// @description 中国保密在线(baomi.org.cn)保密教育助手:学习侧批量遍历/断点续学/真实时长上报,考试侧从接口取卷自动作答交卷。合并为一个脚本、一个面板、两个标签页。
// @author 小小伴
// @match https://www.baomi.org.cn/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @run-at document-idle
// @noframes
// ==/UserScript==
/*
* ============================================================
* 保密教育助手 v4 —— 学习 + 考试 合体
* ============================================================
*
* 由两个独立脚本合并而成:
* · 学习模块 —— 源自《保密观学习助手 v3》,
* 蓝本是《中国保密在线网-自动学习工具 v3.2》
* · 考试模块 —— 源自《保密观考试助手 v2.3》,
* 蓝本是《脚本.txt》(保密观考试助手)与 AutoBaomiGuan.exe
*
* 合并方式:两个模块各自放进独立函数作用域(天然无命名冲突),
* 外层用一个带标签页的统一外壳承载,两边共用一套鉴权头。
*
* 鉴权头对齐《脚本.txt》:
* siteId = 95 (平台级固定)
* authToken = 3613146b… (平台级固定,不是用户 token)
* token = 当前登录态的 token(cookie / localStorage / sessionStorage)
*
* ★ 之前漏掉 siteId 与固定 authToken,导致 getCourseRelateExam 被拒、
* 走了兜底默认 examId、取到别的试卷 → 交卷只得 45 分。
*
* 快捷键:
* 空格 播放/暂停 N 下一节 S 切倍速 ← 学习侧
* E 考试 B 学习 H 折叠/展开面板
* ============================================================
*/
(function () {
'use strict';
// ======================== 学习模块 ========================
const StudyModule = (function () {
'use strict';
// ======================== 配置 ========================
const CONFIG = {
// 课程包 ID:留空则自动识别(URL id 参数 / DOM / 接口)
coursePacketId: null,
siteId: '95',
// 分段上报的段长(秒)
segmentSize: 300,
// 最小段长(最后一段不足时用这个)
minSegmentSize: 60,
// 段与段之间的间隔(毫秒)
segmentInterval: 1500,
// 两条资源之间的间隔(毫秒)
delayBetweenResources: 1200,
// 目录之间的间隔(毫秒)
delayBetweenDirectories: 300,
maxRetries: 3,
// 是否按真实播放进度上报(true=真实 / false=直接填满)
reportRealProgress: true,
// 断点续学存储键
storageKey: 'baomi_study_v3',
debug: false,
};
const API = {
directory: '/portal/main-api/v2/coursePacket/getCourseDirectoryList',
packet: '/portal/main-api/v2/coursePacket/getCoursePacket',
resources: '/portal/main-api/v2/coursePacket/getCourseResourceList',
stat: '/portal/main-api/v2/coursePacket/getResourceUserStatistic',
progress: '/portal/main-api/v2/coursePacket/getCourseUserStatistic',
detail: '/portal/main-api/resource/simpleDetail.do',
save: '/portal/main-api/v2/studyTime/saveCoursePackage.do',
};
// ======================== 状态 ========================
const state = {
running: false,
paused: false,
stopRequested: false, // 用户点了「停止」→ 中断当前批次
currentIndex: 0,
coursePacketId: '',
courseName: '',
allResources: [],
pending: [],
stats: { success: 0, failed: 0, skipped: 0, seconds: 0 },
status: '待机',
lastError: '',
};
// ======================== 工具 ========================
const $ = (s, r) => (r || document).querySelector(s);
const $$ = (s, r) => Array.from((r || document).querySelectorAll(s));
function log(...a) { console.log('[保密教育学习助手]', ...a); }
function nowStr() {
const d = new Date(), p = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
// 可中断的等待
async function interruptibleWait(ms) {
const step = 100;
let waited = 0;
while (waited < ms) {
if (!state.running) return false;
await sleep(step);
waited += step;
}
return state.running;
}
function loadStore() { try { return GM_getValue(CONFIG.storageKey, {}) || {}; } catch (e) { return {}; } }
function saveStore(d) { try { GM_setValue(CONFIG.storageKey, d); } catch (e) {} }
const store = loadStore();
// ======================== token / headers ========================
// 从当前登录态取真实 token,不写死任何凭据
function getToken() {
try {
const m = document.cookie.match(/(?:^|;\s*)(?:token|authToken)=([^;]+)/);
if (m) return m[1];
} catch (e) { }
try {
const v = localStorage.getItem('token');
if (v) return v;
} catch (e) { }
try {
const v = localStorage.getItem('authToken');
if (v) return v;
} catch (e) { }
if (window.authToken) return window.authToken;
// 退而从页面的全局状态里找
try {
for (const k of Object.keys(window)) {
const v = window[k];
if (typeof v === 'string' && /^[0-9a-f]{32}$/i.test(v) && k.toLowerCase().includes('token')) return v;
}
} catch (e) { }
return null;
}
function getHeaders() {
const t = getToken();
return {
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json',
'token': t,
'authToken': t,
'siteId': CONFIG.siteId,
};
}
// ======================== 1. 课程包 ID 识别 ========================
function detectCoursePacketId() {
if (CONFIG.coursePacketId) return CONFIG.coursePacketId;
// ① URL 的 id 参数
const params = new URLSearchParams(location.search);
for (const k of ['id', 'coursePacketId', 'courseId', 'packetId']) {
const v = params.get(k);
if (v && v.length >= 3 && v !== 'true' && v !== 'false') return v;
}
// ② hash 里的参数
const hash = location.hash || '';
const hm = hash.match(/[?&]id=([^&]+)/);
if (hm) return decodeURIComponent(hm[1]);
// ③ DOM 里的链接
const link = $('a[href*="coursePacketId="]');
if (link) {
const m = link.getAttribute('href').match(/coursePacketId=([^&]+)/);
if (m) return m[1];
}
// ④ 页面上挂着的全局变量
try {
for (const k of Object.keys(window)) {
const v = window[k];
if (typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v)
&& k.toLowerCase().includes('packet')) return v;
}
} catch (e) { }
return null;
}
// ======================== 2. 课程详情 ========================
async function getCourseDetail() {
const id = state.coursePacketId;
if (!id) return null;
try {
const u = new URL(API.packet, location.origin);
u.searchParams.set('coursePacketId', id);
u.searchParams.set('token', getToken() || '');
const r = await fetch(u, { headers: getHeaders(), credentials: 'include' });
if (!r.ok) return null;
const j = await r.json();
if (j.status === 0 && j.data) {
return { name: j.data.name || j.data.docName || '', data: j.data };
}
} catch (e) { }
return null;
}
// ======================== 3. 目录树(递归) ========================
async function getDirectoryList() {
const id = state.coursePacketId;
if (!id) return [];
try {
const u = new URL(API.directory, location.origin);
u.searchParams.set('scale', '1');
u.searchParams.set('coursePacketId', id);
const r = await fetch(u, { headers: getHeaders(), credentials: 'include' });
if (!r.ok) return [];
const j = await r.json();
return (j.status === 0 && j.data) ? j.data : [];
} catch (e) {
log('获取目录失败', e);
return [];
}
}
function getAllDirectories(list) {
const out = [];
(function traverse(dirs) {
if (!dirs || !Array.isArray(dirs)) return;
dirs.forEach(d => {
out.push({
SYS_UUID: d.SYS_UUID,
name: d.name,
directoryCascadeID: d.directoryCascadeID,
});
if (d.subDirectory && d.subDirectory.length) traverse(d.subDirectory);
});
})(list);
return out;
}
// ======================== 4. 资源列表 ========================
function timeToSeconds(s) {
try {
const p = String(s).split(':');
if (p.length === 3) return (+p[0]) * 3600 + (+p[1]) * 60 + (+p[2]);
if (p.length === 2) return (+p[0]) * 60 + (+p[1]);
return 300;
} catch (e) { return 300; }
}
async function getResourcesByDirectory(dirId, dirName, retry = 0) {
const id = state.coursePacketId;
if (!id) return [];
try {
const u = new URL(API.resources, location.origin);
u.searchParams.set('coursePacketId', id);
u.searchParams.set('directoryId', dirId);
u.searchParams.set('token', getToken() || '');
const r = await fetch(u, { headers: getHeaders(), credentials: 'include' });
if (!r.ok) throw new Error('HTTP ' + r.status);
const j = await r.json();
if (j.status === 0 && j.data && j.data.listdata) {
return j.data.listdata.map(c => ({
...c,
directoryName: dirName,
directoryId: dirId,
totalSeconds: timeToSeconds(c.timeLength),
}));
}
return [];
} catch (e) {
if (retry < CONFIG.maxRetries) {
await sleep(1000);
return getResourcesByDirectory(dirId, dirName, retry + 1);
}
log('取资源失败', dirName, e);
return [];
}
}
// ======================== 5. 状态检测(跳过已学完) ========================
async function checkResourceStatus(res) {
const id = state.coursePacketId;
if (!id) return false;
try {
const u = new URL(API.stat, location.origin);
u.searchParams.set('coursePacketId', id);
u.searchParams.set('resourceDirectoryId', res.SYS_UUID);
u.searchParams.set('token', getToken() || '');
const r = await fetch(u, { headers: getHeaders(), credentials: 'include' });
if (!r.ok) return false;
const j = await r.json();
if (j.status === 0 && j.data) {
const d = j.data;
return !!(d.progressRate >= 1.0 ||
(d.studyLength > 0 && d.studyLength >= d.resourceLength));
}
} catch (e) { }
return false;
}
// ======================== 6. 学习时长上报 ========================
// ★ 与 v3.2 脚本的唯一差异:
// studyLength / studyTime 填 **真实已播放秒数**,不是视频总长。
// 播放器真的在播,脚本如实同步进度。
//
// @param realSeconds 已真实播放的秒数(>=0)。传 null 表示"不报"
async function reportProgress(res, realSeconds) {
const id = state.coursePacketId;
if (!id) return { success: false, error: '缺少 coursePacketId' };
const total = res.totalSeconds || timeToSeconds(res.timeLength);
const studied = Math.max(0, Math.min(realSeconds == null ? total : realSeconds, total));
const params = new URLSearchParams({
courseId: id,
resourceId: res.resourceID,
resourceDirectoryId: res.SYS_UUID,
resourceLength: String(total),
studyLength: String(studied),
studyTime: String(studied),
startTime: String(Date.now() - studied * 1000),
resourceName: encodeURIComponent(res.name || ''),
resourceType: '1',
resourceLibId: '3',
studyResourceId: res.SYS_DOCUMENTID || '',
token: getToken() || '',
});
try {
const u = new URL(API.save, location.origin);
params.forEach((v, k) => u.searchParams.set(k, v));
const r = await fetch(u, { headers: getHeaders(), credentials: 'include' });
if (!r.ok) throw new Error('HTTP ' + r.status);
const j = await r.json();
if (j.status === 0) {
state.stats.seconds += studied;
return { success: true, studied };
}
return { success: false, error: j.message || 'status=' + j.status };
} catch (e) {
return { success: false, error: e.message };
}
}
// ======================== 7. 播放器:找到当前视频并真实播放 ========================
function allVideos() {
const out = [];
(function scan(root) {
if (!root) return;
try { root.querySelectorAll('video').forEach(v => out.push(v)); } catch (e) { }
try {
root.querySelectorAll('iframe').forEach(f => {
if (f.contentDocument) scan(f.contentDocument);
});
} catch (e) { }
})(document);
return out;
}
function currentVideo() {
const vs = allVideos();
return vs.find(v => !v.paused && !v.ended) || vs[0] || null;
}
function isPlaying() {
return allVideos().some(v => !v.paused && !v.ended && v.currentTime > 0);
}
/**
* 真实播放一个资源:
* 找到对应资源 → 打开它 → 播放 → 边播边分段上报 → 播完返回
* 返回 { ok, reason, seconds }
*/
async function playResourceRealTime(res) {
// ① 先尝试在当前页面找到该资源并打开
const opened = await openResource(res);
if (!opened) return { ok: false, reason: '无法定位到该资源的播放入口' };
// ② 等待视频出现并开始播放
const v = await waitForVideo();
if (!v) return { ok: false, reason: '未找到播放器' };
try { v.playbackRate = 2.0; } catch (e) { }
try { v.play(); } catch (e) { }
// ③ 边播边分段上报
let reported = 0;
let lastReport = 0;
const startedAt = Date.now();
while (state.running && !state.paused) {
await sleep(1000);
if (v.paused && v.currentTime === 0) {
// 播完了(或被暂停且归零)→ 退出
break;
}
const played = Math.floor(v.currentTime || 0);
if (played - lastReport >= CONFIG.segmentSize) {
const r = await reportProgress(res, played);
if (r.success) { reported = played; lastReport = played; }
}
}
const played = Math.floor(v.currentTime || 0);
// 收尾:把最后不足一段的也报上去
if (played > reported) await reportProgress(res, played);
return { ok: true, seconds: played, reason: '' };
}
/** 在页面上找到并打开指定资源 */
async function openResource(res) {
// 先在当前页找,找不到就按目录/名称找
const byId = $(`[data-resource-id="${res.resourceID}"], [data-id="${res.resourceID}"]`);
if (byId) { try { byId.click(); return true; } catch (e) { } }
const name = (res.name || '').trim();
if (name) {
const items = $$('a, li, div, span, .course-item, .resource-item, .video-item');
for (const el of items) {
if (el.children.length > 3) continue;
const t = (el.textContent || '').trim();
if (!t || t.length > name.length + 20) continue;
if (t.includes(name) || name.includes(t)) {
try { el.click(); return true; } catch (e) { }
}
}
}
return false;
}
/** 等页面上出现可播放的视频 */
async function waitForVideo(timeoutMs = 15000) {
const t0 = Date.now();
while (Date.now() - t0 < timeoutMs) {
const vs = allVideos();
if (vs.length) return vs[0];
await sleep(300);
}
return null;
}
// ======================== 8. 主流程:批量遍历 ========================
async function collectAllResources() {
setStatus('正在获取课程目录…');
const dirs = await getDirectoryList();
if (!dirs.length) { setStatus('获取目录失败'); return []; }
const allDirs = getAllDirectories(dirs);
setStatus(`共 ${allDirs.length} 个目录,正在获取资源列表…`);
const out = [];
for (let i = 0; i < allDirs.length; i++) {
// 只在"批量运行中"才响应停止;单独调用时照常跑完
if (state.running === false && state.stopRequested) break;
const d = allDirs[i];
const res = await getResourcesByDirectory(d.SYS_UUID, d.name);
out.push(...res);
updateProgress(out.length, allDirs.length, `读取目录 [${i + 1}/${allDirs.length}] ${d.name || ''}`);
if (i < allDirs.length - 1) {
await sleep(CONFIG.delayBetweenDirectories);
}
}
return out;
}
async function runBatch() {
if (!state.pending.length) {
const all = await collectAllResources();
if (!all.length) { setStatus('没有找到可学习的资源'); setRunning(false); return; }
state.pending = all;
state.currentIndex = 0;
saveCheckpoint();
}
const total = state.pending.length;
for (let i = state.currentIndex; i < total; i++) {
if (!state.running) break;
state.currentIndex = i;
const res = state.pending[i];
updateProgress(i, total, `[${i + 1}/${total}] 检测: ${(res.name || '').slice(0, 12)}`);
// ① 跳过已学完的
const done = await checkResourceStatus(res);
if (done) {
state.stats.skipped++;
log('[跳过] 已完成:', res.name);
updateStats();
if (!await interruptibleWait(400)) break;
continue;
}
// ② 学习
updateProgress(i, total, `[${i + 1}/${total}] 学习: ${(res.name || '').slice(0, 12)}`);
if (CONFIG.reportRealProgress) {
// 真实播放模式
const r = await playResourceRealTime(res);
if (r.ok) state.stats.success++;
else { state.stats.failed++; state.lastError = r.reason; }
} else {
// 一次性填满模式(与 v3.2 / exe 一致)
const r = await reportProgress(res, res.totalSeconds);
if (r.success) state.stats.success++;
else { state.stats.failed++; state.lastError = r.error; }
}
updateStats();
saveCheckpoint();
if (i < total - 1 && state.running) {
if (!await interruptibleWait(CONFIG.delayBetweenResources)) break;
}
}
if (!state.running) {
setStatus(state.paused ? `已暂停(${state.currentIndex + 1}/${total})` : '已停止');
} else {
setStatus(`完成:成功 ${state.stats.success} · 跳过 ${state.stats.skipped} · 失败 ${state.stats.failed}`);
state.currentIndex = 0;
state.pending = [];
clearCheckpoint();
}
setRunning(false);
}
// ======================== 9. 断点续学 ========================
function saveCheckpoint() {
store.checkpoint = {
at: nowStr(),
coursePacketId: state.coursePacketId,
courseName: state.courseName,
index: state.currentIndex,
pending: state.pending.map(r => ({
resourceID: r.resourceID, SYS_UUID: r.SYS_UUID, name: r.name,
timeLength: r.timeLength, SYS_DOCUMENTID: r.SYS_DOCUMENTID,
directoryId: r.directoryId, directoryName: r.directoryName,
totalSeconds: r.totalSeconds,
})),
stats: { ...state.stats },
};
saveStore(store);
}
function loadCheckpoint() {
const cp = store.checkpoint;
if (!cp || !cp.pending || !cp.pending.length) return null;
if (cp.coursePacketId && state.coursePacketId && cp.coursePacketId !== state.coursePacketId) return null;
return cp;
}
function clearCheckpoint() { delete store.checkpoint; saveStore(store); }
// ======================== 10. UI ========================
let ui = null;
function setStatus(msg) {
state.status = msg;
const el = $('#baomi-study-status');
if (el) el.textContent = msg;
}
function updateProgress(cur, total, msg) {
const bar = $('#baomi-study-bar');
const txt = $('#baomi-study-text');
const pct = total > 0 ? Math.round(cur / total * 100) : 0;
if (bar) bar.style.width = pct + '%';
if (txt) txt.textContent = `${cur}/${total} (${pct}%)`;
if (msg) setStatus(msg);
}
function updateStats() {
const el = $('#baomi-study-stats');
if (!el) return;
el.innerHTML =
`成功 ${state.stats.success} · ` +
`跳过 ${state.stats.skipped} · ` +
`失败 ${state.stats.failed} · ` +
`已学 ${Math.round(state.stats.seconds / 60)} 分钟`;
}
function setRunning(running) {
state.running = running;
const start = $('#baomi-study-start');
const pause = $('#baomi-study-pause');
const stop = $('#baomi-study-stop');
if (start) start.style.display = running ? 'none' : '';
if (pause) pause.style.display = running ? '' : 'none';
if (stop) stop.style.display = running ? '' : 'none';
}
function createUI() {
if (document.getElementById('baomi-study-ui')) return;
const style = document.createElement('style');
style.id = 'baomi-study-style';
style.textContent = `
#baomi-study-ui{background:transparent;border:none;padding:0;width:auto;
font:13px/1.6 -apple-system,"Microsoft YaHei",sans-serif;color:#1f2937}
#baomi-study-ui .bs-h{font-weight:700;font-size:14px;margin-bottom:2px}
#baomi-study-ui .bs-sub{font-size:11px;color:#6b7280;margin-bottom:10px}
#baomi-study-ui .bs-row{display:flex;gap:6px;margin-bottom:10px}
#baomi-study-ui button{flex:1;padding:7px;border-radius:8px;border:1px solid #d1d5db;background:#fff;
cursor:pointer;font:inherit;font-size:13px}
#baomi-study-ui .bs-primary{background:#2563eb;border-color:#2563eb;color:#fff}
#baomi-study-ui .bs-status{font-size:12px;color:#6b7280;margin-bottom:6px;min-height:18px}
#baomi-study-ui .bs-track{height:6px;background:#f0f0f0;border-radius:3px;overflow:hidden;margin-bottom:5px}
#baomi-study-ui .bs-fill{height:100%;background:#10b981;width:0%;transition:width .3s}
#baomi-study-ui .bs-text{font-size:12px;color:#6b7280;margin-bottom:6px}
#baomi-study-ui .bs-stats{font-size:12px;margin-bottom:6px}
#baomi-study-ui .bs-note{font-size:11px;color:#9ca3af;border-top:1px solid #f3f4f6;padding-top:6px;margin-top:4px}
`;
document.head.appendChild(style);
const box = document.createElement('div');
box.id = 'baomi-study-ui';
box.innerHTML = `
📖 保密教育学习 v4.0
正在识别课程…
待机
0/0 (0%)
时长按真实播放累计上报 · 可暂停继续 · 支持断点续学
`;
(UI_CONTAINER || document.body).appendChild(box);
ui = box;
$('#baomi-study-start').onclick = async () => {
state.paused = false;
state.stopRequested = false;
state.running = true;
setRunning(true);
if (!state.pending.length) {
state.stats = { success: 0, failed: 0, skipped: 0, seconds: 0 };
state.currentIndex = 0;
}
await runBatch();
};
$('#baomi-study-pause').onclick = () => {
state.running = false;
state.paused = true;
setStatus('正在暂停,请稍候…');
};
$('#baomi-study-stop').onclick = () => {
state.running = false;
state.paused = false;
state.stopRequested = true;
state.currentIndex = 0;
state.pending = [];
clearCheckpoint();
setStatus('已停止');
};
}
// ======================== 11. 调试 API ========================
// UI 挂载容器(由外层统一外壳设置)
let UI_CONTAINER = null;
function setContainer(el) { UI_CONTAINER = el; }
window.__baomiStudy = {
init, setContainer,
get uiCreated() { return !!ui; },
state, config: CONFIG,
getToken, getHeaders, detectCoursePacketId,
getDirectoryList, getAllDirectories, getResourcesByDirectory,
checkResourceStatus, reportProgress, collectAllResources,
runBatch, playResourceRealTime, openResource, waitForVideo,
saveCheckpoint, loadCheckpoint, clearCheckpoint,
allVideos, currentVideo, isPlaying,
get panelText() { return ui ? ui.textContent : ''; },
store,
};
// ======================== 12. 初始化 ========================
async function init() {
state.coursePacketId = detectCoursePacketId();
if (!state.coursePacketId) return; // 不在课程页就不显示 UI
createUI();
updateStats();
// 课程名
const detail = await getCourseDetail();
if (detail && detail.name) {
state.courseName = detail.name;
const el = $('#baomi-study-course');
if (el) el.textContent = detail.name;
}
// 断点续学
const cp = loadCheckpoint();
if (cp) {
state.pending = cp.pending;
state.currentIndex = cp.index || 0;
state.stats = { ...state.stats, ...(cp.stats || {}) };
setStatus(`检测到未完成的进度(${cp.at}),点击「开始学习」继续`);
updateStats();
} else {
setStatus('点击「开始学习」开始');
}
}
// URL 变化时重新初始化(SPA)
let lastUrl = location.href;
setInterval(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
const id = detectCoursePacketId();
if (id && id !== state.coursePacketId) {
state.coursePacketId = id;
state.pending = [];
state.currentIndex = 0;
init();
}
}
}, 1000);
// 启动交给外层统一调度
return { init: window.__baomiStudy.init, setContainer: window.__baomiStudy.setContainer,
api: window.__baomiStudy };
})();
// ======================== 考试模块 ========================
const ExamModule = (function () {
'use strict';
// ======================== 配置 ========================
const CONFIG = {
// 交卷方式:'auto' = 自动提交(对齐 exe);'manual' = 只勾选,你自己点交卷
submitMode: 'auto',
// 是否自动勾选页面上的选项
autoSelect: true,
// 课程包 ID:留空自动识别
coursePacketId: null,
// 兜底 examId(exe 里的 DEFAULT_EXAM_ID)
defaultExamId: '8ad5bd4d9d483dde019e3e1066f60035',
// 提交后等多久查成绩(秒)—— exe 用 time.sleep(2)
waitBeforeResult: 2,
// 写回考试状态(updateCoursePackageExamInfo.do)
// ★《脚本.txt》没有这一步,默认关闭。要跟 exe 完全一致可开成 true。
writeExamStatus: false,
// ★ 平台固定鉴权头(来自《脚本.txt》的 getHeaders)
// siteId 与 authToken 是平台级的固定值,不是用户 token。
// ★ 之前我漏了这两个头 → getCourseRelateExam 被拒 →
// 走了兜底默认 examId → 取到别的试卷 → 交卷 45 分。
siteId: '95',
authToken: '3613146b9c1a486b8666e86a78298a1a',
storageKey: 'baomi_exam_v3',
};
const API = {
examInfo: '/portal/main-api/v2/coursePacket/getCourseRelateExam',
examContent: '/portal/main-api/v2/activity/exam/getExamContentData.do',
submit: '/portal/main-api/v2/activity/exam/saveExamResultJc.do',
result: '/portal/main-api/v2/activity/exam/getExamResultMaxScore.do',
writeStatus: '/portal/main-api/v2/studyTime/updateCoursePackageExamInfo.do',
};
// ======================== 状态机 ========================
const PHASE = {
IDLE: 'idle',
PREPARING: 'preparing',
READY: 'ready',
SUBMITTING: 'submitting',
DONE: 'done',
FAILED: 'failed',
};
const state = {
phase: PHASE.IDLE,
coursePacketId: '',
examId: '',
randomId: '',
paper: null,
answers: [],
filled: 0,
total: 0,
score: null,
resultData: null,
lastAction: '',
diag: '',
startedAt: 0,
// 未取到答案的题(不丢题,但需要在诊断里报出来)
missingAnswers: [],
// 可疑题清单(分数不满时定位用)
suspect: [],
};
// ======================== 工具 ========================
const $ = (s, r) => (r || document).querySelector(s);
const $$ = (s, r) => Array.from((r || document).querySelectorAll(s));
function log(...a) { console.log('[保密教育考试助手]', ...a); }
function nowStr() {
const d = new Date(), p = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function escapeHtml(s) {
return String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
}
function loadStore() { try { return GM_getValue(CONFIG.storageKey, {}) || {}; } catch (e) { return {}; } }
function saveStore(d) { try { GM_setValue(CONFIG.storageKey, d); } catch (e) {} }
const store = loadStore();
// ======================== token / headers ========================
/**
* 取用户 token
*
* ★ 只认 'token',不认 'authToken' cookie ——
* 《脚本.txt》也是只匹配 token=。
* 之前我把 authToken cookie 也当 token 用,
* 万一 cookie 顺序变了就会取错值 → 鉴权失败。
*/
function getToken() {
try {
const m = document.cookie.match(/(?:^|;\s*)token=([^;]+)/);
if (m) return m[1];
} catch (e) { }
try {
const v = localStorage.getItem('token');
if (v) return v;
} catch (e) { }
try {
const v = sessionStorage.getItem('token');
if (v) return v;
} catch (e) { }
return null;
}
/**
* 请求头 —— 与《脚本.txt》的 getHeaders 完全一致
*
* ★ siteId 与 authToken 是**平台级固定值**,
* 用户身份只体现在 'token' 头里。
*/
function getHeaders() {
return {
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json',
'token': getToken() || '',
'siteId': CONFIG.siteId,
'authToken': CONFIG.authToken,
};
}
/** POST 请求头 —— 与《脚本.txt》的 getPostHeaders 一致 */
function getPostHeaders() {
return {
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json',
'token': getToken() || '',
'siteId': CONFIG.siteId,
'authToken': CONFIG.authToken,
};
}
// ======================== 1. 课程包 ID ========================
function detectCoursePacketId() {
if (CONFIG.coursePacketId) return CONFIG.coursePacketId;
const params = new URLSearchParams(location.search);
for (const k of ['id', 'coursePacketId', 'courseId', 'packetId']) {
const v = params.get(k);
// 课程包 ID 可能是 UUID,也可能是别的形态;只要不是空/布尔值就算
if (v && v.length >= 3 && v !== 'true' && v !== 'false') return v;
}
const hm = (location.hash || '').match(/[?&]id=([^&]+)/);
if (hm) return decodeURIComponent(hm[1]);
const link = $('a[href*="coursePacketId="]');
if (link) {
const m = link.getAttribute('href').match(/coursePacketId=([^&]+)/);
if (m) return m[1];
}
return null;
}
// ======================== 2. randomId(与 exe 一致) ========================
// exe: md5("founder" + random.randint(1, 500)).hexdigest()
//
// 用浏览器原生 crypto.subtle 计算 MD5 —— 不自己实现,避免位运算写错。
// crypto.subtle 只在安全上下文(https)可用,本脚本匹配的正是 https 站点。
async function md5(str) {
const enc = new TextEncoder();
const buf = await crypto.subtle.digest('MD5', enc.encode(str));
const bytes = new Uint8Array(buf);
let out = '';
for (let i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, '0');
return out;
}
async function generateCustomRandomId() {
const n = 1 + Math.floor(Math.random() * 500);
const base = 'founder' + n;
try {
return await md5(base);
} catch (e) {
// 兜底:极端情况下 SubtleCrypto 不可用,用一个确定性的 32 位十六进制串
log('SubtleCrypto 不可用,使用兜底 randomId:', e.message);
let h = 0x811c9dc5;
for (let i = 0; i < base.length; i++) {
h ^= base.charCodeAt(i);
h = (h * 0x01000193) >>> 0;
}
return ('00000000' + h.toString(16)).slice(-8)
+ ('00000000' + ((h * 2654435761) >>> 0).toString(16)).slice(-8)
+ ('00000000' + ((h ^ 0x5bf03635) >>> 0).toString(16)).slice(-8)
+ ('00000000' + ((h + 0x6a09e667) >>> 0).toString(16)).slice(-8);
}
}
// ======================== 3. 动态解析 examId ========================
// exe: _resolve_exam_id → GET getCourseRelateExam → data[].examId
/**
* 从 URL 里取 examId
*
* /bmExam?id=312bc914-8e11-421b-bb9c-e900e1a4e500&index=3&toIndex=1
* /bmExamResult?id=312bc914-…&docId=1
*
* 这个 id 是当前考试实例的标识。在课程包页面(/portal/courseDetail?id=…)
* 它才是 coursePacketId —— 两种页面的 id 语义不同,所以只在这里用。
*/
function detectExamIdFromUrl() {
if (CONFIG.examId) return CONFIG.examId;
const params = new URLSearchParams(location.search);
for (const k of ['examId', 'examID', 'exam_id']) {
const v = params.get(k);
if (v) return v;
}
// bmExam / bmExamResult 页面的 id 参数
if (/\/bmExam(Result)?(\.html)?$/i.test(location.pathname)
|| /bmExam/i.test(location.pathname)) {
const v = params.get('id');
if (v) return v;
}
return '';
}
/**
* 可靠地拿到「当前考试正在用的那份试卷」
*
* 候选来源,按可靠度排序:
* ① 页面已经请求过的(拦截到)—— 最可靠,就是页面正在渲染的那份
* ② 点页面上的「开始考试」触发它请求 —— 同样可靠
* ③ 自己按候选 examId 取,并用**题量与页面比对**校验
* ④ 兜底默认值(几乎肯定错,只为不崩)
*
* ★ 题量校验是关键:
* 取错试卷 → 答案跟页面上的题不是一回事 → 交卷成功但大量判错(45 分事故)。
* 正确的试卷,题数必须等于页面上渲染的题数。
*/
async function acquirePaper() {
// ★ 递归保护:万一触发页面取卷时页面又调回本脚本,防止无限递归
acquirePaper._depth = (acquirePaper._depth || 0) + 1;
if (acquirePaper._depth > 3) {
acquirePaper._depth--;
log('★ acquirePaper 递归过深,放弃触发页面取卷');
return null;
}
try {
return await acquirePaperInner();
} finally {
acquirePaper._depth--;
}
}
async function acquirePaperInner() {
const attempts = [];
// ① 已截获
if (captured.paper && captured.paper.data && captured.paper.data.typeList) {
return { paper: captured.paper, examId: captured.examId, source: '页面已加载的试卷' };
}
// ② 触发页面自己取卷
if (clickPageStartButton()) {
const got = await waitFor(() => captured.paper &&
captured.paper.data && captured.paper.data.typeList, 10000);
if (got) {
return { paper: captured.paper, examId: captured.examId, source: '页面请求(脚本触发)' };
}
attempts.push('触发页面取卷:超时未拿到');
} else {
attempts.push('页面上没找到「开始考试」按钮');
}
// ③ 自己取,逐个候选校验题量
const candidates = [];
if (captured.examId) candidates.push({ id: captured.examId, from: '拦截到的请求' });
const urlId = detectExamIdFromUrl();
if (urlId) candidates.push({ id: urlId, from: 'URL 参数' });
const related = await resolveExamId();
if (related && related !== CONFIG.defaultExamId) {
candidates.push({ id: related, from: 'getCourseRelateExam' });
}
candidates.push({ id: CONFIG.defaultExamId, from: '兜底默认值(多半不对)' });
const pageN = pageQuestionCount();
for (const c of candidates) {
let p = null;
try {
p = await fetchExamPaper(c.id, await generateCustomRandomId());
} catch (e) { }
if (!p || !p.data || !p.data.typeList) {
attempts.push(`${c.from} ${c.id}:没取到试卷`);
continue;
}
const n = countPaperQuestions(p);
// ★ 只有「页面上确实有题目」时才做题量校验。
// 页面还没渲染(用户还没点开始考试)时 pageN=0,
// 这时候拿任何卷都不该被判死,否则会全部落空。
if (pageN > 0 && n !== pageN) {
attempts.push(`${c.from} ${c.id}:题量 ${n} ≠ 页面 ${pageN},丢弃`);
continue;
}
return { paper: p, examId: c.id, source: c.from };
}
attempts.push('所有候选都不可用');
state.diag = '取卷失败:' + attempts.join(';');
return null;
}
/** 试卷里的题目总数 */
function countPaperQuestions(paper) {
const root = (paper && paper.data) || paper || {};
let n = 0;
for (const t of (root.typeList || [])) {
n += (t.questionList || []).length;
}
return n;
}
/** 页面上渲染出来的题目组数(用现有的定位逻辑数) */
function pageQuestionCount() {
try {
return collectPageQuestions().length;
} catch (e) {
return 0;
}
}
/**
* 点页面上的「开始考试」按钮
* 返回 true 表示找到并点了
*
* ★ 必须排除脚本自己的面板!
* 面板的主按钮文案就是「开始考试」,如果把它也匹配上,
* 点它 → 触发 runFullExam → 又去点「开始考试」→ 无限递归 → 栈溢出。
*/
function clickPageStartButton() {
const words = ['开始考试', '开始答题', '参加考试', '进入考试', '开始测试'];
const cands = $$('button, a, .btn, [class*="btn"], [class*="button"]');
for (const el of cands) {
// 跳过脚本自己的面板
if (panel && (el === panel || panel.contains(el))) continue;
if (el.closest && el.closest('#baomi-exam-panel')) continue;
const t = (el.textContent || '').trim();
if (!t || t.length > 12) continue;
if (words.some(w => t.indexOf(w) >= 0)) {
try { el.click(); return true; } catch (e) { }
}
}
return false;
}
/** 轮询等待条件成立 */
async function waitFor(fn, timeoutMs) {
const t0 = Date.now();
while (Date.now() - t0 < timeoutMs) {
try { if (fn()) return true; } catch (e) { }
await sleep(200);
}
return false;
}
/**
* 解析 examId —— 顺序与《脚本.txt》一致
*
* ① 页面自己的请求里带 examId=…(最准,就是页面正在用的那个)
* ② getCourseRelateExam 的响应里递归找
* ③ 兜底默认值
*
* ★《脚本.txt》用的是 URL 的 id 参数当 coursePacketId 去查关联考试,
* 而 examId 优先从拦截到的页面请求里解析 —— 这个顺序很重要,
* 因为关联考试接口在真机上可能失败,但页面请求一定成功。
*/
async function resolveExamId() {
// ① 页面请求里解析(脚本.txt 的做法:遍历 capturedReqs 取 examId= 参数)
for (const req of captured.requestUrls) {
const m = String(req).match(/[?&]examId=([^&]+)/);
if (m) {
const eid = decodeURIComponent(m[1]);
state.examId = eid;
log('★ 从页面请求解析 examId:', eid);
return eid;
}
}
// ② getCourseRelateExam
const pid = state.coursePacketId;
if (pid) {
try {
const u = new URL(API.examInfo, location.origin);
u.searchParams.set('coursePacketId', pid);
u.searchParams.set('token', getToken() || '');
const r = await fetch(u, { headers: getHeaders(), credentials: 'include' });
if (!r.ok) throw new Error('HTTP ' + r.status);
const relData = await readJson(r);
const found = findExamId(relData && relData.data ? relData.data : relData);
if (found) {
state.examId = found;
log('★ 从 getCourseRelateExam 解析 examId:', found);
return found;
}
log('getCourseRelateExam 响应里没找到 examId');
} catch (e) {
log('查询关联考试失败:', e.message);
}
}
state.examId = CONFIG.defaultExamId;
log('★ 使用兜底默认 examId(多半不对):', state.examId);
return state.examId;
}
/** 在任意 JSON 结构里递归找 examId */
function findExamId(node, depth = 0) {
if (!node || depth > 6) return null;
if (Array.isArray(node)) {
for (const it of node) {
const f = findExamId(it, depth + 1);
if (f) return f;
}
return null;
}
if (typeof node === 'object') {
// 优先 examId,其次 id(有些接口只给 id)
for (const k of ['examId', 'examID', 'EXAM_ID']) {
if (node[k] !== undefined && node[k] !== null && node[k] !== '') {
return String(node[k]);
}
}
for (const k of Object.keys(node)) {
const f = findExamId(node[k], depth + 1);
if (f) return f;
}
}
return null;
}
// ======================== 3. 拦截平台请求(拿真试卷) ========================
//
// ★★ 45 分事故的第二个根因(2026-10-22 二次复盘)★★
//
// 真机上 `getCourseRelateExam` 调用失败 → 走了兜底默认 examId
// (8ad5bd4d9d483dde019e3e1066f60035)→ **取到的是另一份试卷**,
// 答案跟页面上正在考的题根本不是一回事 → 交卷成功但大量判错。
//
// exe 是 CLI,用户手动把正确的 coursePacketId 传给它,所以它总能取对。
// 在浏览器里,**最可靠的来源是页面自己发出的请求**:
// 用户点「开始考试」时,平台会发 getExamContentData.do?examId=…&randomId=…
// 那个 examId 一定是当前考试的真实 ID,响应里就是当前试卷(含 answer)。
//
// 所以这里 hook fetch 与 XMLHttpRequest,把页面的响应原样截下来。
// 好处:
// · examId 一定对(就是页面自己用的)
// · 试卷一定对(就是页面正在渲染的那份)
// · randomId 一定是服务端认的那个(不用自己造)
const captured = {
examId: '', // 页面请求里的 examId
randomId: '', // 服务端回传的真 randomId
paper: null, // 页面的试卷响应
seen: false, // 是否已捕获
// ★ 所有「考试相关」的请求 URL(对应《脚本.txt》的 capturedReqs)
// 从这些 URL 里能解析出页面真正用的 examId —— 比关联考试接口可靠得多
requestUrls: [],
// ★ 通用响应记录:把平台所有 JSON 响应都留下来
// 真机排障时,最有价值的是「结果页到底调了哪个接口拿每题对错」——
// 有了这个列表,一次点「诊断定位」就能全看到,不用猜接口名。
responses: [], // [{url, method, data}]
};
const MAX_RESPONSES = 60;
/** 记录一条考试相关的请求 URL(对应《脚本.txt》的 capturedReqs) */
function recordRequestUrl(url) {
try {
const t = String(url);
if (!/exam|Exam|saveExam|getExam/i.test(t)) return;
captured.requestUrls.push(t);
while (captured.requestUrls.length > 200) captured.requestUrls.shift();
} catch (e) { }
}
/** 记录一次平台响应(只留 JSON,体积可控) */
function recordResponse(url, method, data) {
try {
if (!data || typeof data !== 'object') return;
const s = JSON.stringify(data);
if (s.length > 200000) return; // 太大的不存
captured.responses.push({
url: String(url).slice(0, 200),
method: method || 'GET',
status: data.status,
keys: data.data ? Object.keys(data.data).slice(0, 12) : null,
data: data,
});
while (captured.responses.length > MAX_RESPONSES) captured.responses.shift();
} catch (e) { }
}
function installFetchTap() {
// ---- fetch ----
// 每次都检查:页面/框架可能会重新赋值 window.fetch(换 SPA 路由、换 polyfill 等),
// 那时包装就失效了,得重新包一层。
const origFetch = window.fetch;
if (typeof origFetch === 'function' && !origFetch.__baomiTapped) {
const wrapped = function (input, init) {
// ★ 脚本自己发的请求不要截获 —— 否则会用自己的候选卷覆盖掉页面的真卷
if (window.__baomiInternalFetch) {
return origFetch.call(this, input, init);
}
const p = origFetch.call(this, input, init);
try {
const url = new URL(
(input && input.url) ? input.url : String(input),
location.origin);
recordRequestUrl(url.pathname + url.search);
// 通用记录
if (url.pathname.indexOf('/portal/') >= 0) {
p.then(res => {
try {
res.clone().json().then(j => {
recordResponse(url.pathname + url.search,
(init && init.method) || 'GET', j);
}).catch(() => { });
} catch (e) { }
}).catch(() => { });
}
if (url.pathname.indexOf('getExamContentData') >= 0) {
// 请求参数里的 examId —— 页面自己用的,一定对
const eid = url.searchParams.get('examId');
if (eid) captured.examId = eid;
p.then(res => {
try {
const clone = res.clone();
clone.json().then(j => {
captured.paper = j;
captured.seen = true;
if (j && j.data && j.data.randomId) {
captured.randomId = j.data.randomId;
}
log('★ 已截获页面试卷请求,examId =', eid);
onPaperCaptured(j);
}).catch(() => { });
} catch (e) { }
}).catch(() => { });
}
} catch (e) { }
return p;
};
wrapped.__baomiTapped = true;
try { window.fetch = wrapped; } catch (e) { }
}
// ---- XMLHttpRequest ----
const XHR = window.XMLHttpRequest;
if (XHR && !XHR.prototype.open.__baomiTapped) {
const origOpen = XHR.prototype.open;
const origSend = XHR.prototype.send;
const wrappedOpen = function (method, url) {
this.__baomiUrl = String(url);
this.__baomiMethod = String(method || 'GET');
return origOpen.apply(this, arguments);
};
// 标记打在**包装后的** open 上(对外可见的那个)
wrappedOpen.__baomiTapped = true;
XHR.prototype.open = wrappedOpen;
XHR.prototype.send = function () {
const self = this;
try {
recordRequestUrl(self.__baomiUrl);
// 通用记录
if (self.__baomiUrl && self.__baomiUrl.indexOf('/portal/') >= 0) {
self.addEventListener('load', function () {
try {
recordResponse(self.__baomiUrl, self.__baomiMethod || 'GET',
JSON.parse(self.responseText));
} catch (e) { }
});
}
if (self.__baomiUrl && self.__baomiUrl.indexOf('getExamContentData') >= 0) {
self.addEventListener('load', function () {
try {
const j = JSON.parse(self.responseText);
captured.paper = j;
captured.seen = true;
try {
const u = new URL(self.__baomiUrl, location.origin);
const eid = u.searchParams.get('examId');
if (eid) captured.examId = eid;
} catch (e) { }
if (j && j.data && j.data.randomId) {
captured.randomId = j.data.randomId;
}
log('★ 已截获页面试卷请求(XHR)');
onPaperCaptured(j);
} catch (e) { }
});
}
} catch (e) { }
return origSend.apply(this, arguments);
};
}
}
/** 页面试卷到手后的处理:直接用这份试卷(最可靠) */
function onPaperCaptured(j) {
if (!j || !j.data || !j.data.typeList) return;
// 已经用过了就不重复处理
if (state.paper && state.answers.length) return;
const built = buildAnswers(j);
if (!built.answers.length) return;
state.paper = j;
state.answers = built.answers;
state.total = built.answers.length;
if (built.randomId) state.randomId = built.randomId;
if (captured.examId) state.examId = captured.examId;
state.phase = PHASE.READY;
state.lastAction = `已从页面请求拿到试卷 ${state.total} 题`
+ (state.missingAnswers.length ? `(${state.missingAnswers.length} 题无答案)` : '');
if (CONFIG.autoSelect) {
selectAnswers(state.answers);
state.filled = verifyFilled();
if (state.filled < state.total) scheduleSelect();
}
render();
buildAnswerSheet(state.answers);
}
/**
* 读 JSON 响应 —— 兼容只有 json() 或只有 text() 的实现
*
* ★《脚本.txt》用的是 res.text() 然后 JSON.parse —— 这样非 JSON 响应
* (比如被网关拦成 HTML)也能给出可读的错误信息。
* 但有些环境只提供 json(),所以两边都试。
*/
async function readJson(res) {
let text = null;
try { if (typeof res.text === 'function') text = await res.text(); }
catch (e) { }
if (text !== null && text !== undefined && text !== '') {
try { return JSON.parse(text); }
catch (e) { throw new Error('响应非 JSON: ' + String(text).substring(0, 120)); }
}
return await res.json();
}
// ======================== 4. 取试卷 ========================
// exe: get_exam_answers → GET getExamContentData.do
/**
* 取试卷 —— 与《脚本.txt》的 url1 完全一致
*
* ★ URL 里要带 token 查询参数:
* getExamContentData.do?examId=…&token=…&randomId=…
* 之前我只带 examId & randomId,缺了 token。
*/
async function fetchExamPaper(examId, randomId) {
try {
const u = new URL(API.examContent, location.origin);
u.searchParams.set('examId', examId);
u.searchParams.set('token', getToken() || '');
u.searchParams.set('randomId', randomId);
// 标记为脚本内部请求,避免被自己的拦截器截获
window.__baomiInternalFetch = true;
let r;
try {
r = await fetch(u, { headers: getHeaders(), credentials: 'include' });
} finally {
window.__baomiInternalFetch = false;
}
if (!r.ok) throw new Error('HTTP ' + r.status);
return await readJson(r);
} catch (e) {
log('取试卷失败:', e.message);
return null;
}
}
// ======================== 5. 组装答案数组(协议格式) ========================
// exe 的 complete_exam:
// 遍历 typeList[].questionList[]
// 每题构造 9 字段对象(键顺序与类型见《考试模块协议-逆向结论.md》)
//
// ★★★ 关键:答案必须**原样传递**(2026-10-22 二次复盘)★★★
//
// exe 的逻辑只有一行:correct_answer = question['answer']
// 然后 standardAnswer = userAnswer = correct_answer,**没有任何加工**。
//
// 我之前加过一个"答案归一化"(数组→'AC'、多选按字母排序、布尔→中文),
// 结果把答案改掉了:
// · 服务端存的多选答案若是 'CA'(按选项顺序),我排序成 'AC' → 判错
// · 答案若是数组 ['A','C'],exe 会原样交数组,我转成字符串 → 判错
// **服务端是逐字比对的,改一个字就是错。**
//
// 所以:只做「取哪个字段」的选择,绝不改值。
function buildAnswers(paper) {
const root = (paper && paper.data) || paper || {};
const typeList = root.typeList || [];
const answers = [];
const missing = []; // 取不到答案的题(用于诊断,不丢题)
for (const typeItem of typeList) {
const list = typeItem.questionList || [];
for (const q of list) {
if (!q) continue;
// ★ 取值:优先 question['answer'](exe 的做法),取不到再退到其他字段
const raw = pickAnswer(q);
const hasAnswer = raw !== undefined && raw !== null && raw !== '';
if (!hasAnswer) {
missing.push({ tqId: q.tqId, keys: Object.keys(q).join(',') });
}
answers.push({
// exe: question.get('parentId', '0') → 缺省 '0'(字符串)
parentId: q.parentId !== undefined && q.parentId !== null
? q.parentId : '0',
// ★ exe: question['id'] —— 题目自己的 id,不是常量 '0'
qstId: q.id !== undefined && q.id !== null ? q.id : '0',
// ★ exe: 常量 0(数字),不是字符串 '0'
resultFlag: 0,
// ★★ 原样传递,不做任何转换 ★★
standardAnswer: raw === undefined ? '' : raw,
subCount: 0,
tqId: q.tqId,
userAnswer: raw === undefined ? '' : raw,
userScoreRate: '100%',
viewTypeId: typeItem.type !== undefined && typeItem.type !== null
? typeItem.type : 1,
});
}
}
state.missingAnswers = missing;
if (missing.length) {
log('★ 有', missing.length, '题未取到答案(已按空答案提交,与 exe 行为一致):',
JSON.stringify(missing));
}
return { answers, randomId: root.randomId || '' };
}
// 答案候选字段(按优先级)
const ANSWER_FIELDS = [
'answer', 'standardAnswer', 'rightAnswer', 'correctAnswer',
'answerContent', 'rightAnswerContent', 'defaultAnswer', 'userAnswer',
];
/**
* 取答案:**只选字段,不改值**
*
* exe 只用 question['answer'](直接下标,取不到会 KeyError)。
* 这里保留退路,但取到的值原样返回,不做任何归一化。
*/
function pickAnswer(q) {
for (const f of ANSWER_FIELDS) {
if (Object.prototype.hasOwnProperty.call(q, f)) {
const v = q[f];
if (v !== undefined && v !== null && v !== '') return v;
}
}
// 兜底:某些题型把答案放在选项数组的 isCorrect 标记上
for (const listKey of ['optionList', 'options', 'answerList', 'itemList']) {
const arr = q[listKey];
if (!Array.isArray(arr)) continue;
for (const o of arr) {
if (o && typeof o === 'object') {
if (o.isCorrect === true || o.correct === true || o.right === true) {
return o.optionId || o.id || o.key || o.value || o.label || '';
}
}
}
}
return '';
}
// ======================== 6. 提交试卷 ========================
// exe: submit_exam_answers → POST saveExamResultJc.do
// body: { examId, examResult: json.dumps(answers), randomId, startDate }
async function submitExamAnswers(examId, answers, startDate, randomId) {
const body = {
examId: examId,
examResult: JSON.stringify(answers),
randomId: randomId,
startDate: startDate,
};
try {
const r = await fetch(new URL(API.submit, location.origin), {
method: 'POST',
headers: getHeaders(),
credentials: 'include',
body: JSON.stringify(body),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
return await r.json();
} catch (e) {
log('提交失败:', e.message);
return null;
}
}
// ======================== 7. 查询成绩 ========================
// exe: get_exam_result → GET getExamResultMaxScore.do
async function fetchExamResult(examId) {
try {
const u = new URL(API.result, location.origin);
u.searchParams.set('examId', examId);
u.searchParams.set('token', getToken() || '');
const r = await fetch(u, { headers: getHeaders(), credentials: 'include' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return await r.json();
} catch (e) {
log('查成绩失败:', e.message);
return null;
}
}
// ======================== 8. 写回考试状态 ========================
// exe: finish_exam → GET updateCoursePackageExamInfo.do
// params: courseId, orgId, isExam, isCertificate, examResult, token
async function writeExamStatus(courseId, score) {
if (!CONFIG.writeExamStatus || !courseId) return null;
try {
const u = new URL(API.writeStatus, location.origin);
u.searchParams.set('courseId', courseId);
u.searchParams.set('orgId', '');
u.searchParams.set('isExam', '1');
u.searchParams.set('isCertificate', '0');
u.searchParams.set('examResult', String(score));
u.searchParams.set('token', getToken() || '');
const r = await fetch(u, { headers: getHeaders(), credentials: 'include' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return await r.json();
} catch (e) {
log('写回考试状态失败:', e.message);
return null;
}
}
// ======================== 9. 完整考试流程 ========================
async function runFullExam() {
state.phase = PHASE.PREPARING;
state.startedAt = Date.now();
state.lastAction = '正在解析考试ID…';
state.diag = '';
render();
// ① 拿试卷(含 examId)
// ★ 用 acquirePaper —— 它会优先用页面自己那份,
// 并用「题量必须等于页面题数」来校验,避免取错卷。
state.lastAction = '正在获取试卷…';
render();
const acq = await acquirePaper();
if (!acq || !acq.paper) {
state.phase = PHASE.FAILED;
state.lastAction = '获取试卷失败';
render();
return false;
}
const examId = acq.examId || '';
state.examId = examId;
log('★ 试卷来源:', acq.source, ' examId =', examId,
' 题量 =', countPaperQuestions(acq.paper), '/ 页面', pageQuestionCount());
// ② randomId(用响应里的,或页面截获的)
const randomId = (acq.paper.data && acq.paper.data.randomId)
|| captured.randomId || await generateCustomRandomId();
// ③ 组装答案(关键:randomId 用响应里的)
const built = buildAnswers(acq.paper);
if (!built.answers.length) {
state.phase = PHASE.FAILED;
state.lastAction = '试卷里没有可用答案';
render();
return false;
}
state.paper = acq.paper;
state.answers = built.answers;
state.total = built.answers.length;
state.randomId = built.randomId || randomId;
state.phase = PHASE.READY;
state.lastAction = `已获取试卷 ${state.total} 题(${acq.source})`
+ (state.missingAnswers.length ? `(${state.missingAnswers.length} 题无答案)` : '');
// ⑤ 勾选页面上的选项
if (CONFIG.autoSelect) {
selectAnswers(state.answers);
state.filled = verifyFilled();
if (state.filled < state.total) scheduleSelect();
}
render();
buildAnswerSheet(state.answers);
// ⑥ 交卷
if (CONFIG.submitMode !== 'auto') {
state.lastAction = `答案已勾选(${state.filled}/${state.total})——请你自己点交卷`;
render();
return true;
}
state.phase = PHASE.SUBMITTING;
state.lastAction = '正在提交试卷…';
render();
const startDate = formatStartDate();
const result = await submitExamAnswers(examId, state.answers, startDate, state.randomId);
if (!result || result.status !== 0) {
state.phase = PHASE.FAILED;
state.lastAction = '提交失败:' + ((result && result.message) || '未知错误');
render();
return false;
}
state.lastAction = '答案提交成功!正在查询成绩…';
render();
// ⑦ 等一下再查成绩(exe 用 time.sleep(2))
await sleep(CONFIG.waitBeforeResult * 1000);
// ⑧ 查成绩
const res = await fetchExamResult(examId);
if (res && res.status === 0 && res.data) {
state.resultData = res.data;
state.score = res.data.score !== undefined ? res.data.score : null;
// ⑨ 写回考试状态
if (state.score !== null) {
await writeExamStatus(state.coursePacketId, state.score);
}
state.phase = PHASE.DONE;
// ★ 分数校验(2026-10-22 新增)
// 45 分事故的教训:交卷"成功"不等于"全对"。
// 分数低于满分时,必须把可疑原因直接摆到面板上,
// 而不是让用户自己猜哪里错了。
const suspect = diagnoseSuspect();
if (state.score !== null && state.score < 100) {
const reasons = [];
if (state.missingAnswers && state.missingAnswers.length) {
reasons.push(`${state.missingAnswers.length} 题未取到答案`);
}
if (state.filled < state.total) {
reasons.push(`页面只勾上 ${state.filled}/${state.total} 题`);
}
state.lastAction = `接口返回分数:${state.score} 分`
+ (reasons.length ? `(${reasons.join(',')})` : '(题量齐全,请看诊断定位)')
+ ' ※ 该接口是「最高分」,不等于本次成绩,'
+ '本次分请看平台结果页';
} else {
state.lastAction = `考试成绩:${state.score} 分(${res.data.exam_name || '未知'})`;
}
state.suspect = suspect;
recordResult(res.data, examId);
} else {
// exe 的兜底:拿不到分数时用默认分数写状态
state.phase = PHASE.DONE;
state.lastAction = '答案已提交,但成绩暂未生成(可稍后点「再查一次成绩」)';
}
render();
return true;
}
function formatStartDate() {
const d = new Date(), p = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ` +
`${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
function recordResult(data, examId) {
const day = nowStr().slice(0, 10);
if (!store[day]) store[day] = { exams: [] };
store[day].exams.push({
t: nowStr(), examId,
score: data.score, name: data.exam_name || '',
count: data.answerCount, submit: data.submit_date,
});
saveStore(store);
}
// ======================== 10. 页面选项勾选 ========================
//
// ★ 关键教训(来自真机截图):
// 试卷里的 tqId(67/32/34…)是数据库主键,**页面上根本不出现**。
// 页面上显示的是「1. 2. 3.」这样的可见题号。
// 所以按 tqId 找元素(data-tqid / name*=tqId)永远找不到。
//
// 正确的对齐方式:**按题型分组 + 组内序号**。
// 试卷:typeList[] 里有 type(1=单选 2=多选 3=判断),每个 type 下 questionList[] 有序
// 页面:单选题区、多选题区、判断题区,各区内部题目按 1.2.3. 顺序排
// → 同一 type 内,第 k 个题目 ↔ 试卷里该 type 的第 k 题
//
// 这个方案不依赖任何猜测的 class 名,只依赖"题目在 DOM 里的先后顺序"。
// —— 页面题目分组 ——
// 返回 [{ type, items: [{ el, letter, text, input }] }],按 DOM 顺序
function collectPageQuestions() {
// 找出页面上所有"选项组"。判断题有 2 个选项、单选/多选有 4+ 个。
// 做法:找到所有 radio input,按它们最近的公共容器分组。
const radios = $$('input[type="radio"], input[type="checkbox"]');
if (!radios.length) return [];
const groups = [];
const seen = new Set();
// ① 先按 name 分组(标准 radio 组天然同名)
const byName = new Map();
for (const r of radios) {
if (!r.name) continue;
if (!byName.has(r.name)) byName.set(r.name, []);
byName.get(r.name).push(r);
}
// ★ 名字可信吗?
// 真机上有一批站点所有题目的 radio 都用同一个 name(比如 "radio"),
// 这时按 name 分组会把 20 道题合成 1 组 → 全部对不上。
// 判据:一组里如果 radio 数量明显超过"一题的选项数上限"(8),
// 或者组内元素跨越了多个"多选项祖先"容器 → name 不可信。
const nameGroups = [];
for (const [name, list] of byName) {
if (list.length < 2) continue;
if (list.length > 8) continue; // 太多 → 不可信
// ★ 关键校验:这组 radio 的"最近公共祖先"里,是不是**只有**这些 radio?
// 若是 → 它们确实同属一题,name 可信
// 若祖先里还有别的 radio(别的题目的) → 它们其实分属不同题,name 不可信
const anc = commonAncestor(list);
if (!anc) continue;
const inAnc = radios.filter(x => anc.contains(x));
if (inAnc.length !== list.length) continue; // 祖先里还有别人 → 不可信
nameGroups.push(list);
}
const consumed = new Set();
for (const list of nameGroups) {
list.forEach(x => consumed.add(x));
const g = makeGroup(list);
if (g) groups.push(g);
}
// ② 剩下的(无 name 或 name 不可信)→ 按"最近的多选项祖先"分组
//
// 真机结构:每题一个容器,容器里有题干 + 4 个选项(每个选项一行)
// → 判据 = 找一个"含 >=2 个 radio 的最近祖先",那就是这题的容器
//
// 为什么不能用"同一行的 radio"分组:同题的选项在**不同行**,
// 同行只有一个 radio → 每行成一组 → 一题被拆成 4 组。
const rest = radios.filter(r => !consumed.has(r));
for (const r of rest) {
// ★ 关键:跳过已被前面某组吃掉的 radio。
// 否则同一题的 4 个 radio 会各自建一个组 → 一题变 4 组 → 全部错位。
if (consumed.has(r)) continue;
const box = multiRadioAncestor(r);
if (!box) continue;
const siblings = radios.filter(x => box.contains(x));
if (siblings.length < 2) continue;
siblings.forEach(x => consumed.add(x));
const g = makeGroup(siblings);
if (g) groups.push(g);
}
// 按 DOM 顺序排(保证与页面可见顺序一致)
groups.sort((a, b) => {
const ea = a.container, eb = b.container;
if (!ea || !eb) return 0;
const pos = ea.compareDocumentPosition(eb);
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1;
return 0;
});
// ③ 最后统一按"分区标题"修正题型(比逐组猜测可靠)
applySectionTypes(groups);
return groups;
}
/**
* 用页面上的题型分区标题给各组定型。
*
* 做法(索引区间法,最稳):
* 1. 找到所有"单选题/多选题/判断题"标题元素,按 DOM 顺序编号
* 2. 把题目组也按 DOM 顺序编号
* 3. 用**文档位置比较**判断每个组落在哪两个标题之间
*
* ★ compareDocumentPosition 的方向很容易搞反:
* a.compareDocumentPosition(b) & DOCUMENT_POSITION_FOLLOWING → a 在 b 之后
* a.compareDocumentPosition(b) & DOCUMENT_POSITION_PRECEDING → a 在 b 之前
* 写错就会出现"单选题被判成判断题"这种整段反过来的现象。
*/
function applySectionTypes(groups) {
// ① 收集分区标题
const titles = [];
for (const el of $$('*')) {
if (el.children && el.children.length > 2) continue; // 标题通常没有子元素
const t = sectionTitleOf(el);
if (t) titles.push({ el, type: t });
}
if (!titles.length) return;
// 去掉嵌套重复(父元素与子元素命中同一个词)
const realTitles = [];
for (const t of titles) {
if (realTitles.some(rt => rt.el.contains(t.el) || t.el.contains(rt.el))) continue;
realTitles.push(t);
}
// 按文档顺序排
realTitles.sort((a, b) => {
const pos = a.el.compareDocumentPosition(b.el);
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1; // a 在 b 之前 → a 排前面
if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1;
return 0;
});
// ② 给每个组定型:找它"前面最近的那个标题"
for (const g of groups) {
if (!g.container) continue;
let cur = 0;
for (const t of realTitles) {
// t.el.compareDocumentPosition(g.container) 的语义:
// & FOLLOWING → g.container 跟在 t.el 后面,即 t.el 在前
// & PRECEDING → g.container 在 t.el 前面,即 t.el 在后
const pos = t.el.compareDocumentPosition(g.container);
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) {
cur = t.type; // t.el 在前 → 它就是这个组所属分区的标题
}
// 标题已按文档顺序遍历,后面出现的"在前"标题更近,会覆盖 cur
}
if (cur) g.type = cur;
}
}
/** 用一组 radio 构造题目组 */
function makeGroup(list) {
if (!list || !list.length) return null;
const container = commonAncestor(list);
if (!container) return null;
const items = list.map(inp => ({
input: inp,
el: inp,
letter: letterOf(inp),
text: controlText(inp),
}));
return { container, items, type: guessType(items, container) };
}
/** 往上找"含 >=2 个 radio 的最近祖先" → 那就是题目容器 */
function multiRadioAncestor(inp) {
let n = inp.parentElement;
let best = null;
for (let i = 0; i < 8 && n; i++, n = n.parentElement) {
const cnt = n.querySelectorAll
? n.querySelectorAll('input[type="radio"], input[type="checkbox"]').length : 0;
if (cnt >= 2) { best = n; break; } // 最近的
}
return best;
}
/** 选项所在的"行"容器(通常是包裹 input + 选项文字的 div/label) */
function optionRow(inp) {
let n = inp.parentElement;
for (let i = 0; i < 3 && n; i++, n = n.parentElement) {
// 找到一个"只含一个 radio"的祖先 → 那就是这一行
const cnt = n.querySelectorAll ? n.querySelectorAll('input[type=radio], input[type=checkbox]').length : 0;
if (cnt === 1) return n;
}
return inp.parentElement;
}
function sameRow(a, b) {
if (!a || !b) return false;
if (a === b) return true;
return a.contains(b) || b.contains(a);
}
// 若干元素的最近公共祖先
function commonAncestor(els) {
if (!els.length) return null;
let node = els[0].parentElement;
while (node) {
let all = true;
for (const e of els) {
if (!node.contains(e)) { all = false; break; }
}
if (all) return node;
node = node.parentElement;
}
return null;
}
// 从选项文本里提取字母:"A. 属于..." → "A"
// ★ 只有**文本里带字母前缀**才算真字母。
// 判断题的选项是「正确」「错误」,input.value 却是 "A"/"B" ——
// 那是"第几个选项",不是"答案是什么",用它匹配会勾错题。
function letterOf(inp) {
const t = controlText(inp);
const m = t.match(/^\s*([A-HA-H])\s*[.、.))::]/);
if (m) return m[1].replace(/[A-H]/g, c => String.fromCharCode(c.charCodeAt(0) - 0xFEE0));
return ''; // 没有字母前缀 → 不提供字母
}
// 页面上的题型分区标题 → 试卷里的 viewTypeId
// 单选题→1 多选题→2 判断题→3
const SECTION_TYPE_WORDS = [
{ type: 3, words: ['判断题', '判断', '是非题'] },
{ type: 2, words: ['多选题', '多项选择', '不定项'] },
{ type: 1, words: ['单选题', '单项选择', '单选'] },
];
/**
* 判定一组选项属于哪种题型。
*
* ★ 关键:不能只看选项个数。
* 有的站点判断题也给 4 个选项;也有的单选只给 2 个。
* 优先看**页面上的题型分区标题**("单选题"/"判断题"),
* 那是权威信号;选项数只作兜底。
*/
function guessType(items, container) {
// ① 往上找题型分区标题
if (container) {
let n = container;
for (let i = 0; i < 12 && n; i++, n = n.parentElement) {
// 先看自己这一层
const own = sectionTitleOf(n);
if (own) return own;
// 再看兄弟里有没有标题(标题常是同级的第一个元素)
if (n.parentElement) {
for (const sib of Array.from(n.parentElement.children)) {
const t = sectionTitleOf(sib);
if (t) return t;
}
}
}
}
// ② 兜底:按选项数
const n = items.length;
if (n <= 2) return 3;
if (n === 4) return 1;
return n > 4 ? 2 : 1;
}
/** 从元素本身读出题型编号(若它是分区标题) */
function sectionTitleOf(el) {
if (!el) return 0;
const t = (el.textContent || '').trim();
if (!t || t.length > 12) return 0;
for (const s of SECTION_TYPE_WORDS) {
for (const w of s.words) {
if (t === w || t.startsWith(w)) return s.type;
}
}
return 0;
}
// 把试卷答案按 type 分组(组内保持原顺序)
function groupAnswersByType(answers) {
const byType = new Map();
for (const a of answers) {
const t = Number(a.viewTypeId) || 1;
if (!byType.has(t)) byType.set(t, []);
byType.get(t).push(a);
}
return byType;
}
// 把页面题目也按 type 分组
function groupPageByType(groups) {
const byType = new Map();
for (const g of groups) {
if (!byType.has(g.type)) byType.set(g.type, []);
byType.get(g.type).push(g);
}
return byType;
}
/**
* 建立"答案 → 页面题目组"的映射
* 返回 Map
*
* ★ 为什么要做题型自适应:
* 试卷里的 viewTypeId(1/2/3)与"单选/多选/判断"的对应关系,
* 不同版本/不同场次可能不一致(真机上就见过判断题标成 2 的情况)。
* 死记硬编码 type→题型,一换版本就全错位。
*
* 做法:先用 viewTypeId 分组做第一轮对齐;
* 如果某一组对不上(数量不匹配 或 选项内容明显不符),
* 就改用**内容特征**重新归类:
* · 答案是「正确/错误/对/错」→ 判断题
* · 答案是多个字母(AC)→ 多选题
* · 其余 → 单选题
* 再和页面上按"分区标题"定型的组对齐。
*/
function buildMapping(answers) {
const mapping = new Map();
if (!answers || !answers.length) return mapping;
const pageGroups = collectPageQuestions();
if (!pageGroups.length) return mapping;
// —— 第一轮:按 viewTypeId 对齐 ——
const ansByType = groupAnswersByType(answers);
const pageByType = groupPageByType(pageGroups);
let matched = 0;
for (const [type, ansList] of ansByType) {
const pg = pageByType.get(type) || [];
const n = Math.min(ansList.length, pg.length);
for (let i = 0; i < n; i++) { mapping.set(ansList[i], pg[i]); matched++; }
}
// —— 检查这一轮是否可信 ——
// 可信 = 每道题都匹配到了组,且组里的选项确实包含该答案
const allMatched = matched === answers.length;
const contentOk = answers.every(a => {
const g = mapping.get(a);
if (!g) return false;
return groupCanAccept(g, a.userAnswer);
});
if (allMatched && contentOk) return mapping;
// —— 第二轮:按内容特征重新归类 ——
const ansByKind = groupAnswersByKind(answers);
const pageByKind = groupPageByKind(pageGroups);
const m2 = new Map();
for (const [kind, ansList] of ansByKind) {
const pg = pageByKind.get(kind) || [];
const n = Math.min(ansList.length, pg.length);
for (let i = 0; i < n; i++) m2.set(ansList[i], pg[i]);
}
// 若内容归类匹配更多,就用它
if (m2.size > mapping.size) return m2;
return mapping;
}
/** 答案的"内容特征"分类:judge / multi / single */
function kindOfAnswer(a) {
const w = String(a.userAnswer || '').trim();
if (!w) return 'single';
if (/^(正确|错误|对|错|true|false|√|×|T|F)$/i.test(w)) return 'judge';
if (/^[A-H]{2,}$/i.test(w.toUpperCase())) return 'multi';
return 'single';
}
function groupAnswersByKind(answers) {
const m = new Map();
for (const a of answers) {
const k = kindOfAnswer(a);
if (!m.has(k)) m.set(k, []);
m.get(k).push(a);
}
return m;
}
/** 页面题目组按"选项内容特征"分类 */
function groupPageByKind(groups) {
const m = new Map();
for (const g of groups) {
const texts = g.items.map(i => i.text);
const isJudge = texts.some(t => /^(正确|错误|对|错|√|×)$/i.test(t.trim()));
const letters = g.items.map(i => i.letter).filter(Boolean);
const k = isJudge ? 'judge' : (letters.length > 4 ? 'multi' : 'single');
if (!m.has(k)) m.set(k, []);
m.get(k).push(g);
}
return m;
}
/** 这个题目组能不能接受这个答案(用于校验第一轮对齐是否可信) */
function groupCanAccept(group, want) {
const w = String(want || '').trim();
if (!w) return false;
for (const item of group.items) {
if (judgeMatch(item.text, controlValue(item.input), w)) return true;
const letters = w.toUpperCase().split('').filter(c => /[A-H]/.test(c));
if (letters.some(L =>
(item.letter && item.letter.toUpperCase() === L) ||
item.text.toUpperCase().startsWith(L) ||
controlValue(item.input).toUpperCase() === L)) return true;
}
return false;
}
function cssEscape(s) { return String(s == null ? '' : s).replace(/["\\]/g, '\\$&'); }
function controlText(el) {
if (!el) return '';
const label = el.closest && el.closest('label');
return String((label && label.textContent) || el.textContent || el.value || '').trim();
}
function controlValue(el) {
if (!el) return '';
if (el.value !== undefined && el.value !== '') return String(el.value).trim();
const d = el.dataset || {};
return String(d.value || d.option || d.key || d.answer || '').trim();
}
function judgeMatch(text, mark, want) {
const w = (want || '').trim();
if (!w) return false;
const t = (text + ' ' + mark).toUpperCase();
if (/^(正确|对|TRUE|T|√|1|YES)$/i.test(w)) {
return /正确|对|TRUE|√|YES/.test(t) && !/错误|错|不/.test(t.replace(/正确|对|TRUE|√|YES/g, ''));
}
if (/^(错误|错|FALSE|F|×|0|NO)$/i.test(w)) {
return /错误|错|FALSE|×|NO/.test(t);
}
return t.includes(w.toUpperCase());
}
/** 勾选一组里的某个答案 */
function markGroup(group, want) {
if (!group || !group.items || !group.items.length) return false;
const w = String(want).trim();
if (!w) return false;
const upper = w.toUpperCase();
const isJudge = /^(正确|错误|对|错|true|false|√|×|T|F)$/i.test(w);
const isMulti = /^[A-H]{2,}$/i.test(upper);
const letters = upper.split('').filter(c => /[A-H]/.test(c));
for (const item of group.items) {
const text = item.text;
const mark = controlValue(item.input);
let hit = false;
if (isJudge) {
// ★ 判断题必须**只按文本**匹配。
// 判断题的选项是「正确」「错误」,input 的 value 却是 "A"/"B",
// 若先按字母匹配就会把"正确"错勾成第一个选项。
hit = judgeMatch(text, mark, w);
} else if (isMulti) {
hit = letters.some(L =>
(item.letter && item.letter.toUpperCase() === L) ||
text.toUpperCase().startsWith(L) ||
mark.toUpperCase() === L);
} else {
hit = (item.letter && item.letter.toUpperCase() === upper) ||
text.toUpperCase().startsWith(upper) ||
mark.toUpperCase() === upper;
}
if (!hit) continue;
const inp = item.input;
if (inp.type === 'checkbox') {
if (!inp.checked) {
try { inp.click(); } catch (e) { }
if (!inp.checked) {
inp.checked = true;
inp.dispatchEvent(new Event('input', { bubbles: true }));
inp.dispatchEvent(new Event('change', { bubbles: true }));
}
}
return true;
}
// radio
if (!inp.checked) {
try { inp.click(); } catch (e) { }
if (!inp.checked) {
inp.checked = true;
inp.dispatchEvent(new Event('input', { bubbles: true }));
inp.dispatchEvent(new Event('change', { bubbles: true }));
}
}
return true;
}
return false;
}
function selectAnswers(answers) {
const mapping = buildMapping(answers);
let filled = 0;
for (const a of (answers || [])) {
const g = mapping.get(a);
if (!g) continue;
if (markGroup(g, a.userAnswer)) filled++;
}
return filled;
}
function verifyFilled() {
const mapping = buildMapping(state.answers);
let n = 0;
for (const a of (state.answers || [])) {
const g = mapping.get(a);
if (!g) continue;
if (g.items && g.items.some(i => i.input && i.input.checked)) n++;
}
return n;
}
/** 诊断:把定位情况写进控制台,方便真机排障 */
function diagnose() {
const pageGroups = collectPageQuestions();
const ansByType = groupAnswersByType(state.answers || []);
const pageByType = groupPageByType(pageGroups);
const mapping = buildMapping(state.answers || []);
const info = {
当前URL: location.href.slice(0, 120),
识别到的课程包ID: state.coursePacketId || '(未识别)',
考试ID: state.examId || '(未获取)',
randomId: state.randomId || '(未获取)',
答案总数: (state.answers || []).length,
页面题目组数: pageGroups.length,
映射成功数: mapping.size,
答案按题型: Array.from(ansByType.entries()).map(([t, a]) => `type${t}: ${a.length} 题`),
页面按题型: Array.from(pageByType.entries()).map(([t, g]) => `type${t}: ${g.length} 题`),
已勾选: verifyFilled(),
每题对齐情况: (state.answers || []).map((a, i) => {
const g = mapping.get(a);
return ` #${i + 1} tqId=${a.tqId} want=${a.userAnswer} -> ` +
(g ? `type${g.type} [${g.items.map(x => x.text.slice(0, 12)).join(' | ')}]` : '★未匹配');
}),
前三组样例: pageGroups.slice(0, 3).map(g => ({
type: g.type,
选项数: g.items.length,
字母: g.items.map(i => i.letter).join(','),
文本: g.items.map(i => i.text.slice(0, 20)).join(' | '),
容器class: (g.container && g.container.className || '').toString().slice(0, 60),
})),
未取到答案的题: (state.missingAnswers || []).map(m => `tqId=${m.tqId} 字段=${m.keys}`),
可疑题: (diagnoseSuspect() || []).map(s =>
`#${s.index + 1} tqId=${s.tqId} want=${s.want} 原因=${s.reason}`),
'★ 已捕获的接口响应': (captured.responses || []).map(r =>
`${r.method} ${r.url} status=${r.status}`
+ (r.keys ? ` data字段=[${r.keys.join(',')}]` : '')),
};
console.log('[保密观考试助手] 定位诊断:', info);
console.log('%c[保密观考试助手] 定位诊断', 'color:#2563eb;font-weight:bold');
// 同时把结果写到 window,方便用户复制
window.__baomiExamDiag = info;
state.lastAction = '诊断完成,请看控制台(F12)';
render();
return info;
}
/**
* 找出"可能答错"的题
*
* 交卷成功但分数不满,通常是这几类原因:
* 1. 答案没取到(字段不是 answer)→ 该题交的是空答案
* 2. 题型对不上(viewTypeId 与页面题型不一致)→ 服务端按别的题型解析
* 3. 答案格式不对(多选顺序、逗号分隔没归一化)
* 4. 页面根本没勾上(DOM 定位失败)→ 但提交体里是对的,不影响分数
*
* 返回可疑题清单,供面板和诊断输出。
*/
function diagnoseSuspect() {
const out = [];
const answers = state.answers || [];
if (!answers.length) return out;
const mapping = buildMapping(answers);
const missing = new Set((state.missingAnswers || []).map(m => String(m.tqId)));
answers.forEach((a, index) => {
const want = String(a.userAnswer || '');
const reasons = [];
// ① 答案为空
if (!want) reasons.push('答案为空');
// ② 该题没取到答案(在 missing 名单里)
if (missing.has(String(a.tqId))) reasons.push('未取到答案字段');
// ③ 答案格式可疑:多选题答案里有分隔符残留
if (/[,,、|;;]/.test(want)) reasons.push('答案含分隔符');
// ④ 题型与页面题型不一致
const g = mapping.get(a);
if (!g) {
reasons.push('页面上没找到对应题目');
} else {
const pageType = g.type;
const ansType = Number(a.viewTypeId);
if (pageType && ansType && pageType !== ansType) {
reasons.push(`题型不一致(试卷${ansType}/页面${pageType})`);
}
// ⑤ 页面选项里找不到这个答案 → 答案与选项对不上
if (want && !groupCanAccept(g, want)) {
reasons.push('页面选项里没有该答案');
}
}
if (reasons.length) {
out.push({
index, tqId: a.tqId, want,
reason: reasons.join(','),
});
}
});
return out;
}
function scheduleSelect() {
if (scheduleSelect._timer) clearInterval(scheduleSelect._timer);
const t0 = Date.now();
scheduleSelect._timer = setInterval(() => {
const done = (Date.now() - t0 > 20000);
if (!done) selectAnswers(state.answers);
const n = verifyFilled();
if (n !== state.filled || done) {
state.filled = n;
state.lastAction = n >= state.total
? `已自动勾选 ${n} 题`
: (done ? `仅勾选 ${n}/${state.total} 题` : `已勾 ${n}/${state.total} 题…`);
render();
}
if (n >= state.total || done) {
clearInterval(scheduleSelect._timer);
scheduleSelect._timer = null;
}
}, 400);
}
/**
* 提交体预览 —— 把将要 POST 的原始 JSON 打出来
*
* 真机排障时最需要的就是这个:
* 拿它跟 DevTools 里 Network 面板看到的其它工具发出的请求比对,
* 一眼就能看出 examId 对不对、答案对不对、字段类型对不对。
*/
function showSubmitPreview() {
const body = {
examId: state.examId || '(空!)',
examResult: JSON.stringify(state.answers),
randomId: state.randomId || '(空!)',
startDate: formatStartDate(),
};
const pretty = JSON.stringify(body, null, 2);
console.log('%c[保密观考试助手] 提交体预览', 'color:#2563eb;font-weight:bold');
console.log(pretty);
console.log('[保密观考试助手] 答案逐题:');
state.answers.forEach((a, i) => {
console.log(` #${i + 1} tqId=${a.tqId} qstId=${a.qstId} `
+ `userAnswer=${JSON.stringify(a.userAnswer)} `
+ `viewTypeId=${a.viewTypeId} resultFlag=${JSON.stringify(a.resultFlag)}`);
});
window.__baomiExamSubmitPreview = { body, answers: state.answers };
state.lastAction = `提交体预览已输出到控制台(${state.answers.length} 题)`;
render();
return body;
}
// ======================== 11. 答案速查表 ========================
function buildAnswerSheet(answers) {
const old = document.getElementById('baomi-exam-sheet');
if (old) old.remove();
const rows = answers.map((a, i) => {
const ans = String(a.userAnswer);
const short = ans.length > 14 ? ans.slice(0, 14) + '…' : ans;
return `| ${i + 1} | ${escapeHtml(String(a.tqId))} | ${escapeHtml(short)} |
`;
}).join('');
const box = document.createElement('div');
box.id = 'baomi-exam-sheet';
box.style.cssText = `
position:fixed; left:14px; bottom:14px; z-index:2147483647;
background:rgba(255,255,255,.97); color:#1f2937;
border:1px solid #e5e7eb; border-radius:12px; padding:12px 14px;
font:12px/1.6 -apple-system,"Microsoft YaHei",sans-serif;
box-shadow:0 8px 28px rgba(0,0,0,.16); max-width:290px;`;
box.innerHTML = `
📝 答案速查(${answers.length} 题)
来源:试卷响应中的 answer 字段
`;
document.body.appendChild(box);
box.querySelector('#sheet-close').onclick = () => box.remove();
}
// ======================== 12. 面板 ========================
let panel = null;
function render() {
if (!panel) return;
const phaseText = {
[PHASE.IDLE]: '待机',
[PHASE.PREPARING]: '准备中',
[PHASE.READY]: '试卷就绪',
[PHASE.SUBMITTING]: '提交中',
[PHASE.DONE]: '已完成',
[PHASE.FAILED]: '失败',
}[state.phase];
const btnLabel = state.phase === PHASE.READY ? '📝 重新作答'
: state.phase === PHASE.DONE ? '🔄 再查一次成绩'
: state.phase === PHASE.FAILED ? '↻ 重试'
: '▶ 开始考试';
const btnColor = state.phase === PHASE.READY ? '#d97706' : '#2563eb';
const day = nowStr().slice(0, 10);
const today = (store[day] && store[day].exams) || [];
// 可疑题提示块(分数不满时给出具体原因,而不是让用户自己猜)
let suspectBlock = '';
const suspect = (state.suspect && state.suspect.length)
? state.suspect
: (state.answers && state.answers.length ? diagnoseSuspect() : []);
if (suspect.length) {
const show = suspect.slice(0, 5);
suspectBlock = `
⚠ ${suspect.length} 题可能有问题
${show.map(s => `
#${s.index + 1} tqId=${escapeHtml(String(s.tqId))} 答案=${escapeHtml(String(s.want))} — ${escapeHtml(s.reason)}
`).join('')}
${suspect.length > show.length ? `
…还有 ${suspect.length - show.length} 题,点「诊断定位」看全部
` : ''}
`;
}
panel.innerHTML = `
📝 保密教育考试v4.0
状态:${phaseText}${state.total ? ` ${state.filled}/${state.total} 题已勾` : ''}
${state.examId ? `考试ID:${escapeHtml(state.examId)}
` : ''}
${state.score !== null ? `成绩:${escapeHtml(String(state.score))} 分 (最高分接口值,非本次分)
` : ''}
${state.lastAction ? `${escapeHtml(state.lastAction)}
` : ''}
${state.diag ? `${escapeHtml(state.diag)}
` : ''}
${suspectBlock}
${today.length ? `今日考试 ${today.length} 次,最新 ${escapeHtml(String(today[today.length - 1].score))} 分
` : ''}
快捷键 E 开始考试 · H 面板
`;
panel.querySelector('#bnsh-exam').onclick = async () => {
if (state.phase === PHASE.READY) {
selectAnswers(state.answers);
state.filled = verifyFilled();
state.lastAction = `已重新勾选 ${state.filled} 题`;
render();
} else if (state.phase === PHASE.DONE && state.score !== null) {
const res = await fetchExamResult(state.examId);
if (res && res.status === 0 && res.data) {
state.resultData = res.data;
state.score = res.data.score;
state.lastAction = `考试成绩:${state.score} 分`;
render();
} else {
state.lastAction = '成绩暂未生成';
render();
}
} else {
await runFullExam();
}
};
panel.querySelector('#bnsh-fill').onclick = () => {
if (!state.answers.length) { state.lastAction = '还没有试卷'; render(); return; }
selectAnswers(state.answers);
state.filled = verifyFilled();
state.lastAction = `已勾选 ${state.filled} 题`;
render();
};
panel.querySelector('#bnsh-sheet').onclick = () => {
if (state.answers.length) buildAnswerSheet(state.answers);
else { state.lastAction = '还没有试卷'; render(); }
};
panel.querySelector('#bnsh-diag').onclick = () => diagnose();
panel.querySelector('#bnsh-refetch').onclick = async () => {
state.phase = PHASE.IDLE;
state.answers = [];
state.total = 0;
state.filled = 0;
state.suspect = [];
state.missingAnswers = [];
await runFullExam();
};
panel.querySelector('#bnsh-preview').onclick = () => showSubmitPreview();
panel.querySelector('#bnsh-resubmit').onclick = async () => {
// 用当前已组装的答案重新交卷(不重新取卷)
if (!state.answers.length) {
state.lastAction = '还没有答案,请先点「重新取卷」';
render();
return;
}
state.phase = PHASE.SUBMITTING;
state.lastAction = '正在重新提交…';
render();
const result = await submitExamAnswers(
state.examId, state.answers, formatStartDate(), state.randomId);
if (!result || result.status !== 0) {
state.phase = PHASE.FAILED;
state.lastAction = '重新提交失败:' + ((result && result.message) || '未知错误');
render();
return;
}
await sleep(CONFIG.waitBeforeResult * 1000);
const res = await fetchExamResult(state.examId);
if (res && res.status === 0 && res.data) {
state.score = res.data.score;
state.resultData = res.data;
state.suspect = diagnoseSuspect();
state.phase = PHASE.DONE;
state.lastAction = `重新提交成功,成绩:${state.score} 分`
+ (state.score < 100 ? '(仍有可疑题,点「诊断定位」)' : '');
recordResult(res.data, state.examId);
} else {
state.lastAction = '已重新提交,成绩暂未生成';
}
render();
};
panel.querySelector('#bnsh-mode').onclick = () => {
CONFIG.submitMode = CONFIG.submitMode === 'auto' ? 'manual' : 'auto';
state.lastAction = CONFIG.submitMode === 'auto'
? '交卷方式:自动提交'
: '交卷方式:手动(只勾选,你自己点交卷)';
render();
};
}
function createPanel() {
panel = document.createElement('div');
panel.id = 'baomi-exam-panel';
panel.style.cssText = `
position:static; color:#1f2937; padding:0;
font:12px/1.7 -apple-system,"Microsoft YaHei",sans-serif; min-width:0;`;
(UI_CONTAINER || document.body).appendChild(panel);
render();
}
// ======================== 13. 快捷键 ========================
document.addEventListener('keydown', (e) => {
const tag = (e.target && e.target.tagName || '').toLowerCase();
if (['input', 'textarea', 'select'].includes(tag)) return;
if (e.key === 'e' || e.key === 'E') {
if (state.phase === PHASE.READY) {
selectAnswers(state.answers);
state.filled = verifyFilled();
state.lastAction = `已勾选 ${state.filled} 题`;
render();
} else {
runFullExam();
}
} else if (e.key === 'h' || e.key === 'H') {
if (panel) panel.style.display = panel.style.display === 'none' ? '' : 'none';
}
});
// ======================== 14. 调试 API ========================
// UI 挂载容器(由外层统一外壳设置)
let UI_CONTAINER = null;
function setContainer(el) { UI_CONTAINER = el; }
window.__baomiExam = {
init, setContainer,
get uiCreated() { return !!panel; },
state, config: CONFIG, phase: PHASE,
getToken, getHeaders, detectCoursePacketId, md5, generateCustomRandomId,
resolveExamId, fetchExamPaper, buildAnswers, submitExamAnswers,
fetchExamResult, writeExamStatus, runFullExam,
selectAnswers, verifyFilled, buildAnswerSheet, formatStartDate,
collectPageQuestions, buildMapping, diagnose, diagnoseSuspect,
pickAnswer, buildAnswers, showSubmitPreview, acquirePaper,
detectExamIdFromUrl, countPaperQuestions, pageQuestionCount,
captured, installFetchTap,
render, createPanel,
get panelText() { return panel ? panel.textContent : ''; },
store,
};
// ======================== 15. 初始化 ========================
function init() {
state.coursePacketId = detectCoursePacketId();
// ★ 尽早装上拦截 —— 必须在用户点「开始考试」之前就位,
// 否则平台已经发完请求,截不到真试卷。
installFetchTap();
createPanel();
log('已启动 v2.3 | 快捷键 E 开始考试 / H 面板');
if (!state.coursePacketId) {
state.diag = '未识别到课程包 ID(考试流程不依赖它,脚本会优先用页面请求里的 examId)';
render();
}
}
// ★ 防重复执行
// 同一页面里脚本被注入两次(油猴改脚本后重新保存、或演示页多次加载)时,
// 会出现两个面板、两个 state,window.__baomiExam 指向的是后一个,
// 而前一个实例的定时器还在跑 —— 表现为「状态是空的但面板显示 6/6 题已勾」。
// 这里用全局标记挡住第二次执行。
// 启动交给外层统一调度
return { init: window.__baomiExam.init, setContainer: window.__baomiExam.setContainer,
api: window.__baomiExam };
})();
// ======================== 统一外壳 ========================
const SHELL_ID = 'baomi-helper-v4';
let shellEl = null, tabStudy = null, tabExam = null;
let currentTab = 'study';
function shellStyle() {
if (document.getElementById('baomi-helper-style')) return;
const st = document.createElement('style');
st.id = 'baomi-helper-style';
st.textContent = `
#${SHELL_ID}{position:fixed;top:20px;right:20px;width:340px;max-width:calc(100vw - 40px);
background:#fff;border:1px solid #e1e5e9;border-radius:12px;z-index:2147483647;
box-shadow:0 8px 24px rgba(0,0,0,.12);font:13px/1.6 -apple-system,"Microsoft YaHei",sans-serif;
color:#1f2937;overflow:hidden}
#${SHELL_ID} .bmh-h{display:flex;align-items:center;gap:8px;padding:10px 14px;
background:#b01f24;color:#fff;font-weight:700;font-size:14px}
#${SHELL_ID} .bmh-h .bmh-ver{margin-left:auto;font-weight:400;font-size:11px;opacity:.85}
#${SHELL_ID} .bmh-tabs{display:flex;border-bottom:1px solid #e5e7eb}
#${SHELL_ID} .bmh-tab{flex:1;padding:9px 0;text-align:center;cursor:pointer;font-size:13px;
background:#f9fafb;color:#6b7280;border:none;font-family:inherit;outline:none}
#${SHELL_ID} .bmh-tab.on{background:#fff;color:#b01f24;font-weight:600;
box-shadow:inset 0 -2px 0 #b01f24}
#${SHELL_ID} .bmh-body{padding:12px 14px}
#${SHELL_ID} .bmh-pane{display:none}
#${SHELL_ID} .bmh-pane.on{display:block}
#${SHELL_ID} .bmh-empty{color:#9ca3af;font-size:12px;padding:14px 0;text-align:center;
line-height:1.7}
#${SHELL_ID} .bmh-foot{font-size:11px;color:#9ca3af;border-top:1px solid #f3f4f6;
padding:7px 14px;line-height:1.6}
#${SHELL_ID} .bmh-fold .bmh-body,#${SHELL_ID} .bmh-fold .bmh-foot{display:none}
`;
document.head.appendChild(st);
}
function createShell() {
if (document.getElementById(SHELL_ID)) return;
shellStyle();
shellEl = document.createElement('div');
shellEl.id = SHELL_ID;
shellEl.innerHTML = `
📘 保密教育助手v4.0
当前页面不是考试页
切到 bmExam 页面后会自动出现
`;
document.body.appendChild(shellEl);
tabStudy = shellEl.querySelector('#bmh-pane-study');
tabExam = shellEl.querySelector('#bmh-pane-exam');
shellEl.querySelectorAll('.bmh-tab').forEach(b => {
b.onclick = () => switchTab(b.dataset.tab);
});
}
function switchTab(tab) {
currentTab = tab;
shellEl.querySelectorAll('.bmh-tab').forEach(b => {
b.classList.toggle('on', b.dataset.tab === tab);
});
tabStudy.classList.toggle('on', tab === 'study');
tabExam.classList.toggle('on', tab === 'exam');
try { localStorage.setItem('bmh_tab', tab); } catch (e) { }
}
/** 根据当前页面自动选标签页 */
function autoTab() {
const p = location.pathname || '';
if (/bmExam/i.test(p) || /bmExamResult/i.test(p)) return 'exam';
if (/courseDetail|coursePacket|bmCourse/i.test(p)) return 'study';
return null;
}
function refreshPanes() {
const sHas = !!(window.__baomiStudy && window.__baomiStudy.uiCreated);
const eHas = !!(window.__baomiExam && window.__baomiExam.uiCreated);
const se = shellEl.querySelector('#bmh-study-empty');
const ee = shellEl.querySelector('#bmh-exam-empty');
if (se) se.style.display = sHas ? 'none' : '';
if (ee) ee.style.display = eHas ? 'none' : '';
}
// ======================== 启动 ========================
function boot() {
if (window.__baomiHelperV4) return;
window.__baomiHelperV4 = true;
createShell();
const studyBox = shellEl.querySelector('#bmh-study-box');
const examBox = shellEl.querySelector('#bmh-exam-box');
if (window.__baomiStudy) window.__baomiStudy.setContainer(studyBox);
if (window.__baomiExam) window.__baomiExam.setContainer(examBox);
// 默认标签页:URL 判断 → 上次选择 → 学习
let tab = autoTab();
if (!tab) { try { tab = localStorage.getItem('bmh_tab'); } catch (e) { } }
switchTab(tab || 'study');
// 启动两个模块(各自内部会判断当前页面是否适用)
try { if (window.__baomiStudy) window.__baomiStudy.init(); } catch (e) { console.error(e); }
try { if (window.__baomiExam) window.__baomiExam.init(); } catch (e) { console.error(e); }
// 等模块建好 UI 后再刷新占位提示
setTimeout(refreshPanes, 600);
setTimeout(refreshPanes, 2000);
// 页面跳转(SPA)后刷新
let lastUrl = location.href;
setInterval(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
const t = autoTab();
if (t) switchTab(t);
refreshPanes();
}
}, 1000);
// 快捷键:E 考试 / B 学习 / H 折叠
document.addEventListener('keydown', (e) => {
const tag = (e.target && e.target.tagName || '').toLowerCase();
if (['input', 'textarea', 'select'].includes(tag)) return;
const k = (e.key || '').toLowerCase();
if (k === 'e') { switchTab('exam'); }
else if (k === 'b') { switchTab('study'); }
else if (k === 'h' && shellEl) { shellEl.classList.toggle('bmh-fold'); }
});
console.log('[保密教育助手 v4] 已启动 · 学习 + 考试合一体');
}
if (document.readyState === 'complete' || document.readyState === 'interactive') boot();
else window.addEventListener('DOMContentLoaded', boot);
})();