// ==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 = `