// ==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 = '
' + (opts.sub || '') + '
上: 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 = `
准备中…
脚本的每一项功能都对所有人开放,没有任何"赞助解锁"的隐藏开关。打赏只代表一件事:你愿意让这个工具继续被维护下去。
| 机制 | 平台实现 | 后果 |
|---|---|---|
| 计时器 | setInterval(fn,1000) 里 durationTime++ | 按真实墙上时钟累计,与视频播放位置无关 |
| 上报 | 每 25 秒 POST /heartbeat-center/hb/report,watchTime:25 | watchTime 是写死的常量,不是视频进度 |
| 时长要求 | courseCompleteTimeRate:100 | 每门课必须看满 100% 时长 |
| 进度条 | boolDragLock:1 | 禁止拖动,跳片尾无效 |
| 防挂机 | boolFoolproofLock:2 · foolproofDuration:1800 | 挂机检测 |
| 弹题 | openTestPercentage:60 | 60% 处弹验证题,需本人作答 |
| 多端互斥 | boolMultiClientOpen:2(错误码 3028002) | 同一账号只能一个终端学 |
Chrome / Edge 装 Tampermonkey(篡改猴);360 极速浏览器在扩展中心搜 Tampermonkey。装完工具栏会出现黑色方块图标。
把 研修网学习助手.user.js 拖进浏览器窗口,油猴弹出安装页,点「安装」。
在要挂机的电脑上登录,然后打开「学习空间」页面(网址含 /train2/workspace/)。右下角出现蓝色「研」按钮即成功。
推荐「最短达标」:按时长从短到长排好,用最少的课凑满 ${REQUIRE_MIN} 分钟。点「开始自动看课」,第一门课自动打开。
播放页自动:静音 → 播放 → 播完自动下一节 → 本门播完自动下一门 → 队列跑完自动停。播放页意外关闭时,学习空间页的「看门狗」会自动重开。
我用浏览器抓到了平台自己发出的心跳请求,规则是确定的,不是猜的:
| 字段 | 实测值 | 含义 |
|---|---|---|
| 端点 | POST /heartbeat-center/hb/report | 每 ~25 秒一次 |
| watchTime | 25(写死) | 每次固定 +25 秒,不是视频进度 |
| watchLocation | 累计 +25 / 次 | 累计观看位置,到时长即本节完成 |
| 响应 | {"code":200,"data":"success"} | 服务端按收到次数累计学时 |
① 面板可拖动、可缩放;② 平台弹验证题 / 继续计时时回来点一下就行,其余全自动。
统一按时长考核 · 需观看 ${REQUIRE_MIN} 分钟 / 满分 ${MAX_SCORE} 分
| # | 课程名称 | 模块 | 待看 | 操作 |
|---|
| 课程模块 | 课程数 | 已完成 | 剩余时长 |
|---|
| # | 课程 | 待看 |
|---|
| 课程名称 | 模块 | 类型 | 时长 | 进度 | 待看 | 状态 | 操作 |
|---|
Chrome / Edge 装 Tampermonkey(篡改猴);360 极速浏览器在扩展中心搜 Tampermonkey。
把 研修网学习助手.user.js 拖进浏览器窗口 → 油猴弹窗点「安装」。
网址含 /train2/workspace/,右下角出现蓝色「研」按钮即生效。
选「最短达标」可用最少门数凑满 ${REQUIRE_MIN} 分钟。点开始后自动打开第一门课。
播放页自动静音、自动下一节、本门完自动下一门。两个面板都能拖动和缩放。