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