// ==UserScript== // @name 教师研修网(仅自动刷课) // @namespace https://ipx.yanxiu.com/ // @version 3.15.1 // @description 登录之后,它替你打开课程、自动播放、播完回列表挑下一门。必修、选修全部达标以后,还能继续把没看完的课一门门连播下去。入口有两个:浮窗上的「设置」,或脚本管理器菜单里的「自动看课 · 打开设置」。改完点「保存并应用」立即生效,不需要刷新页面。脚本仅用于减少重复点击,观看行为仍由你本人账号产生。请遵守所在单位与平台的培训规则;因使用产生的考核、学分、账号等后果由使用者自行承担。 // @author 2220472141 // @match https://ipx.yanxiu.com/train2/* // @match https://ipx.yanxiu.com/grain/* // @match https://ipx.yanxiu.com/cms/project/* // @icon https://srt-read-online.3ren.cn/basebusiness/headimg/20200711/1594463211851X00rZyPRV8-23.png // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_registerMenuCommand // @grant unsafeWindow // @noframes // @run-at document-idle // @license GPL-3.0 // @tag 在线教育 // @tag 自动挂课 // @tag 教师研修网 // ==/UserScript== /* ==UserConfig== 设置: 静音: title: 自动静音 description: 建议保持开启。浏览器不允许带声音的页面自动播放,关掉可能播不起来。 type: checkbox default: true 倍速: title: 2 倍速播放 description: 这个网站不允许倍速播放(播放器里压根没有倍速选项),建议保持关闭。 type: checkbox default: false 连播: title: 必修、选修都达标后继续连播 description: 开启后「自动学习」:即使必修和选修都已经达标,也会把没看完的课一门门接着播完。关掉的话,全达标就停下。 type: checkbox default: true 时长模式: title: 观看时长模式 description: 跟随课程 = 播到站点提示「您已完成」;自定义上限 = 每门最多看 N 分钟。浮窗「设置」里也能改。 type: select values: [full, fixed] default: full 时长分钟: title: 自定义上限(分钟) description: 仅当「观看时长模式」选了 fixed 时生效,范围 1 – 600。 type: number default: 30 min: 1 max: 600 unit: 分钟 达标续播: title: 一门课达标后自动开下一门 description: 关掉后,一门课达标就停在当前页,等你点「启动」再继续;全达标连播模式不受这一项限制。 type: checkbox default: true 浮窗: title: 显示状态浮窗 description: 右下角的面板,显示实时进度和「最近做了什么」。点最上面那行可以收起或展开;不想要整个面板就取消勾选这里。 type: checkbox default: true ==/UserConfig== */ /* 教师研修网 自动看课 —— 打开「工作台」就自己开始;用法见「使用说明.html」,原因见「开发笔记.md」 */ /* v3.15.0 修订(桌面原件保持不动) · 悬浮窗整体重做:渐变头、状态呼吸灯、进度流光、分区卡片、时间线日志、折叠动画 · 修:@version 与 VERSION 不一致;静音 / 倍速由开转关不生效;致命错误不落「已停止」 · 修:ak_pt / ak_credits 不带项目 ID,换培训项目会串数据;UserConfig 少了 3 项 · 修:停止后残留定时器、waitFor 不响应停止、学分接口无超时、W.open 重复挂钩 · 修:翻页不校验、pane 快照失效、applyDraft 无保护、myStudyFlow 丢弃结果、akReset 漏键 */ (function () { "use strict"; if (window !== top) return; var LOG = function () { console.log.apply(console, ["[自动看课]"].concat([].slice.call(arguments))); }; /* ================= 零、定时器与停止钩子的统一登记处 ================= 旧版「停止」只清掉 creditTimer 一个,看课页上另外 3 个 interval 会一直跑; 而且 waitFor / playResource 是 Promise,硬清 interval 会让它们永远不 resolve, flowBusy 卡死、重启后点「启动」没反应。这里两件事一起解决: · 所有长生命周期定时器都登记,stopWork 一次性清干净; · 会挂起 Promise 的地方注册停止钩子,先让 Promise 正常收尾再清定时器。 ==================================================================== */ var trackedIntervals = []; var trackedTimeouts = []; var stopHooks = []; function trackInterval(id) { trackedIntervals.push(id); return id; } function untrackInterval(id) { var i = trackedIntervals.indexOf(id); if (i >= 0) trackedIntervals.splice(i, 1); } function trackTimeout(id) { trackedTimeouts.push(id); return id; } function untrackTimeout(id) { var i = trackedTimeouts.indexOf(id); if (i >= 0) trackedTimeouts.splice(i, 1); } function clearAllTrackedTimers() { for (var i = 0; i < trackedIntervals.length; i++) { try { clearInterval(trackedIntervals[i]); } catch (e) {} } for (var j = 0; j < trackedTimeouts.length; j++) { try { clearTimeout(trackedTimeouts[j]); } catch (e) {} } trackedIntervals.length = 0; trackedTimeouts.length = 0; } function onStop(fn) { if (stopHooks.indexOf(fn) === -1) stopHooks.push(fn); } function offStop(fn) { var i = stopHooks.indexOf(fn); if (i >= 0) stopHooks.splice(i, 1); } function runStopHooks() { var list = stopHooks.slice(); stopHooks.length = 0; for (var i = 0; i < list.length; i++) { try { list[i](); } catch (e) {} } } /* ================= 一、设置 ================= */ function cfgRaw(key, def) { var v = GM_getValue("设置." + key); return v === undefined || v === null ? def : v; } function cfg(key, def) { return !!cfgRaw(key, def); } function isMuted() { return cfg("静音", true); } /* 站点 playbackRates 是空数组,明确不允许倍速;默认关闭,关着时连 playbackRate 都不碰 */ function speedEnabled() { return cfg("倍速", false); } function hudEnabled() { return cfg("浮窗", true); } /* ---- 观看时长:full = 播到站点提示「您已完成」;fixed = 每门最多 N 分钟 ---- */ var DUR_MIN_DEFAULT = 30; var DUR_MIN_LOW = 1; var DUR_MIN_HIGH = 600; function watchMode() { return cfgRaw("时长模式", "full") === "fixed" ? "fixed" : "full"; } /* 存进去的可能是字符串(用户手改过存储),统一在这儿收口成合法整数 */ function watchLimitMin() { var n = Number(cfgRaw("时长分钟", DUR_MIN_DEFAULT)); if (!isFinite(n) || n < DUR_MIN_LOW) n = DUR_MIN_DEFAULT; return Math.min(DUR_MIN_HIGH, Math.max(DUR_MIN_LOW, Math.round(n))); } /* 0 表示「不限」,调用方拿 0 就当没有截止时间 */ function watchLimitMs() { return watchMode() === "fixed" ? watchLimitMin() * 60000 : 0; } /* 一门课达标后要不要自己去开下一门 */ function autoNextAfterPass() { return cfg("达标续播", true); } /* ---- 必修、选修全达标后继续连播(「自动学习」总开关) ---- 全达标之后,站点上还没看完(进度不到 100%)的课继续一门门自动播完。 「全达标」这个事实只有工作台页读得出来(考核卡片在那儿),由它写进 ak_pt, 课程列表页 / 看课页读这个标记,三处对同一件事说同一句话。 ⚠️ 标记必须跟项目 ID 绑定:从 A 项目切到 B 项目时,A 的「已全达标」 不能被 B 继承,否则会凭空误开连播。老版本存的裸 true/false 一律作废。 ============================================================ */ var PT_KEY = "ak_pt"; var PID_KEY = "ak_pid"; var cachedPid = null; /* 工作台 / 课程列表页能从地址读出项目 ID;看课页(/grain/…)读不到, 就沿用最近一次记住的项目,免得连播判定在看课页上突然失效。 */ function knownProjectId() { var pid = pageProjectId(); if (!pid) { if (cachedPid) return cachedPid; try { cachedPid = String(GM_getValue(PID_KEY) || ""); } catch (e) { cachedPid = ""; } return cachedPid; } if (cachedPid !== pid) { cachedPid = pid; try { GM_setValue(PID_KEY, pid); } catch (e) {} } return pid; } /* ak_pt 结构:{ <项目ID>: { passed, ts }, ... }。 老版本存的是裸 true/false(连项目都没有),一律判失效。 */ function ptAll() { var v; try { v = GM_getValue(PT_KEY); } catch (e) { return {}; } return v && typeof v === "object" && !Array.isArray(v) ? v : {}; } /* 取「当前项目」这一份,附带把 pid 一起返回,方便调用方校验 */ function ptRecord() { var pid = knownProjectId(); if (!pid) return null; var r = ptAll()[pid]; if (!r || typeof r !== "object" || typeof r.passed !== "boolean") return null; return { pid: pid, passed: r.passed, ts: r.ts || 0 }; } function autoPlayAfterPass() { return cfg("连播", true); } function setAllPassed(on) { var pid = knownProjectId(); if (!pid) return; var all = ptAll(); all[pid] = { passed: !!on, ts: Date.now() }; /* 只留最近 5 个项目,别让这个键无限长大 */ var keys = Object.keys(all); if (keys.length > 5) { keys.sort(function (x, y) { return (all[y].ts || 0) - (all[x].ts || 0); }); for (var i = 5; i < keys.length; i++) delete all[keys[i]]; } try { GM_setValue(PT_KEY, all); } catch (e) {} } function isAllPassed() { var r = ptRecord(); return !!r && r.passed === true; } /* 「连播模式」= 考核已全达标 + 用户开着连播 */ function playThrough() { return autoPlayAfterPass() && isAllPassed(); } /* ================= 二、右下角小浮窗 ================= */ var VERSION = "3.15.0"; /* 必须与头部 @version 一致,「复制状态」报的就是它 */ var HUD = { root: null, dot: null, line: null, head: null, body: null, arrow: null, live: null, log: null, detail: null, toast: null, runBtn: null, events: [], toastTimer: null, logList: null, }; var COLORS = { running: "#378ADD", skip: "#1D9E75", done: "#1D9E75", error: "#E24B4A", idle: "#8A94A6", }; function nowText() { var d = new Date(); var p = function (n) { return (n < 10 ? "0" : "") + n; }; return p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds()); } function isPanelOpen() { var v = GM_getValue("ak_hud_open"); return v === undefined || v === null ? true : !!v; } function setPanelOpen(isOpen) { GM_setValue("ak_hud_open", !!isOpen); /* 类名负责动画;inline display 是兜底 —— 站点 CSP 拦了