// ==UserScript== // @name 研修网 · 学习助手(自动连播 + 学情看板) // @namespace https://ipx.yanxiu.com/ // @version 3.0.7 // @description 教师研修网(ipx.yanxiu.com)学习助手:学情看板(1440 分钟达标排程、课程与章节视频清单、进度与得分折算、CSV 导出)+ 播放页自动连播(静音自动播放、播完自动下一节、本门学完自动下一门、后台保活、断点续播、看门狗自动重开)。面板可拖动可缩放。平台心跳与防挂机验证保持原生行为。 // @author 爱国者 // @match https://ipx.yanxiu.com/train2/workspace/* // @match https://ipx.yanxiu.com/grain/course/* // @run-at document-start // @grant none // @noframes // @license All Rights Reserved // ==/UserScript== /* eslint-disable no-empty, no-cond-assign */ (function () { 'use strict'; if (window.__YX_HELPER__) return; window.__YX_HELPER__ = true; /* ================================================================== * * 0. 常量与配置 * ================================================================== */ const VERSION = '3.0.7'; const API = 'https://ipx-api.yanxiu.com'; const BASIC_AUTH = 'Basic c2FucmVuLXdhbmQtcGM6UjNOaHR2MUkyVVpsR1RmcTBv'; const CLIENT_ID = 'ums-teacher-pc'; const K_CFG = 'YX_HELPER_CFG_V3'; const K_QUEUE = 'YX_HELPER_QUEUE_V3'; const K_CACHE = 'YX_HELPER_CACHE_V3'; const K_QQ = 'YX_HELPER_QQ_V3'; const K_LOCK = 'YX_HELPER_LOCK_V3'; const K_DASH = 'YX_HELPER_DASH_BOX_V3'; const K_MINI = 'YX_HELPER_MINI_BOX_V3'; const K_DONE = 'YX_HELPER_DONE_V3'; const K_PC = 'YX_HELPER_PCOURSE_V3'; const REQUIRE_MIN = 1440; // 需观看分钟数 const MAX_SCORE = 45; // 看课满分 const QQ_GROUP = 'https://qm.qq.com/q/Kacqy4da26'; const QR_ALIPAY = 'https://a1.boltp.com/2026/08/31/6a950ca30913e.jpg'; const QR_WECHAT = 'https://a1.boltp.com/2026/08/31/6a950ca33c5d1.png'; const DEFAULT_CFG = { muted: true, // 静音播放 autoNext: true, // 播完自动下一节 / 自动下一门 keepAlive: true, // 后台保活(防浏览器冻结/节流) keepAliveGain: 0.008, // 保活音量 resume: true, // 断点续播:跳过本机已完整播完的章节 watchdog: true, // 看门狗:播放页意外关闭时自动重开 notice: true, // 需要你处理时发桌面通知 sound: true, // 需要你处理时响铃 focusTab: true, // 需要你处理时把播放页拉到前台 autoRate: true, // 自动提交课程评分 rateValue: 5, // 自动评分星数 textWait: 8, // 文本/文档段最短停留秒数(等平台完成标记) autoReadGuide: false, // 本门全部章节学完后,自动打开并滚动阅读「课程指南/专家介绍」 autoStart: true // 进入播放页自动开始 }; const log = (...a) => console.log('%c[学习助手]', 'color:#2563eb;font-weight:700', ...a); const num = (v) => (v === undefined || v === null ? 0 : Number(v) || 0); const clamp = (v, a, b) => Math.min(Math.max(num(v), a), b); const hhmm = (s) => { s = Math.max(0, Math.round(num(s))); const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60; if (h) return h + '小时' + m + '分'; if (m) return m + '分' + (ss && m < 10 ? ss + '秒' : ''); return ss + '秒'; }; const clock = (s) => { s = Math.max(0, Math.round(num(s))); const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60; const p = (n) => (n < 10 ? '0' + n : '' + n); return (h ? h + ':' : '') + p(m) + ':' + p(ss); }; const fmtMin = (s) => Math.round(num(s) / 60); const $ = (sel, root) => (root || document).querySelector(sel); const $$ = (sel, root) => Array.prototype.slice.call((root || document).querySelectorAll(sel)); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); /* ================================================================== * * 1. 存储 * ================================================================== */ function readJSON(key, def) { try { const v = JSON.parse(localStorage.getItem(key) || 'null'); return v == null ? def : v; } catch (e) { return def; } } function writeJSON(key, val) { try { localStorage.setItem(key, JSON.stringify(val)); } catch (e) {} } let cfg = Object.assign({}, DEFAULT_CFG, readJSON(K_CFG, {})); const saveCfg = () => writeJSON(K_CFG, cfg); function loadQueue() { return readJSON(K_QUEUE, null); } function saveQueue(q) { writeJSON(K_QUEUE, q); } function loadDone() { return readJSON(K_DONE, {}); } function markDone(courseId, segIdx) { if (courseId == null || segIdx == null || segIdx < 0) return; const d = loadDone(); const k = String(courseId); const arr = d[k] || (d[k] = []); if (arr.indexOf(segIdx) < 0) { arr.push(segIdx); arr.sort((a, b) => a - b); } writeJSON(K_DONE, d); } function isDone(courseId, segIdx) { if (courseId == null) return false; const arr = loadDone()[String(courseId)]; return Array.isArray(arr) && arr.indexOf(segIdx) >= 0; } /* ================================================================== * * 2. 登录令牌捕获 + API * ================================================================== */ const auth = { token: '', passport: '', toolId: '' }; let tokenWaiters = []; function notifyToken() { tokenWaiters.splice(0).forEach((fn) => fn(auth.token)); } /** 页面自己会请求 ?toolId=xxx,直接嗅探出来,比任何列表接口都可靠 */ function sniffUrl(u) { try { const m = String(u == null ? '' : u).match(/[?&]toolId=(\d{6,})/); if (m) { if (auth.toolId !== m[1]) log('嗅探到 toolId', m[1]); auth.toolId = m[1]; } } catch (e) {} } (function hookHeaders() { try { const OX = window.XMLHttpRequest; if (OX && OX.prototype) { const oSet = OX.prototype.setRequestHeader; OX.prototype.setRequestHeader = function (k, v) { try { const key = String(k).toLowerCase(); if (key === 'x-dt-accesstoken' && v) { auth.token = String(v); notifyToken(); } else if (key === 'x-dt-passport' && v && String(v).length < 200) auth.passport = String(v); } catch (e) {} return oSet.apply(this, arguments); }; const oOpen = OX.prototype.open; OX.prototype.open = function (method, url) { try { sniffUrl(url); } catch (e) {} return oOpen.apply(this, arguments); }; } if (window.fetch) { const of = window.fetch; window.fetch = function (input, init) { try { sniffUrl(typeof input === 'string' ? input : (input && (input.url || input.href))); const h = (init && init.headers) || (input && input.headers) || {}; const g = (n) => (h instanceof Headers ? h.get(n) : h[n] || h[n.toLowerCase()]); const t = g('X-DT-accessToken'); if (t) { auth.token = String(t); notifyToken(); } } catch (e) {} return of.apply(this, arguments); }; } } catch (e) {} })(); function waitToken(timeout) { if (auth.token) return Promise.resolve(auth.token); return new Promise((res) => { const t = setTimeout(() => res(''), timeout || 20000); tokenWaiters.push((v) => { clearTimeout(t); res(v); }); }); } function apiHeaders() { return { Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', Authorization: BASIC_AUTH, 'X-DT-accessToken': auth.token, 'X-DT-clientId': CLIENT_ID, 'X-DT-Passport': auth.passport || '' }; } async function api(path, body) { const res = await fetch(API + path, { method: body === undefined ? 'GET' : 'POST', headers: apiHeaders(), credentials: 'include', body: body === undefined ? undefined : JSON.stringify(body) }); const txt = await res.text(); let json = {}; try { json = JSON.parse(txt); } catch (e) { throw new Error('接口返回非 JSON'); } const code = json.status && json.status.code; if (code !== 200) throw new Error((json.status && json.status.desc) || '接口异常'); return json.data; } /* ================================================================== * * 3. 数据模型(学习空间) * ================================================================== */ const state = { ctx: null, summary: null, modules: [], courses: [], chapters: {}, loading: false }; function parseCtx() { const pm = location.pathname.match(/\/train2\/workspace\/(\d+)/); const projectId = (pm && pm[1]) || new URLSearchParams(location.search).get('projectId') || ''; if (!projectId) return null; const qs = new URLSearchParams(location.search); const toolId = qs.get('toolId') || localStorage.getItem('YX_TOOL_ID_V3') || ''; return { projectId, toolId }; } /** 工具 ID 不在网址里:优先用嗅探到的(页面自己会请求),再退回工具列表 */ async function resolveToolId(ctx) { const tried = []; const test = async (tid) => { if (!tid || tried.indexOf(String(tid)) >= 0) return false; tried.push(String(tid)); try { const d = await api('/task-center/course/V1/userCourseModule?projectId=' + ctx.projectId + '&toolId=' + tid); const mods = (d && d.modules) || []; if (mods.length) { ctx.foundModules = mods; return true; } } catch (e) {} return false; }; if (await test(ctx.toolId)) return ctx.toolId; if (await test(localStorage.getItem('YX_TOOL_ID_V3'))) return localStorage.getItem('YX_TOOL_ID_V3'); if (await test(auth.toolId)) return auth.toolId; // 页面还在加载中,等一会儿嗅探结果 for (let i = 0; i < 20; i++) { await sleep(500); if (auth.toolId && await test(auth.toolId)) return auth.toolId; } // 最后兜底:工具列表接口(本项目实测返回空,仅作兼容) const list = await api('/train-project-center/train/manage/my/substance/list?projectId=' + ctx.projectId + '&roleKey=MEMBER&roleLevel=0').catch(() => null); const arr = (list && (list.list || list.rows)) || (Array.isArray(list) ? list : []); const cands = []; arr.forEach((x) => { [x.toolId, x.substanceId, x.id, x.toolInfo && x.toolInfo.toolId, x.substanceInfo && x.substanceInfo.toolId] .forEach((v) => { if (v) cands.push(v); }); }); for (let i = 0; i < cands.length; i++) if (await test(cands[i])) return cands[i]; return ''; } async function loadAll(onStep) { const ctx = state.ctx; if (!ctx) throw new Error('未识别到项目上下文'); onStep && onStep('读取学情统计…'); try { state.summary = await api('/train-project-center/person/summary/statistics?trainProjectId=' + ctx.projectId + '&roleLevel=MEMBER&userRole=MEMBER'); } catch (e) { log('summary 失败', e); } onStep && onStep('定位看课工具…'); const tid = await resolveToolId(ctx); if (!tid) throw new Error('未找到「看课」工具,可能该项目不是时长考核'); ctx.toolId = tid; localStorage.setItem('YX_TOOL_ID_V3', tid); onStep && onStep('读取课程模块…'); let mods = ctx.foundModules; if (!mods) { const modData = await api('/task-center/course/V1/userCourseModule?projectId=' + ctx.projectId + '&toolId=' + ctx.toolId); mods = (modData && modData.modules) || []; } state.modules = mods; onStep && onStep('读取课程列表…'); const all = []; for (const mod of state.modules) { let page = 1, totalPage = 1; do { const d = await api('/task-center/course/V1/queryCourseList?projectId=' + ctx.projectId + '&toolId=' + ctx.toolId + '&moduleId=' + mod.moduleId + '&pageIndex=' + page + '&pageSize=50&roleKey=100&moduleSort=1&learnStatus=0&courseType='); (d.rows || []).forEach((r) => { r._module = mod.moduleName; all.push(r); }); totalPage = d.totalPage || 1; page++; } while (page <= totalPage); } state.courses = all; onStep && onStep('完成'); saveCache(); return state; } const sgnm = (sg) => sg.sgnm || sg.sgname || sg.name || ''; const segUrl = (sg) => { const urls = {}; (sg.sgurl || []).forEach((u) => { urls[u.reso] = u.url; }); return urls.m || urls.l || urls.s || ''; }; /** 把 getUserCoursePage 的返回解析成「课程 + 章节」统一结构 */ function parseCoursePage(d) { const it = d.courseItemVO || {}; const sm = d.courseSummaryVO || {}; const segs = []; const chps = (((d.courseApiInfoVO || {}).info) || {}).chps || []; chps.forEach((ch) => (ch.segs || []).forEach((sg) => { // 平台源码里 sgmd 1/2 对应 icon-doc(文档/文本段),3+ 对应 icon-media(视频/媒体段)。 // 不能靠 sgurl 判断:某些文本/说明段也会带 sgurl(封面或占位),导致被误判为视频。 const video = num(sg.sgmd) >= 3; const notes = (sg.sgnotes && sg.sgnotes.istanscode === 1 && sg.sgnotes.sgurl && sg.sgnotes.sgurl.url2) ? sg.sgnotes.sgurl.url2 : ''; segs.push({ chp: ch.chpnm || '', name: sgnm(sg), sec: num(sg.sgtm), url: segUrl(sg), id: String(sg.sgid || ''), i: segs.length, sgmd: num(sg.sgmd), video: video, tips: sg.sgtips || '', tipsType: String(sg.courseware_type || ''), notes: notes }); })); return { id: String(it.id || ''), name: it.courseName || '', teacher: it.mainTeacher || '', module: it.subjectName || '', total: num(it.totalDuration) || segs.reduce((a, s) => a + s.sec, 0), visited: num(sm.visitedDuration), segCount: segs.length, config: d.courseConfigVO || {}, segs }; } async function loadChapters(course) { if (state.chapters[course.id]) return state.chapters[course.id]; const d = await api('/task-center/course/getUserCoursePage?courseId=' + course.id + '&projectId=' + state.ctx.projectId + '&toolId=' + state.ctx.toolId + '&courseSourceId=' + course.courseSourceId + '&isMember=0'); const info = parseCoursePage(d); state.chapters[course.id] = { list: info.segs, config: info.config, meta: info }; return state.chapters[course.id]; } function saveCache() { writeJSON(K_CACHE, { t: Date.now(), ctx: state.ctx, modules: state.modules, courses: state.courses, summary: state.summary }); } function loadCache() { const c = readJSON(K_CACHE, null); if (!c || Date.now() - c.t > 6 * 3600e3) return false; state.ctx = c.ctx || state.ctx; state.modules = c.modules || []; state.courses = c.courses || []; state.summary = c.summary; return state.courses.length > 0; } function stats() { const courses = state.courses; const remainSec = courses.reduce((a, c) => a + num(c.noStudyTime), 0); const doneSec = courses.reduce((a, c) => a + num(c.completeTime), 0); const todo = courses.filter((c) => num(c.noStudyTime) > 0); const watchedMin = fmtMin(doneSec); return { total: courses.length, todo: todo.length, finished: courses.length - todo.length, remainSec, doneSec, watchedMin, score: Math.min(MAX_SCORE, +(watchedMin / REQUIRE_MIN * MAX_SCORE).toFixed(2)), remainMin: Math.max(0, REQUIRE_MIN - watchedMin) }; } /** 最短优先:剩余时长升序,累积到 1440 分钟即达标 */ function shortestQueue() { const todo = state.courses.filter((c) => num(c.noStudyTime) > 0) .sort((a, b) => num(a.noStudyTime) - num(b.noStudyTime)); const out = []; let acc = fmtMin(stats().doneSec); for (const c of todo) { if (acc >= REQUIRE_MIN) break; out.push({ id: c.id, sid: c.courseSourceId, name: c.courseName, needSec: num(c.noStudyTime), module: c._module }); acc += num(c.noStudyTime) / 60; } return { items: out, accMin: Math.round(acc) }; } const playerUrl = (id, sid) => { const pc = playerCtx(); const qs = pc ? new URLSearchParams(location.search) : null; const projectId = (state.ctx && state.ctx.projectId) || (pc && pc.projectId) || (qs && qs.get('projectId')) || ''; const toolId = (state.ctx && state.ctx.toolId) || (pc && pc.toolId) || (qs && qs.get('toolId')) || (auth.toolId) || ''; return 'https://ipx.yanxiu.com/grain/course/' + id + '/detail?projectId=' + projectId + '&toolId=' + toolId + '&courseSourceId=' + sid + '&role=100'; }; /* ================================================================== * * 3.5 播放页课程上下文(标题 / 时长 / 章节表) * ================================================================== */ const pdata = { loading: false, loaded: false, err: '', name: '', teacher: '', total: 0, visited: 0, segs: [], config: {} }; function playerCtx() { const m = location.pathname.match(/\/grain\/course\/(\d+)\/detail/); if (!m) return null; const qs = new URLSearchParams(location.search); const o = { courseId: m[1], projectId: qs.get('projectId') || '', toolId: qs.get('toolId') || '', courseSourceId: qs.get('courseSourceId') || '' }; // 播放页网址里带着 toolId,回写给学习空间页用 if (o.toolId) { auth.toolId = o.toolId; try { if (localStorage.getItem('YX_TOOL_ID_V3') !== o.toolId) localStorage.setItem('YX_TOOL_ID_V3', o.toolId); } catch (e) {} } return o; } function pcCacheKey(pc) { return K_PC + '_' + VERSION + '_' + pc.courseId; } function loadPdataFromCache() { const pc = playerCtx(); if (!pc) return false; const c = readJSON(pcCacheKey(pc), null); if (!c || !c.segs || !c.segs.length) return false; if (Date.now() - (c.t || 0) > 12 * 3600e3) return false; Object.assign(pdata, c, { loaded: true, loading: false, err: '' }); return true; } async function loadPlayerCourse(force) { const pc = playerCtx(); if (!pc) return; if (pdata.loading) return; if (pdata.loaded && !force) return; pdata.loading = true; pdata.err = ''; fireUpdate(); try { await waitToken(); const d = await api('/task-center/course/getUserCoursePage?courseId=' + pc.courseId + '&projectId=' + pc.projectId + '&toolId=' + pc.toolId + '&courseSourceId=' + pc.courseSourceId + '&isMember=0'); const info = parseCoursePage(d); Object.assign(pdata, { name: info.name, teacher: info.teacher, total: info.total, visited: info.visited, segs: info.segs, config: info.config, loaded: true, loading: false, err: '' }); writeJSON(pcCacheKey(pc), { t: Date.now(), name: pdata.name, teacher: pdata.teacher, total: pdata.total, visited: pdata.visited, segs: pdata.segs, config: pdata.config }); log('课程数据已加载', pdata.name, pdata.segs.length + ' 节'); } catch (e) { pdata.loading = false; pdata.err = e.message || String(e); log('课程数据加载失败', e); } fireUpdate(); } /** 当前节序号:DOM 优先,其次按标题在接口章节表里匹配 */ function curSegIndex() { const items = resItems(); const i = activeIdx(); if (i >= 0 && i < items.length) { const t = $('.res-name', items[i]); const title = t ? (t.getAttribute('title') || t.innerText || '').trim() : ''; if (pdata.segs.length) { const hit = pdata.segs.findIndex((s) => s.name === title); if (hit >= 0) return hit; } return i; } return engine.idx; } const curSeg = () => (pdata.segs.length ? pdata.segs[curSegIndex()] || null : null); /* ================================================================== * * 4. 样式 * ================================================================== */ const CSS = ` :host{all:initial} *{box-sizing:border-box;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif} .fab{position:fixed;right:24px;bottom:24px;width:56px;height:56px;border-radius:18px;border:0;cursor:pointer; background:linear-gradient(135deg,#1e3a8a 0%,#2563eb 55%,#f59e0b 130%); box-shadow:0 10px 30px -8px rgba(30,58,138,.65),0 2px 6px rgba(0,0,0,.12); color:#fff;font-size:24px;line-height:56px;text-align:center;z-index:2147483000;transition:.25s;font-weight:800} .fab:hover{transform:translateY(-2px) scale(1.04)} .fab .dot{position:absolute;right:8px;top:8px;width:9px;height:9px;border-radius:50%;background:#f59e0b;box-shadow:0 0 0 3px rgba(245,158,11,.28)} .wrap,.mini-panel{position:fixed;display:flex;flex-direction:column;z-index:2147483001;border-radius:20px;overflow:hidden; background:rgba(255,255,255,.88);backdrop-filter:blur(22px) saturate(180%);-webkit-backdrop-filter:blur(22px) saturate(180%); border:1px solid rgba(255,255,255,.92);box-shadow:0 24px 60px -18px rgba(15,23,42,.45),0 2px 10px rgba(15,23,42,.08); color:#0f172a;font-size:13px} .wrap{width:880px;height:640px;max-width:calc(100vw - 20px);max-height:calc(100vh - 20px);min-width:420px;min-height:300px} .wrap.hide{display:none} .mini-panel{width:404px;height:min(600px,72vh);min-width:300px;min-height:180px;border-radius:18px;z-index:2147483002} .hd{padding:14px 18px;display:flex;align-items:center;gap:11px; background:linear-gradient(120deg,rgba(30,58,138,.97),rgba(37,99,235,.93) 60%,rgba(245,158,11,.88));color:#fff;flex:0 0 auto} .hd .logo{width:36px;height:36px;border-radius:11px;background:rgba(255,255,255,.18);border:1px solid rgba(255,255,255,.35); display:flex;align-items:center;justify-content:center;font-weight:800;font-size:17px;letter-spacing:-1px;flex:0 0 auto} .hd h1{margin:0;font-size:14.5px;font-weight:700;letter-spacing:.3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .hd p{margin:2px 0 0;font-size:11px;opacity:.9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .hd .sp{flex:1;min-width:6px} .icobtn{width:28px;height:28px;border-radius:9px;border:1px solid rgba(255,255,255,.35);background:rgba(255,255,255,.14); color:#fff;cursor:pointer;font-size:13px;line-height:1;flex:0 0 auto;padding:0} .icobtn:hover{background:rgba(255,255,255,.28)} .drag{cursor:move;user-select:none} .strip{padding:11px 18px 12px;background:rgba(248,250,252,.9);border-bottom:1px solid rgba(15,23,42,.07);flex:0 0 auto} .strip .top{display:flex;align-items:baseline;gap:9px;font-size:12px;color:#475569;margin-bottom:7px;flex-wrap:wrap} .strip .top b{font-size:15px;color:#0f172a;font-weight:800;letter-spacing:-.3px} .tabs{display:flex;gap:3px;padding:9px 14px 0;background:rgba(248,250,252,.75);border-bottom:1px solid rgba(15,23,42,.06);overflow-x:auto;flex:0 0 auto} .tab{padding:7px 14px;border-radius:10px 10px 0 0;border:0;background:transparent;cursor:pointer;font-size:12.5px;color:#475569;font-weight:500;white-space:nowrap} .tab.on{background:#fff;color:#1d4ed8;font-weight:700} .bd{padding:14px 16px 16px;overflow:auto;flex:1 1 auto;min-height:0} .mini-panel .bd{display:flex;flex-direction:column} .mini-panel .bd > *{flex:0 0 auto;min-height:0} .mini-panel .bd > .segs.fill{flex:1 1 auto} .foot{padding:7px 16px;border-top:1px solid rgba(15,23,42,.09);background:#f8fafc; display:flex;align-items:center;gap:11px;font-size:10.5px;color:#94a3b8;flex:0 0 auto} .grip{position:absolute;right:1px;bottom:1px;width:18px;height:18px;cursor:nwse-resize;z-index:6;opacity:.55; background:repeating-linear-gradient(135deg,transparent 0 4px,rgba(37,99,235,.75) 4px 6px,transparent 6px 10px)} .grip:hover{opacity:1} .cards{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:12px} .card{border-radius:14px;padding:11px 13px;background:#fff;border:1px solid rgba(15,23,42,.07);box-shadow:0 1px 2px rgba(15,23,42,.04)} .card b{display:block;font-size:19px;font-weight:800;letter-spacing:-.4px} .card span{font-size:11px;color:#64748b} .card.grad{background:linear-gradient(135deg,#1e3a8a,#2563eb 70%,#f59e0b 145%);color:#fff;border:0} .card.grad span{color:rgba(255,255,255,.86)} .bar{height:9px;border-radius:9px;background:rgba(15,23,42,.08);overflow:hidden;margin:6px 0 4px} .bar i{display:block;height:100%;border-radius:9px;background:linear-gradient(90deg,#2563eb,#f59e0b);transition:.5s} .bar.thin{height:6px;margin:5px 0 2px} .sec{font-size:12px;font-weight:700;color:#334155;margin:14px 0 8px;display:flex;align-items:center;gap:8px} .sec:before{content:'';width:3px;height:13px;border-radius:2px;background:linear-gradient(#2563eb,#f59e0b)} .sec .r{margin-left:auto;font-weight:500;font-size:11px;color:#64748b} table{width:100%;border-collapse:collapse;font-size:12px} th{position:sticky;top:0;background:rgba(248,250,252,.98);text-align:left;padding:7px 8px;color:#64748b;font-weight:600; border-bottom:1px solid rgba(15,23,42,.08);white-space:nowrap;z-index:1} td{padding:7px 8px;border-bottom:1px solid rgba(15,23,42,.05);vertical-align:top} tr:hover td{background:rgba(37,99,235,.045)} .nm{max-width:330px;line-height:1.45;word-break:break-word} .pill{display:inline-block;padding:1.5px 7px;border-radius:99px;font-size:10.5px;font-weight:700;white-space:nowrap} .p-run{background:rgba(37,99,235,.12);color:#1d4ed8} .p-done{background:rgba(22,163,74,.12);color:#15803d} .p-todo{background:rgba(100,116,139,.14);color:#475569} .p-must{background:rgba(245,158,11,.16);color:#b45309} .p-warn{background:rgba(239,68,68,.14);color:#b91c1c} .btn{border:0;border-radius:9px;padding:6px 11px;font-size:11.5px;font-weight:600;cursor:pointer; background:linear-gradient(135deg,#1e3a8a,#2563eb);color:#fff;white-space:nowrap;transition:.2s} .btn:hover{filter:brightness(1.08);transform:translateY(-1px)} .btn.ghost{background:rgba(15,23,42,.05);color:#334155} .btn.ghost:hover{background:rgba(15,23,42,.1)} .btn.gold{background:linear-gradient(135deg,#f59e0b,#f97316)} .btn.lg{padding:9px 18px;font-size:13px;border-radius:11px} .btn.sm{padding:4px 9px;font-size:11px;border-radius:8px} .btn:disabled{opacity:.5;cursor:not-allowed;transform:none} .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap} .muted{color:#64748b;font-size:11.5px;line-height:1.7} .empty{padding:26px;text-align:center;color:#64748b;font-size:12.5px} .kbd{font-family:ui-monospace,Consolas,monospace;background:rgba(15,23,42,.06);border-radius:5px;padding:1px 5px;font-size:11px} .search{flex:1;min-width:130px;border:1px solid rgba(15,23,42,.14);border-radius:9px;padding:6px 10px;font-size:12px;background:#fff;color:#0f172a} select,input[type=number]{border:1px solid rgba(15,23,42,.14);border-radius:8px;padding:5px 8px;font-size:12px;background:#fff;color:#0f172a} .toast{position:absolute;left:50%;bottom:18px;transform:translateX(-50%);background:rgba(15,23,42,.92);color:#fff; padding:8px 14px;border-radius:10px;font-size:12px;opacity:0;transition:.25s;pointer-events:none;z-index:9;max-width:80%;text-align:center} .toast.on{opacity:1} .switch{display:flex;align-items:center;gap:10px;padding:9px 0;border-bottom:1px dashed rgba(15,23,42,.08)} .switch .lb{flex:1} .switch .lb b{display:block;font-size:12.5px;font-weight:600} .switch .lb span{font-size:11px;color:#64748b;line-height:1.5} .sw{width:42px;height:24px;border-radius:99px;background:rgba(15,23,42,.16);position:relative;cursor:pointer;transition:.2s;flex:0 0 auto} .sw:after{content:'';position:absolute;width:18px;height:18px;border-radius:50%;background:#fff;top:3px;left:3px;transition:.2s;box-shadow:0 1px 3px rgba(0,0,0,.2)} .sw.on{background:linear-gradient(135deg,#1e3a8a,#2563eb)} .sw.on:after{left:21px} .stat{display:flex;align-items:center;gap:10px;padding:10px 13px;border-radius:13px;margin-bottom:10px;font-size:12.5px;flex:0 0 auto} .stat.run{background:linear-gradient(120deg,rgba(37,99,235,.1),rgba(37,99,235,.04));border:1px solid rgba(37,99,235,.2);color:#1d4ed8} .stat.warn{background:linear-gradient(120deg,rgba(239,68,68,.12),rgba(245,158,11,.08));border:1px solid rgba(239,68,68,.28);color:#b91c1c} .stat.done{background:linear-gradient(120deg,rgba(22,163,74,.12),rgba(22,163,74,.04));border:1px solid rgba(22,163,74,.24);color:#15803d} .stat .d{width:9px;height:9px;border-radius:50%;background:currentColor;flex:0 0 auto} .stat.warn .d{animation:pulse 1.1s infinite} @keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.35;transform:scale(1.5)}} .qrbox{display:grid;grid-template-columns:1fr 1fr;gap:14px} .qr{border-radius:16px;padding:14px;text-align:center;background:#fff;border:1px solid rgba(15,23,42,.08);box-shadow:0 4px 14px -6px rgba(15,23,42,.16)} .qr img{width:100%;max-width:210px;border-radius:11px;display:block;margin:0 auto 9px;background:#f8fafc} .qr b{font-size:13px;display:block} .qr span{font-size:11px;color:#64748b;display:block;margin-top:3px;line-height:1.5} .hero{border-radius:16px;padding:16px 18px;color:#fff;background:linear-gradient(135deg,#1e3a8a,#2563eb 60%,#f59e0b 150%);margin-bottom:14px} .hero h2{margin:0 0 6px;font-size:15.5px;font-weight:800} .hero p{margin:0;font-size:12px;line-height:1.75;opacity:.95} .steps{counter-reset:s} .step{position:relative;padding:0 0 13px 32px;border-left:2px dashed rgba(37,99,235,.28);margin-left:12px} .step:last-child{border-left-color:transparent;padding-bottom:0} .step:before{counter-increment:s;content:counter(s);position:absolute;left:-13px;top:-2px;width:24px;height:24px;border-radius:50%; background:linear-gradient(135deg,#1e3a8a,#2563eb);color:#fff;font-size:11.5px;font-weight:700;display:flex;align-items:center;justify-content:center} .step b{display:block;font-size:12.5px;margin-bottom:3px} .step p{margin:0;font-size:11.5px;color:#475569;line-height:1.7} .fld{display:flex;align-items:center;gap:8px;margin:8px 0} .fld label{font-size:12px;color:#475569;min-width:96px} .mask{position:fixed;inset:0;background:rgba(15,23,42,.5);backdrop-filter:blur(4px);z-index:2147483200;display:flex;align-items:center;justify-content:center;animation:fade .25s} @keyframes fade{from{opacity:0}to{opacity:1}} .dlg{width:460px;max-width:calc(100vw - 40px);max-height:86vh;overflow:auto;border-radius:20px; background:rgba(255,255,255,.98);box-shadow:0 30px 70px -20px rgba(15,23,42,.6);animation:pop .3s cubic-bezier(.22,1.4,.4,1)} @keyframes pop{from{transform:scale(.9) translateY(14px);opacity:0}to{transform:none;opacity:1}} .dlg .dh{padding:18px 20px;color:#fff;background:linear-gradient(130deg,#1e3a8a,#2563eb 62%,#f59e0b 150%)} .dlg .dh h3{margin:0;font-size:15.5px;font-weight:800} .dlg .dh p{margin:5px 0 0;font-size:11.5px;opacity:.92;line-height:1.6} .dlg .db{padding:16px 20px} .dlg .df{padding:0 20px 18px;display:flex;gap:9px;justify-content:flex-end} .segs{border:1px solid rgba(15,23,42,.08);border-radius:12px;overflow:hidden;background:#fff} .segs.fill{flex:1 1 auto;min-height:120px;display:flex;flex-direction:column} .segs.fill .sl{flex:1 1 auto;min-height:0;max-height:none} .segs .sh{padding:7px 11px;background:rgba(248,250,252,.96);font-size:11px;color:#64748b;font-weight:600; display:flex;gap:8px;align-items:center;border-bottom:1px solid rgba(15,23,42,.06)} .segs .sl{max-height:230px;overflow:auto} .segrow{display:flex;gap:8px;align-items:center;padding:6px 11px;border-bottom:1px dashed rgba(15,23,42,.06);font-size:12px} .segrow:last-child{border-bottom:0} .segrow .idx{width:22px;flex:0 0 auto;color:#94a3b8;font-size:10.5px;text-align:right} .segrow .t{flex:1;line-height:1.4;word-break:break-word} .segrow .t small{display:block;color:#94a3b8;font-size:10.5px;margin-top:1px;word-break:break-all} .segrow.on{background:rgba(37,99,235,.08)} .segrow.on .t{font-weight:700;color:#1d4ed8} .segrow.done .t{color:#15803d} .segrow .tm{color:#64748b;font-size:11px;flex:0 0 auto;font-variant-numeric:tabular-nums} .segrow .t .badge{display:inline-block;vertical-align:middle;margin-right:5px;padding:1px 6px;border-radius:6px;font-size:10px;font-weight:600;line-height:1.5} .badge.vid{background:rgba(37,99,235,.12);color:#1d4ed8} .badge.mat{background:rgba(245,158,11,.16);color:#b45309} .badge.doc{background:rgba(21,128,61,.14);color:#15803d} @media (max-width:900px){.cards{grid-template-columns:repeat(2,1fr)}.qrbox{grid-template-columns:1fr}} `; /* ================================================================== * * 5. UI 基础设施 * ================================================================== */ let root = null, shadow = null, toastEl = null; function ensureRoot() { if (root && document.documentElement.contains(root)) return shadow; root = document.createElement('div'); root.id = 'yx-helper-host'; root.style.cssText = 'all:initial;position:fixed;inset:auto 0 0 auto;z-index:2147483000'; document.documentElement.appendChild(root); shadow = root.attachShadow({ mode: 'open' }); const style = document.createElement('style'); style.textContent = CSS; shadow.appendChild(style); toastEl = document.createElement('div'); toastEl.className = 'toast'; shadow.appendChild(toastEl); return shadow; } function el(tag, cls, html) { const n = document.createElement(tag); if (cls) n.className = cls; if (html !== undefined) n.innerHTML = html; return n; } function toast(msg, ms) { ensureRoot(); if (!toastEl) return; toastEl.textContent = msg; toastEl.classList.add('on'); clearTimeout(toast._t); toast._t = setTimeout(() => toastEl.classList.remove('on'), ms || 2100); } function copyText(text, okMsg) { const done = () => toast(okMsg || '已复制到剪贴板', 2400); const fb = () => { const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;left:-9999px'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); done(); } catch (e) { toast('复制失败', 3000); } ta.remove(); }; try { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(done, fb); return; } } catch (e) {} fb(); } function modal(opts) { ensureRoot(); const mask = el('div', 'mask'); const dlg = el('div', 'dlg'); dlg.innerHTML = '

