// ==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} 题)
${rows}
#题号答案
来源:试卷响应中的 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 页面后会自动出现
空格 播放/暂停 · N 下一节 · S 倍速 · E 考试 · B 学习 · H 折叠
`; 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); })();