// ==UserScript== // @name 保密观常规学习助手 // @namespace https://baomi-helper.local // @version 1.0.3 // @description 保密观自动学习助手,支持视频自动播放自动处理完所有课程,考试暂时未做,需要联系群主付费解决! // @namespace 一心向善 // @match https://www.baomi.org.cn/* // @icon https://file.baomi.org.cn/spc/upload/pagemanagement/2022-02-08/204OKKJW0JQLU4926994.png // @grant none // ==/UserScript== (() => { 'use strict'; /* ===================== 常量与配置 ===================== */ const OPTS = { reqGap: 2000, // 课程之间的请求间隔(ms) segSize: 300, // 长视频分段时长(秒) segMin: 60, // 最小分段时长(秒) longThreshold: 600, // 长视频判定阈值(秒) retryMax: 3, // 网络请求最大重试次数 segGap: 1500, // 分段提交间隔(ms) segGapRetry: 2000, // 分段失败后的重试间隔(ms) pollStep: 100 // 可中断等待的轮询步长(ms) }; const API = { statistic: 'https://www.baomi.org.cn/portal/main-api/v2/coursePacket/getResourceUserStatistic', detail: 'https://www.baomi.org.cn/portal/main-api/resource/simpleDetail.do', directory: 'https://www.baomi.org.cn/portal/main-api/v2/coursePacket/getCourseDirectoryList', resources: 'https://www.baomi.org.cn/portal/main-api/v2/coursePacket/getCourseResourceList', submit: 'https://www.baomi.org.cn/portal/main-api/v2/studyTime/saveCoursePackage.do' }; const SITE_AUTH = '3613146b9c1a486b8666e86a78298a1a'; const LS_KEY = 'bmHelper.autoEnabled'; // 自动模式开关持久化 key /* ===================== 通用工具 ===================== */ const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const queryMap = () => Object.fromEntries(new URLSearchParams(location.search)); /** 将 HH:MM:SS 时长字符串解析为秒数 */ const parseDuration = (t = '') => { const p = String(t).split(':'); return p.length === 3 ? (+p[0]) * 3600 + (+p[1]) * 60 + (+p[2]) : 300; }; /** 从 cookie / localStorage / 全局变量中读取用户 token */ function readToken() { const m = document.cookie.match(/token=([^;]+)/); if (m) return m[1]; return localStorage.getItem('token') || window.authToken || null; } /** 构造请求头 */ function buildHeaders() { return { 'Accept': 'application/json, text/plain, */*', 'Content-Type': 'application/json', 'token': readToken(), 'siteId': '95', 'authToken': SITE_AUTH }; } /** 可被外部中断的异步等待 */ async function waitCancellable(ms, isAlive) { const step = OPTS.pollStep; let elapsed = 0; while (elapsed < ms) { if (!isAlive()) return; await sleep(step); elapsed += step; } } /** 带重试的 fetch 封装 */ async function fetchJson(url, attempt = 0) { try { const res = await fetch(url, { headers: buildHeaders() }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json(); } catch (err) { if (attempt < OPTS.retryMax) { await sleep(1000); return fetchJson(url, attempt + 1); } throw err; } } /* ===================== 学习器主类 ===================== */ class BaomiLearner { constructor() { this.packetId = null; // 课程包 ID this.params = {}; // URL 参数 this.detail = null; // 课程详情 this.courses = []; // 课程列表 this.cursor = 0; // 当前处理位置 this.active = false; // 运行中 this.hold = false; // 暂停态 this.stats = { ok: 0, fail: 0, skip: 0 }; this.ui = null; // UI 控制器引用 } isAlive() { return this.active; } /* ---- 数据获取 ---- */ async fetchDetail() { const { pubId, siteId } = this.params; if (!pubId || !siteId) return null; try { const d = await fetchJson(`${API.detail}?pubId=${pubId}&siteId=${siteId}`); return (d.successResult && d.data) ? d.data : null; } catch { return null; } } async fetchDirectoryTree() { try { const d = await fetchJson(`${API.directory}?scale=1&coursePacketId=${this.packetId}`); return (d.status === 0 && d.data) ? d.data : []; } catch { return []; } } /** 递归展平目录树为 {id, name} 列表 */ flattenDirs(list, acc = []) { (list || []).forEach(node => { acc.push({ id: node.SYS_UUID, name: node.name }); if (node.subDirectory?.length) this.flattenDirs(node.subDirectory, acc); }); return acc; } async fetchResourcesByDir(dirId, dirName) { try { const d = await fetchJson( `${API.resources}?coursePacketId=${this.packetId}&directoryId=${dirId}&token=${readToken()}` ); if (d.status === 0 && d.data?.listdata) { return d.data.listdata.map(c => ({ ...c, dirName, dirId, secs: parseDuration(c.timeLength) })); } } catch { /* 静默 */ } return []; } async loadAllCourses() { const tree = await this.fetchDirectoryTree(); if (!tree.length) return []; const flat = this.flattenDirs(tree); const out = []; for (let i = 0; i < flat.length; i++) { out.push(...await this.fetchResourcesByDir(flat[i].id, flat[i].name)); if (i < flat.length - 1) await sleep(300); } return out; } /* ---- 状态检测 ---- */ async isFinished(course) { const url = `${API.statistic}?coursePacketId=${this.packetId}&token=${readToken()}&resourceDirectoryId=${course.SYS_UUID}`; try { const r = await fetchJson(url); if (r.status === 0 && r.data) { const d = r.data; return d.progressRate >= 1.0 || (d.studyLength > 0 && d.studyLength >= d.resourceLength); } } catch { /* 检测失败则走默认提交 */ } return false; } /* ---- 进度提交 ---- */ /** 组装提交参数 */ buildParams(course, studyLen, studyTime, segIdx) { const total = course.secs || parseDuration(course.timeLength); const baseStart = Date.now() - total * 1000; return new URLSearchParams({ courseId: this.packetId, resourceId: course.resourceID, resourceDirectoryId: course.SYS_UUID, resourceLength: total, studyLength: studyLen, studyTime: studyTime, startTime: segIdx > 0 ? baseStart + (segIdx - 1) * OPTS.segSize * 1000 : baseStart, resourceName: encodeURIComponent(course.name), resourceType: '1', resourceLibId: '3', studyResourceId: course.SYS_DOCUMENTID, token: readToken() }); } async submitOne(course) { const total = course.secs || parseDuration(course.timeLength); return total > OPTS.longThreshold ? this.submitLong(course, total) : this.submitShort(course, total); } /** 短视频:一次性提交 */ async submitShort(course, total) { const qs = this.buildParams(course, total, total, 0); try { const r = await fetchJson(`${API.submit}?${qs}`); return r.status === 0 ? { ok: true, name: course.name, dir: course.dirName } : { ok: false, name: course.name, err: r.message, dir: course.dirName }; } catch (e) { return { ok: false, name: course.name, err: e.message, dir: course.dirName }; } } /** 长视频:分段逐次提交 */ async submitLong(course, total) { const segCount = Math.ceil(total / OPTS.segSize); let remain = total; let accumulated = 0; for (let s = 1; s <= segCount; s++) { if (!this.active) break; const isLast = s === segCount; const segTime = isLast ? Math.max(remain, OPTS.segMin) : OPTS.segSize; accumulated += segTime; const curLen = Math.min(accumulated, total); const qs = this.buildParams(course, curLen, segTime, s); try { const r = await fetchJson(`${API.submit}?${qs}`); if (r.status !== 0) console.warn(`分段 ${s}/${segCount} 返回异常:`, r.message); remain -= segTime; if (!isLast) await waitCancellable(OPTS.segGap, () => this.active); } catch (e) { console.error('分段提交出错', e); if (!isLast) await waitCancellable(OPTS.segGapRetry, () => this.active); } } if (!this.active) return { ok: false, name: course.name, paused: true }; return { ok: true, name: course.name, dir: course.dirName }; } /* ---- 主循环 ---- */ async run() { if (!this.courses.length) { this.ui.status('加载课程目录…'); this.courses = await this.loadAllCourses(); if (!this.courses.length) { this.ui.status('未获取到课程,请刷新重试'); this.active = false; this.ui.setState('idle'); return; } } this.active = true; this.ui.setState('busy'); const total = this.courses.length; for (let i = this.cursor; i < total; i++) { if (!this.active) break; this.cursor = i; const c = this.courses[i]; this.ui.progress(i, total, `检测中 [${i + 1}/${total}] ${c.name.slice(0, 12)}`); if (await this.isFinished(c)) { this.stats.skip++; this.ui.pushLog(`⏭ 已跳过 · ${c.name.slice(0, 20)}`, 'warn'); await waitCancellable(500, () => this.active); continue; } this.ui.progress(i, total, `学习中 [${i + 1}/${total}] ${c.name.slice(0, 12)}`); const r = await this.submitOne(c); if (r.paused) break; if (r.ok) { this.stats.ok++; this.ui.pushLog(`✓ 已完成 · ${c.name.slice(0, 20)}`, 'ok'); } else { this.stats.fail++; this.ui.pushLog(`✗ 失败 · ${c.name.slice(0, 20)}${r.err ? ' (' + r.err.slice(0, 16) + ')' : ''}`, 'err'); } if (i < total - 1 && this.active) { await waitCancellable(OPTS.reqGap, () => this.active); } } if (!this.active) { // 被中断 if (this.hold) { this.ui.status(`已暂停 · 进度 ${this.cursor + 1}/${total}`); this.ui.setState('paused'); } else { this.ui.status('已停止'); this.ui.setState('idle'); } } else { // 正常完成 this.ui.finish(this.stats, total); this.active = false; this.cursor = 0; this.courses = []; this.ui.setState('done'); } } /* ---- 控制入口 ---- */ start() { // 统一门控:自动模式开关关闭时拦截自动触发(手动点击也经过这里,保持一致契约) if (this.ui && !this.ui.autoEnabled) { this.ui.status('⚠ 自动模式已关闭,请先开启顶部开关'); return; } this.active = true; if (this.hold) { this.hold = false; } else { this.cursor = 0; this.stats = { ok: 0, fail: 0, skip: 0 }; this.courses = []; } this.run(); } pause() { this.active = false; this.hold = true; this.ui.status('暂停中,请稍候…'); } stop() { this.active = false; this.hold = false; this.cursor = 0; this.ui.status('停止中,请稍候…'); } } /* ===================== UI 控制器 ===================== */ class Panel { constructor(learner) { this.learner = learner; // 单一状态源:从 localStorage 读取自动模式开关,默认开启 this.autoEnabled = localStorage.getItem(LS_KEY) !== '0'; this.injectStyle(); this.mount(); this.bindEvents(); this.enableDrag(); learner.ui = this; this.setState('idle'); // ★ 关键:mount 完立刻亮按钮,修复"按钮全隐藏" } injectStyle() { if (document.getElementById('bm-helper-css')) return; const style = document.createElement('style'); style.id = 'bm-helper-css'; style.textContent = ` #bm-panel { --bg: #161b2e; --card: #1e2640; --accent: #7c5cff; --accent2: #4d8dff; --ok: #2dd4a7; --warn: #ffb547; --err: #ff5c7c; --text: #e6e9f5; --muted: #8a90a8; position: fixed; bottom: 24px; left: 24px; width: 300px; background: var(--card); border: 1px solid rgba(124, 92, 255, .35); border-radius: 16px; z-index: 99999; color: var(--text); font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; box-shadow: 0 12px 40px rgba(0, 0, 0, .45); overflow: hidden; } #bm-panel .bm-bar { display: flex; align-items: center; gap: 8px; padding: 12px 14px; cursor: move; background: linear-gradient(135deg, var(--accent), var(--accent2)); font-size: 13px; font-weight: 600; user-select: none; } #bm-panel .bm-switch { margin-left: auto; cursor: pointer; display: flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 400; } #bm-panel .bm-switch input { display: none; } #bm-panel .bm-switch .bm-track { width: 32px; height: 18px; background: rgba(255,255,255,.25); border-radius: 10px; position: relative; transition: background .2s; } #bm-panel .bm-switch .bm-track::after { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; background: #fff; border-radius: 50%; transition: transform .2s; } #bm-panel .bm-switch input:checked + .bm-track { background: rgba(45,212,167,.8); } #bm-panel .bm-switch input:checked + .bm-track::after { transform: translateX(14px); } #bm-panel .bm-body { padding: 12px 14px; } #bm-panel .bm-meta { font-size: 11px; color: var(--muted); margin-bottom: 10px; line-height: 1.7; } #bm-panel .bm-meta b { color: var(--text); font-weight: 600; } #bm-panel .bm-actions { display: flex; gap: 8px; margin-bottom: 14px; } #bm-panel .bm-btn { flex: 1; border: 0; border-radius: 10px; padding: 8px 6px; font-size: 12px; color: #fff; cursor: pointer; display: none; align-items: center; justify-content: center; gap: 4px; transition: transform .15s, filter .15s; } #bm-panel .bm-btn:hover { filter: brightness(1.12); transform: translateY(-1px); } #bm-panel .bm-btn:disabled { opacity: .5; cursor: not-allowed; transform: none; } #bm-panel .bm-btn--go { background: linear-gradient(135deg, #2dd4a7, #20b08a); } #bm-panel .bm-btn--pause { background: linear-gradient(135deg, #ffb547, #e89525); } #bm-panel .bm-btn--stop { background: linear-gradient(135deg, #ff5c7c, #d23a5c); } #bm-panel .bm-ring-row { display: flex; align-items: center; gap: 14px; margin-bottom: 6px; } #bm-panel .bm-ring { width: 56px; height: 56px; flex-shrink: 0; } #bm-panel .bm-ring circle { fill: none; stroke-width: 5; } #bm-panel .bm-ring .track { stroke: rgba(255, 255, 255, .08); } #bm-panel .bm-ring .arc { stroke: var(--accent); stroke-linecap: round; transition: stroke-dashoffset .35s ease; } #bm-panel .bm-nums { font-size: 11px; color: var(--muted); line-height: 1.6; } #bm-panel .bm-nums b { color: var(--text); font-size: 15px; } #bm-panel .bm-log { margin-top: 8px; padding: 6px 8px; height: 96px; overflow: hidden; background: rgba(0,0,0,.25); border-radius: 8px; font-size: 11px; font-family: ui-monospace, Consolas, monospace; line-height: 1.55; } #bm-panel .bm-log ul { list-style: none; margin: 0; padding: 0; } #bm-panel .bm-log li { padding: 1px 0; color: var(--muted); animation: bm-fadein .3s ease; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #bm-panel .bm-log li.ok { color: var(--ok); } #bm-panel .bm-log li.warn { color: var(--warn); } #bm-panel .bm-log li.err { color: var(--err); } #bm-panel .bm-log li.info { color: var(--text); } @keyframes bm-fadein { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } } @keyframes bm-rotate { to { transform: rotate(360deg); } } #bm-panel .bm-rotate { display: inline-block; animation: bm-rotate 1s linear infinite; } `; document.head.appendChild(style); } mount() { document.getElementById('bm-panel')?.remove(); const el = document.createElement('div'); el.id = 'bm-panel'; el.innerHTML = `
🎓 保密学习助手
已自动识别待学课程:加载中…
各类教务/课程批量合作进Q群:1103746012 联系群主!
0 / 0
0%
`; document.body.appendChild(el); } bindEvents() { // 顶部自动模式开关 —— 写回 localStorage,形成单一状态源契约 document.getElementById('bmSwitch').addEventListener('change', (e) => { this.autoEnabled = e.target.checked; localStorage.setItem(LS_KEY, this.autoEnabled ? '1' : '0'); if (!this.autoEnabled && this.learner.active) { // 运行中关闭开关 → 立即停止当前任务 this.learner.stop(); } this.status(this.autoEnabled ? '自动模式:已开启' : '自动模式:已关闭'); }); document.getElementById('bmBtnGo').addEventListener('click', () => this.learner.start()); document.getElementById('bmBtnPause').addEventListener('click', (e) => { const btn = e.currentTarget; btn.innerHTML = '⏳ 暂停中'; btn.disabled = true; this.pushLog('⏸ 暂停中,等待当前请求结束…', 'warn'); this.learner.pause(); }); document.getElementById('bmBtnStop').addEventListener('click', (e) => { const btn = e.currentTarget; btn.innerHTML = '⏳ 停止中'; btn.disabled = true; this.pushLog('⏹ 停止中,等待当前请求结束…', 'err'); this.learner.stop(); }); } enableDrag() { const handle = document.getElementById('bmDragHandle'); const panel = document.getElementById('bm-panel'); let dragging = false, offX = 0, offY = 0; handle.addEventListener('mousedown', (e) => { dragging = true; const rect = panel.getBoundingClientRect(); offX = e.clientX - rect.left; offY = e.clientY - rect.top; panel.style.right = 'auto'; panel.style.bottom = 'auto'; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!dragging) return; panel.style.left = (e.clientX - offX) + 'px'; panel.style.top = (e.clientY - offY) + 'px'; }); document.addEventListener('mouseup', () => { dragging = false; }); } /** * 写入一条日志到滚动日志框,最多保留最新 5 条 * @param {string} msg 日志内容 * @param {'info'|'ok'|'warn'|'err'} type 日志级别 */ pushLog(msg, type = 'info') { const ul = document.getElementById('bmLogList'); if (!ul) return; const li = document.createElement('li'); li.className = type; // 加时间戳 HH:MM:SS const t = new Date(); const pad = (n) => String(n).padStart(2, '0'); const ts = `${pad(t.getHours())}:${pad(t.getMinutes())}:${pad(t.getSeconds())}`; li.textContent = `[${ts}] ${msg}`; ul.prepend(li); // 只保留前 5 条 while (ul.children.length > 5) ul.removeChild(ul.lastElementChild); } /** 兼容旧调用 —— status 语义等同于 info 日志 */ status(msg) { this.pushLog(msg, 'info'); } progress(cur, total, msg) { const pct = total > 0 ? Math.round((cur / total) * 100) : 0; const CIRC = 175.93; // 2 * π * 28 const arc = document.getElementById('bmRingArc'); if (arc) arc.style.strokeDashoffset = String(CIRC * (1 - pct / 100)); document.getElementById('bmCount').textContent = `${cur} / ${total}`; document.getElementById('bmPct').textContent = `${pct}%`; if (msg) this.pushLog(msg, msg.includes('检测') ? 'info' : (msg.includes('学习') ? 'ok' : 'info')); } finish(stats, total) { this.progress(total, total, '全部完成'); this.pushLog(`✓ 全部完成 · 提交 ${stats.ok} / 跳过 ${stats.skip} / 失败 ${stats.fail}`, stats.fail ? 'warn' : 'ok'); } setState(state) { const go = document.getElementById('bmBtnGo'); const pause = document.getElementById('bmBtnPause'); const stop = document.getElementById('bmBtnStop'); // 复位按钮 pause.disabled = false; stop.disabled = false; pause.innerHTML = '⏸ 暂停'; stop.innerHTML = '⏹ 停止'; go.style.display = pause.style.display = stop.style.display = 'none'; switch (state) { case 'busy': pause.style.display = stop.style.display = 'flex'; break; case 'paused': go.style.display = stop.style.display = 'flex'; go.innerHTML = '▶ 继续'; break; case 'idle': go.style.display = 'flex'; go.innerHTML = '▶ 开始'; break; case 'done': go.style.display = 'flex'; go.innerHTML = '✓ 已完成'; setTimeout(() => { go.innerHTML = '▶ 开始'; }, 2000); break; } } } /* ===================== 启动入口 ===================== */ async function boot() { const learner = new BaomiLearner(); learner.params = queryMap(); learner.packetId = learner.params.id; if (!learner.packetId || !readToken()) return; const panel = new Panel(learner); panel.status('加载课程信息…'); learner.detail = await learner.fetchDetail(); if (learner.detail) { const nameEl = document.getElementById('bmCourse'); if (nameEl) nameEl.textContent = learner.detail.docName || learner.detail.name; panel.status('课程已就绪,点击开始'); } } /* SPA 路由监控:进入课程详情页时自动注入面板 */ setInterval(() => { const onDetailPage = location.href.includes('bmCourseDetail'); const panelExists = document.getElementById('bm-panel'); if (onDetailPage && !panelExists) boot(); }, 1000); })();