' + esc(opts.title) + '

' + (opts.sub || '') + '

' + '
' + opts.body + '
' + '
'; const df = dlg.querySelector('.df'); (opts.buttons || []).forEach((b) => { const btn = el('button', 'btn ' + (b.cls || 'ghost'), b.text); btn.addEventListener('click', () => { if (b.onClick) b.onClick(mask); if (b.close !== false) mask.remove(); }); df.appendChild(btn); }); mask.appendChild(dlg); mask.addEventListener('click', (e) => { if (e.target === mask) mask.remove(); }); shadow.appendChild(mask); return { mask, dlg }; } /* ---- 拖动 + 缩放(位置与尺寸本地记忆) ---- */ function placeBox(node, key, def) { const b = Object.assign({}, def, readJSON(key, {})); const maxW = Math.max(300, window.innerWidth - 16), maxH = Math.max(200, window.innerHeight - 16); const w = clamp(b.w || def.w, def.minW || 260, maxW); const h = clamp(b.h || def.h, def.minH || 160, maxH); const left = clamp(b.left == null ? window.innerWidth - w - (def.gapX == null ? 24 : def.gapX) : b.left, 6, Math.max(6, window.innerWidth - w - 6)); const top = clamp(b.top == null ? (def.gapY == null ? 90 : def.gapY) : b.top, 6, Math.max(6, window.innerHeight - 60)); node.style.width = w + 'px'; node.style.height = h + 'px'; node.style.left = left + 'px'; node.style.top = top + 'px'; return { left, top, w, h }; } function makeMoveResize(node, handle, grip, key, def) { const save = () => writeJSON(key, { left: parseInt(node.style.left, 10) || 24, top: parseInt(node.style.top, 10) || 24, w: parseInt(node.style.width, 10) || def.w, h: parseInt(node.style.height, 10) || def.h }); placeBox(node, key, def); if (handle) { let on = false, dx = 0, dy = 0; handle.addEventListener('pointerdown', (e) => { if (e.target.closest('button') || e.target.closest('a')) return; on = true; const r = node.getBoundingClientRect(); dx = e.clientX - r.left; dy = e.clientY - r.top; try { handle.setPointerCapture(e.pointerId); } catch (err) {} e.preventDefault(); }); handle.addEventListener('pointermove', (e) => { if (!on) return; const w = node.offsetWidth; node.style.left = clamp(e.clientX - dx, 4, Math.max(4, window.innerWidth - w - 4)) + 'px'; node.style.top = clamp(e.clientY - dy, 4, Math.max(4, window.innerHeight - 44)) + 'px'; e.preventDefault(); }); const end = (e) => { if (!on) return; on = false; try { handle.releasePointerCapture(e.pointerId); } catch (err) {} save(); }; handle.addEventListener('pointerup', end); handle.addEventListener('pointercancel', end); } if (grip) { let on = false, sx = 0, sy = 0, w0 = 0, h0 = 0; grip.addEventListener('pointerdown', (e) => { on = true; sx = e.clientX; sy = e.clientY; w0 = node.offsetWidth; h0 = node.offsetHeight; try { grip.setPointerCapture(e.pointerId); } catch (err) {} e.preventDefault(); e.stopPropagation(); }); grip.addEventListener('pointermove', (e) => { if (!on) return; node.style.width = clamp(w0 + (e.clientX - sx), def.minW || 300, window.innerWidth - 12) + 'px'; node.style.height = clamp(h0 + (e.clientY - sy), def.minH || 180, window.innerHeight - 12) + 'px'; e.preventDefault(); }); const end = (e) => { if (!on) return; on = false; try { grip.releasePointerCapture(e.pointerId); } catch (err) {} save(); }; grip.addEventListener('pointerup', end); grip.addEventListener('pointercancel', end); } } /* ================================================================== * * 6. 声音 / 桌面通知 / 拉前台 * ================================================================== */ let audioCtx = null; function getCtx() { if (!audioCtx) { try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) {} } if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume().catch(() => {}); return audioCtx; } function beep(times) { if (!cfg.sound) return; const ctx = getCtx(); if (!ctx) return; const n = Math.min(8, Math.max(1, times || 1)); for (let i = 0; i < n; i++) { const t0 = ctx.currentTime + i * 0.42; const o = ctx.createOscillator(), g = ctx.createGain(); o.type = 'sine'; o.frequency.setValueAtTime(880, t0); g.gain.setValueAtTime(0.0001, t0); g.gain.exponentialRampToValueAtTime(0.32, t0 + 0.03); g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.32); o.connect(g).connect(ctx.destination); o.start(t0); o.stop(t0 + 0.36); } } function desktopNotify(title, body) { if (!cfg.notice) return; try { if (!('Notification' in window)) return; if (Notification.permission === 'granted') new Notification(title, { body }); else if (Notification.permission !== 'denied') Notification.requestPermission().then((p) => { if (p === 'granted') new Notification(title, { body }); }); } catch (e) {} } function pullToFront() { if (!cfg.focusTab) return; try { window.focus(); } catch (e) {} } /* ================================================================== * * 7. 后台保活(防浏览器冻结后台标签页) * ================================================================== */ const keepAlive = { osc: null, gain: null, wake: null }; function startKeepAlive() { if (!cfg.keepAlive) return; try { const ctx = getCtx(); if (!ctx) return; if (!keepAlive.osc) { const o = ctx.createOscillator(), g = ctx.createGain(); o.type = 'sine'; o.frequency.value = 32; g.gain.value = clamp(num(cfg.keepAliveGain) || 0.008, 0, 0.05); o.connect(g).connect(ctx.destination); o.start(); keepAlive.osc = o; keepAlive.gain = g; } requestWakeLock(); } catch (e) {} } function stopKeepAlive() { try { if (keepAlive.osc) { keepAlive.osc.stop(); keepAlive.osc.disconnect(); } } catch (e) {} keepAlive.osc = null; keepAlive.gain = null; releaseWakeLock(); } function releaseWakeLock() { try { if (keepAlive.wake) { keepAlive.wake.release(); keepAlive.wake = null; } } catch (e) {} } function requestWakeLock() { if (!('wakeLock' in navigator) || keepAlive.wake) return; try { navigator.wakeLock.request('screen').then((w) => { keepAlive.wake = w; w.addEventListener('release', () => { keepAlive.wake = null; }); }).catch(() => {}); } catch (e) {} } (function resumeAudioOnGesture() { const once = () => { const c = getCtx(); if (c && c.state === 'suspended') c.resume().catch(() => {}); if (keepAlive.osc && keepAlive.gain) keepAlive.gain.gain.value = clamp(num(cfg.keepAliveGain) || 0.008, 0, 0.05); requestWakeLock(); document.removeEventListener('pointerdown', once, true); document.removeEventListener('keydown', once, true); document.removeEventListener('click', once, true); }; document.addEventListener('pointerdown', once, true); document.addEventListener('keydown', once, true); document.addEventListener('click', once, true); })(); /* ================================================================== * * 8. 自动看课引擎(仅播放页) * ================================================================== */ const engine = { running: false, state: 'idle', reason: '', tabId: Math.random().toString(36).slice(2), course: null, segTitle: '', idx: -1, total: 0, startedAt: 0, segStartedAt: 0, lastAdvance: 0, lastTick: 0, lastShield: 0, lastLock: 0, blocked: '', doneFor: '', pending: null, timer: null, resumedFor: '', onUpdate: null, diag: {}, // 被动心跳探针(只观察、绝不修改请求):记录平台真实上报次数与累计观看位置 hb: { count: 0, lastLoc: null, firstLoc: null, lastTs: 0, dur: null }, guideRead: false, scroller: null }; function fireUpdate() { if (engine.onUpdate) engine.onUpdate(); } function visible(n) { if (!n) return false; const r = n.getBoundingClientRect(); return r.width > 2 && r.height > 2; } function resItems() { return $$('.category-list .res-item'); } function activeIdx() { const items = resItems(); for (let i = 0; i < items.length; i++) if (items[i].classList.contains('active')) return i; return -1; } function keyText(n) { return (n.innerText || n.textContent || '').replace(/\s+/g, ''); } function domSegTitle(i) { const items = resItems(); if (i < 0 || i >= items.length) return ''; const t = $('.res-name', items[i]); return t ? (t.getAttribute('title') || t.innerText || '').trim() : ''; } /* 平台(spring-grain)把「选中章节」的点击处理器只挂在

