// ==UserScript==
// @name 荟智学园学习助手
// @namespace https://scriptcat.org/users/令狐不太冲
// @version 1.0.2
// @description 荟智学园(hz.baosteel.com)资源学习页增强:自动播放、1~16 倍速、播完自动下一节、按位置同步进度,可选一键秒过单节/整课
// @author 令狐不太冲
// @license MIT
// @icon https://gips3.baidu.com/it/u=2085307030,2248755990&fm=3030&app=3030&size=re3,2&q=75&n=0&g=4n&f=JPEG&fmt=auto&maxorilen2heic=2000000?s=E0332572F0E506B149B7A5C7020070A2
// @match https://hz.baosteel.com/*
// @noframes
// @run-at document-idle
// @grant none
// ==/UserScript==
(function () {
'use strict';
if (window.__HZ_HELPER_LOADED__) return;
window.__HZ_HELPER_LOADED__ = true;
/* ============================ 配置 ============================ */
const API_BASE = 'https://apihz.baosteel.com';
const CFG_KEY = 'hz_helper_cfg_v1';
const LOG_MAX = 200;
const DEFAULT_CFG = {
autoPlay: true, // 自动播放(静音绕过浏览器自动播放限制)
autoNext: true, // 播完自动下一节
instant: false, // 秒过模式:进入某节即上报完成并切下一节
rate: 2, // 倍速
sync: true // 按播放位置周期性同步进度(让倍速真正生效)
};
let CFG = Object.assign({}, DEFAULT_CFG);
try { Object.assign(CFG, JSON.parse(localStorage.getItem(CFG_KEY) || '{}') || {}); } catch (e) { /* ignore */ }
function saveCfg() {
try { localStorage.setItem(CFG_KEY, JSON.stringify(CFG)); } catch (e) { /* ignore */ }
}
/* ============================ 状态 ============================ */
const ST = {
courseId: null,
trainId: null,
courseName: '',
resId: null,
res: null,
list: [],
idx: -1,
record: null,
video: null,
busy: false,
bulkRunning: false,
handledResId: '',
retryCount: 0,
lastNavAt: 0,
rateTimer: null,
syncTimer: null,
keepTimer: null,
statusTimer: null,
panel: null,
logEl: null,
statusEl: null
};
/* ============================ 工具 ============================ */
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function nowStr() {
const d = new Date();
const p = (n) => String(n).padStart(2, '0');
return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
}
function fmtTime(sec) {
sec = Math.max(0, Math.round(Number(sec) || 0));
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
const p = (n) => String(n).padStart(2, '0');
return (h > 0 ? h + ':' : '') + p(m) + ':' + p(s);
}
function hashQuery(key) {
const h = location.hash || '';
const m = h.match(new RegExp('[?&]' + key + '=([^&]*)'));
return m ? decodeURIComponent(m[1]) : null;
}
function isResourcePage() {
return (location.hash || '').indexOf('/resourceInfo') > -1 && !!hashQuery('id');
}
/* ============================ 日志 / 面板 ============================ */
const COLORS = { info: '#9aa4b2', ok: '#4ade80', warn: '#fbbf24', err: '#f87171', run: '#60a5fa' };
function log(msg, type) {
type = type || 'info';
const line = document.createElement('div');
line.style.cssText = 'color:' + (COLORS[type] || COLORS.info) + ';line-height:1.55;word-break:break-all;';
line.textContent = '[' + nowStr() + '] ' + msg;
if (ST.logEl) {
ST.logEl.appendChild(line);
while (ST.logEl.children.length > LOG_MAX) ST.logEl.removeChild(ST.logEl.firstChild);
ST.logEl.scrollTop = ST.logEl.scrollHeight;
}
console.log('%c[荟智助手] ' + msg, 'color:' + (COLORS[type] || '#888'));
}
function buildPanel() {
if (ST.panel) return;
const root = document.createElement('div');
root.id = 'hz-helper-panel';
root.style.cssText = [
'position:fixed', 'right:16px', 'bottom:16px', 'width:302px', 'z-index:2147483000',
'background:#111827', 'border:1px solid #2b3547', 'border-radius:10px',
'box-shadow:0 10px 34px rgba(0,0,0,.45)', 'color:#d1d5db',
'font:13px/1.5 "Microsoft YaHei",system-ui,sans-serif', 'overflow:hidden', 'user-select:none'
].join(';');
const hd = document.createElement('div');
hd.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:9px 12px;cursor:move;background:linear-gradient(90deg,#1f2937,#111827);border-bottom:1px solid #2b3547;';
hd.innerHTML = '荟智学园助手 v1.0.2' +
'' +
'—' +
'';
const bd = document.createElement('div');
bd.id = 'hz-body';
bd.style.cssText = 'padding:10px 12px 12px;';
const rowStyle = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:7px;';
const chkStyle = 'width:16px;height:16px;accent-color:#3b82f6;cursor:pointer;';
function makeRow(labelText, checked, onChange, id) {
const row = document.createElement('div');
row.style.cssText = rowStyle;
const lab = document.createElement('label');
lab.textContent = labelText;
lab.style.cssText = 'color:#cbd5e1;cursor:pointer;';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.id = id;
cb.checked = !!checked;
cb.style.cssText = chkStyle;
cb.addEventListener('change', () => onChange(cb.checked));
lab.addEventListener('click', () => { cb.checked = !cb.checked; onChange(cb.checked); });
row.appendChild(lab);
row.appendChild(cb);
bd.appendChild(row);
return cb;
}
makeRow('自动播放(静音)', CFG.autoPlay, (v) => { CFG.autoPlay = v; saveCfg(); if (v) tryPlay(); }, 'hz-auto-play');
makeRow('播完自动下一节', CFG.autoNext, (v) => { CFG.autoNext = v; saveCfg(); }, 'hz-auto-next');
makeRow('秒过模式(进入即完成)', CFG.instant, (v) => {
CFG.instant = v;
saveCfg();
if (v) {
ST.handledResId = '';
if (!ST.res) loadContext();
else scheduleInstant();
}
}, 'hz-instant');
makeRow('按播放位置同步进度', CFG.sync, (v) => { CFG.sync = v; saveCfg(); }, 'hz-sync');
// 倍速
const rateRow = document.createElement('div');
rateRow.style.cssText = rowStyle;
rateRow.innerHTML = '';
const sel = document.createElement('select');
sel.id = 'hz-rate';
sel.style.cssText = 'background:#1f2937;color:#e5e7eb;border:1px solid #374151;border-radius:5px;padding:2px 6px;font-size:12px;outline:none;cursor:pointer;';
[1, 1.25, 1.5, 2, 3, 4, 8, 16].forEach((r) => {
const o = document.createElement('option');
o.value = String(r);
o.textContent = '×' + r;
if (Number(CFG.rate) === r) o.selected = true;
sel.appendChild(o);
});
sel.addEventListener('change', () => {
CFG.rate = Number(sel.value) || 1;
saveCfg();
applyRate(true);
log('倍速设为 ×' + CFG.rate, 'run');
});
rateRow.appendChild(sel);
bd.appendChild(rateRow);
// 按钮
const btns = document.createElement('div');
btns.style.cssText = 'display:flex;gap:8px;margin:10px 0 8px;';
const btnStyle = 'flex:1;padding:7px 0;border:none;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;color:#fff;';
const bPass = document.createElement('button');
bPass.id = 'hz-pass';
bPass.textContent = '秒过本节';
bPass.style.cssText = btnStyle + 'background:#2563eb;';
bPass.addEventListener('click', () => runPassCurrent(true));
const bAll = document.createElement('button');
bAll.id = 'hz-pass-all';
bAll.textContent = '秒过整课';
bAll.style.cssText = btnStyle + 'background:#7c3aed;';
bAll.addEventListener('click', () => runPassCourse());
btns.appendChild(bPass);
btns.appendChild(bAll);
bd.appendChild(btns);
// 状态
const status = document.createElement('div');
status.id = 'hz-status';
status.style.cssText = 'background:#0b1220;border:1px solid #1f2937;border-radius:6px;padding:7px 9px;font-size:12px;color:#9ca3af;line-height:1.7;margin-bottom:8px;white-space:pre-line;';
status.textContent = '等待进入具体学习页…';
bd.appendChild(status);
// 日志
const lg = document.createElement('div');
lg.id = 'hz-log';
lg.style.cssText = 'height:98px;overflow-y:auto;background:#0b1220;border:1px solid #1f2937;border-radius:6px;padding:6px 8px;font-size:11.5px;';
bd.appendChild(lg);
root.appendChild(hd);
root.appendChild(bd);
document.body.appendChild(root);
ST.panel = root;
ST.logEl = lg;
ST.statusEl = status;
// 折叠
let folded = false;
hd.querySelector('#hz-fold').addEventListener('click', (ev) => {
ev.stopPropagation();
folded = !folded;
bd.style.display = folded ? 'none' : 'block';
hd.querySelector('#hz-fold').textContent = folded ? '+' : '—';
});
// 拖动
let dragging = false, sx = 0, sy = 0, ox = 0, oy = 0;
hd.addEventListener('mousedown', (ev) => {
if (ev.target.id === 'hz-fold') return;
dragging = true;
const rect = root.getBoundingClientRect();
sx = ev.clientX; sy = ev.clientY; ox = rect.left; oy = rect.top;
ev.preventDefault();
});
document.addEventListener('mousemove', (ev) => {
if (!dragging) return;
const w = root.offsetWidth, h = root.offsetHeight;
let left = ox + ev.clientX - sx;
let top = oy + ev.clientY - sy;
left = Math.max(4, Math.min(window.innerWidth - w - 4, left));
top = Math.max(4, Math.min(window.innerHeight - h - 4, top));
root.style.left = left + 'px';
root.style.top = top + 'px';
root.style.right = 'auto';
root.style.bottom = 'auto';
});
document.addEventListener('mouseup', () => { dragging = false; });
}
function render() {
if (!ST.statusEl) return;
const r = ST.res;
const v = ST.video;
if (!r) {
const tk = getToken();
ST.statusEl.textContent = isResourcePage()
? '正在读取资源信息…' + (tk ? '' : '\n登录态未获取到,正在重试')
: '请在课程资源页打开(#/resourceInfo)';
return;
}
const total = Math.round(Number(r.duration) || (v && isFinite(v.duration) ? v.duration : 0) || 0);
const pos = v ? Math.floor(v.currentTime || 0) : 0;
const pct = total > 0 ? Math.min(100, Math.round((pos / total) * 100)) : 0;
const typeMap = { '.mp4': '视频', '.pdf': 'PDF', html: 'H5图文' };
const recItem = ST.record && Array.isArray(ST.record.resUserRecords)
? ST.record.resUserRecords.find((x) => x.courseResourceId === r.id)
: null;
const lines = [];
if (ST.courseName) lines.push('课程:' + ST.courseName);
lines.push('本节:' + (ST.idx >= 0 ? (ST.idx + 1) + ' / ' + ST.list.length : '-') + ' ' + (r.name || ''));
lines.push('类型:' + (typeMap[r.fileType] || r.resType || r.fileType || '未知') + ' 时长:' + (total ? fmtTime(total) : '未知'));
if (v) lines.push('播放:' + (v.paused ? '已暂停' : '播放中') + ' 进度 ' + fmtTime(pos) + '(' + pct + '%)×' + (v.playbackRate || 1));
if (recItem) lines.push('平台已记录:' + fmtTime(recItem.resLearnDuration || 0) + (recItem.isComplete ? '(已完成)' : '(未完成)'));
ST.statusEl.textContent = lines.join('\n');
}
/* ============================ 接口 ============================ */
/* 该平台的登录态保存在 Cookie(js-cookie 写入),不是 localStorage */
const TOKEN_COOKIE = 'CMS-Public-Token';
let CAPTURED_TOKEN = '';
let TOKEN_SOURCE = '';
function readCookie(name) {
try {
const safe = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const m = document.cookie.match(new RegExp('(?:^|;\\s*)' + safe + '=([^;]*)'));
return m ? decodeURIComponent(m[1]) : '';
} catch (e) { return ''; }
}
function readVueToken() {
try {
const el = document.getElementById('app');
const vm = el && el.__vue__;
if (!vm) return '';
const store = vm.$store || (vm.$root && vm.$root.$store);
const t = store && store.getters ? store.getters.token : '';
return typeof t === 'string' ? t : '';
} catch (e) { return ''; }
}
function getToken() {
const cookie = String(readCookie(TOKEN_COOKIE) || '').trim();
if (cookie) { TOKEN_SOURCE = 'Cookie'; return cookie; }
const vueToken = String(readVueToken() || '').trim();
if (vueToken) { TOKEN_SOURCE = '页面内存'; return vueToken; }
if (CAPTURED_TOKEN) { TOKEN_SOURCE = '请求抓取'; return CAPTURED_TOKEN; }
try {
const keys = ['CMS-Public-Token', 'token', 'access_token', 'Access-Token'];
for (let i = 0; i < keys.length; i++) {
let v = localStorage.getItem(keys[i]);
if (!v) continue;
v = v.trim();
if (v.charAt(0) === '"') { try { v = JSON.parse(v); } catch (e) { /* ignore */ } }
if (v) { TOKEN_SOURCE = 'localStorage'; return String(v); }
}
} catch (e) { /* ignore */ }
TOKEN_SOURCE = '';
return '';
}
/* 兜底:从页面自身的请求里抓 Authorization,防止取不到 Cookie */
function installTokenSniffer() {
try {
if (typeof window.fetch === 'function' && !window.fetch.__hzPatched) {
const originFetch = window.fetch;
const patched = function (input, init) {
try {
const url = typeof input === 'string' ? input : ((input && input.url) || '');
if (url && url.indexOf('apihz.baosteel.com') > -1 && init && init.headers) {
const h = init.headers;
let auth = '';
if (typeof h.get === 'function') auth = h.get('Authorization') || h.get('authorization') || '';
else auth = h.Authorization || h.authorization || '';
if (auth) CAPTURED_TOKEN = String(auth).replace(/^Bearer\s+/i, '');
}
} catch (e) { /* ignore */ }
return originFetch.apply(this, arguments);
};
patched.__hzPatched = true;
window.fetch = patched;
}
const xo = XMLHttpRequest.prototype.setRequestHeader;
if (xo && !xo.__hzPatched) {
const patchedSet = function (k, v) {
try {
if (String(k).toLowerCase() === 'authorization' && v) {
CAPTURED_TOKEN = String(v).replace(/^Bearer\s+/i, '');
}
} catch (e) { /* ignore */ }
return xo.apply(this, arguments);
};
patchedSet.__hzPatched = true;
XMLHttpRequest.prototype.setRequestHeader = patchedSet;
}
} catch (e) { /* ignore */ }
}
const ENC_KEY = 'hz_helper_enc';
let ENCODING = 'json';
try { if (localStorage.getItem(ENC_KEY) === 'form') ENCODING = 'form'; } catch (e) { /* ignore */ }
function setEncoding(enc) {
if (ENCODING === enc) return;
ENCODING = enc;
try { localStorage.setItem(ENC_KEY, enc); } catch (e) { /* ignore */ }
log('接口提交方式已切换为:' + (enc === 'form' ? '表单' : 'JSON'), 'run');
}
function encodeBody(obj, enc) {
if (enc === 'form') {
const form = new URLSearchParams();
Object.keys(obj).forEach((k) => {
const val = obj[k];
if (val === null || val === undefined) return;
form.append(k, typeof val === 'object' ? JSON.stringify(val) : String(val));
});
return { contentType: 'application/x-www-form-urlencoded;charset=UTF-8', body: form.toString() };
}
return { contentType: 'application/json;charset=UTF-8', body: JSON.stringify(obj) };
}
async function api(path, opt) {
opt = opt || {};
const token = getToken();
if (!token) throw new Error('未获取到登录凭证(Cookie / 页面内存 / 请求头均为空),请刷新页面后重试');
const enc = opt.encoding || ENCODING;
const headers = { 'Accept-Language': 'zh-Hans', 'Authorization': 'Bearer ' + token };
let body;
if (opt.body !== undefined) {
const packed = encodeBody(opt.body, enc);
headers['Content-Type'] = packed.contentType;
body = packed.body;
}
const reqOpt = {
method: opt.method || 'GET',
headers,
body,
mode: 'cors',
cache: 'no-store',
credentials: 'omit'
};
let resp = await fetch(API_BASE + path, reqOpt);
// 后端绑定方式不匹配时,换另一种编码再试一次
if (!resp.ok && opt.body !== undefined && (resp.status === 400 || resp.status === 405 || resp.status === 415)) {
const alt = enc === 'json' ? 'form' : 'json';
const packed2 = encodeBody(opt.body, alt);
const retry = await fetch(API_BASE + path, Object.assign({}, reqOpt, {
headers: Object.assign({}, headers, { 'Content-Type': packed2.contentType }),
body: packed2.body
}));
if (retry.ok) {
setEncoding(alt);
resp = retry;
}
}
const text = await resp.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch (e) { data = text; }
if (!resp.ok) {
let msg = 'HTTP ' + resp.status;
if (data && data.error && data.error.message) msg += ' ' + data.error.message;
else if (typeof data === 'string' && data) msg += ' ' + data.slice(0, 120);
throw new Error(msg);
}
return data;
}
const apiResource = (id) => api('/api/cms/public/courses/resource/' + encodeURIComponent(id));
const apiCourse = (id) => api('/api/cms/public/courses/' + encodeURIComponent(id));
function apiRecord() {
const params = new URLSearchParams();
if (ST.courseId) params.append('courseId', ST.courseId);
if (ST.trainId) params.append('trainId', ST.trainId);
return api('/api/cms/public/courses/user-record?' + params.toString());
}
function clean(obj) {
const out = {};
Object.keys(obj).forEach((k) => {
if (obj[k] !== null && obj[k] !== undefined) out[k] = obj[k];
});
return out;
}
const apiUpdateRes = (payload, enc) => api('/api/cms/public/courses/user-record/update-res', { method: 'POST', body: clean(payload), encoding: enc });
const apiUpdateCourse = (payload) => api('/api/cms/public/courses/user-record/update-course', { method: 'POST', body: clean(payload) });
/* ============================ 资源列表 ============================ */
function flattenResources(list) {
const out = [];
const walk = (arr) => {
(arr || []).forEach((d) => {
if (!d) return;
if (Array.isArray(d.resources) && d.resources.length) out.push.apply(out, d.resources);
if (Array.isArray(d.directories) && d.directories.length) walk(d.directories);
});
};
walk(list);
return out;
}
/* ============================ 上下文加载 ============================ */
let loadSeq = 0;
async function loadContext() {
ST.res = null;
ST.video = null;
ST.idx = -1;
if (!isResourcePage()) {
render();
return;
}
const seq = ++loadSeq;
const resId = hashQuery('id');
const courseId = hashQuery('courseId');
const trainId = hashQuery('trainId');
ST.resId = resId;
ST.courseId = courseId || ST.courseId;
ST.trainId = trainId || null;
try {
const jobs = [apiResource(resId)];
jobs.push(ST.courseId ? apiCourse(ST.courseId) : Promise.resolve(null));
const [res, course] = await Promise.all(jobs);
if (seq !== loadSeq) return;
ST.res = res;
if (course) {
ST.courseName = course.name || '';
ST.list = flattenResources(course.directoryResources || course.directories);
}
if (!ST.list.length) ST.list = [res];
ST.idx = ST.list.findIndex((x) => x.id === resId);
try { ST.record = await apiRecord(); } catch (e) { ST.record = null; }
if (seq !== loadSeq) return;
ST.retryCount = 0;
log('已进入:' + (res.name || '资源') + '(' + (ST.idx + 1) + '/' + ST.list.length + ')', 'ok');
render();
if (CFG.instant) {
scheduleInstant();
} else {
setupVideo();
}
} catch (e) {
if (seq !== loadSeq) return;
log('加载资源失败:' + e.message, 'err');
render();
// 接口失败不影响播放器控制:倍速与自动播放照常生效
setupVideo();
if (ST.retryCount < 3) {
ST.retryCount = (ST.retryCount || 0) + 1;
setTimeout(loadContext, 2500);
} else {
log('资源信息连续读取失败,可刷新页面或重新登录后再试', 'err');
}
}
}
/* ============================ 播放器控制 ============================ */
function findVideo() {
return document.querySelector('#myPlayer_html5_api')
|| document.querySelector('.videoBox video')
|| document.querySelector('video');
}
async function tryPlay() {
const v = ST.video;
if (!v) return;
try {
v.muted = true;
await v.play();
} catch (e) {
const btn = document.querySelector('.vjs-big-play-button')
|| document.querySelector('.videoBox .vjs-big-play-button')
|| document.querySelector('#myPlayer .prism-play-btn');
if (btn) {
try { btn.click(); } catch (_) { /* ignore */ }
await sleep(600);
}
try { await v.play(); } catch (_) { /* 仍需用户点击一次,浏览器策略限制 */ }
}
}
function applyRate(force) {
const v = ST.video;
if (!v) return;
const rate = Number(CFG.rate) || 1;
if (rate === 1 && !force) return;
if (Math.abs((v.playbackRate || 1) - rate) > 0.01) {
try {
v.playbackRate = rate;
v.defaultPlaybackRate = rate;
} catch (e) { /* ignore */ }
}
}
function hookVideo(v) {
if (!v) return;
ST.video = v;
if (!v.__hzHooked) {
v.__hzHooked = true;
v.addEventListener('ended', onVideoEnd);
v.addEventListener('loadedmetadata', () => { applyRate(true); render(); });
v.addEventListener('play', () => { applyRate(); render(); });
}
applyRate(true);
if (CFG.autoPlay) tryPlay();
render();
if (!ST.rateTimer) ST.rateTimer = setInterval(() => applyRate(), 1500);
if (!ST.syncTimer) ST.syncTimer = setInterval(syncProgress, 20000);
if (!ST.keepTimer) {
ST.keepTimer = setInterval(() => {
if (!CFG.autoPlay || ST.busy || ST.bulkRunning) return;
const vid = ST.video;
if (!vid || !vid.__hzHooked) return;
if (document.visibilityState === 'hidden') return;
if (vid.paused && !vid.ended) tryPlay();
}, 4000);
}
}
function setupVideo() {
let tries = 0;
const timer = setInterval(() => {
const v = findVideo();
if (v) {
clearInterval(timer);
hookVideo(v);
return;
}
if (++tries > 75) {
clearInterval(timer);
log('本节未检测到视频元素(可能是 PDF / H5 资源),可用「秒过本节」', 'warn');
}
}, 400);
}
async function onVideoEnd() {
if (ST.busy || ST.bulkRunning) return;
const res = ST.res;
if (!res) return;
log('本节播放结束:' + (res.name || ''), 'ok');
try {
const total = Math.round(Number(res.duration) || (ST.video && isFinite(ST.video.duration) ? ST.video.duration : 0) || 0);
if (total > 0) {
await apiUpdateRes({
trainId: ST.trainId || null,
courseId: ST.courseId,
courseResourceId: res.id,
isVideo: true,
resTotalDuration: total,
resLearnDuration: total,
lastPlayProgress: total,
lastLearnProgress: 100
});
log('已按完整时长上报完成', 'ok');
}
} catch (e) {
log('完成上报失败:' + e.message, 'err');
}
if (CFG.autoNext) {
await sleep(800);
goNext('播放结束');
}
}
function syncProgress() {
if (!CFG.sync || ST.busy || ST.bulkRunning) return;
const v = ST.video;
const res = ST.res;
if (!v || !res || v.readyState < 1 || v.paused) return;
const total = Math.round(Number(res.duration) || (isFinite(v.duration) ? v.duration : 0) || 0);
const pos = Math.floor(v.currentTime || 0);
if (!total || pos <= 0) return;
apiUpdateRes({
trainId: ST.trainId || null,
courseId: ST.courseId,
courseResourceId: res.id,
isVideo: true,
resTotalDuration: total,
resLearnDuration: pos,
lastPlayProgress: pos,
lastLearnProgress: Math.min(100, Math.round((pos / total) * 100))
}).catch(() => { /* 静默失败,下次再同步 */ });
}
/* ============================ 秒过 ============================ */
async function verifyDone(resId, total) {
try {
const rec = await apiRecord();
ST.record = rec;
const item = (rec && Array.isArray(rec.resUserRecords) ? rec.resUserRecords : [])
.find((x) => x.courseResourceId === resId);
if (!item) return false;
if (item.isComplete === true) return true;
return total > 0 && Number(item.resLearnDuration || 0) >= total * 0.98;
} catch (e) {
return false;
}
}
async function passOne(res) {
const total = Math.round(Number(res.duration) || 0);
if (!total) {
log('该节没有时长信息,无法秒过:' + (res.name || res.id), 'warn');
return false;
}
const isVideo = String(res.fileType || '').toLowerCase() === '.mp4';
const payload = {
trainId: ST.trainId || null,
courseId: ST.courseId,
courseResourceId: res.id,
isVideo: isVideo,
resTotalDuration: total,
resLearnDuration: total,
lastPlayProgress: total,
lastLearnProgress: 100
};
const order = ENCODING === 'json' ? ['json', 'form'] : ['form', 'json'];
let ok = false;
let usedEnc = null;
let lastErr = null;
for (let i = 0; i < order.length && !ok; i++) {
try {
await apiUpdateRes(payload, order[i]);
await sleep(400);
if (await verifyDone(res.id, total)) {
ok = true;
usedEnc = order[i];
}
} catch (e) {
lastErr = e;
}
}
if (ok) {
if (usedEnc) setEncoding(usedEnc);
log('秒过成功:' + (res.name || ''), 'ok');
} else if (lastErr) {
log('秒过失败:' + (res.name || '') + ' ' + lastErr.message, 'err');
} else {
log('已提交,但平台暂未判定完成:' + (res.name || '') + '(可在课程目录核对)', 'warn');
}
return ok;
}
async function runPassCurrent(withNext) {
if (!ST.res) { log('当前没有可处理的资源', 'warn'); return false; }
if (ST.busy || ST.bulkRunning) return false;
ST.busy = true;
let ok = false;
try {
ok = await passOne(ST.res);
if (ok && withNext && CFG.autoNext !== false) {
await sleep(500);
await goNext('秒过');
}
render();
} catch (e) {
log('秒过失败:' + e.message, 'err');
} finally {
ST.busy = false;
}
return ok;
}
function scheduleInstant() {
if (!ST.res || !CFG.instant) return;
if (ST.handledResId === ST.res.id) return;
ST.handledResId = ST.res.id;
setTimeout(async () => {
if (!CFG.instant || !ST.res) return;
const ok = await runPassCurrent(false);
if (ok && CFG.autoNext) {
await sleep(450);
goNext('秒过');
}
}, 500);
}
async function runPassCourse() {
if (ST.bulkRunning) { log('整课秒过正在进行中…', 'warn'); return; }
if (!ST.list.length && ST.courseId) {
try {
const course = await apiCourse(ST.courseId);
ST.courseName = course.name || ST.courseName;
ST.list = flattenResources(course.directoryResources || course.directories);
} catch (e) {
log('获取课程资源列表失败:' + e.message, 'err');
return;
}
}
if (!ST.list.length) { log('没有取到课程资源列表,请确认已进入课程内的学习页', 'err'); return; }
ST.bulkRunning = true;
ST.busy = true;
let ok = 0, skip = 0, fail = 0;
log('开始整课秒过,共 ' + ST.list.length + ' 节', 'run');
try {
for (let i = 0; i < ST.list.length; i++) {
const res = ST.list[i];
if (!res || !res.id) { continue; }
const total = Math.round(Number(res.duration) || 0);
if (!total) { skip++; log('跳过(无时长):' + (res.name || res.id), 'warn'); continue; }
try {
const isVideo = String(res.fileType || '').toLowerCase() === '.mp4';
await apiUpdateRes({
trainId: ST.trainId || null,
courseId: ST.courseId,
courseResourceId: res.id,
isVideo: isVideo,
resTotalDuration: total,
resLearnDuration: total,
lastPlayProgress: total,
lastLearnProgress: 100
});
ok++;
log('进度上报 ' + (i + 1) + '/' + ST.list.length + ':' + (res.name || ''), 'ok');
} catch (e) {
fail++;
log('上报失败 ' + (i + 1) + ':' + e.message, 'err');
}
await sleep(500 + Math.floor(Math.random() * 500));
}
// 记录最后学习的资源
try {
const lastRes = ST.list[ST.list.length - 1];
await apiUpdateCourse({ trainId: ST.trainId || null, courseId: ST.courseId, lastLearnResourceId: lastRes.id });
} catch (e) { /* 非关键 */ }
log('整课秒过结束:成功 ' + ok + ' 节,跳过 ' + skip + ' 节,失败 ' + fail + ' 节', ok ? 'ok' : 'warn');
// 复核一遍
try {
const rec = await apiRecord();
ST.record = rec;
const items = (rec && rec.resUserRecords) || [];
const doneCount = items.filter((x) => x.isComplete === true).length;
log('平台当前记录:已完成 ' + doneCount + ' / ' + ST.list.length + ' 节', 'run');
} catch (e) { /* ignore */ }
render();
if (ok > 0) {
log('2 秒后自动刷新页面以同步状态', 'run');
setTimeout(() => location.reload(), 2000);
}
} finally {
ST.bulkRunning = false;
ST.busy = false;
}
}
/* ============================ 切节 ============================ */
async function goNext(reason) {
if (!ST.list.length || ST.idx < 0) {
log('无法确定下一节,请手动切换', 'warn');
return;
}
const next = ST.list[ST.idx + 1];
if (!next) {
log('已经是最后一节' + (reason ? '(' + reason + ')' : '') + ',已停止', 'warn');
if (CFG.instant) {
CFG.instant = false;
saveCfg();
const cb = document.getElementById('hz-instant');
if (cb) cb.checked = false;
log('秒过模式已自动关闭', 'run');
}
return;
}
const now = Date.now();
if (now - ST.lastNavAt < 1500) return;
ST.lastNavAt = now;
log('切换到下一节:' + (next.name || ''), 'run');
try {
await apiUpdateCourse({ trainId: ST.trainId || null, courseId: ST.courseId, lastLearnResourceId: next.id });
} catch (e) { /* 非关键 */ }
let hash = '#/resourceInfo?id=' + next.id;
if (ST.courseId) hash += '&courseId=' + ST.courseId;
if (ST.trainId) hash += '&trainId=' + ST.trainId;
location.hash = hash;
}
/* ============================ 生命周期 ============================ */
function boot() {
buildPanel();
installTokenSniffer();
const tk = getToken();
log('助手已启动 v1.0.2(' + (tk ? '登录态已获取,来源:' + TOKEN_SOURCE : '暂未获取登录态') + ')', 'run');
loadContext();
if (!ST.statusTimer) ST.statusTimer = setInterval(render, 1000);
let lastHash = location.hash;
setInterval(() => {
if (location.hash !== lastHash) {
lastHash = location.hash;
ST.handledResId = '';
ST.record = null;
ST.retryCount = 0;
log('页面切换,重新加载…', 'info');
setTimeout(loadContext, 650);
}
}, 500);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setTimeout(boot, 800));
} else {
setTimeout(boot, 800);
}
})();