// ==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网安
`;
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();
})();