上: render: n("p",{staticClass:"res-name",on:{click:()=>handleSelectResource([i,a],t)}}) —— 点 .content-wrapper / .res-item 都是死元素,必须点 .res-name。 */ function clickRes(i) { const items = resItems(); const el = items[i]; if (!el) return false; const target = $('.res-name', el) || $('.content-wrapper', el) || el; try { target.click(); } catch (e) {} return true; } /** 平台的「在场确认」提示(只提醒,不代点) */ function idleTip() { const ac = $('.alarmClock-wrapper'); if (ac && visible(ac)) return ac; const cands = $$('.action-timer span, .action-timer button, .action-timer a, .vcp-player button, .vcp-error-tips'); for (let i = 0; i < cands.length; i++) { const t = keyText(cands[i]); if (/继续计时|继续学习|继续观看|还在看|是否继续|仍在观看|点我/.test(t) && visible(cands[i])) return cands[i]; } return null; } /** 判断是否出现"必须由本人处理"的拦截 */ function detectBlock() { const ac = $('.answerCard-wrapper'); if (ac && !ac.classList.contains('answerCard-hidden')) { const c = $('.content', ac); if (!c || getComputedStyle(c).display !== 'none') return 'answer'; } const ex = $('.beginExam'); if (ex && !ex.classList.contains('hidden') && visible(ex)) return 'answer'; const sc = $('.scoring-wrapper'); if (sc && getComputedStyle(sc).display !== 'none') return 'rate'; if (idleTip()) return 'idle'; return ''; } function doRate() { const sc = $('.scoring-wrapper'); if (!sc) return false; const stars = $$('.rating .rate-item', sc); if (!stars.length) return false; const v = Math.min(Math.max(1, num(cfg.rateValue) || 5), stars.length); stars[v - 1].click(); setTimeout(() => { const btns = $$('button', sc); const hit = btns.find((b) => /提交|确定|完成|保存/.test(keyText(b))); if (hit) hit.click(); else { const any = btns[btns.length - 1]; if (any) any.click(); } }, 600); engine.blocked = ''; toast('已按你的设置提交评分 ' + v + ' 星'); return true; } function findVideo() { return $('.vcp-player video') || document.querySelector('video'); } function endedMask() { return $('.ended-mask'); } function endedMaskVisible() { const m = endedMask(); if (!m) return false; const s = getComputedStyle(m); return s.display !== 'none' && s.visibility !== 'hidden' && m.offsetWidth > 10 && m.offsetHeight > 10; } function completedTextVisible() { const body = document.body; return /已完成本节内容|本节内容已学习完成/.test((body && body.innerText) || ''); } function segKey() { return engine.idx + '|' + engine.segTitle; } function syncVideo() { const v = findVideo(); if (!v) return null; try { v.muted = !!cfg.muted; // 学时按「服务端收到的 25 秒心跳」累计:实测抓包确认 watchTime 是写死常量 25、watchLocation 每 25 秒 +25, // 与视频实际播放位置无关。倍速只让视频提前播完、反而少报几次心跳 → 学时更少,所以锁死 1x 是最优解。 if (v.playbackRate !== 1) v.playbackRate = 1; if (cfg.muted) v.volume = 0.0001; } catch (e) {} return v; } /* 长文本自动滚动:用于「文本/文档段阅读浮层」与「课程指南/专家介绍」——平滑滚到底,模拟阅读、帮助平台判完成 */ const _sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function findScrollable(root) { root = root || document; const all = $$('div,p,article,section,main', root); let best = null, bestLen = 0; all.forEach((el) => { const max = el.scrollHeight - el.clientHeight; const len = (el.innerText || '').length; if (max > 40 && len > 80 && len > bestLen) { best = el; bestLen = len; } }); return best; } function smoothScroll(container, ms) { return new Promise((resolve) => { const max = container.scrollHeight - container.clientHeight; if (max <= 4) { resolve(); return; } const start = container.scrollTop || 0; const t0 = performance.now(); function step(now) { const p = Math.min(1, (now - t0) / ms); const e = 1 - Math.pow(1 - p, 3); // ease-out container.scrollTop = start + (max - start) * e; if (p < 1) requestAnimationFrame(step); else resolve(); } requestAnimationFrame(step); }); } // 自动阅读:切到「课程指南」「专家介绍」tab 并平滑滚动其长文本(信息区,不影响学时;仅满足"自动滚动阅读"需求) async function readGuide() { if (engine.scroller) return; // 防止重入 engine.scroller = true; try { const tabs = $$('.tab, .ivu-tabs-tab'); // 只取真正可点击的 tab,避免点到容器 const names = ['课程指南', '专家介绍']; for (const name of names) { const tab = tabs.find((t) => (t.textContent || '').indexOf(name) >= 0); if (!tab) continue; tab.click(); await _sleep(700); const pane = $('.ivu-tabs-content .ivu-tabs-tabpane, .ivu-tabs-content') || $('.ivu-tabs-content'); const el = pane ? findScrollable(pane) : findScrollable(document); if (el) { toast('正在自动阅读「' + name + '」…', 1800); await smoothScroll(el, 4000); } await _sleep(300); } // 切回目录,避免挡住播放区 const cat = tabs.find((t) => (t.textContent || '').indexOf('目录') >= 0); if (cat) cat.click(); toast('已自动阅读课程指南 / 专家介绍', 2600); } finally { engine.scroller = false; } } /* 被动心跳探针:包装 fetch / XHR,仅读取 hb/report 请求体里的 watchLocation,绝不修改任何请求。 用于面板实时显示「平台已收到几次心跳、累计看到第几分几秒」,让学时积累过程透明可见。 */ function installHbProbe() { if (window.__yxHbProbe) return; window.__yxHbProbe = true; const isHb = (u) => /heartbeat-center\/hb\/report|hbCenter|heartbeat/i.test(u || ''); const parse = (body) => { if (!body || typeof body !== 'string') return null; try { return JSON.parse(body); } catch (e) { return null; } }; const note = (o) => { if (!o) return; engine.hb.count++; if (o.watchLocation != null) engine.hb.lastLoc = num(o.watchLocation); if (engine.hb.firstLoc == null && o.watchLocation != null) engine.hb.firstLoc = num(o.watchLocation); engine.hb.lastTs = Date.now(); if (o.watchTime != null) engine.hb.dur = num(o.watchTime); }; try { const of = window.fetch; window.fetch = function (url, opts) { try { if (isHb(url) && opts && typeof opts.body === 'string') note(parse(opts.body)); } catch (e) {} return of.apply(this, arguments); }; } catch (e) {} try { const ox = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function (m, u) { this.__yxu = u; return ox.apply(this, arguments); }; const os = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function (b) { try { if (isHb(this.__yxu)) note(parse(typeof b === 'string' ? b : (b && b.toString ? b.toString() : ''))); } catch (e) {} return os.apply(this, arguments); }; } catch (e) {} } async function tryPlay() { const v = syncVideo(); if (!v) return false; if (!v.paused && v.currentTime > 0) return true; try { await v.play(); return true; } catch (e) { const big = $('.vcp-bigplay'); if (big && visible(big)) { try { big.click(); } catch (e2) {} } try { await v.play(); return true; } catch (e3) { return false; } } } function advance(reason) { const now = Date.now(); if (now - engine.lastAdvance < 6000) return; engine.lastAdvance = now; if (engine.idx >= 0) markDone(engine.course && engine.course.id, engine.idx); engine.scroller = false; // 切换节时清掉阅读滚动锁,避免后台标签页 rAF 未触发导致卡死 engine.doneFor = segKey(); const p = { at: now, title: engine.segTitle, idx: engine.idx, tries: 0, clicked: false }; engine.pending = p; engine.reason = '本节已播完,准备切换下一节…'; log('advance scheduled', reason); setTimeout(() => { if (engine.running && engine.pending === p) doAdvance(p); }, 2500); } function nextVideo(start, requireUndone) { if (!pdata.segs.length) return -1; const cid = engine.course && engine.course.id; for (let k = num(start) + 1; k < pdata.segs.length; k++) { const s = pdata.segs[k]; if (!s || !s.video) continue; if (!requireUndone) return k; if (!isDone(cid, s.i)) return k; } return -1; } function isTextSeg(i) { return !!(pdata.segs[i] && !pdata.segs[i].video); } // 下一节(文本/文档段也参与队列,不再被跳过) function nextSeg(start, requireUndone) { if (!pdata.segs.length) return -1; const cid = engine.course && engine.course.id; for (let k = num(start) + 1; k < pdata.segs.length; k++) { const s = pdata.segs[k]; if (!s) continue; if (!requireUndone) return k; if (!isDone(cid, s.i)) return k; } return -1; } function doAdvance(p) { const items = resItems(); const cur = engine.idx >= 0 ? engine.idx : activeIdx(); log('doAdvance cur=', cur, 'items=', items.length, 'segs=', pdata.segs.length); // 1) 官方「下一节」按钮(平台自己的完播信号,最可靠;浮层隐藏时按钮不可见) const nb = $('.ended-mask .btns .next') || $('.btns .next'); if (visible(nb)) { p.clicked = true; p.at = Date.now(); nb.click(); engine.state = 'playing'; engine.reason = '已点击「下一节」,正在切换…'; log('advance -> .next'); return; } // 2) 目录里的下一节:优先跳“未学完的节”,没有则跳“下一个节”;文本/文档段也参与队列 if (items.length && pdata.segs.length) { let next = nextSeg(cur, true); // 先找未学完 if (next < 0) next = nextSeg(cur, false); // 兜底:找下一个节(应对无缓存场景) if (next < 0) { // 仍没找到:检查前面是否还有未访问的节(混合课程里文本节可能排在视频前面,向后搜索会漏掉) const cid = engine.course && engine.course.id; next = pdata.segs.findIndex((s, k) => k !== cur && !isDone(cid, s.i)); } if (next >= 0) { p.clicked = true; p.at = Date.now(); const clicked = clickRes(next); engine.state = 'playing'; engine.reason = '已切换到第 ' + (next + 1) + ' 节'; log('advance -> catalog', next, clicked ? 'clicked' : 'click failed'); if (!clicked) { engine.pending = null; engine.state = 'blocked'; engine.reason = '目录点击失败,请手动切到第 ' + (next + 1) + ' 节'; beep(3); pullToFront(); } return; } } // 3) 本门学完 engine.pending = null; finishCourse(); } function loadCachedCourses() { const c = readJSON(K_CACHE, null); if (!c || !c.courses || !c.courses.length) return []; return c.courses; } function findNextCourse(curId) { const all = loadCachedCourses(); if (!all.length) return null; const cur = all.find((c) => String(c.id) === String(curId)); if (!cur) { // 当前课程不在缓存:按列表顺序找第一门未学 return all.find((c) => num(c.noStudyTime) > 0) || null; } // 同一模块内,当前课程之后的下一门未学 const sameModule = all.filter((c) => c._module === cur._module); const idx = sameModule.findIndex((c) => String(c.id) === String(curId)); let next = sameModule.slice(idx + 1).find((c) => num(c.noStudyTime) > 0); if (next) return next; // 本模块学完,按模块在 state.modules 里的顺序找下一个模块的未学课 const c = readJSON(K_CACHE, null); const mods = (c && c.modules) || []; const modIdx = mods.findIndex((m) => String(m.moduleName) === String(cur._module)); for (let i = modIdx + 1; i < mods.length; i++) { const name = mods[i].moduleName; next = all.find((c) => c._module === name && num(c.noStudyTime) > 0); if (next) return next; } // 所有模块都学完:从开头找第一门未学(兜底) return all.find((c) => num(c.noStudyTime) > 0) || null; } function finishCourse() { // 本门全部章节学完后,可选先自动阅读「课程指南/专家介绍」(仅滚动阅读,不影响学时) if (cfg.autoReadGuide && !engine.guideRead) { engine.guideRead = true; engine.state = 'reading'; engine.reason = '本门学完,正在自动阅读课程指南/专家介绍…'; fireUpdate(); readGuide().then(() => { finishCourse(); }); return; } const q = loadQueue(); if (!q || !q.items || !q.items.length) { // 无队列时,尝试从学习空间缓存的课程列表找下一门 const next = findNextCourse(engine.course && engine.course.id); if (next && String(next.id) !== String(engine.course && engine.course.id)) { engine.state = 'jumping'; engine.reason = '本门学完,正在进入下一门:' + next.courseName; toast('本门学完,2 秒后进入「' + next.courseName + '」…', 2600); fireUpdate(); setTimeout(() => { location.href = playerUrl(next.id, next.courseSourceId); }, 2200); return; } engine.state = 'done'; engine.reason = '本课程已全部播完'; stopEngine(false); desktopNotify('本课程已学完', (engine.course && engine.course.name ? engine.course.name : '') + ' 全部章节已播完'); beep(4); return; } const here = q.items.findIndex((x) => String(x.id) === String(engine.course && engine.course.id)); const from = here >= 0 ? here : (num(q.idx) || 0); const nextI = from + 1; if (nextI >= q.items.length) { q.running = false; saveQueue(q); engine.state = 'done'; engine.reason = '队列全部完成 🎉'; desktopNotify('自动看课完成', '队列中的 ' + q.items.length + ' 门课程已全部播完'); beep(5); fireUpdate(); return; } q.idx = nextI; q.running = true; saveQueue(q); engine.state = 'jumping'; engine.reason = '正在进入下一门:' + q.items[nextI].name; toast('本门学完,2 秒后进入下一门…', 2600); fireUpdate(); setTimeout(() => { location.href = playerUrl(q.items[nextI].id, q.items[nextI].sid); }, 2200); } function markBlocked(kind) { if (engine.blocked === kind) return; engine.blocked = kind; if (kind === 'answer') { engine.reason = '平台要求「回答问题完成验证」,请本人作答'; beep(6); pullToFront(); desktopNotify('需要你本人作答', '播放已暂停。这是平台的防挂机验证,助手不会替你作答,请回到页面完成。'); toast('⚠ 平台弹出验证题,请本人作答(助手不作答)', 5000); } else if (kind === 'rate') { if (cfg.autoRate) { if (doRate()) fireUpdate(); return; } engine.reason = '平台要求给课程评分,请本人评星'; beep(3); pullToFront(); desktopNotify('需要你评分', '请在播放器中给课程评星后继续。'); } else if (kind === 'idle') { engine.reason = '平台弹出了「继续计时 / 继续学习」确认,请本人点一下'; beep(6); pullToFront(); desktopNotify('需要你确认一下', '播放已暂停。这是平台的在场确认,助手不会替你点,请回到页面点击「继续」。'); toast('⚠ 平台要求点击「继续计时」,请本人点一下(助手不代点)', 5000); } fireUpdate(); } function tick() { if (!engine.running) return; const now = Date.now(); engine.lastTick = now; if (now - engine.lastLock > 3000) { engine.lastLock = now; holdLock(); } if (!engine.course) { const pc = playerCtx(); const q = loadQueue(); const item = (q && q.items && q.items.find((x) => String(x.id) === String(pc.courseId))) || null; engine.course = { id: pc.courseId, sid: pc.courseSourceId, name: (pdata.name || (item ? item.name : '') || document.title || '本课程') }; if (!pdata.loaded && !pdata.loading) loadPlayerCourse(); } else if (pdata.name && engine.course.name !== pdata.name) { engine.course.name = pdata.name; } // 目录与当前节 const items = resItems(); engine.total = pdata.segs.length || items.length; const i = activeIdx(); if (i >= 0) { if (engine.idx !== i) engine.segStartedAt = now; engine.idx = i; engine.segTitle = domSegTitle(i); } // 断点续播:进入本门后,跳到第一个未学完的视频;当前段已学完也立即往后跳 if (cfg.resume && pdata.loaded && pdata.segs.length && engine.resumedFor !== engine.course.id && !engine.pending && !engine.blocked && engine.idx >= 0) { engine.resumedFor = engine.course.id; const firstUndone = pdata.segs.findIndex((s) => !isDone(engine.course.id, s.i)); if (firstUndone < 0) { // 本门所有视频段本机都已 markDone,直接结束本门 engine.reason = '本门所有章节本机已学完,准备进入下一门…'; finishCourse(); return; } if (firstUndone !== engine.idx && items[firstUndone]) { engine.reason = '断点续播:跳到第 ' + (firstUndone + 1) + ' 节(前面的章节本机已播完)'; toast('断点续播 → 第 ' + (firstUndone + 1) + ' 节', 2600); clickRes(firstUndone); fireUpdate(); return; } } // 运行中途:如果当前段已经被标记为已学完(且不是最后一节),立即 advance if (cfg.autoNext && cfg.resume && pdata.loaded && engine.idx >= 0 && !engine.pending && !engine.blocked && isDone(engine.course.id, engine.idx) && nextSeg(engine.idx, true) >= 0) { engine.reason = '当前段本机已学完,准备跳到下一个未学段…'; advance('resume-skip'); return; } // 等待「下一节」切换完成 if (engine.pending) { const p = engine.pending; const changed = (engine.idx !== p.idx) || (engine.segTitle && engine.segTitle !== p.title); if (changed) { engine.pending = null; engine.reason = '已进入下一节,继续播放'; log('switch ok ->', engine.segTitle); } else if (p.clicked && now - p.at > 13000) { p.tries++; if (p.tries <= 3) { p.clicked = false; p.at = now; engine.reason = '切换未生效,重试…'; doAdvance(p); } else { engine.pending = null; engine.state = 'blocked'; engine.reason = '自动切换下一节失败,请手动点一下「下一节」或刷新页面'; beep(3); pullToFront(); desktopNotify('切换失败', '未能自动进入下一节,请回到页面手动点击。'); } } syncVideo(); fireUpdate(); return; } // 需要本人处理的拦截 const block = detectBlock(); if (block) { markBlocked(block); syncVideo(); fireUpdate(); return; } if (engine.blocked) { engine.blocked = ''; engine.reason = '继续播放'; } const mask = endedMaskVisible(); const completedText = completedTextVisible(); const nb = $('.ended-mask .btns .next') || $('.btns .next'); const key = segKey(); // 四路完播信号(前三路接近片尾;第四路:结束浮层出现即视为播完) const v = syncVideo(); const dur = v ? num(v.duration) : 0; const cur = v ? num(v.currentTime) : 0; const doneByEnd = v && v.ended && dur > 0 && cur >= dur - 2 && cur > 2; const doneByTail = v && dur > 60 && cur >= dur - 1.5 && cur > 30; const doneByBtn = v && visible(nb) && v.paused && dur > 0 && cur >= dur * 0.9; const doneByMask = mask || completedText; engine.diag = { video: !!v, dur: Math.round(dur), cur: Math.round(cur), ended: !!(v && v.ended), paused: !!(v && v.paused), nextBtn: visible(nb), mask: mask, completedText: completedText, domItems: items.length, apiSegs: pdata.segs.length, idx: engine.idx, title: engine.segTitle, courseName: pdata.name, resumed: engine.resumedFor }; if ((doneByEnd || doneByTail || doneByBtn || doneByMask) && engine.doneFor !== key) { const reason = doneByEnd ? 'ended' : doneByTail ? 'tail' : doneByBtn ? 'nextbtn' : 'mask'; log('完播信号', reason, { dur, cur, mask, completedText }); advance(reason); return; } // 文本/文档段:没有 video,不能按视频完播判定;等平台「已完成本节内容」或停留兜底时间 if (isTextSeg(engine.idx)) { const tw = clamp(cfg.textWait, 3, 120); const dwell = now - engine.segStartedAt; // 若有可滚动的阅读浮层/正文,平滑自动滚动(模拟阅读,帮助平台判定"已读") if (!engine.scroller) { const reader = findScrollable(document.querySelector('.reading-courseware, .sg-notes, [class*="reading"], [class*="notes"]') || document.body); if (reader && reader.scrollHeight - reader.clientHeight > 40) { engine.scroller = true; smoothScroll(reader, clamp(tw, 3, 12) * 1000).then(() => { engine.scroller = false; }); } } if (dwell >= tw * 1000) { engine.reason = '文本段已停留 ' + Math.round(dwell / 1000) + '/' + tw + 's,继续下一节'; advance('text-dwell'); } else { engine.reason = '文本学习中 ' + Math.round(dwell / 1000) + '/' + tw + 's(自动滚动阅读中…)'; fireUpdate(); } return; } if (!v) { engine.reason = '等待播放器加载…'; fireUpdate(); return; } if (dur > 0 && cur > 0) { engine.reason = '播放中 ' + clock(cur) + ' / ' + clock(dur) + '(第 ' + (engine.idx + 1) + '/' + (engine.total || '?') + ' 节)'; } if (v.paused && now - engine.lastShield > 4000) { engine.lastShield = now; tryPlay(); } fireUpdate(); } /* --- 多标签互斥(平台限制单端播放) --- */ function holdLock() { writeJSON(K_LOCK, { id: engine.tabId, ts: Date.now(), courseId: engine.course && engine.course.id, name: pdata.name || (engine.course && engine.course.name) || '', idx: engine.idx, total: engine.total, state: engine.reason }); } function otherTabAlive() { const l = readJSON(K_LOCK, null); return l && l.id !== engine.tabId && Date.now() - l.ts < 12000; } function startEngine(silent) { if (engine.running) return; if (otherTabAlive()) { engine.state = 'blocked'; engine.reason = '检测到另一个标签页正在学习,已暂停本页'; fireUpdate(); return; } const pc = playerCtx(); if (!pc) return; engine.running = true; engine.startedAt = Date.now(); engine.state = 'playing'; engine.reason = '自动连播已启动'; startKeepAlive(); tryPlay(); clearInterval(engine.timer); engine.timer = setInterval(tick, 1000); tick(); if (!silent) toast('自动连播已启动(静音 · 播完自动下一节)', 2600); log('engine started', pc); fireUpdate(); } function stopEngine(manual) { engine.running = false; engine.state = 'idle'; if (manual) engine.reason = '已手动暂停'; clearInterval(engine.timer); engine.timer = null; stopKeepAlive(); writeJSON(K_LOCK, { id: '', ts: 0 }); fireUpdate(); } /* ================================================================== * * 9. 播放页悬浮面板 * ================================================================== */ const MINI_DEF = { w: 404, h: 560, minW: 320, minH: 200, gapX: 20, gapY: 86 }; function mountPlayerPanel() { ensureRoot(); if (shadow.getElementById('yx-mini')) return; const p = el('div', 'mini-panel'); p.id = 'yx-mini'; p.innerHTML = `

自动连播助手

准备中…

v${VERSION} · 爱国者 反馈群
`; shadow.appendChild(p); makeMoveResize(p, p.querySelector('#yx-drag'), p.querySelector('#yx-p-grip'), K_MINI, MINI_DEF); let collapsed = false; p.querySelector('#yx-p-min').addEventListener('click', () => { collapsed = !collapsed; p.querySelector('#yx-p-body').style.display = collapsed ? 'none' : 'block'; p.querySelector('.foot').style.display = collapsed ? 'none' : 'flex'; p.style.height = collapsed ? 'auto' : (readJSON(K_MINI, {}).h || MINI_DEF.h) + 'px'; }); p.querySelector('#yx-p-refresh').addEventListener('click', () => { renderPlayerPanel._sig = ''; loadPlayerCourse(true); toast('正在刷新课程数据…'); }); p.querySelector('#yx-p-qq').addEventListener('click', (e) => { e.preventDefault(); window.open(QQ_GROUP, '_blank'); }); engine.onUpdate = renderPlayerPanel; renderPlayerPanel(); if (!loadPdataFromCache()) loadPlayerCourse(); } function segRowsHtml(cur, courseId) { if (!pdata.segs.length) { const msg = pdata.loading ? '正在读取章节…' : (pdata.err ? '章节读取失败:' + esc(pdata.err) : '暂无章节数据,点右上角 ⟳ 重试'); return '
' + msg + '
'; } let lastChp = ''; return pdata.segs.map((s, i) => { let h = ''; if (s.chp && s.chp !== lastChp) { lastChp = s.chp; h = '
' + esc(s.chp) + '
'; } const on = i === cur ? ' on' : ''; const done = courseId && isDone(courseId, i) ? ' done' : ''; const sub = on ? '正在播放' : (done ? '本机已播完' : ''); const badge = !s.video ? '文档' : (s.tips ? '视频·附课件' : '视频'); let mat = ''; if (s.tips) mat += ''; if (s.notes) mat += ''; const playLabel = s.video ? '播' : '看'; return h + '
' + '' + (i + 1) + '' + '' + badge + esc(s.name) + (sub ? '' + sub + '' : '') + '' + '' + clock(s.sec) + '' + '' + mat + '
'; }).join(''); } function renderPlayerPanel() { const body = shadow && shadow.getElementById('yx-p-body'); if (!body) return; const st = shadow.getElementById('yx-p-state'); const ct = shadow.getElementById('yx-p-course'); const q = loadQueue(); const qInfo = q && q.items && q.items.length ? ('队列 ' + (Math.min(num(q.idx) + 1, q.items.length)) + '/' + q.items.length) : '未设置队列'; const v0 = findVideo(); const cur = v0 ? num(v0.currentTime) : 0, dur = v0 ? num(v0.duration) : 0; const curIdx = curSegIndex(); const seg = pdata.segs.length ? pdata.segs[curIdx] : null; // 视频时长在流加载完成前不可信(可能只有 12 秒的占位),优先用接口里这一节的时长 const showDur = seg && seg.sec ? seg.sec : dur; const sig = [engine.running, engine.state, engine.reason, engine.blocked, engine.idx, engine.total, engine.segTitle, qInfo, Math.round(cur / 5), pdata.loading, pdata.loaded, pdata.err, pdata.segs.length, curIdx, pdata.name, pdata.visited, engine.resumedFor, cfg.muted, cfg.autoNext, cfg.keepAlive, cfg.autoRate, cfg.resume, engine.hb.count, engine.hb.lastLoc].join('|'); if (sig === renderPlayerPanel._sig) return; renderPlayerPanel._sig = sig; let cls = 'run', txt = engine.reason || '准备中…'; if (engine.blocked === 'answer') { cls = 'warn'; txt = '需要你本人作答验证题'; } else if (engine.blocked === 'rate') { cls = 'warn'; txt = '需要你给课程评分'; } else if (engine.blocked === 'idle') { cls = 'warn'; txt = '需要你点一下「继续计时」'; } else if (engine.state === 'done') { cls = 'done'; txt = engine.reason || '已完成'; } else if (engine.state === 'blocked') { cls = 'warn'; } if (st) st.textContent = txt; if (ct) ct.textContent = pdata.name || (engine.course && engine.course.name) || '自动连播助手'; const pct = showDur > 3 ? Math.min(100, cur / showDur * 100) : 0; const cid = engine.course && engine.course.id; const doneN = pdata.segs.filter((s) => isDone(cid, s.i)).length; body.innerHTML = `
${esc(txt)}
第 ${curIdx >= 0 ? curIdx + 1 : '-'} / ${pdata.segs.length || engine.total || '-'} 节
${esc(engine.segTitle || (seg ? seg.name : '—'))}
${qInfo}${pdata.teacher ? '
' + esc(pdata.teacher) : ''}
本节 ${clock(cur)} / ${clock(showDur)}${pct.toFixed(0)}%
加速学习 · 已是最快合规路径
心跳已上报 ${engine.hb.count} 次 · 最近位置 ${engine.hb.lastLoc != null ? clock(engine.hb.lastLoc) : '--'}
${engine.running ? '' : ''}
章节列表 ${pdata.segs.length ? pdata.segs.length + ' 节 · ' + hhmm(pdata.total) : ''}${doneN ? ' · 已播 ' + doneN + ' 节' : ''}
课程总时长 ${hhmm(pdata.total)}本门已学 ${hhmm(pdata.visited)}
${segRowsHtml(curIdx, cid)}
${cfg.muted ? '🔇 静音' : '🔊 有声'} · ${cfg.autoNext ? '自动下一节' : '手动下一节'} · ${cfg.keepAlive ? '保活开' : '保活关'} · ${cfg.resume ? '断点续播' : '整门重看'} · 评分${cfg.autoRate ? '自动 ' + cfg.rateValue + ' 星' : '手动'} 设置
`; const bind = (id, fn) => { const n = shadow.getElementById(id); if (n) n.addEventListener('click', fn); }; bind('yx-start', () => { if (engine.running) stopEngine(true); else startEngine(); }); bind('yx-skip', () => { engine.lastAdvance = 0; engine.pending = null; engine.doneFor = ''; advance('manual'); }); bind('yx-nextc', () => finishCourse()); bind('yx-tutor', () => showTutorial()); bind('yx-guide', () => readGuide()); bind('yx-hb-info', () => showHbRules()); bind('yx-donate', () => showDonate()); bind('yx-cfg', (e) => { e.preventDefault(); showSettings(); }); bind('yx-diag', () => { const info = { 版本: VERSION, 运行: engine.running, 状态: engine.state, 说明: engine.reason, 课程接口名: pdata.name, 课程接口章节数: pdata.segs.length, 当前节: engine.segTitle, 当前节接口时长: seg ? seg.sec : null, DOM目录数: resItems().length, 视频时长: Math.round(dur), 就绪信号: engine.diag, 静音: cfg.muted, 保活: cfg.keepAlive, 保活音频: keepAlive.osc ? '已启动' : '未启动', 平台弹窗: (function () { const b = detectBlock(); return b === 'answer' ? '验证题弹出中' : b === 'rate' ? '评分弹出中' : b === 'idle' ? '继续计时提示中' : '无'; })(), 页面: location.href }; copyText(JSON.stringify(info, null, 2), '自检信息已复制,可粘贴到反馈群'); }); body.querySelectorAll('[data-go]').forEach((b) => b.addEventListener('click', () => { const i = num(b.getAttribute('data-go')); if (clickRes(i)) toast('已跳到第 ' + (i + 1) + ' 节'); else toast('目录里找不到第 ' + (i + 1) + ' 节'); })); body.querySelectorAll('[data-tips]').forEach((b) => b.addEventListener('click', () => { const u = b.getAttribute('data-tips'); if (u) { window.open(u, '_blank', 'noopener'); toast('已在新标签页打开课件 / 资料', 2000); } })); body.querySelectorAll('[data-notes]').forEach((b) => b.addEventListener('click', () => { const u = b.getAttribute('data-notes'); if (u) { window.open(u, '_blank', 'noopener'); toast('已在新标签页打开文稿', 2000); } })); } /* ================================================================== * * 10. 打赏 / 教程 / 设置 / QQ 群 * ================================================================== */ function showDonate() { modal({ title: '请我喝杯咖啡 ☕', sub: '全部功能永久免费,不打赏也一个不少', body: `

这不是付费墙

脚本的每一项功能都对所有人开放,没有任何"赞助解锁"的隐藏开关。打赏只代表一件事:你愿意让这个工具继续被维护下去。

支付宝收款码支付宝扫码打赏
微信收款码微信扫码打赏
图片可右键「图片另存为」保存。若二维码加载失败,说明图床被浏览器拦了,稍后再试或直接进群找我。
`, buttons: [{ text: '加入反馈群', cls: 'ghost', close: false, onClick: () => window.open(QQ_GROUP, '_blank') }, { text: '好的', cls: '' }] }); } const WHY_HTML = `
为什么不能"秒学完"——平台源码级证据
我把播放器主包(spring-grain)反混淆后拿到了它自己的计时与上报代码,结论是确定的:
机制平台实现后果
计时器setInterval(fn,1000)durationTime++真实墙上时钟累计,与视频播放位置无关
上报每 25 秒 POST /heartbeat-center/hb/reportwatchTime:25watchTime写死的常量,不是视频进度
时长要求courseCompleteTimeRate:100每门课必须看满 100% 时长
进度条boolDragLock:1禁止拖动,跳片尾无效
防挂机boolFoolproofLock:2 · foolproofDuration:1800挂机检测
弹题openTestPercentage:6060% 处弹验证题,需本人作答
多端互斥boolMultiClientOpen:2(错误码 3028002)同一账号只能一个终端学
1. 倍速不会更快拿到学时。上报的是墙上时钟,2 倍速只让视频提前播完,学时仍按真实秒数记——一门 60 分钟的课,2 倍速 30 分钟播完,你只拿到约 30 分钟,剩下白丢。所以助手锁死 1 倍速,这是数学上的最优解。
2. 唯一能"秒学完"的办法是绕过播放器直接伪造 25 秒一次的 hb/report 请求刷学时。那是伪造学习记录,结果会进你的继续教育学分,一旦核查是实打实的问题——所以助手不做。
3. 现实结论:1440 分钟 = 24 小时真实时间,这个下限压不掉。助手能把"你必须坐在电脑前"的 24 小时,压成"只需在平台强制本人确认时点一下"。
`; function showTutorial() { modal({ title: '使用教程', sub: '三分钟上手 · 看得懂就会用', body: `
装好油猴

Chrome / Edge 装 Tampermonkey(篡改猴);360 极速浏览器在扩展中心搜 Tampermonkey。装完工具栏会出现黑色方块图标。

安装本脚本

研修网学习助手.user.js 拖进浏览器窗口,油猴弹出安装页,点「安装」。

登录研修网

在要挂机的电脑上登录,然后打开「学习空间」页面(网址含 /train2/workspace/)。右下角出现蓝色「研」按钮即成功。

点「研」→ 自动挂机 → 生成队列 → 开始

推荐「最短达标」:按时长从短到长排好,用最少的课凑满 ${REQUIRE_MIN} 分钟。点「开始自动看课」,第一门课自动打开。

然后去干别的

播放页自动:静音 → 播放 → 播完自动下一节 → 本门播完自动下一门 → 队列跑完自动停。播放页意外关闭时,学习空间页的「看门狗」会自动重开。

面板怎么用
两个面板都能拖动(按住顶部彩色标题栏)和缩放(拖右下角斜纹),位置大小会自动记住。
播放页面板显示:课程名 / 讲师 / 本节序号 / 本节标题 / 本节时长 / 全章节列表(含每节时长和"本机已播完"标记);点章节右侧「播」可直接跳过去。
必须知道的三件事
1. 别关播放页标签页。可以切到别的窗口、别的标签页;开了「后台保活」浏览器不会冻结它。学习空间页也留着,看门狗靠它重开播放页。
2. 平台会弹「回答问题完成验证」和「继续计时」。这是研修网的防挂机机制。助手会响铃 + 桌面通知 + 把窗口拉到前台提醒你,但不替你作答、也不替你点「继续」——你处理完,自动连播自己继续。
3. 学时按真实时长累计,倍速会少记,所以助手固定 1 倍速;进度条受平台锁定,不可拖动。
常见问题
Q:面板读不到章节标题和时长?点面板右上角 ⟳ 刷新课程数据;仍不行就点「自检」把信息发到反馈群。
Q:切出去回来发现停了?多半是弹验证题,面板会变红提示,答完自动继续。
Q:能开两个播放页吗?不能,平台限制单端(错误码 3028002),助手会自动暂停第二个。
Q:看板数据准吗?来自研修网官方接口,与学习空间一致。
${WHY_HTML}`, buttons: [{ text: '看打赏', cls: 'gold', onClick: () => setTimeout(showDonate, 60) }, { text: '知道了', cls: '' }] }); } function showSettings() { const rows = [ ['muted', '静音播放', '播放时自动静音,不影响你听歌/开会'], ['autoNext', '自动连播', '一节播完自动下一节,本门播完自动下一门'], ['keepAlive', '后台保活', '防止浏览器把后台标签页冻结导致中断'], ['resume', '断点续播', '重新进入某门课时,跳过本机已经完整播完的章节'], ['autoReadGuide', '自动读指南', '本门学完后自动打开并滚动阅读「课程指南/专家介绍」'], ['watchdog', '看门狗', '播放页被关掉或崩溃时,自动重新打开'], ['notice', '桌面通知', '需要你处理时弹出系统通知'], ['sound', '响铃提醒', '需要你处理时播放提示音'], ['focusTab', '自动拉前台', '需要你处理时把播放页窗口拉到最前'], ['autoRate', '自动评分', '平台要求给课程评星时,按你设定的星数自动提交'], ['autoStart', '进入即自动开始', '打开队列里的课程时自动开始连播'] ]; const body = rows.map((r) => `
${r[1]}${r[2]}
`).join('') + `
倍速锁死为 1 倍速:平台 watchTime 上报的是墙上时钟常量,倍速只会让你少记学时,不会更快达标。
`; const m = modal({ title: '设置', sub: '改完立即生效', body, buttons: [ { text: '取消', cls: 'ghost' }, { text: '保存', cls: '', onClick: () => { m.dlg.querySelectorAll('.sw').forEach((n) => { cfg[n.getAttribute('data-k')] = n.classList.contains('on'); }); const rv = m.dlg.querySelector('#yx-rv'); if (rv) cfg.rateValue = clamp(rv.value, 1, 5); saveCfg(); if (!cfg.keepAlive) stopKeepAlive(); else if (engine.running) startKeepAlive(); syncVideo(); renderPlayerPanel._sig = ''; fireUpdate(); render(); toast('设置已保存'); } } ] }); m.dlg.querySelectorAll('.sw').forEach((n) => n.addEventListener('click', () => n.classList.toggle('on'))); } /* 加速学习(合规)说明:真实心跳规则 + 为什么倍速无效 + 一键最优自检 */ function showHbRules() { const levers = [ ['autoNext', '自动连播', '一节播完自动下一节、本门播完自动下一门'], ['keepAlive', '后台保活', '离开电脑时浏览器不冻结后台标签页,挂着也跑'], ['resume', '断点续播', '跳过本机已完整播完的章节,不重复看'], ['autoStart', '进入即自动开始', '打开队列里的课程立刻连播,不用手动点'] ]; const off = levers.filter((l) => !cfg[l[0]]); const stateLine = off.length ? '有 ' + off.length + ' 项未开启,按下面「一键开启最优」即可拉满' : '当前已是合规最快配置 ✓'; const leverRows = levers.map((l) => `
${l[1]}${l[2]}
`).join(''); const m = modal({ title: '加速学习 · 真实心跳规则', sub: '结论:学时由服务端按 25 秒心跳累计,合规最快 = 1x + 无缝连播 + 保活 + 断点', body: `

为什么不能"秒学"——真实抓包证据

我用浏览器抓到了平台自己发出的心跳请求,规则是确定的,不是猜的:

字段实测值含义
端点POST /heartbeat-center/hb/report每 ~25 秒一次
watchTime25(写死)每次固定 +25 秒,不是视频进度
watchLocation累计 +25 / 次累计观看位置,到时长即本节完成
响应{"code":200,"data":"success"}服务端按收到次数累计学时
① 倍速不会更快,反而少记。学时 = 心跳次数 × 25 秒(真实墙上时钟)。2 倍速只让视频提前播完、心跳次数变少 → 你拿到的学时更少。所以助手锁死 1 倍速
② 唯一能"秒学完"的办法是伪造 25 秒一次的心跳刷学时,那是伪造学习记录、会进你的继续教育学分,核查是实打实的风险——本项目底线明确不做。
③ 真正最快的合规路径:1x 真实观看 + 无缝自动连播(本门播完自动下一节/下一门)+ 后台保活(离开也跑)+ 断点续播(跳过已看)。下面四项全开就拉满:
速度自检 ${stateLine}
${leverRows} ${off.length ? '' : '
已是最优,挂上即可。学到的学时 = 课程真实总时长,压不掉的下限就是真实时间。
'}`, buttons: [ { text: '看打赏', cls: 'ghost', onClick: () => setTimeout(showDonate, 60) }, { text: '知道了', cls: '' } ] }); m.dlg.querySelectorAll('.sw').forEach((n) => n.addEventListener('click', () => n.classList.toggle('on'))); const opt = m.dlg.querySelector('#yx-opt'); if (opt) opt.addEventListener('click', () => { levers.forEach((l) => { cfg[l[0]] = true; }); saveCfg(); if (!cfg.keepAlive) {} else if (engine.running) startKeepAlive(); syncVideo(); renderPlayerPanel._sig = ''; fireUpdate(); const b = m.dlg.querySelector('#yx-opt'); if (b) b.remove(); const sec = m.dlg.querySelector('.sec'); if (sec) sec.innerHTML = '速度自检 已拉满最优 ✓'; toast('已开启全部加速开关'); }); } function showQQOnce() { if (readJSON(K_QQ, false)) return; setTimeout(() => { if (readJSON(K_QQ, false)) return; writeJSON(K_QQ, true); modal({ title: '欢迎使用「研修网学习助手」', sub: 'v' + VERSION + ' · 由 爱国者 制作 · 完全免费', body: `

用好它,只需记住两句

① 面板可拖动、可缩放;② 平台弹验证题 / 继续计时时回来点一下就行,其余全自动。

遇到问题先别急:播放页面板的「自检」会把当前课程、章节数、播放器状态一次性复制出来,粘贴到群里我一眼就能定位。
章节标题时长读不到、自动切换失败,同样把自检信息发我。
反馈与更新都发在群里
群链接:${QQ_GROUP}
`, buttons: [{ text: '加入反馈群', cls: 'gold', close: false, onClick: () => window.open(QQ_GROUP, '_blank') }, { text: '开始使用', cls: '' }] }); }, 2600); } /* ================================================================== * * 11. 学习空间看板 * ================================================================== */ let ui = { tab: 'overview', open: {} }; let courseFilter = 'todo', courseSort = 'left-desc', courseSearch = ''; const DASH_DEF = { w: 880, h: 640, minW: 420, minH: 320, gapX: 24, gapY: 96 }; function mountDashboard() { ensureRoot(); if (shadow.getElementById('yx-fab')) return; const fab = el('button', 'fab', '研'); fab.id = 'yx-fab'; fab.title = '研修网学习助手(点击展开)'; fab.addEventListener('click', () => ui.panel.classList.toggle('hide')); const panel = el('div', 'wrap hide'); panel.id = 'yx-panel'; panel.innerHTML = `

学习助手 · 看课

统一按时长考核 · 需观看 ${REQUIRE_MIN} 分钟 / 满分 ${MAX_SCORE} 分

v${VERSION} · 爱国者 反馈群 打赏 教程
`; shadow.appendChild(fab); shadow.appendChild(panel); ui.panel = panel; ui.body = panel.querySelector('#yx-body'); makeMoveResize(panel, panel.querySelector('#yx-drag'), panel.querySelector('#yx-grip'), K_DASH, DASH_DEF); panel.querySelector('#yx-min').addEventListener('click', () => panel.classList.add('hide')); panel.querySelector('#yx-refresh').addEventListener('click', () => boot(true)); panel.querySelectorAll('.tab').forEach((b) => b.addEventListener('click', () => { panel.querySelectorAll('.tab').forEach((x) => x.classList.remove('on')); b.classList.add('on'); ui.tab = b.dataset.t; render(); })); panel.querySelector('#yx-f-qq').addEventListener('click', (e) => { e.preventDefault(); window.open(QQ_GROUP, '_blank'); }); panel.querySelector('#yx-f-donate').addEventListener('click', (e) => { e.preventDefault(); showDonate(); }); panel.querySelector('#yx-f-tutor').addEventListener('click', (e) => { e.preventDefault(); showTutorial(); }); } function renderStrip() { const n = shadow && shadow.getElementById('yx-strip'); if (!n) return; if (state.loading) { n.innerHTML = '
正在读取数据…
'; return; } if (!state.courses.length) { const lock = readJSON(K_LOCK, null); const live = lock && lock.id && Date.now() - lock.ts < 20000; n.innerHTML = '
暂无数据点右上角 ⟳ 拉取
' + (live ? '
播放页正在学习:' + esc(lock.name || '') + (lock.idx >= 0 ? '(第 ' + (lock.idx + 1) + '/' + (lock.total || '?') + ' 节)' : '') + '
' : ''); return; } const s = stats(); const pct = Math.min(100, s.watchedMin / REQUIRE_MIN * 100); const lock = readJSON(K_LOCK, null); const live = lock && lock.id && Date.now() - lock.ts < 20000; n.innerHTML = `
${s.watchedMin}/ ${REQUIRE_MIN} 分钟 ${s.score} / ${MAX_SCORE} 分 · 还差 ${s.remainMin} 分钟
${live ? '
▶ 播放页正在学习:' + esc(lock.name || '') + '' + (lock.idx >= 0 ? ' · 第 ' + (lock.idx + 1) + '/' + (lock.total || '?') + ' 节' : '') + '
' : ''}`; } function render() { renderStrip(); if (!ui.body) return; if (ui.tab === 'help') { renderHelp(); return; } if (state.loading) { ui.body.innerHTML = '
正在读取学习数据…
'; return; } if (!state.courses.length) { ui.body.innerHTML = '
暂无数据。
请点右上角 ⟳ 拉取;若失败,请确认已在「学习空间」登录。
'; return; } if (ui.tab === 'overview') renderOverview(); else if (ui.tab === 'auto') renderAuto(); else renderLib(); } function switchTab(t) { ui.tab = t; const p = shadow.getElementById('yx-panel'); if (p) p.querySelectorAll('.tab').forEach((x) => x.classList.toggle('on', x.dataset.t === t)); render(); } function renderOverview() { const s = stats(); const pct = Math.min(100, s.watchedMin / REQUIRE_MIN * 100); const mods = state.modules.map((m) => { const cs = state.courses.filter((c) => c._module === m.moduleName); const left = cs.reduce((a, c) => a + num(c.noStudyTime), 0); return '' + esc(m.moduleName) + '' + cs.length + ' 门' + cs.filter((c) => num(c.noStudyTime) === 0).length + ' 门' + hhmm(left) + ''; }).join(''); const sq = shortestQueue(); const planRows = sq.items.slice(0, 8).map((c, i) => ` ${i + 1}${esc(c.name)}${esc(c.module || '')}${hhmm(c.needSec)} `).join(''); ui.body.innerHTML = `
${s.watchedMin} / ${REQUIRE_MIN} 分已观看时长
${s.score} 分看课折算得分(满 ${MAX_SCORE})
${hhmm(s.remainSec)}剩余视频时长
${s.todo} 门未完成课程(共 ${s.total} 门)
达标进度 ${pct.toFixed(1)}% | 还差 ${s.remainMin} 分钟折算满分,约等于再观看 ${hhmm(s.remainSec)} 的视频。
最短达标路径按剩余时长升序 · 用最少门数凑满 ${REQUIRE_MIN} 分钟
看完下面这些课可累计约 ${sq.accMin} 分钟(含已学 ${fmtMin(stats().doneSec)} 分钟),共 ${sq.items.length} 门。
${planRows}
#课程名称模块待看操作
${sq.items.length > 8 ? '
仅显示前 8 门,「课程库」里可以看全部。
' : ''}
模块概览
${mods}
课程模块课程数已完成剩余时长
`; const a = ui.body.querySelector('#yx-goauto'); if (a) a.addEventListener('click', () => switchTab('auto')); const b2 = ui.body.querySelector('#yx-golib'); if (b2) b2.addEventListener('click', () => switchTab('lib')); bindOpen(); } let queueDraft = null; function renderAuto() { const done = stats(); if (!queueDraft) queueDraft = Object.assign({ mode: 'shortest' }, shortestQueue()); const q = loadQueue(); const running = q && q.running; const items = queueDraft.items; const totalSec = items.reduce((a, c) => a + num(c.needSec), 0); const lock = readJSON(K_LOCK, null); const live = lock && lock.id && Date.now() - lock.ts < 20000; const rows = items.slice(0, 300).map((c, i) => ` ${i + 1} ${esc(c.name)}
${esc(c.module || '')}
${hhmm(c.needSec)} `).join(''); ui.body.innerHTML = `
${running ? '队列运行中:第 ' + (num(q.idx) + 1) + ' / ' + q.items.length + ' 门' + (q.items[num(q.idx)] ? '(' + esc(q.items[num(q.idx)].name) + ')' : '') + (live ? ' · 播放页在线' : ' · 播放页未运行') : '当前没有正在运行的队列'}
共 ${items.length} 门 · ${hhmm(totalSec)}
${queueDraft.mode === 'shortest' ? '按时长从短到长排序,用最少的课凑满 ' + REQUIRE_MIN + ' 分钟(预计累计 ' + queueDraft.accMin + ' 分钟)。' : '所有未完成课程按模块顺序排列。'}
观看队列${running && live ? '播放页正在学习' : '等待启动'}
${rows}
#课程待看
${running ? '' : ''}
点「开始」会打开第一门课的播放页并自动连播。已学 ${done.watchedMin} 分钟,达标还差 ${done.remainMin} 分钟。
播放页被关掉时,本页的「看门狗」${cfg.watchdog ? '会' : '不会(已在设置里关闭)'}自动重新打开它——所以这个页面也要留着
`; ui.body.querySelectorAll('.yx-mode').forEach((b) => b.addEventListener('click', () => { const m = b.dataset.m; queueDraft = m === 'shortest' ? Object.assign({ mode: 'shortest' }, shortestQueue()) : { mode: 'all', items: state.courses.filter((c) => num(c.noStudyTime) > 0).map((c) => ({ id: c.id, sid: c.courseSourceId, name: c.courseName, needSec: num(c.noStudyTime), module: c._module })) }; renderAuto(); })); ui.body.querySelector('#yx-run').addEventListener('click', () => { const it = queueDraft.items; if (!it.length) { toast('没有可加入队列的课程'); return; } saveQueue({ items: it, idx: 0, running: true, startedAt: Date.now() }); toast('队列已保存,正在打开第一门课…', 2400); setTimeout(() => window.open(playerUrl(it[0].id, it[0].sid), '_blank'), 500); setTimeout(renderAuto, 900); }); const sr = ui.body.querySelector('#yx-stoprun'); if (sr) sr.addEventListener('click', () => { const q2 = loadQueue(); if (q2) { q2.running = false; saveQueue(q2); } toast('已停止队列'); renderAuto(); }); ui.body.querySelector('#yx-cfg').addEventListener('click', showSettings); bindOpen(); } function libList() { const kw = courseSearch.trim().toLowerCase(); let list = state.courses.filter((c) => { if (courseFilter === 'todo') return num(c.noStudyTime) > 0; if (courseFilter === 'done') return num(c.noStudyTime) === 0; if (courseFilter === 'must') return String(c.typeName || '必修').indexOf('必修') >= 0 || c.type === 102; return true; }); if (kw) list = list.filter((c) => (c.courseName || '').toLowerCase().indexOf(kw) >= 0 || (c._module || '').toLowerCase().indexOf(kw) >= 0); const cmp = { 'left-desc': (a, b) => num(b.noStudyTime) - num(a.noStudyTime), 'left-asc': (a, b) => num(a.noStudyTime) - num(b.noStudyTime), 'dur-desc': (a, b) => num(b.totalDuration) - num(a.totalDuration), 'name': (a, b) => String(a.courseName).localeCompare(String(b.courseName), 'zh') }; return list.slice().sort(cmp[courseSort] || cmp['left-desc']); } function chapterBox(course) { const d = state.chapters[course.id]; if (!d) return '
正在读取章节…
'; if (d.err) return '
章节读取失败:' + esc(d.err) + '
'; const list = d.list || []; if (!list.length) return '
这门课没有章节数据。
'; const rows = list.map((s, i) => `
${i + 1} ${esc(s.name)}${esc(s.chapter || s.chp || '')}${isDone(course.id, i) ? ' · 本机已播完' : ''} ${clock(s.sec)}
`).join(''); const total = d.meta ? d.meta.total : list.reduce((a, s) => a + s.sec, 0); return `
${list.length} 节 · 共 ${hhmm(total)} ${d.meta && d.meta.teacher ? esc(d.meta.teacher) : ''}
${rows}
`; } function renderLib() { const list = libList(); const rows = list.map((c) => { const sec = num(c.totalDuration), learn = num(c.completeTime), left = num(c.noStudyTime); const pct = sec ? Math.round(learn / sec * 100) : 0; const st = left === 0 ? '已完成' : learn > 0 ? '进行中' : '未开始'; const isOpen = !!ui.open[c.id]; return ` ${esc(c.courseName)}
${esc(c.mainTeacher || '')}
${esc(c._module)} ${esc(c.typeName || '必修')} ${hhmm(sec)}${pct}%${hhmm(left)}${st} ` + (isOpen ? `${chapterBox(c)}` : ''); }).join(''); ui.body.innerHTML = `
显示 ${list.length} 门。点最左侧 ▸ 展开章节,可看每节标题、时长、播放地址
${rows}
课程名称模块类型时长进度待看状态操作
`; const kw = ui.body.querySelector('#yx-kw'); kw.addEventListener('input', () => { courseSearch = kw.value; const p = kw.selectionStart; renderLib(); const k2 = ui.body.querySelector('#yx-kw'); if (k2) { k2.focus(); try { k2.setSelectionRange(p, p); } catch (e) {} } }); ui.body.querySelector('#yx-sort').addEventListener('change', (e) => { courseSort = e.target.value; renderLib(); }); ui.body.querySelectorAll('.yx-f').forEach((b) => b.addEventListener('click', () => { courseFilter = b.dataset.f; renderLib(); })); ui.body.querySelector('#yx-exp').addEventListener('click', exportCoursesCSV); ui.body.querySelector('#yx-expall').addEventListener('click', exportAllVideosCSV); ui.body.querySelectorAll('[data-exp]').forEach((b) => b.addEventListener('click', () => { const id = b.getAttribute('data-exp'); ui.open[id] = !ui.open[id]; if (ui.open[id] && !state.chapters[id]) { ui.body.querySelector('[data-exp="' + id + '"]').textContent = '▾'; const c = state.courses.find((x) => String(x.id) === String(id)); const holder = b.closest('tr'); const tr = document.createElement('tr'); tr.innerHTML = '' + chapterBox(c) + ''; holder.parentNode.insertBefore(tr, holder.nextSibling); loadChapters(c).then(() => { if (ui.tab === 'lib' && ui.open[id]) renderLib(); }) .catch((e) => { state.chapters[id] = { list: [], meta: null, err: e.message }; if (ui.tab === 'lib') renderLib(); }); return; } renderLib(); })); ui.body.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyText(b.getAttribute('data-copy'), '已复制 m3u8 播放地址'))); bindOpen(); } function renderHelp() { ui.body.innerHTML = `
装好油猴

Chrome / Edge 装 Tampermonkey(篡改猴);360 极速浏览器在扩展中心搜 Tampermonkey。

安装本脚本

研修网学习助手.user.js 拖进浏览器窗口 → 油猴弹窗点「安装」。

登录研修网并打开学习空间

网址含 /train2/workspace/,右下角出现蓝色「研」按钮即生效。

自动挂机 → 生成队列 → 开始

选「最短达标」可用最少门数凑满 ${REQUIRE_MIN} 分钟。点开始后自动打开第一门课。

去干别的

播放页自动静音、自动下一节、本门完自动下一门。两个面板都能拖动和缩放。

必须知道的三件事
1. 别关播放页标签页;切到其他窗口没关系,开了「后台保活」就不会被浏览器冻结。学习空间页也要留着——看门狗靠它重开播放页。
2. 平台在约 60% 处会弹「回答问题完成验证」,长时间无操作还会弹「继续计时」,这是防挂机机制。助手会响铃 + 桌面通知 + 把窗口拉到前台,但不替你作答、也不替你点「继续」
3. 学时按真实时长累计,倍速会少记,所以固定 1 倍速;进度条被平台锁定,不可拖动。
常见问题
Q:面板读不到章节标题和时长?点面板右上角 ⟳ 刷新课程数据;仍不行就点「自检」把信息发到反馈群。
Q:切出去回来发现停了?多半是弹验证题,面板会变红提示,答完自动继续。
Q:能开两个播放页吗?不能,平台限制单端(错误码 3028002),助手会自动暂停第二个。
Q:数据准吗?来自研修网官方接口,与学习空间一致。
${WHY_HTML}
`; const a = ui.body.querySelector('#yx-q2'); if (a) a.addEventListener('click', () => window.open(QQ_GROUP, '_blank')); const b = ui.body.querySelector('#yx-d2'); if (b) b.addEventListener('click', showDonate); const c = ui.body.querySelector('#yx-t2'); if (c) c.addEventListener('click', showTutorial); } function bindOpen() { $$('.yx-open', ui.body).forEach((b) => b.addEventListener('click', () => { window.open(playerUrl(b.dataset.id, b.dataset.sid), '_blank'); })); } /* --- CSV 导出 --- */ function download(name, text) { const blob = new Blob(['\ufeff' + text], { type: 'text/csv;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 3000); } const q1 = (s) => '"' + String(s == null ? '' : s).replace(/"/g, '""') + '"'; function exportCoursesCSV() { const head = ['模块', '课程ID', '课程名称', '主讲', '类型', '时长', '已学', '待看', '进度%', '播放页']; const lines = [head.map(q1).join(',')]; state.courses.forEach((c) => lines.push([c._module, c.id, c.courseName, c.mainTeacher, c.typeName || '必修', hhmm(num(c.totalDuration)), hhmm(num(c.completeTime)), hhmm(num(c.noStudyTime)), Math.round(num(c.completeTime) / (num(c.totalDuration) || 1) * 100), playerUrl(c.id, c.courseSourceId)].map(q1).join(','))); download('研修网看课清单.csv', lines.join('\n')); toast('已导出课程清单'); } async function exportAllVideosCSV() { toast('正在抓取章节视频…'); const lines = [['模块', '课程', '章节', '视频标题', '时长', '秒', 'm3u8地址'].map(q1).join(',')]; for (const c of state.courses) { try { const d = await loadChapters(c); d.list.forEach((s) => lines.push([c._module, c.courseName, s.chp, s.name, hhmm(s.sec), s.sec, s.url].map(q1).join(','))); } catch (e) { log('章节失败', c.courseName, e); } } download('研修网章节视频清单.csv', lines.join('\n')); toast('已导出章节视频清单'); } /* ================================================================== * * 12. 看门狗(学习空间页常驻,播放页掉线自动重开) * ================================================================== */ function watchdog() { if (!cfg.watchdog) return; const q = loadQueue(); if (!q || !q.running || !q.items || !q.items.length) return; const lock = readJSON(K_LOCK, null); const alive = lock && lock.id && Date.now() - lock.ts < 45000; if (alive) { watchdog._miss = 0; return; } watchdog._miss = (watchdog._miss || 0) + 1; if (watchdog._miss < 3) return; // 连续 3 个周期(≈90 秒)都没心跳才动手 if (Date.now() - (watchdog._last || 0) < 240000) return; watchdog._last = Date.now(); watchdog._miss = 0; const it = q.items[num(q.idx)] || q.items[0]; if (!it) return; log('watchdog 重开播放页', it.name); toast('看门狗:播放页掉线,正在重新打开「' + it.name + '」', 3200); writeJSON(K_LOCK, { id: '', ts: 0 }); window.open(playerUrl(it.id, it.sid), '_blank'); } /* ================================================================== * * 13. 启动 * ================================================================== */ async function boot(force) { mountDashboard(); state.ctx = parseCtx(); if (!state.ctx) { render(); return; } if (!force && loadCache()) { render(); return; } state.loading = true; render(); try { await waitToken(); if (!auth.token) throw new Error('未捕获到登录令牌,请刷新页面后重试'); await loadAll(); } catch (e) { state.loading = false; log('加载失败', e); renderStrip(); if (ui.body) ui.body.innerHTML = '
加载失败:' + esc(e.message) + '
请确认已登录,然后点右上角 ⟳ 重试。
'; return; } state.loading = false; queueDraft = null; render(); renderStrip(); toast('数据已更新'); } function bootPlayer() { mountPlayerPanel(); installHbProbe(); try { if (document.title.indexOf('研修网') < 0) document.title = '研修网 · 自动连播'; } catch (e) {} const q = loadQueue(); const pc = playerCtx(); const inQueue = !!(q && q.items && pc && q.items.some((x) => String(x.id) === String(pc.courseId))); if (q && q.running && cfg.autoStart && inQueue) { let tries = 0; const wait = setInterval(() => { tries++; if (findVideo() || tries > 40) { clearInterval(wait); startEngine(true); } }, 500); } else { renderPlayerPanel(); } let m = 0; const mi = setInterval(() => { m++; syncVideo(); if (m > 20) clearInterval(mi); }, 700); } function ready(fn) { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn); else fn(); } function route() { const isPlayer = /\/grain\/course\/\d+\/detail/.test(location.pathname); const isDash = /\/train2\/workspace\//.test(location.pathname); if (isPlayer) { ready(() => setTimeout(bootPlayer, 1200)); } else if (isDash) { ready(() => setTimeout(() => boot(false), 1200)); setInterval(watchdog, 30000); setInterval(renderStrip, 5000); // 播放页学习进度实时回显 } ready(() => showQQOnce()); } try { route(); } catch (e) { log('启动失败', e); } })();