// ==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;
}
/* 空源视频自愈:重复点「开始学习」会让平台把