// ==UserScript== // @name 气象继续教育 · 视频页自动刷课(精简版) // @namespace cma.jxjy.videoonly // @version 1.5.0 // @description 只做一件事:你手动点进某个视频学习页后,自动把当前视频静音 + 倍速播放;播完自动点下一个未完成的节点继续刷。不碰课程列表、不碰班级页、不自动进下一门课 —— 所有跳转只发生在「视频/课件目录」内部。 // @author WorkBuddy // @match https://cmatcpx-cmatc.cma.cn/* // @match https://pxkckj-cmatc.cma.cn/* // @match https://kjcourse-cmatc.cma.cn/* // @match https://kjkejian2-cmatc.cma.cn/* // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_addStyle // @grant unsafeWindow // @run-at document-idle // ==/UserScript== * * 触发方式:你【手动】点进一个视频/课件节点(学习页),脚本检测到目录和视频后才接管。 * 自动跳转:只在这个课程的「节点目录」里往下跳(点下一个未完成节点), * 绝不跳出课程去选别的课。 * * 完成判定(与 v2 同源,均来自真机逆向,非猜测): * · 权威接口 getSingleItemCompleteCase.json → result.completed == "1" * · 目录里出现对号 .item_done_icon.done_icon_show * · completestate 属性【不可信】,仅作诊断,不参与判定。 * * 面板:右下角一个小卡片,可「开始/暂停」「静音开/关」「倍速 +/−」「日志」。 * ===================================================================== */ (function () { 'use strict'; var UW = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window; /* ---------------- 常量(真机逆向,非猜测) ---------------- */ var U = { ITEM_DONE: '/learnspace/learn/learnCourseware/getSingleItemCompleteCase.json' }; var SEL = { FRAME_MENU: '#mainContent', POINT: 'div.s_point', POINT_ICON: '.item_done_icon', POINT_DONE_ICON: '.item_done_icon.done_icon_show', CW_VIDEO: '#course_player', CW_VIDEO_BOX: '#video_box', CW_START: '.continue .user_choise', /* 网页课件连播循环(天气动力学那类 #playerlist 目录,videoarr/playvideo/nowD) */ PLAYER_LIST: '#playerlist', /* 专题列表页(getMyTopicPatternTheme)与专题详情页(mb0/index.html?classCode) */ TOPIC_CARD: '#classList > li', TOPIC_TITLE: '.title', TOPIC_ENTER: 'a.btn-solid-primary[href*="classCode"], a.btn-solid-primary[href*="mb0"]', TOPIC_DONE_LAB: '.done-lab', TOPIC_COURSE_CARD: '.cma-card', TOPIC_COURSE_TITLE: '.title', TOPIC_COURSE_ENTER: 'a[onclick*="learnCourseUrl"]', TOPIC_COURSE_PROGRESS: '.split span[style*="width"]', /* 学习时长元素(等效显示:真实值 × 倍速) */ LEARN_TIMER: '#learnTimer, #learnTime, .learnTime, [id*=learnTimer], [id*=learnTime]' }; var T = { POINT_PREFIX: 's_point_', CS_ATTR: 'completestate', ITEM_TYPE_ATTR: 'itemtype' }; /* ---------------- 可调参数 ---------------- */ var CFG = { RATE: 16, // 播放倍速(网页课件平台不限制,实测 16 都能跑满) MUTE: true, // 默认静音 POLL_MS: 1500, // 轮询间隔 DONE_WAIT_SEC: 360, // 播完后等平台写完成状态的上限 WC_REPORT_WAIT_MS: 22000, // 网页课件播完后等上报再切节点 DONE_HTTP_TIMEOUT_MS: 8000, // 查完成接口超时 DONE_CACHE_MS: 3000, // 完成状态短缓存 CW_CLICK_MAX: 3, // 「开始学习」最多点几次 CW_HEAL_MAX: 3, // 空源视频自愈最多几次(重复点开始会把视频换成空源) CW_BADGE: true, // 课件 iframe 里贴角标(跨站 iframe 父页面看不到里面) LAYER_POLL_MS: 1500, // 弹窗轮询 SEG_POLL_MS: 2000, // 网页课件分段连播:段序号采样间隔 SEG_SWITCH_WAIT_MS: 2000, // 分段切换后给播放器重建的时间 EQ_TICK_MS: 500, // 等效时长刷新间隔(比平台每秒写一次更快) EQ_TAIL: true // 数值后挂小尾巴(倍速/真实值/已刷比例) }; var GMKEY = { ON: 'jx2vo_on', MUTE: 'jx2vo_mute', RATE: 'jx2vo_rate' }; /* ---------------- 工具 ---------------- */ function $(sel, root) { try { return (root || document).querySelector(sel); } catch (e) { return null; } } function $$(sel, root) { try { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); } catch (e) { return []; } } function txt(el) { return el ? String(el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim() : ''; } function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); } function nowMs() { return Date.now(); } function rawGet(k, d) { try { var v = GM_getValue(k, undefined); return (v === undefined) ? d : v; } catch (e) { return d; } } function rawSet(k, v) { try { GM_setValue(k, v); } catch (e) { } } function waitFor(fn, timeoutMs, stepMs) { return new Promise(function (resolve) { var t0 = nowMs(); (function tick() { var v = null; try { v = fn(); } catch (e) { v = null; } if (v) return resolve(v); if (nowMs() - t0 >= timeoutMs) return resolve(null); setTimeout(tick, stepMs || 400); })(); }); } /* ---------------- 日志与面板 ---------------- */ var LOG = []; var log = { info: function (m) { LOG.push('[信息] ' + m); paintLog(); }, ok: function (m) { LOG.push('[完成] ' + m); paintLog(); }, warn: function (m) { LOG.push('[警告] ' + m); paintLog(); }, err: function (m) { LOG.push('[错误] ' + m); paintLog(); } }; function paintLog() { var lg = document.getElementById('jx2vo_log'); if (lg && lg.style.display === 'block') { lg.textContent = LOG.slice(-40).join('\n'); lg.scrollTop = lg.scrollHeight; } } function setState(s) { var e = document.getElementById('jx2vo_state'); if (e) e.textContent = s; } function isRunning() { return rawGet(GMKEY.ON, false) === true; } function setRunning(v) { rawSet(GMKEY.ON, !!v); var b = document.getElementById('jx2vo_go'); if (b) { b.className = v ? 'on' : ''; b.textContent = v ? '暂停' : '开始'; } } function buildPanel() { if (document.getElementById('jx2vo_panel')) return; var div = document.createElement('div'); div.id = 'jx2vo_panel'; div.innerHTML = '
视频自动刷课 —
' + '
' + '
倍速:' + CFG.RATE + 'x 静音:' + (CFG.MUTE ? '开' : '关') + '
' + '
' + '
' + ' ' + ' ' + ' ' + ' ' + ' ' + '
' + ''; div.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:2147483000;' + 'width:300px;background:#fff;border:1px solid #d8dee6;border-radius:10px;' + 'box-shadow:0 6px 24px rgba(0,0,0,.15);font:12px/1.5 "Microsoft YaHei",sans-serif;color:#333;'; document.body.appendChild(div); var stl = document.createElement('style'); stl.textContent = '#jx2vo_panel .hd{background:#1a4f96;color:#fff;padding:8px 12px;border-radius:10px 10px 0 0;font-weight:700}' + '#jx2vo_panel .hd .st{font-weight:400;opacity:.85;float:right}' + '#jx2vo_panel .rows{padding:8px 12px 4px}' + '#jx2vo_panel .row{padding:2px 0}' + '#jx2vo_panel .btns{display:flex;gap:4px;padding:0 12px 10px}' + '#jx2vo_panel button{flex:1 1 auto;min-width:0;padding:5px 2px;border:1px solid #d8dee6;background:#f7f9fc;border-radius:6px;cursor:pointer;font-size:12px;line-height:1.2;color:#333;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' + '#jx2vo_panel button.on{background:#1a4f96;color:#fff;border-color:#1a4f96}' + '#jx2vo_panel .log{display:none;max-height:220px;overflow:auto;margin:0 12px 10px;padding:6px;background:#f6f8fa;border:1px solid #eee;border-radius:6px;white-space:pre-wrap}'; document.head.appendChild(stl); document.getElementById('jx2vo_go').addEventListener('click', function () { setRunning(!isRunning()); }); document.getElementById('jx2vo_mutebtn').addEventListener('click', function () { CFG.MUTE = !CFG.MUTE; rawSet(GMKEY.MUTE, CFG.MUTE); paintMute(); log.info('静音 ' + (CFG.MUTE ? '开' : '关')); }); document.getElementById('jx2vo_rateup').addEventListener('click', function () { CFG.RATE = Math.min(16, (CFG.RATE || 1) + 1); rawSet(GMKEY.RATE, CFG.RATE); paintRate(); log.info('倍速 → ' + CFG.RATE + 'x'); }); document.getElementById('jx2vo_ratedn').addEventListener('click', function () { CFG.RATE = Math.max(1, (CFG.RATE || 1) - 1); rawSet(GMKEY.RATE, CFG.RATE); paintRate(); log.info('倍速 → ' + CFG.RATE + 'x'); }); document.getElementById('jx2vo_logbtn').addEventListener('click', function () { var lg = document.getElementById('jx2vo_log'); lg.style.display = (lg.style.display === 'block') ? 'none' : 'block'; paintLog(); }); paintMute(); paintRate(); } function paintMute() { var e = document.getElementById('jx2vo_mute'); if (e) e.textContent = CFG.MUTE ? '开' : '关'; } function paintRate() { var e = document.getElementById('jx2vo_rate'); if (e) e.textContent = CFG.RATE; } /* ---------------- 目录读取 ---------------- */ function hasPoints(d) { if (!d) return false; try { return !!d.querySelector(SEL.POINT); } catch (e) { return false; } } function menuDoc() { if (hasPoints(document)) return document; var f = $(SEL.FRAME_MENU); if (f) { try { if (f.contentDocument && hasPoints(f.contentDocument)) return f.contentDocument; } catch (e) { } } return null; } function menuWin() { try { if (typeof UW.openLearnResItem === 'function') return UW; } catch (e) { } try { if (typeof window.openLearnResItem === 'function') return window; } catch (e) { } var f = $(SEL.FRAME_MENU); if (!f) return null; try { return f.contentWindow; } catch (e) { return null; } } function readPoints() { var d = menuDoc(); if (!d) return null; var nodes = $$(SEL.POINT, d); if (!nodes.length) return null; return nodes.map(function (n) { var byAttr = n.getAttribute(T.CS_ATTR) === '1'; // 不可信,仅诊断 var byIcon = false; try { byIcon = !!n.querySelector(SEL.POINT_DONE_ICON); } catch (e) { } return { id: String(n.id || '').replace(T.POINT_PREFIX, ''), type: n.getAttribute(T.ITEM_TYPE_ATTR) || '', done: byIcon, csFlag: byAttr, title: txt(n).replace(/^[^:]*:/, '').slice(0, 60), el: n }; }); } function forceTick(p) { try { var el = p.el; if (!el) return; el.setAttribute(T.CS_ATTR, '1'); try { el.classList.remove('undo_item_bgc'); } catch (e) { } var icon = el.querySelector(SEL.POINT_ICON); if (icon) icon.classList.add('done_icon_show'); } catch (e) { } } /* ---------------- 完成判定(权威接口) ---------------- */ var DONE_CACHE = {}; async function checkDone(courseId, itemId) { var ck = String(courseId) + '|' + String(itemId); try { var c = DONE_CACHE[ck]; if (c && nowMs() - c.t < CFG.DONE_CACHE_MS) return c.v; } catch (e) { } var val = null; var ctl = null, timer = 0; try { if (typeof AbortController === 'function') { ctl = new AbortController(); timer = setTimeout(function () { try { ctl.abort(); } catch (e) { } }, CFG.DONE_HTTP_TIMEOUT_MS); } var r = await fetch(U.ITEM_DONE, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest' }, body: new URLSearchParams({ 'params.courseId': courseId, 'params.itemId': itemId }).toString(), credentials: 'include', signal: ctl ? ctl.signal : undefined }); var j = await r.json(); if (j && j.success && j.result) val = (String(j.result.completed) === '1'); } catch (e) { val = null; } try { if (timer) clearTimeout(timer); } catch (e) { } try { DONE_CACHE[ck] = { t: nowMs(), v: val }; } catch (e) { } return val; } /* ---------------- 媒体驱动 ---------------- */ function eachDoc(cb, doc, depth) { doc = doc || document; depth = depth || 0; try { if (cb(doc) === false) return; } catch (e) { } if (depth >= 6) return; /* 放宽到 6 层,覆盖深嵌套 iframe(跨域帧由各自域实例兜底) */ var fs = null; try { fs = doc.querySelectorAll('iframe'); } catch (e) { return; } for (var i = 0; i < fs.length; i++) { var d = null; try { d = fs[i].contentDocument; } catch (e) { d = null; } if (d) eachDoc(cb, d, depth + 1); } } function mediaList() { var out = []; eachDoc(function (d) { try { var a = d.querySelectorAll('video,audio'); for (var i = 0; i < a.length; i++) if (out.indexOf(a[i]) < 0) out.push(a[i]); } catch (e) { } }, document, 0); return out; } function driveAll() { var ms = mediaList(); if (!ms.length) return null; var rate = CFG.RATE; var st = { n: 0, t: 0, dur: 0, ended: false, paused: false, el: null }; for (var i = 0; i < ms.length; i++) { var m = ms[i]; try { if (CFG.MUTE) { try { m.muted = true; m.defaultMuted = true; } catch (e) { } } var dur = Number(m.duration) || 0; var t = Number(m.currentTime) || 0; var atEnd = !!m.ended || (dur > 0 && t >= dur - 1); if (!atEnd && m.paused) { var p = m.play(); if (p && p.catch) p.catch(function () { }); } if (rate && rate !== 1 && m.playbackRate !== rate) { try { m.playbackRate = rate; } catch (e) { } } st.n++; if (dur >= st.dur) { st.dur = Math.round(dur); st.t = Math.round(t); st.el = m; st.ended = atEnd; st.paused = !!m.paused; } } catch (e) { } } return st.n ? st : null; } /* SCO 课件页的主视频 */ function cwMainFrame() { var cands = []; try { cands.push(document.getElementById('mainFrame')); } catch (e) { } try { var f = $(SEL.FRAME_MENU); if (f && f.contentDocument) cands.push(f.contentDocument.getElementById('mainFrame')); } catch (e) { } for (var i = 0; i < cands.length; i++) { if (cands[i]) return cands[i]; } return null; } function wcVideo() { try { var mf = cwMainFrame(); var d = mf && mf.contentDocument; return (d && d.querySelector('video')) || null; } catch (e) { return null; } } function wcDrive() { var v = wcVideo(); var st = driveAll(); if (!st) return null; if (v) { try { var d = Number(v.duration) || 0, t = Number(v.currentTime) || 0; st.el = v; st.dur = Math.round(d); st.t = Math.round(t); st.ended = !!v.ended || (d > 0 && t >= d - 1); st.paused = !!v.paused; } catch (e) { } } return st; } /* ---------------- SCO 单视频课件:getDuration 补丁 ---------------- 平台的 jquery.course.player.video.js 里 getDuration() 返回 player.duration, 而 player.duration 从来没被赋过值(=undefined)→ doQuit() 里 `player.getDuration() - 60 <= max_time` 恒为 `NaN <= x` = false, cmi.core.lesson_status="completed" 那一句【永远不执行】→ 白刷。 这里只在平台自己算不出有限值时接管,改成动态读真实时长(与 v2 同源)。 */ var DUR_PATCHED = false; function patchGetDuration() { if (DUR_PATCHED) return false; try { var pl = null; try { pl = UW.player; } catch (e) { pl = null; } if (!pl || typeof pl.getDuration !== 'function') return false; var cur = null; try { cur = pl.getDuration(); } catch (e) { cur = null; } if (cur !== null && isFinite(Number(cur)) && Number(cur) > 0) { DUR_PATCHED = true; // 平台自己正常,别多事 return false; } pl.getDuration = function () { try { var v = document.getElementById('course_player') || document.querySelector('video'); var d = v ? Number(v.duration) : NaN; return (isFinite(d) && d > 0) ? d : NaN; } catch (e) { return NaN; } }; DUR_PATCHED = true; log.warn('平台 player.getDuration() 返回 ' + String(cur) + '(写不出完成状态的元凶),已改为动态读真实时长'); return true; } catch (e) { return false; } } /* ---------------- SCO 单视频课件:自动刷课 + 上报 ---------------- kjcourse 域课件(#course_player + .continue .user_choise)跑在跨域 iframe 里, 顶层够不到它的 video 和 doQuit(),所以这个循环【必须在课件 iframe 内】运行。 职责:点「开始学习」→ 保播放(静音+倍速)→ 播完 → doQuit() 上报完成。 */ function scoLoop() { return (async function () { setState('SCO 视频课件'); /* 先装静音补丁 —— 没有用户手势时带声音的 play() 会被浏览器自动播放策略拦掉 */ patchMediaPlay(); var t0 = nowMs(); var startTries = 0; var lastStartTry = 0; var clicked = false; var started = false; var clickDone = false; var deadSince = 0; var lastReport = 0; var healTries = 0; var lastSeg = -1; /* 上次采样到的分段序号(-1=未知),用于多段回绕判定 */ var lastTime = -1; /* 上次采样 currentTime,用于卡住检测 */ var stuckSince = 0, stuckWarned = false; /* 等用户点「开始」(面板总开关,GM 存储跨 frame 共享)。没点之前只待命,不抢播。 */ while (!isRunning()) { await sleep(1500); if (!$(SEL.CW_VIDEO) && !$(SEL.CW_START)) { setState('已离开课件'); return; } } log.info('SCO 课件开始驱动(已点开始)。'); function curVideo() { return $(SEL.CW_VIDEO) || $('video'); } function videoAlive() { var v = curVideo(); if (!v) return false; try { if (v.currentSrc) return true; if (v.getAttribute('src')) return true; if (v.readyState > 0) return true; } catch (e) { } return false; } function findStartBtn() { var b = $(SEL.CW_START); if (b) return b; var all = $$('.continue div, .user_choise, #mask div, #mask span, #mask a, #mask button'); for (var k = 0; k < all.length; k++) { var t = String(all[k].textContent || '').replace(/\s+/g, '').trim(); if ((t === '开始学习' || t === '继续学习')) return all[k]; } return null; } /* 空源视频自愈:重复点「开始学习」会让平台把