// ==UserScript== // @name ko网安云课堂 // @name:zh-CN ko网安云课堂 // @namespace ko-topsec-edu // @version 4.8.2 // @description ko网安云课堂:考试答案提取/高亮/自动答题;视频倍速+学习时长补齐 // @description:zh-CN ko网安云课堂:考试答案提取/高亮/自动答题;视频倍速+学习时长补齐 // @author you // @match *://*.topsec-edu.cn/* // @grant GM_setValue // @grant GM_getValue // @run-at document-start // @noframes // ==/UserScript== (function () { 'use strict'; /* ================= 通用工具 ================= */ const store = { get: (k, d) => { try { return GM_getValue(k, d); } catch (e) { return d; } }, set: (k, v) => { try { return GM_setValue(k, v); } catch (e) { /* ignore */ } }, }; const CONFIG = { autoFetch: store.get('autoFetch', true), // 考试页自动抓取答案并高亮正确选项 speed: store.get('speed', 1), enableSpeed: store.get('enableSpeed', true), heartbeatFill: store.get('heartbeatFill', true), // 心跳补齐(防缺时长) autoFillDelay: store.get('autoFillDelay', 500), // 自动答题点击间隔(ms) seekUnlock: store.get('seekUnlock', true), // 解锁视频跳转(拦截平台的进度回拉) }; const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const rand = (a, b) => a + Math.random() * (b - a); // 等待:秒答模式压缩到最低限度,但仍让出事件循环给 Vue 刷新选中态 const tick = (ms) => sleep(fastMode ? Math.min(ms, 15) : ms); function norm(s) { if (!s) return ''; // 去 HTML 标签 → 去   → 去全角空格/各类中英文引号 → 折叠空白 return String(s).replace(/<[^>]+>/g, '') .replace(/ /ig, ' ') .replace(/[\u3000"'"''""\u00A0]/g, '') .replace(/\s+/g, ' ') .trim(); } /* ================= 控制面板 ================= */ let panel, panelBody, panelExpanded = false; function buildPanel() { if (document.getElementById('xiaoeknown-helper-panel')) return; const css = document.createElement('style'); css.id = 'xiaoeknown-helper-style'; css.textContent = ` /* ===== iOS 风格 ===== */ #xiaoeknown-helper-panel{position:fixed;top:80px;right:12px;z-index:99999;width:266px; background:#f2f2f7;border:1px solid rgba(60,60,67,.12);border-radius:18px; box-shadow:0 16px 40px rgba(0,0,0,.16),0 3px 10px rgba(0,0,0,.08); font-family:-apple-system,'SF Pro Text','SF Pro Display','PingFang SC','Segoe UI','Microsoft YaHei',system-ui,sans-serif; font-size:13px;color:#1c1c1e;user-select:none;transition:box-shadow .25s ease} #xiaoeknown-helper-panel:hover{box-shadow:0 18px 44px rgba(0,0,0,.2),0 3px 10px rgba(0,0,0,.08)} #xiaoeknown-helper-panel.collapsed{width:52px;height:52px;border-radius:50%;overflow:hidden;cursor:grab;border:none; background:transparent;box-shadow:0 8px 20px rgba(0,0,0,.18)} #xiaoeknown-helper-panel.collapsed:hover{box-shadow:0 10px 26px rgba(0,0,0,.25);transform:scale(1.05)} #xiaoeknown-helper-panel.collapsed #xiaoeknown-helper-body{display:none!important} #xiaoeknown-helper-panel.dragging{box-shadow:0 24px 56px rgba(0,0,0,.24);opacity:.94;cursor:grabbing} #xiaoeknown-helper-head{display:flex;align-items:center;justify-content:space-between;padding:12px 14px 10px; background:#f2f2f7;color:#1c1c1e;border-radius:18px 18px 0 0;cursor:grab;border-bottom:1px solid rgba(60,60,67,.1)} #xiaoeknown-helper-panel.collapsed #xiaoeknown-helper-head{background:#f2f2f7;border:1px solid rgba(60,60,67,.14); border-radius:50%;padding:0;justify-content:center;align-items:center;width:52px;height:52px} #xiaoeknown-helper-head .title{display:flex;align-items:center;gap:7px;font-weight:600;font-size:14px;letter-spacing:.01em} #xiaoeknown-helper-head .title .dot{width:8px;height:8px;border-radius:50%;background:#34c759;box-shadow:0 0 5px rgba(52,199,89,.6)} #xiaoeknown-helper-panel.collapsed #xiaoeknown-helper-head .title{display:none} #xiaoeknown-helper-head .fab{display:none;flex-direction:column;align-items:center;justify-content:center;color:#1c1c1e} #xiaoeknown-helper-head .fab .fab-main{font-size:15px;font-weight:800;letter-spacing:.02em;line-height:1} #xiaoeknown-helper-head .fab .fab-sub{font-size:7px;font-weight:600;opacity:.8;margin-top:1px;letter-spacing:.08em} #xiaoeknown-helper-panel.collapsed #xiaoeknown-helper-head .fab{display:flex} #xiaoeknown-helper-head .tgl{background:rgba(120,120,128,.16);border:none;color:#007aff;border-radius:50%; width:26px;height:26px;line-height:1;cursor:pointer;font-size:12px;display:flex;align-items:center;justify-content:center;font-weight:700} #xiaoeknown-helper-head .tgl:hover{background:rgba(120,120,128,.26)} #xiaoeknown-helper-panel.collapsed #xiaoeknown-helper-head .tgl{display:none} #xiaoeknown-helper-body{padding:10px 12px 12px;display:none;max-height:76vh;overflow-y:auto;background:#f2f2f7} #xiaoeknown-helper-body.show{display:block} #xiaoeknown-helper-body::-webkit-scrollbar{width:5px} #xiaoeknown-helper-body::-webkit-scrollbar-thumb{background:rgba(120,120,128,.28);border-radius:3px} /* iOS 分段控件 */ #xiaoeknown-helper-tabs{display:flex;background:#ececf0;border-radius:9px;padding:2px;margin-bottom:10px} #xiaoeknown-helper-tabs .tab{flex:1;text-align:center;padding:5px 0;border-radius:7px;font-size:12.5px;font-weight:500; color:#6c6c70;cursor:pointer;transition:all .18s ease} #xiaoeknown-helper-tabs .tab.on{background:#ffffff;color:#1c1c1e;box-shadow:0 1px 4px rgba(0,0,0,.12);font-weight:600} .xe-page{display:none} .xe-page.on{display:block} #xiaoeknown-helper-body .row{display:flex;align-items:center;justify-content:space-between;margin:9px 0;gap:8px;min-height:24px} #xiaoeknown-helper-body .row > span{color:#1c1c1e;font-size:12.5px} #xiaoeknown-helper-body .row input[type=range]{flex:1;appearance:none;height:4px;border-radius:2px;background:#d8d8dc;outline:none} #xiaoeknown-helper-body .row input[type=range]::-webkit-slider-thumb{appearance:none;width:20px;height:20px;border-radius:50%; background:#fff;border:1px solid rgba(0,0,0,.1);box-shadow:0 1px 4px rgba(0,0,0,.2);cursor:pointer} #xiaoeknown-helper-body .row input[type=number]{width:60px;padding:4px 6px;border:1px solid rgba(60,60,67,.18);border-radius:7px;font-size:12px;text-align:center;background:#f2f2f7;color:#1c1c1e} #xiaoeknown-helper-body .val{font-weight:600;color:#007aff;min-width:40px;text-align:right;font-variant-numeric:tabular-nums} #xiaoeknown-helper-body .note{margin:8px 0 2px;padding:7px 9px;background:rgba(120,120,128,.12);border-radius:8px; color:#8e8e93;font-size:11px;line-height:1.5} /* 按钮 */ #xiaoeknown-helper-body .btn{display:block;width:100%;margin-top:7px;padding:9px 0;border:none;border-radius:9px; background:#007aff;color:#fff;cursor:pointer;font-size:13px;font-weight:500;transition:filter .15s,transform .1s,opacity .15s} #xiaoeknown-helper-body .btn:hover{filter:brightness(1.06)} #xiaoeknown-helper-body .btn:active{transform:scale(.98)} #xiaoeknown-helper-body .btn.sec{background:#5856d6} #xiaoeknown-helper-body .btn.green{background:#34c759} #xiaoeknown-helper-body .btn.ghost{background:#f2f2f7;color:#007aff;font-weight:600} #xiaoeknown-helper-body .btn.sm{padding:7px 0;font-size:12px;margin-top:6px} #xiaoeknown-helper-body .btn-row{display:flex;gap:7px;margin-top:6px} #xiaoeknown-helper-body .btn-row .btn{margin-top:0} /* iOS 开关 */ #xiaoeknown-helper-body .sw{position:relative;width:44px;height:26px;flex:none} #xiaoeknown-helper-body .sw input{opacity:0;width:0;height:0} #xiaoeknown-helper-body .sw .track{position:absolute;inset:0;background:#e9e9ea;border-radius:26px;transition:.2s;cursor:pointer} #xiaoeknown-helper-body .sw .track::before{content:'';position:absolute;left:2px;top:2px;width:22px;height:22px; background:#fff;border-radius:50%;transition:.2s;box-shadow:0 2px 5px rgba(0,0,0,.2)} #xiaoeknown-helper-body .sw input:checked + .track{background:#34c759} #xiaoeknown-helper-body .sw input:checked + .track::before{transform:translateX(18px)} /* 提示 */ #xiaoeknown-helper-snack{position:fixed;bottom:26px;left:50%;transform:translateX(-50%) translateY(16px);z-index:100000; background:rgba(28,28,30,.92);color:#fff;padding:11px 18px;border-radius:12px;font-size:12.5px;opacity:0; transition:opacity .28s,transform .28s;pointer-events:none;max-width:82vw;font-family:inherit;backdrop-filter:blur(8px)} #xiaoeknown-helper-snack.show{opacity:1;transform:translateX(-50%) translateY(0)} [data-xiaoe-correct]{outline:2px solid #34c759!important;outline-offset:2px;background:rgba(52,199,89,.1)!important;border-radius:4px} .xe-busy{opacity:.55;pointer-events:none} `; document.head.appendChild(css); panel = document.createElement('div'); panel.id = 'xiaoeknown-helper-panel'; panel.innerHTML = `
ko网安云课堂 KO网安
课程
答题
倍速播放
倍速${CONFIG.speed}×
倍速/跳转后学习时长会少算,视频结束会自动补齐,也可手动点「补齐学习时长」。
时长补齐
解锁跳转
自动抓取/高亮
答题间隔(ms)
就绪。「秒答全部」先并发抓全答案,再快速填完并自动加载后续题目。
`; document.body.appendChild(panel); panelBody = document.getElementById('xiaoeknown-helper-body'); // 标签切换 const savedTab = store.get('panelTab', 'course'); switchTab(savedTab); panel.querySelectorAll('#xiaoeknown-helper-tabs .tab').forEach(t => { t.onclick = () => switchTab(t.dataset.tab); }); // 恢复位置与展开状态 const savedPos = store.get('panelPos', null); if (savedPos && savedPos.left != null) { panel.style.left = savedPos.left + 'px'; panel.style.top = savedPos.top + 'px'; panel.style.right = 'auto'; } const startExpanded = store.get('panelExpanded', true); if (!startExpanded) collapsePanel(); else expandPanel(); // 折叠/展开:tgl 按钮切换;拖拽由 mousedown 处理(含折叠态点击展开) document.getElementById('xe-tgl').onclick = (e) => { e.stopPropagation(); togglePanel(); }; panel.querySelector('#xiaoeknown-helper-head').addEventListener('mousedown', onPanelDragStart); document.getElementById('xe-auto').onchange = (e) => { CONFIG.autoFetch = e.target.checked; store.set('autoFetch', CONFIG.autoFetch); }; document.getElementById('xe-fill').onclick = () => { setBusy(true); autoFillPage(false).finally(() => setBusy(false)); }; document.getElementById('xe-fillall').onclick = () => autoFillAll(true); document.getElementById('xe-es').onchange = (e) => { CONFIG.enableSpeed = e.target.checked; store.set('enableSpeed', CONFIG.enableSpeed); CONFIG.enableSpeed ? applySpeedToVideo() : resetVideoSpeed(); }; document.getElementById('xe-speed').oninput = (e) => { CONFIG.speed = parseFloat(e.target.value); document.getElementById('xe-speed-val').textContent = CONFIG.speed + '×'; store.set('speed', CONFIG.speed); applySpeedToVideo(); }; document.getElementById('xe-reset').onclick = () => { CONFIG.enableSpeed = false; document.getElementById('xe-es').checked = false; document.getElementById('xe-speed').value = 1; document.getElementById('xe-speed-val').textContent = '1×'; store.set('speed', 1); store.set('enableSpeed', false); restoreDefaultSpeed(); snack('已恢复默认(1×,交还平台控制)'); }; document.getElementById('xe-hb').onchange = (e) => { CONFIG.heartbeatFill = e.target.checked; store.set('heartbeatFill', CONFIG.heartbeatFill); }; document.getElementById('xe-filltime').onclick = () => { setBusy(true); fillLearnTime().finally(() => setBusy(false)); }; document.getElementById('xe-seek').onchange = (e) => { CONFIG.seekUnlock = e.target.checked; store.set('seekUnlock', CONFIG.seekUnlock); if (CONFIG.seekUnlock) unlockSeekUI(); }; document.getElementById('xe-seekend').onclick = () => seekToEnd(); document.getElementById('xe-delay').onchange = (e) => { CONFIG.autoFillDelay = Math.max(100, +e.target.value || 500); store.set('autoFillDelay', CONFIG.autoFillDelay); }; } function switchTab(name) { panel.querySelectorAll('#xiaoeknown-helper-tabs .tab').forEach(t => t.classList.toggle('on', t.dataset.tab === name)); panel.querySelectorAll('.xe-page').forEach(p => p.classList.toggle('on', p.dataset.page === name)); store.set('panelTab', name); } function togglePanel() { if (panel.classList.contains('collapsed')) expandPanel(); else collapsePanel(); } function expandPanel() { panelExpanded = true; panel.classList.remove('collapsed'); panelBody.classList.add('show'); const tgl = document.getElementById('xe-tgl'); if (tgl) tgl.textContent = '▾'; store.set('panelExpanded', true); } function collapsePanel() { panelExpanded = false; panel.classList.add('collapsed'); panelBody.classList.remove('show'); const tgl = document.getElementById('xe-tgl'); if (tgl) tgl.textContent = '▴'; store.set('panelExpanded', false); } // 拖拽:展开态拖标题栏、折叠态拖圆点均可;位移>阈值才算拖拽,否则视作点击 let dragState = null; function onPanelDragStart(e) { if (e.button !== 0) return; if (e.target.closest('.tgl')) return; // 折叠按钮自己处理 if (e.target.closest('button,input,.tab,.sw')) return; // 点控件/开关不触发拖拽 const r = panel.getBoundingClientRect(); dragState = { sx: e.clientX, sy: e.clientY, ox: r.left, oy: r.top, moved: false, collapsed: panel.classList.contains('collapsed'), }; panel.classList.add('dragging'); document.addEventListener('mousemove', onPanelDragMove); document.addEventListener('mouseup', onPanelDragEnd); e.preventDefault(); } function onPanelDragMove(e) { if (!dragState) return; const dx = e.clientX - dragState.sx, dy = e.clientY - dragState.sy; if (!dragState.moved && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) dragState.moved = true; if (!dragState.moved) return; let nx = dragState.ox + dx, ny = dragState.oy + dy; const w = panel.offsetWidth, h = panel.offsetHeight; nx = Math.max(4, Math.min(window.innerWidth - w - 4, nx)); ny = Math.max(4, Math.min(window.innerHeight - h - 4, ny)); panel.style.left = nx + 'px'; panel.style.top = ny + 'px'; panel.style.right = 'auto'; } function onPanelDragEnd() { if (!dragState) return; panel.classList.remove('dragging'); const moved = dragState.moved; const wasCollapsed = dragState.collapsed; const r = panel.getBoundingClientRect(); store.set('panelPos', { left: Math.round(r.left), top: Math.round(r.top) }); dragState = null; document.removeEventListener('mousemove', onPanelDragMove); document.removeEventListener('mouseup', onPanelDragEnd); // 未发生位移 = 点击:折叠态则展开 if (!moved && wasCollapsed) expandPanel(); } // 计数式 busy:内外层嵌套调用时,只有最外层结束才真正解除,防止并发重复执行 let busyCount = 0; function setBusy(b) { busyCount = Math.max(0, busyCount + (b ? 1 : -1)); if (panel) panel.classList.toggle('xe-busy', busyCount > 0); } /* ================= 通知 ================= */ let snackTimer; function snack(msg) { let el = document.getElementById('xiaoeknown-helper-snack'); if (!el) { el = document.createElement('div'); el.id = 'xiaoeknown-helper-snack'; document.body.appendChild(el); } el.textContent = msg; el.classList.add('show'); clearTimeout(snackTimer); snackTimer = setTimeout(() => el.classList.remove('show'), 4500); } /* ================= 拦截考卷接口 ================= */ // 只记录"接口确实响应了"这一事实:作为懒加载推进失败的诊断信号,不再作为终止判据 let lastPageRespAt = 0; let lastPageErr = null; // 最近一次考卷接口的非 0 返回(用于识别限速/风控) let selfFetchCount = 0; // 自有请求计数:避免 hook 对我们自己抓的答案重复 ingest function notePageResponse(data) { lastPageRespAt = Date.now(); if (data && data.code === 0) { lastPageErr = null; return; } lastPageErr = { code: data && data.code, msg: String((data && (data.msg || data.message || data.error)) || '').slice(0, 60) }; } function pageErrText() { return lastPageErr ? `接口返回 code=${lastPageErr.code}${lastPageErr.msg ? ' ' + lastPageErr.msg : ''}` : ''; } // 缓存已拦截到的考试标识(question_page 请求参数 / join_exam 响应) // SPA 页面 URL 里往往没有 exam_id/uexam_id,但接口请求里一定带 const examReqCache = {}; function cacheExamReqParams(u) { try { const sp = new URL(u, location.href).searchParams; for (const k of ['app_id', 'user_id', 'exam_id', 'uexam_id']) { const v = sp.get(k); if (v) examReqCache[k] = v; } } catch (e) { /* ignore */ } } let __origFetch = window.fetch; function installHooks() { // ---- XHR:考卷答案 + 心跳补齐 ---- const origOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function (method, url) { this.__xeUrl = url; return origOpen.apply(this, arguments); }; const origSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function (body) { const u = String(this.__xeUrl || ''); if (body instanceof URLSearchParams) body = body.toString(); // 兼容 URLSearchParams 提交 // 心跳补齐(XHR 方式):捕获请求 + 响应 data_check if (isHeartbeatUrl(u) && typeof body === 'string') { let hb = null; try { hb = handleRealHeartbeat(body, u); } catch (e) { /* ignore */ } const reqHb = hb; this.addEventListener('load', function () { if (reqHb) { try { markValidCoverage(reqHb, this.responseText); } catch (e) { /* ignore */ } } }); } // 考卷答案 if (u.includes('/xe.exam.question_page/1.0.0')) { cacheExamReqParams(u); this.addEventListener('load', function () { try { if (this.status === 200) { const data = JSON.parse(this.responseText); notePageResponse(data); if (data && data.code === 0 && data.data && data.data.question_list) { ingestQuestions(data.data.question_list, u); if (CONFIG.autoFetch) { const m = u.match(/page=(\d+)/); snack(`已抓取第 ${m ? m[1] : '?'} 页答案并高亮`); } } } } catch (e) { /* ignore */ } }); } return origSend.apply(this, arguments); }; // ---- fetch:考卷答案 + 心跳补齐 ---- const origFetch = window.fetch; window.fetch = function (input, init) { const url = typeof input === 'string' ? input : (input && input.url) || ''; const p = origFetch.apply(this, arguments); if (!selfFetchCount && url.includes('/xe.exam.question_page/1.0.0')) { cacheExamReqParams(url); p.then(r => r.clone().json().then(data => { notePageResponse(data); if (data && data.code === 0 && data.data && data.data.question_list) { ingestQuestions(data.data.question_list, url); } })).catch(() => {}); } // 开始考试接口:拿本次答卷 uexam_id(data.user_join_id) if (url.includes('/xe.exam.join_exam/1.0.0')) { p.then(r => r.clone().json().then(data => { try { if (data && data.code === 0 && data.data && data.data.user_join_id) { examReqCache.uexam_id = data.data.user_join_id; } } catch (e) { /* ignore */ } })).catch(() => {}); } if (isHeartbeatUrl(url) && init && init.body) { try { let body = init.body; if (body instanceof URLSearchParams) body = body.toString(); const reqHb = handleRealHeartbeat(body, url); if (reqHb) { p.then(r => r.clone().text().then(t => markValidCoverage(reqHb, t)).catch(() => {})) .catch(() => {}); } } catch (e) { /* ignore */ } } return p; }; __origFetch = origFetch; } function isHeartbeatUrl(url) { return /learnrecord/i.test(url) && /pushdata/i.test(url); } /* ================= 答案缓存 / 高亮 ================= */ // questionNorm -> Set 正确答案文本集合(兜底用) const answerMap = new Map(); // questionId -> { correctIds, correctIdx:Set, optMap, correctTexts:Set, content } // correctIdx = 正确选项在 q.option 数组中的下标(DOM 选项按同序渲染,按下标点最稳) const answerById = new Map(); let highlightScheduled = false; let answerTotalDeclared = 0; // 接口声明的总题数(用于提示"是否抓全") let fastMode = false; // 秒答模式:压缩所有等待 function ingestQuestions(list, url) { for (const q of list) { const qt = norm(q.content); const opts = q.option || []; const correctIds = new Set((q.correct_answer || []).flat()); const optMap = {}; opts.forEach((op, i) => { optMap[op.id] = op.content; }); // 正确选项下标:correct_answer 里的 option_id 在 option 数组中的位置 const correctIdx = new Set(); for (const id of correctIds) { const idx = opts.findIndex(op => op.id === id); if (idx >= 0) correctIdx.add(idx); } const correctTexts = new Set(); for (const id of correctIds) correctTexts.add(norm(optMap[id] || id)); answerMap.set(qt, correctTexts); answerById.set(q.id, { correctIds, correctIdx, optMap, correctTexts, content: qt }); } // 答案到了,择机高亮(DOM 可能尚未渲染,延迟 + 重试) if (CONFIG.autoFetch) scheduleHighlight(); } // 延迟高亮:rAF 后扫描,等 Vue 渲染出选项 // 关键:一旦某一轮"没有任何新标记"就立即停止,不再空转 function scheduleHighlight() { if (highlightScheduled) return; highlightScheduled = true; let tries = 0; const maxFast = 6, maxSlow = 4; const run = () => { const marked = highlightAll(); tries++; if (marked === 0) { highlightScheduled = false; return; } // 已全部标记或尚未渲染:交给 MutationObserver 兜底 if (tries < maxFast) setTimeout(run, 500); else if (tries < maxFast + maxSlow) setTimeout(run, 2000); else highlightScheduled = false; }; // requestAnimationFrame 在部分环境(如 jsdom/旧浏览器)中可能不存在,fallback 到 setTimeout const raf = window.requestAnimationFrame || ((fn) => setTimeout(fn, 0)); raf(run); } // MutationObserver 兜底:DOM 变化时检查是否有未高亮的正确选项(300ms 节流,避免高频 DOM 变更时重复全量扫描) let _highlightMO = null, _highlightPending = false; function ensureHighlightObserver() { if (_highlightMO) return; _highlightMO = new MutationObserver(() => { if (answerById.size === 0 || _highlightPending) return; _highlightPending = true; setTimeout(() => { _highlightPending = false; checkMissingHighlights(); }, 300); }); _highlightMO.observe(document.documentElement, { childList: true, subtree: true }); } function checkMissingHighlights() { if (answerById.size === 0 || fastMode) return; const idx = domBlockIndex(); if (idx.size === 0) return; // 用索引定位,避免"每题一次全文档查询" for (const [qid, info] of answerById) { const block = idx.get(qid); if (!block) continue; const opts = collectOptionElements(block); for (const i of info.correctIdx) { const el = opts[i]; if (el && !el.getAttribute('data-xiaoe-correct')) { highlightAll(); // 发现未标记的,触发一次全量高亮 return; } } } } // 当前 DOM 题目索引:一次查询建表,替代"每道题各自 document.querySelector"(100 题就是 100 次全文档扫描) function domBlockIndex() { const map = new Map(); document.querySelectorAll('[data-question-id]').forEach(e => { const k = e.getAttribute('data-question-id'); if (k && !map.has(k)) map.set(k, e); }); return map; } // 按下标高亮:对每道已缓存题,定位 DOM 题块,高亮其第 N 个选项;返回本轮新标记数 function highlightAll() { if (answerById.size === 0) return 0; const idx = domBlockIndex(); let marked = 0; for (const [qid, info] of answerById) { let block = idx.get(qid); if (!block && idx.size === 0) block = findQuestionBlockFallback(qid, info.content); // 无 id 的站点才走昂贵兜底 if (!block) continue; const opts = collectOptionElements(block); for (const i of info.correctIdx) { const el = opts[i]; if (el && !el.getAttribute('data-xiaoe-correct')) { el.setAttribute('data-xiaoe-correct', ''); el.setAttribute('data-xiaoe-checked', ''); marked++; } } } return marked; } function cssEscape(s) { try { return CSS.escape(String(s)); } catch (e) { return String(s).replace(/["\\]/g, '\\$&'); } } function findQuestionBlockFallback(qid, content) { // 无 data-question-id 时按题干文本定位 if (content) { const el = findDeepestByNorm(content); if (el) return el.closest('.question-wrapper,.question-item,[class*="question-block"]') || el.parentElement; } return null; } /* ================= 解析考试标识 ================= */ // 优先级:URL 参数 > 已拦截的接口请求参数/响应 > 页面 HTML 抓取 let _pageTextCache = null; // outerHTML 序列化昂贵,缓存一次避免轮询时反复抓取 function resolveIds() { const params = new URLSearchParams(location.search); let examId = params.get('exam_id') || examReqCache.exam_id; let uexamId = params.get('uexam_id') || examReqCache.uexam_id; let userId = params.get('user_id') || examReqCache.user_id; let appId = params.get('app_id') || examReqCache.app_id; if (!examId || !uexamId || !userId || !appId) { const pageText = _pageTextCache || (_pageTextCache = document.documentElement.outerHTML); const grab = (re) => { const m = pageText.match(re); return m ? m[1] : null; }; examId = examId || grab(/["']?exam_id["']?\s*[:=]\s*["']([^"']+)["']/); uexamId = uexamId || grab(/["']?uexam_id["']?\s*[:=]\s*["']([^"']+)["']/); userId = userId || grab(/["']?user_id["']?\s*[:=]\s*["']([^"']+)["']/); appId = appId || grab(/["']?app_id["']?\s*[:=]\s*["']([^"']+)["']/); } return { examId, uexamId, userId, appId }; } function buildQs(appId, userId, examId, uexamId, page, size) { const qs = new URLSearchParams(); if (appId) qs.set('app_id', appId); if (userId) qs.set('user_id', userId); qs.set('exam_id', examId); qs.set('uexam_id', uexamId); qs.set('page', page); qs.set('size', size); qs.set('include_answer_sheet', 0); return qs.toString(); } /* ================= 隐性获取全部答案 ================= */ // 若标识暂缺,最多等 6 秒等拦截到的接口请求(SPA 首次拉卷有个过程) async function waitForExamIds(timeout) { for (let i = 0; i < (timeout || 6000); i += 200) { const { examId, uexamId } = resolveIds(); if (examId && uexamId) return true; await sleep(200); } return false; } // 单页答案请求 async function fetchAnswerPage(appId, userId, examId, uexamId, page, size) { selfFetchCount++; // 标记自有请求,hook 不再重复 ingest try { const res = await fetch('/xe.exam.question_page/1.0.0?' + buildQs(appId, userId, examId, uexamId, page, size), { credentials: 'include' }); return await res.json(); } finally { selfFetchCount--; } } async function fetchAllAnswersSilent(announce) { let { examId, uexamId, userId, appId } = resolveIds(); if (!examId || !uexamId) { if (announce) snack('正在等待页面加载考试标识…'); const ok = await waitForExamIds(6000); if (!ok) { snack('未找到 exam_id / uexam_id,请在考试答题页运行'); return false; } ({ examId, uexamId, userId, appId } = resolveIds()); } setBusy(true); // 并发 3 页批取:答案接口总请求数很少(100 题=5 个),不再逐页 sleep,整体压到一个来回 const size = 20, CONC = 3, MAXPAGE = 30; let total = 0, declared = 0, page = 1, err = null; answerMap.clear(); answerById.clear(); try { while (page <= MAXPAGE) { const batch = []; for (let k = 0; k < CONC && page + k <= MAXPAGE; k++) batch.push(page + k); const settled = await Promise.all(batch.map(async p => { try { return { p, data: await fetchAnswerPage(appId, userId, examId, uexamId, p, size) }; } catch (e) { return { p, err: e }; } })); let short = false; for (const s of settled) { if (s.err) { if (!err) err = s.err; continue; } const data = s.data; if (!data || data.code !== 0 || !data.data || !data.data.question_list) { if (!err) err = { code: data && data.code, msg: (data && (data.msg || data.message)) || '' }; continue; } const list = data.data.question_list; ingestQuestions(list, 'page=' + s.p); total += list.length; // 优先按接口返回的总数提前结束;否则按短分页判断末页 declared = Math.max(declared, parseInt(data.data.total || 0, 10) || 0); if (list.length < size) short = true; } if (total === 0) { const why = err ? (err.message || err.msg || ('code=' + err.code)) : '接口返回异常'; snack('抓取答案失败:' + why); return false; } if (short || (declared && total >= declared)) break; page += CONC; } } finally { setBusy(false); } answerTotalDeclared = declared || total; if (announce) { snack(`已抓取 ${total} 题答案${declared && declared > total ? `(接口共 ${declared} 题,可能未抓全)` : ''},开始秒答`); } console.log('[ko网安云课堂] 已缓存答案,题数=', total, '接口声明总数=', declared); return total > 0; } /* ================= 自动答题:DOM 识别与填充 ================= - 定位优先级 data-question-id(与接口 q.id 同值)> 结构化 class > 文本匹配 - 真正可点元素是内层 .question-option-item(role=button tabindex=0) - element.click() 在 Vue 上静默失效,必须派发真实鼠标事件序列 - 选中态类挂在内层 .question-option-item,is-active/is-selected/is-checked - 验证用页面自身计数器“已完成 X/Y”,而非猜 class */ // className 可能是 SVGAnimatedString(SVG 元素),统一安全转小写字符串 function classStr(el) { const c = el && el.className; return String(c && c.baseVal !== undefined ? c.baseVal : c || '').toLowerCase(); } // 取一个选项的“正文文本”:优先 .question-option-item__content,否则去掉前导字母 function optionContentText(el) { const c = el.querySelector('.question-option-item__content') || el.querySelector('[class*="content"]'); if (c) return c.textContent; const t = norm(el.textContent).replace(/^[A-Z]\s*/, ''); return t; } function collectOptionElements(scope) { scope = scope || document; // 真实可点元素是内层 .question-option-item(role=button tabindex=0) let els = scope.querySelectorAll('.question-option-item'); if (els.length === 0) els = scope.querySelectorAll('[role="button"][tabindex]'); if (els.length === 0) els = scope.querySelectorAll('.question-select-item'); if (els.length === 0) { const set = new Set(); scope.querySelectorAll('[class*="option"],[role="radio"],[role="checkbox"],li,label') .forEach(e => { if (isOptionish(e)) set.add(e); }); els = [...set]; } return [...els]; } function isOptionish(el) { const t = norm(optionContentText(el)); if (!t || t.length > 200) return false; if (el.querySelector('input[type=radio],input[type=checkbox]')) return true; return /opt|answer|choice|select-item/.test(classStr(el)); } // 取真正接收点击的元素:.question-option-item 已是可点层,否则往下找 function clickTarget(el) { if (el.classList && el.classList.contains('question-option-item')) return el; const inner = el.querySelector('.question-option-item, [role="button"][tabindex]'); if (inner) return inner; const inp = el.querySelector('input[type=radio],input[type=checkbox]'); return inp || el; } function isSelected(el) { const tgt = clickTarget(el); // 选中态类在 .question-option-item 这层(注意外层 .active 包裹层是结构类,非选中态,不能用于判断) if (/(^|\W)(is-active|is-selected|is-checked|is-choosed|is-on|selected|checked|chosen)(\W|$)/.test(classStr(tgt))) return true; if (tgt.getAttribute('aria-checked') === 'true') return true; if (tgt.getAttribute('aria-pressed') === 'true') return true; const inp = tgt.querySelector ? tgt.querySelector('input[type=radio],input[type=checkbox]') : null; if (inp && inp.checked) return true; return false; } // 派发完整 pointer + mouse 事件序列(Vue 组件常监听其中任一环节,element.click() 会静默失效) function dispatchMouseSeq(el, common) { const seq = ['pointerover', 'pointermove', 'pointerenter', 'mouseover', 'mousemove', 'mousedown', 'focus', 'pointerup', 'mouseup', 'click']; for (const type of seq) { try { const Evt = type.startsWith('pointer') ? PointerEvent : (type === 'focus' ? FocusEvent : MouseEvent); el.dispatchEvent(new Evt(type, Object.assign({}, common, { type }))); } catch (e) { /* ignore */ } } } // 秒答快路径:一次合成事件序列(dispatchEvent 是同步的,Vue 立即处理),仅极短验证;失败回退完整链 // 注意:Vue 的响应式更新是异步的(微任务),sleep(6) 可能不够,所以失败后必须回退 realClick async function fastClick(el) { const tgt = clickTarget(el); if (isSelected(el)) return true; let r; try { r = tgt.getBoundingClientRect(); } catch (e) { r = null; } const x = r ? Math.round(r.left + r.width / 2) : 0; const y = r ? Math.round(r.top + r.height / 2) : 0; const common = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: 0, composed: true }; dispatchMouseSeq(tgt, common); await sleep(6); // 让出事件循环,等 Vue 刷新选中态 return isSelected(el); } // 真实鼠标事件序列点击;若未选中则回退:点父层 → el.click() → 键盘 Enter async function realClick(el) { const tgt = clickTarget(el); try { tgt.scrollIntoView({ block: 'center' }); } catch (e) { /* ignore */ } await tick(40); let r; try { r = tgt.getBoundingClientRect(); } catch (e) { r = null; } const x = r ? Math.round(r.left + r.width / 2) : 0; const y = r ? Math.round(r.top + r.height / 2) : 0; const common = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: 0, composed: true }; // 策略 1:完整 pointer + mouse 序列 dispatchMouseSeq(tgt, common); await tick(60); if (isSelected(el)) return true; // 策略 2:点外层 .question-select-item(监听可能挂在父层) const parent = tgt.closest('.question-select-item') || tgt.parentElement; if (parent && parent !== tgt) { for (const type of ['mousedown', 'mouseup', 'click']) { try { parent.dispatchEvent(new MouseEvent(type, common)); } catch (e) { /* ignore */ } } await tick(60); if (isSelected(el)) return true; } // 策略 3:原生 .click() try { tgt.click(); } catch (e) { /* ignore */ } await tick(60); if (isSelected(el)) return true; // 策略 4:键盘 Enter 激活(role=button tabindex=0) try { tgt.focus && tgt.focus(); for (const type of ['keydown', 'keypress', 'keyup']) { tgt.dispatchEvent(new KeyboardEvent(type, { bubbles: true, cancelable: true, key: 'Enter', code: 'Enter', keyCode: 13, charCode: 13 })); } } catch (e) { /* ignore */ } await tick(60); return isSelected(el); } function findQuestionBlocks() { const set = new Set(); // 首选:data-question-id(与接口 q.id 同值,最稳) document.querySelectorAll('[data-question-id]').forEach(e => set.add(e)); // 次选:结构化容器 class const sels = [ '.question-wrapper', '.xe-question', '.xe-question-item', '.question-item', '.exam-question', '[class*="question-block"]', '[class*="QuestionItem"]' ]; for (const s of sels) document.querySelectorAll(s).forEach(e => set.add(e)); if (set.size === 0) { // 兜底:用答案题干文本反查容器 for (const qt of answerMap.keys()) { const el = findDeepestByNorm(qt); if (el) { let b = el.parentElement; for (let i = 0; i < 4 && b; i++, b = b.parentElement) { if (b.querySelectorAll('.question-option-item,.question-select-item,[role="button"]').length >= 2) { set.add(b); break; } } } } } return [...set]; } function findDeepestByNorm(text) { if (!text) return null; const all = document.querySelectorAll('p,div,li,span,label,h1,h2,h3,h4,h5,h6'); let best = null, bestDepth = -1; for (const el of all) { if (norm(el.textContent) === text) { const d = depth(el); if (bestDepth < 0 || d > bestDepth) { best = el; bestDepth = d; } } } return best; } function depth(el) { let d = 0; for (let n = el; n; n = n.parentElement) d++; return d; } function getQuestionText(block) { // 题干在 .question-content / .question-block__header,被富文本 xe-preview 包裹 const stem = block.querySelector('.question-content, .question-block__header, [class*="stem"], [class*="content"], p'); if (stem) { const clone = stem.cloneNode(true); clone.querySelectorAll('.question-option-item,.question-select-item,[role="button"],[role="radio"],[role="checkbox"],li').forEach(e => e.remove()); const t = norm(clone.textContent); if (t) return t; } return norm(block.textContent); } // 题号(.question-block__index 文本如 "4.") function getQuestionNo(block) { const idx = block.querySelector('.question-block__index, .question-index, [class*="index"]'); if (idx) { const m = norm(idx.textContent).match(/(\d+)/); if (m) return Number(m[1]); } const m = norm(block.textContent).match(/^(\d+)\./); return m ? Number(m[1]) : null; } // 逐题间隔:秒答模式只让出事件循环,不做人为延迟 async function pauseBetweenClicks(d) { if (fastMode) { await sleep(0); return; } await sleep(rand(d, d * 1.6)); } async function autoFillPage(silent, fast) { const localFast = !!fast; const prevFast = fastMode; fastMode = localFast; if (answerById.size === 0 && answerMap.size === 0) { if (!silent) snack('正在获取答案…'); // 内部会自动隐性获取 await fetchAllAnswersSilent(false); } const blocks = findQuestionBlocks(); let filled = 0, matched = 0, failed = 0, unmatched = []; const d = CONFIG.autoFillDelay; for (const block of blocks) { // 首选:data-question-id 联表 + 下标点选(最稳,不受文本 normalize 影响) const qid = block.getAttribute('data-question-id'); let info = qid ? answerById.get(qid) : null; let mode = info ? 'idx' : null; // 次选:题干文本匹配 if (!info) { const qt = getQuestionText(block); info = answerMap.get(qt); if (!info) { for (const [k, v] of answerMap) { if (qt.includes(k) || k.includes(qt)) { info = v; break; } } } if (info) mode = 'txt'; } if (!info) { const no = getQuestionNo(block); unmatched.push(no || '?'); continue; } matched++; const optEls = collectOptionElements(block); if (mode === 'idx' && info.correctIdx && optEls.length) { // 下标点选 for (const idx of info.correctIdx) { const el = optEls[idx]; if (el && !isSelected(el)) { // 秒答用 fastClick(单次派发+极短验证),失败则回退 realClick(完整级联) let ok = fastMode ? await fastClick(el) : false; if (!ok) ok = await realClick(el); if (ok) filled++; else failed++; await pauseBetweenClicks(d); } } } else { // 文本兜底 const correctTexts = info.correctTexts || info; for (const el of optEls) { const t = norm(optionContentText(el)); if (t && correctTexts.has && correctTexts.has(t) && !isSelected(el)) { let ok = fastMode ? await fastClick(el) : false; if (!ok) ok = await realClick(el); if (ok) filled++; else failed++; await pauseBetweenClicks(d); } } } } // 通用兜底(无题容器时的全页扫描):只在稳妥模式做,秒答路径跳过以保速度 if (blocks.length === 0 && !fastMode) { // 通用兜底:无题容器,全页扫描正确项 for (const el of collectOptionElements(document)) { const t = norm(optionContentText(el)); let isCorrect = false; for (const o of answerById.values()) if (o.correctTexts.has(t)) { isCorrect = true; break; } if (!isCorrect) for (const opts of answerMap.values()) if (opts.has(t)) { isCorrect = true; break; } if (isCorrect && !isSelected(el)) { const ok = await realClick(el); if (ok) filled++; else failed++; await pauseBetweenClicks(d); } } } const prog = readProgress(); if (!silent) { // 以页面自身进度计数器为权威结果;realClick 的 isSelected 判据不可靠, // "失败" 多为误报,故只在确实没选中任何项时才提示,避免答对了却报匹配失败 let msg; const progNum = prog ? parseInt(prog, 10) : 0; // prog 形如 "X/Y",取分子判断 if (filled > 0 || progNum > 0) { msg = `已选中 ${filled} 项`; if (prog) msg += `,进度 ${prog}`; } else { msg = `未选中任何选项(${unmatched.length ? '有题未匹配,' : ''}请检查 DOM 结构)`; if (unmatched.length) msg += `;未匹配题号:${unmatched.join(',')}`; } snack(msg); } fastMode = prevFast; return filled; } // 读页面自身“已完成 X/Y”计数器,作为权威判据 function readProgress() { const m = (document.body.innerText || '').match(/已完成\s*(\d+)\s*\/\s*(\d+)/); if (m) return m[1] + '/' + m[2]; const m2 = (document.body.innerText || '').match(/已答\s*(\d+)\s*\/\s*(\d+)/); return m2 ? m2[1] + '/' + m2[2] : null; } // fast=true:秒答模式,跳过逐题等待,尽快填完并推进懒加载 async function autoFillAll(fast) { const localFast = !!fast; // 局部变量,不碰全局 fastMode(避免 finally 恢复逻辑泄漏) const prevFast = fastMode; fastMode = localFast; if (!(await fetchAllAnswersSilent(true))) { fastMode = prevFast; return; } setBusy(true); try { const total = answerById.size; let rounds = 0, totalFilled = 0, stall = 0; const maxRounds = Math.max(40, Math.ceil(total / 8) + 20); // 流水线:填当前 DOM 的同时就触发下一批加载,加载时间被 fill 时间掩盖 → 零切换停顿 let pendingWait = null, lastSig = null, lastTexts = null; for (; rounds < maxRounds; rounds++) { // 1. 等上一轮触发的加载出题(与本轮填充分重叠,通常立即返回) if (pendingWait) { let ok = await pendingWait; pendingWait = null; if (!ok && lastPageErr && localFast) { // 疑似限速/风控:退化为稳妥节奏重试一次 console.warn('[ko网安云课堂] 接口报错,退化为稳妥节奏重试:' + pageErrText()); fastMode = false; await sleep(1200); await triggerAdvance(); ok = await waitForAdvance(lastSig, lastTexts, 10000); fastMode = true; } if (!ok) { if (++stall >= 2) break; // 连续两轮推进失败才放弃 await sleep(1000); // 退避,避免把限速踩得更死 await triggerAdvance(true); // 重试用完整鼠标序列派发(换一种点击方式) pendingWait = waitForAdvance(lastSig, lastTexts, localFast ? 4000 : 8000); continue; } stall = 0; } // 2. 触发下一批加载(与本次 fill 重叠;按钮无更多页时本次触发会被平台忽略,无害) const sig = snapshotDomQuestions(); lastSig = sig.set; lastTexts = sig.texts; try { triggerAdvance(); } catch (e) { /* ignore */ } // 3. 填当前 DOM totalFilled += await autoFillPage(true, localFast); const st = cacheStats(); setExamStatus(`答题中:已答 ${st.answered}/${total} 题(第 ${rounds + 1} 轮)`); if (total > 0 && st.answered >= total) break; // 缓存里的题全部答完,收工 // 4. 挂起等待(下一轮开头消费) pendingWait = waitForAdvance(lastSig, lastTexts, localFast ? 3000 : 8000); await sleep(localFast ? 30 : rand(500, 900)); } if (pendingWait) { try { await pendingWait; } catch (e) { /* ignore */ } } // 收尾:把当前渲染出来、还没填上的题补一遍 let st = cacheStats(); if (total > 0 && st.pending > 0) { totalFilled += await autoFillPage(true, localFast); st = cacheStats(); } const prog = readProgress(); let msg; if (total > 0) { msg = `答题完成:已答 ${st.answered}/${total} 题`; if (st.answered < total) { const pend = pendingQuestionNos(); msg += pend.length ? `,未答题号 ${pend.slice(0, 8).join(',')}${pend.length > 8 ? '…' : ''}` : '(部分题未加载,可滚动列表后重试)'; } } else { msg = `答题完成:${rounds} 轮,填充 ${totalFilled} 项${prog ? ',进度 ' + prog : ''}`; } setExamStatus(msg); snack(msg); console.log('[ko网安云课堂] 答题结束', { rounds, totalFilled, 缓存题数: total, 已渲染: st.rendered, 已答: st.answered, 页面进度: prog }); } finally { fastMode = prevFast; setBusy(false); // 结束后统一做一次高亮(秒答期间为保速度跳过了高亮) scheduleHighlight(); } } /* ================= 答题推进:纯 DOM 判据 + 多策略懒加载 ================= 终止判据不看页面计数器、也不依赖接口响应结构,而是直接比对“缓存答案里的每道题是否都已渲染并选中”。 这样在滑动窗口(DOM 只保留 ~30 题)下也不会因为旧题被移除而误判“无进展”提前停下。 */ // 当前 DOM 中题目签名集合(优先 data-question-id,无则用题干文本) // 返回 { set, texts },texts 用于滑动窗口下检测"id 复用但内容变化" function snapshotDomQuestions() { const set = new Set(); const texts = new Map(); // qid -> 文本快照,用于检测滑动窗口下的内容变化 const qids = document.querySelectorAll('[data-question-id]'); if (qids.length) { qids.forEach(e => { const k = e.getAttribute('data-question-id'); set.add(k); texts.set(k, norm(e.textContent || '').slice(0, 60)); }); return { set, texts }; } for (const b of findQuestionBlocks()) { const t = getQuestionText(b) || norm(b.textContent).slice(0, 80); set.add(t); texts.set(t, t); } return { set, texts }; } // 是否出现了签名集合之外的新题(追加式与滑动窗口式都能识别) // 关键:滑动窗口会复用 DOM 元素(data-question-id 可能复用),所以还要检查"文本内容是否变化" function hasNewQuestions(prevSig, prevTexts) { if (!prevSig || prevSig.size === 0) return true; const qids = document.querySelectorAll('[data-question-id]'); if (qids.length) { for (const e of qids) { const qid = e.getAttribute('data-question-id'); if (!prevSig.has(qid)) return true; // 即使 id 复用,内容也可能变了(滑动窗口更新数据) if (prevTexts && prevTexts.has(qid)) { const newText = norm(e.textContent || '').slice(0, 60); if (newText !== prevTexts.get(qid)) return true; } } return false; } for (const s of snapshotDomQuestions().set) if (!prevSig.has(s)) return true; return false; } // 等新题渲染出来:MutationObserver 快路径(有 data-question-id 时)+ 轮询兜底 function waitForNewQuestions(prevSig, prevTexts, timeout, poll) { return new Promise(resolve => { let done = false; const finish = (ok) => { if (done) return; done = true; try { mo.disconnect(); } catch (e) { /* ignore */ } clearInterval(iv); clearTimeout(tm); resolve(ok); }; const mo = new MutationObserver(() => { const qids = document.querySelectorAll('[data-question-id]'); if (!qids.length) return; // 无 id 时交给轮询,避免高频重扫 for (const e of qids) { const qid = e.getAttribute('data-question-id'); if (!prevSig.has(qid)) return finish(true); // 检查内容是否变化(滑动窗口复用节点) if (prevTexts && prevTexts.has(qid)) { const newText = norm(e.textContent || '').slice(0, 60); if (newText !== prevTexts.get(qid)) return finish(true); } } }); try { mo.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-question-id'] }); } catch (e) { /* ignore */ } const iv = setInterval(() => { if (hasNewQuestions(prevSig, prevTexts)) finish(true); }, poll || 300); const tm = setTimeout(() => finish(false), timeout || 8000); }); } // 单个题块是否已按缓存答案选好 function isBlockDone(block, info) { const opts = collectOptionElements(block); if (!opts.length) return false; if (info && info.correctIdx && info.correctIdx.size) { for (const idx of info.correctIdx) { const el = opts[idx]; if (!el || !isSelected(el)) return false; } return true; } if (info && info.correctTexts && info.correctTexts.size) { const need = new Set([...info.correctTexts].filter(Boolean)); for (const el of opts) if (isSelected(el)) need.delete(norm(optionContentText(el))); return need.size === 0; } return opts.some(isSelected); } // 缓存视角的进度:total=缓存题数,rendered=当前渲染题数,pending=当前渲染但未答完,answered=累计已答完 // answered 用累计集合而非当前 DOM 计数:滑动窗口下旧题会被移除,只看 DOM 会让进度"倒退"永远到不了终点 const answeredSeen = new Set(); function cacheStats() { const idx = domBlockIndex(); if (idx.size > 0) { let pending = 0; for (const [qid, info] of answerById) { const block = idx.get(qid); if (!block) continue; if (isBlockDone(block, info)) answeredSeen.add(qid); else { answeredSeen.delete(qid); pending++; } } return { total: answerById.size, rendered: idx.size, pending, answered: answeredSeen.size }; } // 无 data-question-id 的站点:退化为按当前 DOM 题块统计 let rendered = 0, domAnswered = 0; for (const b of findQuestionBlocks()) { const opts = collectOptionElements(b); if (!opts.length) continue; rendered++; if (opts.some(isSelected)) domAnswered++; } const answered = Math.max(answeredSeen.size, domAnswered); return { total: answerById.size, rendered, pending: rendered - domAnswered, answered }; } // 已渲染但未答完的题号(用于提示还剩哪些题) function pendingQuestionNos() { const out = []; for (const [qid, info] of answerById) { const block = document.querySelector('[data-question-id="' + cssEscape(qid) + '"]'); if (!block) continue; if (!isBlockDone(block, info)) out.push(getQuestionNo(block) || qid); } return out; } // 滚动到题目列表底部(部分懒加载靠滚动触发而非按钮) function scrollQuestionArea() { const blocks = findQuestionBlocks(); const last = blocks[blocks.length - 1]; if (last) { try { last.scrollIntoView({ block: 'end' }); } catch (e) { /* ignore */ } } let n = last ? last.parentElement : document.body; while (n && n !== document.documentElement) { let st = null; try { st = getComputedStyle(n); } catch (e) { /* ignore */ } if (st && /(auto|scroll)/.test(st.overflowY) && n.scrollHeight > n.clientHeight + 20) { try { n.scrollTop = n.scrollHeight; } catch (e) { /* ignore */ } break; } n = n.parentElement; } try { const se = document.scrollingElement || document.documentElement; if (se.scrollHeight > se.clientHeight + 20) se.scrollTop = se.scrollHeight; } catch (e) { /* ignore */ } } // 触发下一批加载:点按钮(单次,保证一轮只加载一批,与 fill 节奏匹配,避免跳题)。 // 无可用按钮(无限滚动站点)时直接滚动。等待交给 waitForAdvance。 async function triggerAdvance(useDispatch) { const btn = findLoadMoreBtn(); if (btn && !(btn.disabled || btn.getAttribute('aria-disabled') === 'true')) { try { btn.scrollIntoView({ block: 'center' }); } catch (e) { /* ignore */ } await sleep(fastMode ? 10 : 60); try { await clickLoadMoreBtn(btn, useDispatch ? 'dispatch' : 'click'); } catch (e) { /* ignore */ } } else { scrollQuestionArea(); } } // 等待触发生效(新题渲染出来)。 // 两段式:先短等 800ms(按钮有效时足够),无效立即滚动兜底再等剩余时间 // —— 避免按钮失效时死等整个超时造成明显停顿(如最后一批) async function waitForAdvance(prevSig, prevTexts, timeout) { const total = timeout || (fastMode ? 3000 : 6000); if (await waitForNewQuestions(prevSig, prevTexts, Math.min(800, total), fastMode ? 60 : 300)) return true; scrollQuestionArea(); return await waitForNewQuestions(prevSig, prevTexts, Math.max(total - 800, 1200), fastMode ? 60 : 300); } // 面板上的实时状态文本 function setExamStatus(t) { const el = document.getElementById('xe-status'); if (el) el.textContent = t; } // 滑动窗口分页的“加载更多/下一页”按钮:可见性过滤 + 嵌套去重 + 文本变体 const LOAD_MORE_RE = /^(加载下一页|加载更多|查看更多|展开更多|下一批|更多题目)$/; const NO_MORE_RE = /(没有更多|已加载全部|已全部加载|到底了)/; function findLoadMoreBtn() { const textOf = (e) => ((e.innerText || e.textContent || '').trim()); // 可见性:优先用布局矩形;矩形为 0 时退化为样式判断,避免把真实按钮误判为不可见 const visible = (e) => { const r = e.getBoundingClientRect(); if (r.width > 0 && r.height > 0) return true; if (e.hasAttribute && e.hasAttribute('hidden')) return false; try { const st = getComputedStyle(e); return st.display !== 'none' && st.visibility !== 'hidden'; } catch (err) { return true; } }; const all = [...document.querySelectorAll('button,a,[role="button"],li,span,div,p')]; // 1. 精确文本匹配:嵌套时取最内层,优先真正的按钮/链接 const exact = all.filter(e => LOAD_MORE_RE.test(textOf(e)) && visible(e)); if (exact.length) { const inner = exact.filter(e => !exact.some(o => o !== e && e.contains(o))); const btns = inner.filter(e => e.tagName === 'BUTTON' || e.tagName === 'A' || e.getAttribute('role') === 'button'); return btns[0] || inner[0] || exact[exact.length - 1]; } // 2. 页面已明确提示"没有更多",不再寻找 if (NO_MORE_RE.test(document.body.innerText || '')) return null; // 3. 宽松兜底:短文本且含加载语义 const loose = all.filter(e => visible(e) && textOf(e).length < 16 && /加载更多|加载下一页|查看更多|下一批|更多题目/.test(textOf(e))); return loose.length ? loose[loose.length - 1] : null; } // 翻页按钮点击。只派发一种事件,避免同一点击被平台处理两次导致连翻两页(会跳题!): // mode='click' 原生 .click()(默认,多数平台可靠);mode='dispatch' 完整鼠标序列(重试时用) async function clickLoadMoreBtn(el, mode) { try { if (mode === 'dispatch') { const r = el.getBoundingClientRect(); const x = Math.round(r.left + r.width / 2); const y = Math.round(r.top + r.height / 2); const common = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: 0, composed: true }; dispatchMouseSeq(el, common); } else { el.click(); } } catch (e) { /* ignore */ } await sleep(fastMode ? 20 : 60); } /* ================= 心跳补齐(学习时长) ================= - 实时 pushData 在倍速下 client_time 增量 ≠ org_learn_progress 增量,直接注入易被风控识别, 故放弃实时注入,改走补发通道。 - 补发通道 /v1/learnRecord/rePushData:一批包同时到达是补发的正常形态,构造 1× 自洽的 缺口包走补发批量接口,最贴合平台自身设计。 - 补发包格式:对象数组,所有值 String 化,每批 ≤100。 - 有效性:is_play=1 才回 data_check:VALID。 - 自洽:org_learn_progress 增量 ≈ client_time 增量 ≈ 10s/包。 */ // 解析 form-urlencoded function parseFormBody(body) { const params = {}; if (!body) return params; const str = String(body); for (const kv of str.split('&')) { if (!kv) continue; const i = kv.indexOf('='); const k = decodeURIComponent(i < 0 ? kv : kv.slice(0, i)); const v = i < 0 ? '' : decodeURIComponent(kv.slice(i + 1)); params[k] = v; } return params; } // 每资源心跳状态 const hbState = {}; // rid -> { template, duration, seen:Set(VALID覆盖), lastCT, counter, filling } // 解析心跳请求体,返回 { rid, dur, prog, ct, isPlay, params } 或 null function parseHeartbeat(rawBody, url) { if (!isHeartbeatUrl(url)) return null; const p = parseFormBody(rawBody); if (!p.resource_id && !p.app_id) return null; const rid = p.resource_id || (p.app_id + ':' + (p.biz_id || p.resource_id || 'r')); return { rid, params: p, dur: parseFloat(p.duration || 0) || 0, prog: parseFloat(p.org_learn_progress || 0) || 0, ct: parseFloat(p.client_time || 0) || 0, isPlay: p.is_play === '1' || p.is_play === 1, }; } function ensureHbState(rid) { return hbState[rid] || (hbState[rid] = { template: null, duration: 0, seen: new Set(), lastCT: 0, counter: 0, filling: false }); } // 仅捕获模板 + 暂存请求信息(不在此标记覆盖,覆盖以服务端 data_check 为准) function handleRealHeartbeat(rawBody, url) { const hb = parseHeartbeat(rawBody, url); if (!hb) return; const st = ensureHbState(hb.rid); // 仅从"看起来有效"的包取模板(is_play=1 且 duration>0),避开那条常年 INVALID 的冗余通道 if (hb.isPlay && hb.dur > 0) { st.template = hb.params; st.duration = hb.dur || st.duration; st.lastCT = hb.ct || st.lastCT; // 记录真实心跳的上报域名:白标站可能不是 learnreport.xiaoeknow.com,补发必须走同一域名 try { const full = new URL(url, location.href); st.origin = full.origin; if (!st.originLogged) { st.originLogged = true; console.log('[ko网安云课堂] 心跳通道:', full.href); } } catch (e) { /* ignore */ } } return hb; } // 响应回来后,仅当 data_check=VALID 才标记该进度槽位为已覆盖 function markValidCoverage(hb, respText) { if (!hb) return; try { const d = JSON.parse(respText); if (d && d.data && d.data.data_check === 'VALID') { const st = ensureHbState(hb.rid); st.seen.add(Math.round(hb.prog / 10) * 10); } } catch (e) { /* ignore */ } } // 构造一个 1× 自洽的补发包(基于真实模板) function buildBatchPacket(template, prog, dur, clientTime, packetNumber) { const p = Object.assign({}, template); // 复制 channel_id / pv_id / play_state / visit / report_rate 等 // play_state 八段空格分隔:[末次事件, 后台推进, 倍率, paused, ended, readyState, videoFileLength, duration] // 第 3 段(索引2)为播放速率,补发包伪装 1×;paused/ended 置 false 模拟播放中 if (typeof p.play_state === 'string' && p.play_state.trim()) { const segs = p.play_state.trim().split(/\s+/); if (segs.length >= 6) { segs[2] = '1'; // 倍率 = 1 segs[3] = 'false'; // paused segs[4] = 'false'; // ended } p.play_state = segs.join(' '); } p.org_learn_progress = String(prog); p.progress = dur ? String(Math.min(100, Math.round(prog / dur * 10000) / 100)) : (template.progress || '0'); p.client_time = String(clientTime); p.packet_number = String(packetNumber); p.time_interval = '10'; p.time_delay = '10'; p.is_play = '1'; // 有效性判据:is_play=1 才回 VALID p.visit = template.visit || '1'; p.display_state = '0'; // 死字段恒 0 p.isSeekDisabled = template.isSeekDisabled || '1'; // 全部 String 化(补发格式要求) for (const k of Object.keys(p)) p[k] = String(p[k]); return p; } // 补齐单个资源的缺口包;返回 { sent, failed, unknown, range } async function fillOneResource(st) { const tpl = st.template; const dur = st.duration || parseFloat(tpl.duration || 0) || 0; const ti = 10; const totalSlots = dur ? Math.ceil(dur / ti) : 0; // 缺口:所有 10s 槽位中未被真实包覆盖的 const gaps = []; for (let k = 1; k <= totalSlots; k++) { const prog = k * ti; if (!st.seen.has(prog)) gaps.push(prog); } if (gaps.length === 0) return { sent: 0, failed: 0, unknown: 0, range: null }; // 1× 自洽时间线:以最后真实包时刻为锚,按 1× 回推 const lastCT = st.lastCT || Date.now(); const baseCT = lastCT - Math.round(dur * 1000); // 视频起始时刻(1× 假设) let pn = parseInt(tpl.packet_number || 0, 10) || 0; const batch = []; for (const prog of gaps) { pn++; const clientTime = baseCT + Math.round(prog * 1000); // 1×:prog 秒对应 prog*1000ms 后 batch.push(buildBatchPacket(tpl, prog, dur, clientTime, pn)); } // 分批 ≤100,走 rePushData let sent = 0, failed = 0, unknown = 0, lastErr = ''; for (let i = 0; i < batch.length; i += 100) { const chunk = batch.slice(i, i + 100); const resp = await sendRePushData(chunk, st.origin); const d = resp && resp.data; if (d && (d.code === 200 || d.code === 0)) { sent += chunk.length; chunk.forEach(pkt => st.seen.add(Math.round(parseFloat(pkt.org_learn_progress) / 10) * 10)); } else if (!resp || resp.status === 0) { unknown += chunk.length; // 网络层失败(CORS/断网):不标记 seen,保留缺口以便重试 lastErr = '网络请求失败'; } else { failed += chunk.length; // 有响应但不成功(404/403/业务错误) lastErr = 'HTTP ' + resp.status + (resp.bodyText ? ' ' + resp.bodyText : ''); } if (i + 100 < batch.length) await sleep(rand(300, 600)); // 批间间隔,降低风控感知 } return { sent, failed, unknown, range: [gaps[0], gaps[gaps.length - 1]], lastErr }; } // 计算并补齐缺口包(遍历所有已捕获模板的资源,多视频场景也能覆盖) // auto=true:自动触发(ended/周期兜底)。连续失败 ≥3 次的资源自动暂停,避免反复报网络错误刷屏(手动点击不受限) async function fillLearnTime(rid, auto) { if (!CONFIG.heartbeatFill) { snack('心跳补齐已关闭'); return; } const list = rid ? [hbState[rid]] : Object.values(hbState); let targets = list.filter(s => s && s.template && !s.filling); const paused = targets.filter(s => (s.failStreak || 0) >= 3); if (auto) targets = targets.filter(s => (s.failStreak || 0) < 3); if (!targets.length) { if (auto && paused.length) return; // 自动触发时静默跳过已暂停的资源 snack(list.some(s => s && s.filling) ? '正在补齐中…' : paused.length ? '补发连续失败已暂停,请手动点「补齐学习时长」重试' : '未捕获到心跳模板(先正常播放几秒)'); return; } let sent = 0, failed = 0, unknown = 0; const ranges = []; let lastErr = ''; for (const st of targets) { st.filling = true; try { const r = await fillOneResource(st); sent += r.sent; failed += r.failed; unknown += r.unknown; if (r.lastErr) lastErr = r.lastErr; if (r.range) ranges.push(r.range[0] + '~' + r.range[1] + 's'); // 连续失败计数:全部失败则 +1,有成功即清零;满 3 次暂停自动补齐 const allFailed = r.sent === 0 && (r.failed + r.unknown) > 0; st.failStreak = allFailed ? (st.failStreak || 0) + 1 : 0; if (st.failStreak === 3) console.warn('[ko网安云课堂] 该资源连续 3 次补发失败,已暂停自动补齐(' + (r.lastErr || '未知') + ')'); } finally { st.filling = false; } } let msg; if (sent + failed + unknown === 0) { msg = '无缺口,时长已达标'; } else { msg = `补齐 ${sent} 个心跳包(rePushData)`; if (failed) msg += `,失败 ${failed} 个(${lastErr})`; if (unknown) msg += `,${unknown} 个网络失败(已自动重试凭证),可再试`; if (ranges.length) msg += `,覆盖 ${ranges.join('、')}`; } snack(msg); } // 调用补发批量接口(直接用原始 fetch,绕过自身 hook) // origin 取自捕获到的真实心跳域名;带凭证失败时自动降级为不带凭证重试 // (部分上报域返回 Access-Control-Allow-Origin:* ,此时带 credentials 的跨域请求会被浏览器直接拒绝) async function sendRePushData(batch, origin) { const base = origin || 'https://learnreport.xiaoeknow.com'; const url = base + '/v1/learnRecord/rePushData'; const body = JSON.stringify(batch); for (const withCred of [true, false]) { try { const r = await __origFetch.call(window, url, { method: 'POST', headers: { 'Content-Type': 'application/json;charset=UTF-8' }, credentials: withCred ? 'include' : 'omit', body, }); let data = null, text = ''; try { text = await r.text(); data = JSON.parse(text); } catch (e) { /* 非 JSON 响应(可能是错误页) */ } console.log('[ko网安云课堂] rePushData 响应', { status: r.status, origin: base, withCred, resp: text.slice(0, 160) }); return { status: r.status, data, bodyText: text.slice(0, 160) }; } catch (e) { // 网络层失败(CORS 拒绝/断网):降级为不带凭证再试一次 console.warn('[ko网安云课堂] rePushData 请求失败(withCred=' + withCred + ')', { origin: base, err: e && e.message }); } } return { status: 0, data: null, bodyText: '' }; } // 视频结束自动补齐 + 周期补齐 function hookVideoLifecycle() { document.addEventListener('ended', (e) => { const v = e.target; if (!v || v.tagName !== 'VIDEO' || !CONFIG.heartbeatFill) return; // 视频结束即触发补齐(此时缺口最确定) setTimeout(() => fillLearnTime(null, true).catch(() => {}), rand(800, 1500)); }, true); // 周期兜底:每 45s 检查一次(防止用户中途关页,时长来不及补) setInterval(() => { if (!CONFIG.heartbeatFill) return; const playing = [...document.querySelectorAll('video')].some(v => !v.paused && !v.ended); if (playing) fillLearnTime(null, true).catch(() => {}); }, 45000); } /* ================= 视频倍速 ================= forbidChangeSpeed 插件在 timeupdate/ratechange 上几十毫秒内把 playbackRate 强制写回 1, 单纯轮询/重设无法稳定生效。用 Object.defineProperty 重写 video.playbackRate 的访问器: 拦截平台写 1,同时通过原生 setter 真正驱动媒体引擎。 */ function lockVideoRate(v) { if (v.__xeLocked) return; const proto = Object.getPrototypeOf(v); const desc = Object.getOwnPropertyDescriptor(proto, 'playbackRate') || Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'playbackRate'); if (!desc || !desc.set) return; const nativeSet = desc.set, nativeGet = desc.get; v.__xeLocked = true; v.__xeNativeSet = nativeSet; v.__xeEnforce = false; v.__xeRate = 1; try { Object.defineProperty(v, 'playbackRate', { configurable: true, get() { return v.__xeEnforce ? v.__xeRate : nativeGet.call(v); }, set(val) { // 强制模式下,拦截与目标速率不符的写入(平台回写 1) if (v.__xeEnforce && Math.abs(val - v.__xeRate) > 0.001) return; v.__xeRate = val; nativeSet.call(v, val); }, }); } catch (e) { /* ignore */ } } function applySpeedToVideo() { if (!CONFIG.enableSpeed) return; document.querySelectorAll('video').forEach(v => { lockVideoRate(v); v.__xeEnforce = true; v.__xeRate = CONFIG.speed; try { v.__xeNativeSet.call(v, CONFIG.speed); } catch (e) { /* ignore */ } }); } function resetVideoSpeed() { document.querySelectorAll('video').forEach(v => { lockVideoRate(v); v.__xeEnforce = false; v.__xeRate = 1; try { v.__xeNativeSet.call(v, 1); } catch (e) { /* ignore */ } }); } // 恢复默认:1× 且关闭强制,把播放速率控制权完全交还平台 function restoreDefaultSpeed() { CONFIG.speed = 1; store.set('speed', 1); resetVideoSpeed(); } function watchVideos() { const apply = () => { // 统一准备:锁倍速 + 锁时间轴(跳转解锁),再按需应用倍速 document.querySelectorAll('video').forEach(v => { lockVideoRate(v); lockVideoTime(v); }); if (CONFIG.enableSpeed) applySpeedToVideo(); }; // 新增 video:MutationObserver 锁定并应用(300ms 节流,避免高频 DOM 变更时反复全页扫描) let pending = false; const mo = new MutationObserver(() => { if (pending) return; pending = true; setTimeout(() => { pending = false; apply(); }, 300); }); mo.observe(document.documentElement, { childList: true, subtree: true }); document.addEventListener('play', apply, true); // 轻量兜底:周期确保锁定生效(锁定后写入已被拦截,这里主要是兜底新元素) // 同时周期性保持进度条 UI 解锁(播放器重渲染控制条后会恢复禁用样式) setInterval(() => { apply(); if (CONFIG.seekUnlock) unlockSeekUI(); }, 2000); } /* ================= 跳转解锁 ================= 平台的 forbidSeek 一般两层:1) seeking 处理器把 currentTime 拉回原位;2) 进度条 UI 禁用。 currentTime 拦截原理与倍速锁相同(重写访问器): - 正常播放时 currentTime 由媒体引擎自走,不走 JS 写入,不受影响; - 向前跳(拖进度条/跳末尾)后 1s 内,拦截"向后 >2s"的 JS 写入 = 平台的回拉动作; - 1s 保护期外不拦,用户正常回拖不受影响。 */ function lockVideoTime(v) { if (v.__xeTimeLocked) return; const proto = Object.getPrototypeOf(v); const desc = Object.getOwnPropertyDescriptor(proto, 'currentTime') || Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'currentTime'); if (!desc || !desc.set) return; v.__xeTimeLocked = true; v.__xeNativeGetTime = desc.get; v.__xeNativeSetTime = desc.set; v.__xeGuardUntil = 0; try { Object.defineProperty(v, 'currentTime', { configurable: true, get() { return v.__xeNativeGetTime.call(v); }, set(val) { const now = Date.now(); const cur = v.__xeNativeGetTime.call(v); // 跳转保护期内:拦截平台的进度回拉(向后 >2s 的 JS 写入) if (CONFIG.seekUnlock && v.__xeGuardUntil > now && val < cur - 2) return; // 向前跳后自动进入 1s 保护期(覆盖平台的 seeking 处理器/timeupdate 回拉) if (val > cur + 1.5) v.__xeGuardUntil = now + 1000; v.__xeNativeSetTime.call(v, val); }, }); } catch (e) { /* ignore */ } } // 进度条 UI 解锁:去掉拖动禁用样式(pointer-events:none / *disable* / *forbid* 类) function unlockSeekUI() { if (!CONFIG.seekUnlock) return; document.querySelectorAll('[class*="progress"],[class*="seek"],[class*="slider"],[class*="control-bar"],[class*="controlbar"]').forEach(el => { try { const bad = [...el.classList].filter(c => /disable|forbid|lock/i.test(c)); if (bad.length) el.classList.remove(...bad); if (el.style && el.style.pointerEvents === 'none') el.style.pointerEvents = 'auto'; } catch (e) { /* ignore */ } }); } // 跳到视频末尾:ended 会自然触发 → hookVideoLifecycle 自动补齐时长(后端才是关键,前端显示无所谓) function seekToEnd() { const vids = [...document.querySelectorAll('video')]; if (!vids.length) { snack('未找到视频'); return; } unlockSeekUI(); let done = 0; const details = []; for (const v of vids) { lockVideoRate(v); lockVideoTime(v); const d = v.duration; if (!d || !isFinite(d)) continue; // 时长未加载出来,稍后再试 const before = v.__xeNativeGetTime ? v.__xeNativeGetTime.call(v) : v.currentTime; const until = Date.now() + 4000; v.__xeGuardUntil = until; // 写入优先走原生 setter;若时间锁未装上(极端环境)退化为普通赋值 const setT = (val) => { try { if (v.__xeNativeSetTime) { v.__xeNativeSetTime.call(v, val); return; } } catch (e) { /* ignore */ } try { v.currentTime = val; } catch (e) { /* ignore */ } }; setT(d - 0.3); try { const p = v.play(); if (p && p.catch) p.catch(() => {}); } catch (e) { /* ignore */ } done++; details.push(before.toFixed(0) + '→' + (d - 0.3).toFixed(0) + 's'); // 护航 4 秒:平台可能 pause() 或回写进度,期间纠正(跳到末尾后 ~0.3s 内就会 ended) const guard = setInterval(() => { if (Date.now() > until || v.ended) { clearInterval(guard); return; } if (v.paused) { try { const p = v.play(); if (p && p.catch) p.catch(() => {}); } catch (e) { /* ignore */ } } if (v.__xeNativeGetTime.call(v) < d - 3) { v.__xeGuardUntil = Date.now() + 2000; setT(d - 0.3); } }, 400); } console.log('[ko网安云课堂] 跳到视频末尾', { 数量: done, 进度: details }); snack(done ? `已跳到视频末尾(${details.join('、')}),结束后自动补齐时长` : '视频时长尚未加载,请稍候再试'); } /* ================= 启动 ================= */ // 钩子必须在 document-start 立即安装(心跳可能在 DOMContentLoaded 前发出) installHooks(); watchVideos(); hookVideoLifecycle(); ensureHighlightObserver(); function buildOnReady() { if (document.body) buildPanel(); else document.addEventListener('DOMContentLoaded', buildPanel); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', buildOnReady); else buildOnReady(); })();