// ==UserScript==
// @name 气象培训平台自动学习
// @namespace cma.meteorology.autolearn
// @version 1.7.7
// @description 自动遍历"我的自选课"未完成课程,跳过已学完课程,进入学习空间逐个学习课件;以课件页 s_point 对勾/completestate 判断完成状态并断点续学;登录超时弹窗绝不点击;会话失效时自动停止跳转;弹窗被拦截时暂停等待用户允许。适配 ScriptCat / Tampermonkey。
// @author you
// @match *://cmatcpx-cmatc.cma.cn/*
// @match *://pxkckj-cmatc.cma.cn/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @grant GM_notification
// @run-at document-idle
// @noframes
// @license All Rights Reserved
// ==/UserScript==
(function () {
'use strict';
// 供 setupIframeLoadWatcher 使用的模块级状态(必须在注入分发之前声明,否则 TDZ 报错)
let __iframeLoadWatcherInstalled = false;
let __lastIframeLoadTs = 0;
/* ===================== 配置区 ===================== */
const CONFIG = {
// 站点
CMS_HOME: 'https://cmatcpx-cmatc.cma.cn/cms/',
CMS_HOST: 'cmatcpx-cmatc.cma.cn',
LEARN_HOST: 'pxkckj-cmatc.cma.cn',
MY_COURSE_HREF: '/u/student/study/myCourseTheme.action',
// 等待超时(毫秒)
WAIT_TIMEOUT: 20000,
// 单个课件学习等待(秒):视频播放/图文停留
COURSEWARE_WATCH_SEC: 35,
// 课件点击后内容加载缓冲
SETTLE_SEC: 1,
// 轮询间隔
POLL_INTERVAL: 500,
// 最多翻页数(防异常)
MAX_PAGES: 200,
// 是否开启调试日志
DEBUG: true,
// 默认静音播放视频
DEFAULT_MUTE: true,
// 默认模式:'normal'=普通(只学未学完课程),'review'=复习(重新学习所有课程,含已学完)
DEFAULT_MODE: 'normal',
// 默认是否启动(false=需手动点开始)
DEFAULT_RUNNING: false,
// 静音观察器扫描间隔
MUTE_SCAN_INTERVAL: 1500,
// 视频完成检测:每多少毫秒轮询一次
VIDEO_POLL_INTERVAL: 1000,
// 视频单课件最长等待(秒),超时强制结束该课件。
// 必须 ≥ 最长的视频时长,否则长视频没播完就超时跳过、永远学不完(平台按累计播放时长判定)
VIDEO_MAX_WAIT_SEC: 7200,
// 每 N 分钟检查一次当前课件是否已被平台标记完成(已完成则提前结束、跳下一个)
COMPLETION_CHECK_MIN: 5,
// 课程未完成时最大重刷次数(0=不重刷,2=最多重刷2遍,共学3遍)
DEFAULT_RETRY: 2,
// CMS 等待学习空间单门课完成的超时(秒),超时自动跳下一门(防止卡死)。
// 必须 ≥ 单门课全部视频播完所需时间,否则一门没学完就去开下一门 → 标签越开越多
CMS_WAIT_LEARN_SEC: 7200,
// 学习空间致命错误后宽限期(秒),过后自动 markDone+关窗以解除 CMS 阻塞
LEARN_GRACE_SEC: 30,
// 课件内容加载后未发现视频的重试次数(每次间隔 2s)
CONTENT_RETRY_TIMES: 4,
// 时间加速:每个课件向服务器提交的额外学习时长(秒),0=不加速
TIME_ACCELERATE_SEC: 1800,
};
/* ===================== 日志 ===================== */
const log = {
info: (...a) => console.log('%c[气象学习]', 'color:#1976d2;font-weight:bold', ...a),
warn: (...a) => console.warn('[气象学习]', ...a),
err: (...a) => console.error('[气象学习]', ...a),
debug: (...a) => { if (CONFIG.DEBUG) console.log('%c[debug]', 'color:#9e9e9e', ...a); },
};
// 诊断缓冲:记录关键路径/状态,供调试日志查看
const Diag = {
buf: [],
add(...args) {
try {
const s = args.map(a => {
try { return typeof a === 'string' ? a : JSON.stringify(a); } catch (e) { return String(a); }
}).join(' ');
this.buf.push('[' + new Date().toLocaleTimeString() + '] ' + s);
if (this.buf.length > 300) this.buf.splice(0, 50);
} catch (e) {}
},
dump() { return this.buf.join('\n'); },
};
/* ===================== 通用工具 ===================== */
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
function waitUntil(fn, timeout = CONFIG.WAIT_TIMEOUT, interval = 300) {
return new Promise((resolve) => {
const start = Date.now();
(function check() {
Promise.resolve().then(() => {
try { return fn(); } catch (e) { return false; }
}).then(result => {
if (result) return resolve(true);
if (Date.now() - start >= timeout) return resolve(false);
setTimeout(check, interval);
}).catch(() => {
if (Date.now() - start >= timeout) return resolve(false);
setTimeout(check, interval);
});
})();
});
}
// 等待并返回元素本身(不是 boolean)。超时返回 null。
function waitForEl(selOrFn, timeout = CONFIG.WAIT_TIMEOUT) {
return new Promise((resolve) => {
const start = Date.now();
(function check() {
let el = null;
try {
el = typeof selOrFn === 'function' ? selOrFn() : document.querySelector(selOrFn);
} catch (e) { el = null; }
if (el) return resolve(el);
if (Date.now() - start >= timeout) return resolve(null);
setTimeout(check, 300);
})();
});
}
function waitEl(selOrFn, timeout = CONFIG.WAIT_TIMEOUT) {
return waitUntil(() => typeof selOrFn === 'function'
? selOrFn()
: document.querySelector(selOrFn), timeout).then(() =>
typeof selOrFn === 'function' ? selOrFn() : document.querySelector(selOrFn));
}
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
/* ===================== 跨标签页协调(GM 存储) =====================
* CMS 标签:点击"开始学习"打开学习空间新标签 → 轮询 GM:learningDone
* 学习空间标签:学完课件 → GM_setValue('learningDone', true) → 关闭窗口
*/
const STORE = {
// 学习完成标记:GM 存储 + localStorage 双通道。
// localStorage 变化会触发其它标签页的 storage 事件(后台标签也不受定时器节流影响),
// 让 CMS 主控页能第一时间收到"学习完成"通知并进入下一门课
markDone: () => {
GM_setValue('learningDone', true);
try { localStorage.setItem('qxj_done', String(Date.now())); } catch (e) {}
},
clearDone: () => {
GM_setValue('learningDone', false);
try { localStorage.removeItem('qxj_done'); } catch (e) {}
},
isDone: () => {
if (GM_getValue('learningDone', false) === true) return true;
try { return localStorage.getItem('qxj_done') !== null; } catch (e) { return false; }
},
setTask: (title) => GM_setValue('currentTask', title),
getTask: () => GM_getValue('currentTask', ''),
setAutoStart: (v) => GM_setValue('autoStart', v),
isAutoStart: () => GM_getValue('autoStart', false) === true,
// 会话失效标记:曾跳转到超时页/弹出登录超时弹窗 → 重新登录前不再自动跳转
setSessionDead: (v) => GM_setValue('sessionDead', v === true),
isSessionDead: () => GM_getValue('sessionDead', false) === true,
// 断点续学进度:保存/恢复当前遍历位置
saveProgress(page, learned, total, courseTitle, courseIdx) {
GM_setValue('qxj_progress', { page, learned, total, courseTitle: courseTitle || '', courseIdx: courseIdx || 0, ts: Date.now() });
},
loadProgress() {
const p = GM_getValue('qxj_progress', null);
if (p && p.page) return p;
return null;
},
clearProgress() {
GM_deleteValue('qxj_progress');
},
};
// (MiniWindowPool 已移除,改用 window.open 独立标签页模式)
/* ===================== 状态面板(启停/模式/静音/实时状态) ===================== */
const StatusPanel = {
// 持久化状态键
K_PAUSED: 'qxj_paused', // 暂停标记(跨页面)
K_MODE: 'qxj_mode', // 'normal' | 'review'
K_MUTE: 'qxj_mute', // 静音开关
K_RUNNING: 'qxj_running', // 是否在运行(跨页面)
K_RETRY: 'qxj_retry', // 课程未完成时最大重刷次数(0=不重刷)
// 实时状态(仅当前页面内存)
state: {
status: '待命', // 待命/运行中/已暂停/已完成/异常
course: '-', // 当前课程
progress: '', // 进度文本(如 "3/12")
detail: '', // 详情
page: '', // 当前页(如 "第1页/共3页")
totalPages: 0, // 总页数
totalCourses: 0, // 当前页课程总数
learnedCourses: 0, // 已学习课程数(跳过+完成)
},
el: null,
// —— 状态读写 ——
isPaused() { return GM_getValue(this.K_PAUSED, false) === true; },
setPaused(v) { GM_setValue(this.K_PAUSED, v === true); },
isRunning() { return GM_getValue(this.K_RUNNING, false) === true; },
setRunning(v) { GM_setValue(this.K_RUNNING, v === true); },
getMode() { return GM_getValue(this.K_MODE, CONFIG.DEFAULT_MODE); },
setMode(v) { GM_setValue(this.K_MODE, v === 'review' ? 'review' : 'normal'); },
isReview() { return this.getMode() === 'review'; },
isMute() { return GM_getValue(this.K_MUTE, CONFIG.DEFAULT_MUTE); },
setMute(v) { GM_setValue(this.K_MUTE, v === true); },
getRetry() { const v = GM_getValue(this.K_RETRY, CONFIG.DEFAULT_RETRY); return Math.max(0, Math.min(5, parseInt(v, 10) || 0)); },
setRetry(v) { v = Math.max(0, Math.min(5, parseInt(v, 10) || 0)); GM_setValue(this.K_RETRY, v); },
// —— 更新实时状态 ——
update(patch) {
Object.assign(this.state, patch);
this.render();
},
// —— 创建 UI ——
mount() {
if (this.el || document.getElementById('qxj-panel')) return;
const wrap = document.createElement('div');
wrap.id = 'qxj-panel';
wrap.innerHTML = `
气象自动学习
—
状态待命
模式普通
课程-
进度-
页码-
动作-
重刷2
窗口数:
`;
const style = document.createElement('style');
style.textContent = `
#qxj-panel{position:fixed;right:16px;bottom:16px;z-index:2147483647;width:248px;
background:rgba(33,41,56,.96);color:#e6eaf2;font-family:"Microsoft YaHei",Arial,sans-serif;
font-size:12px;border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,.35);
overflow:hidden;user-select:none;line-height:1.6}
#qxj-panel .qxj-head{display:flex;justify-content:space-between;align-items:center;
padding:8px 12px;background:linear-gradient(90deg,#1976d2,#42a5f5);cursor:move}
#qxj-panel .qxj-title{font-weight:700;letter-spacing:1px}
#qxj-panel .qxj-fold{cursor:pointer;width:18px;text-align:center}
#qxj-panel .qxj-body{padding:8px 12px;display:none}
#qxj-panel.qxj-open .qxj-body{display:block}
#qxj-panel .qxj-row{display:flex;margin:2px 0}
#qxj-panel .qxj-row .k{width:42px;color:#9fb3c8;flex-shrink:0}
#qxj-panel .qxj-row .v{color:#e6eaf2;word-break:break-all}
#qxj-panel .qxj-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#qxj-panel .qxj-btns{display:flex;gap:6px;padding:8px 12px;border-top:1px solid rgba(255,255,255,.08)}
#qxj-panel .qxj-btn{flex:1;padding:6px 0;border:none;border-radius:6px;cursor:pointer;
font-size:12px;background:#2d3a52;color:#e6eaf2;transition:.15s}
#qxj-panel .qxj-btn:hover{background:#3b4a66}
#qxj-panel .qxj-btn.active{background:#1976d2;color:#fff}
#qxj-panel .qxj-btn[data-on="true"]{background:#43a047;color:#fff}
#qxj-panel .qxj-btn[data-on="false"]{background:#616161;color:#fff}
#qxj-panel .qxj-btn.qxj-retry{background:#6a1b9a;width:100%;margin-top:2px}
#qxj-panel .qxj-btn.qxj-retry:hover{background:#8e24aa}
#qxj-panel .qxj-btn.qxj-allfinish{background:#e65100;width:100%;margin-top:2px}
#qxj-panel .qxj-btn.qxj-allfinish:hover{background:#f57c00}
#qxj-panel .qxj-btn.qxj-allfinish[data-on="true"]{background:#43a047;color:#fff}
.qxj-open-link{display:block;padding:10px;margin:8px 0;background:#1976d2;color:#fff!important;text-align:center;border-radius:6px;font-weight:bold;font-size:14px;text-decoration:none!important;cursor:pointer;z-index:99999;position:relative;}
.qxj-open-link:hover{background:#1565c0;}
#qxj-panel.qxj-collapsed .qxj-body,#qxj-panel.qxj-collapsed .qxj-btns{display:none}
`;
document.head.appendChild(style);
document.body.appendChild(wrap);
this.el = wrap;
// 默认展开
wrap.classList.add('qxj-open');
// 按钮事件
wrap.querySelector('.qxj-fold').addEventListener('click', () => {
wrap.classList.toggle('qxj-collapsed');
wrap.classList.toggle('qxj-open');
});
wrap.querySelector('.qxj-toggle').addEventListener('click', () => this.onToggle());
wrap.querySelector('.qxj-mode').addEventListener('click', () => this.onMode());
wrap.querySelector('.qxj-mute').addEventListener('click', () => this.onMute());
wrap.querySelector('.qxj-retry').addEventListener('click', () => this.onRetry());
wrap.querySelector('.qxj-allfinish').addEventListener('click', () => this.onAllFinish());
wrap.querySelector('.qxj-diag').addEventListener('click', () => this.onDiag());
// 拖动
this.enableDrag(wrap, wrap.querySelector('.qxj-head'));
// 监听跨页面状态变化(其它标签改变暂停状态时同步按钮)
window.addEventListener('storage', () => this.render());
this.render();
log.info('状态面板已挂载');
},
// —— 渲染 ——
render() {
if (!this.el) return;
const s = this.state;
this.el.querySelector('[data-k="status"]').textContent = s.status;
this.el.querySelector('[data-k="mode"]').textContent = this.isReview() ? '复习' : '普通';
this.el.querySelector('[data-k="course"]').textContent = s.course;
// 进度:显示 总X门 剩Y门
const progressEl = this.el.querySelector('[data-k="progress"]');
if (s.totalCourses > 0) {
const remain = Math.max(0, s.totalCourses - s.learnedCourses);
progressEl.textContent = `总${s.totalCourses}门 剩${remain}门`;
} else {
progressEl.textContent = s.progress || '-';
}
// 页码:显示 第X页/共Y页
const pageEl = this.el.querySelector('[data-k="page"]');
if (s.totalPages > 0) {
const curPage = s.page || '1';
// 如果 page 已经包含"第X页"格式,提取数字;否则直接用
const m = curPage.match(/(\d+)/);
const cur = m ? m[1] : curPage;
pageEl.textContent = `第${cur}页/共${s.totalPages}页`;
} else {
pageEl.textContent = s.page || '-';
}
this.el.querySelector('[data-k="detail"]').textContent = s.detail || '-';
// 重刷次数显示
const retry = this.getRetry();
this.el.querySelector('[data-k="retry"]').textContent = retry === 0 ? '不重刷' : (retry + ' 次');
const retryBtn = this.el.querySelector('.qxj-retry');
retryBtn.textContent = '重刷:' + (retry === 0 ? '关' : retry);
// 全部刷完显示
const allFinish = GM_getValue('qxj_all_finish', false);
const allFinishBtn = this.el.querySelector('.qxj-allfinish');
allFinishBtn.textContent = '全部刷完:' + (allFinish ? '开' : '关');
allFinishBtn.dataset.on = allFinish ? 'true' : 'false';
// 启停按钮:未运行=开始(绿),运行中=暂停(灰),暂停中=开始(绿)
const toggle = this.el.querySelector('.qxj-toggle');
const running = this.isRunning();
const paused = this.isPaused();
toggle.textContent = !running ? '开始' : (paused ? '开始' : '暂停');
const isGreen = !running || paused;
toggle.dataset.on = isGreen ? 'true' : 'false';
toggle.classList.toggle('active', !running);
// 模式按钮
const mode = this.el.querySelector('.qxj-mode');
mode.textContent = this.isReview() ? '复习模式' : '普通模式';
mode.classList.toggle('active', this.isReview());
// 静音按钮
const mute = this.el.querySelector('.qxj-mute');
mute.textContent = '静音:' + (this.isMute() ? '开' : '关');
mute.dataset.on = this.isMute() ? 'true' : 'false';
},
onToggle() {
if (!this.isRunning() || this.isPaused()) {
// 启动/恢复
this.setRunning(true);
this.setPaused(false);
STORE.setSessionDead(false);
// 读取窗口数输入框
const winInput = document.getElementById('qxj-win-count');
const winCount = winInput ? Math.max(1, Math.min(20, parseInt(winInput.value, 10) || 1)) : 1;
GM_setValue('qxj_win_count', winCount);
this.update({ status: '运行中', detail: `窗口数:${winCount}` });
log.info('用户点击【开始】,窗口数:' + winCount);
window.__qxjToken = (window.__qxjToken || 0) + 1;
const host = location.hostname;
if (host === CONFIG.CMS_HOST) {
window.dispatchEvent(new CustomEvent('qxj:start'));
} else if (host === CONFIG.LEARN_HOST) {
window.dispatchEvent(new CustomEvent('qxj:restartLearn'));
}
} else {
// 暂停
this.setPaused(true);
this.update({ status: '已暂停' });
log.info('用户点击【暂停】');
}
this.render();
},
onMode() {
this.setMode(this.isReview() ? 'normal' : 'review');
this.update({ detail: '已切换:' + (this.isReview() ? '复习模式' : '普通模式') });
this.render();
log.info('模式切换:', this.getMode());
},
onMute() {
this.setMute(!this.isMute());
this.render();
log.info('静音切换:', this.isMute() ? '开' : '关');
},
onRetry() {
const cur = this.getRetry();
const next = cur >= 5 ? 0 : cur + 1;
this.setRetry(next);
this.update({ detail: '重刷次数:' + (next === 0 ? '不重刷' : next + ' 次') });
this.render();
log.info('重刷次数切换:', next);
},
onAllFinish() {
const cur = GM_getValue('qxj_all_finish', false);
GM_setValue('qxj_all_finish', !cur);
this.update({ detail: '全部刷完:' + (!cur ? '开' : '关') });
this.render();
log.info('全部刷完切换:', !cur);
},
// 脚本重置刷新:清除所有 GM/localStorage 状态,停止运行,刷新页面
onDiag() {
try {
// 清除所有脚本相关的 GM 存储
const gmKeys = ['learningDone', 'currentTask', 'autoStart', 'sessionDead',
'qxj_paused', 'qxj_mode', 'qxj_mute', 'qxj_running', 'qxj_retry', 'qxj_progress', 'qxj_all_finish'];
gmKeys.forEach(k => { try { GM_deleteValue(k); } catch (e) {} });
// 清除 localStorage 中脚本相关的键
const lsKeys = ['qxj_done', 'qxj_paused', 'qxj_mode', 'qxj_mute', 'qxj_running', 'qxj_retry'];
lsKeys.forEach(k => { try { localStorage.removeItem(k); } catch (e) {} });
// 重置内存状态
window.__qxjToken = (window.__qxjToken || 0) + 1;
window.__qxjLearning = false;
log.info('脚本状态已重置,刷新页面');
// 刷新页面
location.reload();
} catch (e) {
log.debug('重置异常:', e);
location.reload();
}
},
// —— 暂停闸门(主循环在每个关键步骤前 await) ——
async waitIfPaused() {
while (this.isPaused()) {
this.update({ status: '已暂停' });
await sleep(500);
}
this.update({ status: '运行中' });
},
// —— 拖动 ——
enableDrag(box, handle) {
handle.addEventListener('mousedown', (e) => {
if (e.target.classList.contains('qxj-fold')) return;
const rect = box.getBoundingClientRect();
const dx = e.clientX - rect.left;
const dy = e.clientY - rect.top;
const move = (ev) => {
box.style.left = (ev.clientX - dx) + 'px';
box.style.top = (ev.clientY - dy) + 'px';
box.style.right = 'auto';
box.style.bottom = 'auto';
};
const up = () => {
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
};
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
});
},
};
/* ===================== 静音观察器(自动静音所有 video,含 iframe) ===================== */
const MuteWatcher = {
timer: null,
start() {
if (this.timer) return;
// 每5秒扫描一次(不再监听 DOMNodeInserted,避免视频播放时频繁触发卡死主线程)
this.timer = setInterval(() => this.scan(), 5000);
log.debug('静音观察器已启动(每5s)');
},
scan() {
// 无论静音开关如何,都尝试恢复暂停的视频
const docs = [document];
const mc = $('#mainContent');
if (mc) {
try { if (mc.contentDocument) docs.push(mc.contentDocument); } catch (e) {}
}
docs.forEach(d => {
d.querySelectorAll('video').forEach(v => {
if (v.ended) return;
if (!v.muted) { try { v.muted = StatusPanel.isMute(); } catch (e) {} }
if (v.paused) {
// 处理 play() 返回的 Promise(autoplay 被阻止时会 reject)
const p = v.play();
if (p && p.catch) p.catch(() => {
// 静音后重试(浏览器允许静音自动播放)
try { v.muted = true; v.play(); } catch (e2) {}
});
}
});
});
},
};
// ===== 轻量视频监控 + 页面健康检测:每5秒检查,暂停续播 + 错误页自动退出 =====
const VideoMonitor = {
timer: null,
lastTime: new WeakMap(),
start() {
if (this.timer) return;
this.timer = setInterval(() => this.check(), 5000);
log.debug('VideoMonitor 已启动(每5s)');
},
check() {
try {
// 检测错误/超时页面 → 自动退出回课程列表
if (this.isErrorPage()) {
log.warn('【 monitor 】检测到错误/超时页面,自动返回课程列表');
const cmsUrl = 'https://' + CONFIG.CMS_HOST + CONFIG.MY_COURSE_HREF;
try { location.href = cmsUrl; } catch (e) {}
return;
}
if (!StatusPanel.isRunning || StatusPanel.isPaused()) return;
// 仅检查顶层 + mainContent iframe(不递归,避免卡死)
const docs = [document];
const mc = $('#mainContent');
if (mc) {
try { if (mc.contentDocument) docs.push(mc.contentDocument); } catch (e) {}
}
let found = 0, playing = 0, paused = 0;
for (const doc of docs) {
if (!doc) continue;
for (const v of doc.querySelectorAll('video')) {
found++;
if (v.ended) continue;
// 视频快播完时(剩余<5秒)不干预,避免触发重新加载
if (v.duration && isFinite(v.duration) && v.currentTime >= v.duration - 5) continue;
const prev = this.lastTime.get(v) || 0;
const now = v.currentTime || 0;
this.lastTime.set(v, now);
if (!v.paused && now > prev + 0.2) {
playing++;
} else {
// 视频暂停 → 尝试续播
try { v.muted = StatusPanel.isMute(); } catch (e) {}
const p = v.play();
if (p && p.catch) p.catch(() => {
// 静音后重试
try { v.muted = true; v.play(); } catch (e2) {}
});
paused++;
}
}
}
if (found > 0 && StatusPanel.isRunning && StatusPanel.isRunning()) {
if (paused > 0 && playing === 0) {
StatusPanel.update({ detail: `视频暂停,已续播(${paused}个)` });
} else if (playing > 0) {
StatusPanel.update({ detail: `视频播放中(${playing}/${found})` });
}
}
} catch (e) {}
},
isErrorPage() {
const p = location.pathname || '';
if (/\/error\/|timeout|sessionTimeout|loginExpired|login\.(htm|html|action|jsp)/i.test(p)) return true;
try {
const dlg = document.querySelector('.layui-layer-content');
if (dlg && /登录超时|登录已超时|请重新登录|会话已过期/.test(dlg.textContent || '')) return true;
const bt = (document.body && document.body.textContent || '').slice(0, 500);
if (/登录超时|会话已过期|页面无法访问|ERR_|无法访问此网站/.test(bt)) return true;
} catch (e) {}
return false;
},
};
/* ===================== 课件学习核心模块 =====================
* 功能:
* 1. 递归深入嵌套 iframe,精准定位视频/图文/完成按钮
* 2. 视频倍速播放(默认4倍),自动续播,监听 ended/timeupdate 完成状态
* 3. 图文课件按设定时间停留
* 4. 多课件逐个学习,单个学完后自动切下一个
* 5. 完成状态检测:ended事件 / currentTime到末尾 / 完成按钮出现 / 完成标记
*/
const Courseware = {
// 递归收集顶层 + 所有同源嵌套 iframe 的 document
collectDocs(rootDoc) {
const docs = [];
const visited = new Set();
const walk = (d) => {
if (!d || visited.has(d)) return;
try { visited.add(d); docs.push(d); } catch (e) { return; }
try {
const ifs = d.querySelectorAll('iframe');
for (const f of ifs) {
try {
if (f.contentDocument) walk(f.contentDocument);
} catch (e) {}
}
} catch (e) {}
};
walk(rootDoc || document);
return docs;
},
queryAll(selector, docs) {
const out = [];
for (const d of docs) {
try { d.querySelectorAll(selector).forEach(el => out.push(el)); } catch (e) {}
}
return out;
},
// 找视频(递归 iframe;优先未结束的,避免误选上一个课件残留的已结束视频)
findVideo(docs) {
const vs = this.queryAll('video', docs);
return vs.filter(v => !v.ended).find(v => v.readyState >= 1) || vs[0] || null;
},
// 检测当前课件是否已完成
isFinished(docs, curVideo) {
// 视频课件:只看该视频是否真的播完(ended / currentTime 到末尾)。
// 不做宽泛的文本/class 判定——课程页常残留"完成"字样或旧元素,误判会导致视频几秒就被当"学完",
// 课件永远学不完、界面反复重学跳转。
if (curVideo) {
if (curVideo.ended) return true;
if (curVideo.duration && isFinite(curVideo.duration) && curVideo.currentTime >= curVideo.duration - 0.5) return true;
return false;
}
// 图文课件:查找明确的完成标记
const finishKeywords = ['已完成', '学习完成', '已学完', 'completed', 'finished', '本节已学完'];
const texts = this.queryAll('.complete, .finished, .done, .learned, .status-complete, [data-complete="true"]', docs);
for (const el of texts) {
const t = (el.textContent || '').trim();
if (t.length > 0 && t.length < 30 && finishKeywords.some(k => t.includes(k))) return true;
if (el.classList.contains('complete') || el.classList.contains('finished')) return true;
}
return false;
},
// 加速学习时间:直接调用服务器接口累加学习时长(跳过视频播放等待)
async accelerateTime(seconds) {
try {
// 从页面获取 courseId 和 limitId
const courseId = (typeof window.courseId !== 'undefined') ? window.courseId : '';
const limitId = (typeof window.limitId !== 'undefined') ? window.limitId : '';
if (!courseId) { log.debug('[加速] 未找到 courseId'); return; }
// 查询当前已累计的学习时长
let current_time = 0;
try {
const qResp = await new Promise((resolve) => {
$.ajax({
type: 'POST', async: true, cache: false,
data: { courseId: courseId },
url: (typeof getPlatformRootPath === 'function' ? getPlatformRootPath() : '') + '/course/study/learningTime_queryLearningTime.action',
dataType: 'json',
success: (d) => resolve(d),
error: () => resolve(null),
});
});
if (qResp && qResp.success === 'true') {
current_time = parseInt(qResp.learnTime, 10) || 0;
}
} catch (e) {}
// 每次提交300秒,分批提交直到达到目标
const steps = Math.ceil(seconds / 300);
for (let i = 0; i < steps; i++) {
const chunk = Math.min(300, seconds - i * 300);
current_time += chunk;
const url = (typeof getPlatformRootPath === 'function' ? getPlatformRootPath() : '') + '/course/study/learningTime_saveLearningTime.action';
await new Promise((resolve) => {
$.ajax({
type: 'POST', async: true, cache: false,
data: { courseId: courseId, studyTime: current_time, limitId: limitId },
url: url,
dataType: 'json',
success: (d) => { log.debug(`[加速] 提交+${chunk}s,累计${current_time}s`); resolve(d); },
error: () => { log.debug(`[加速] 提交失败`); resolve(null); },
});
});
await sleep(500); // 间隔500ms避免过快
}
log.info(`[加速] 完成,累计学习时长 ${current_time}s(${Math.round(current_time/60)}分钟)`);
StatusPanel.update({ detail: `时间加速完成(${Math.round(current_time/60)}分钟)` });
} catch (e) {
log.debug('[加速] 异常:', e);
}
},
// 学习当前课件内容(视频或图文)。itemId 用于周期检查平台完成状态
async learn(itemId) {
// 并发保护:同一时间只允许一个学习任务(主流程 vs iframe 刷新监听),
// 防止重复点击/并发播放导致平台提示"视频播放异常"
if (window.__qxjLearning) { log.debug(' [课件] 已有学习任务进行中,跳过本次'); return false; }
window.__qxjLearning = true;
window.__qxjLastLearnAt = Date.now();
try {
// 首次未发现视频时多次重试扫描(视频可能延迟加载)
let video = null;
let docs = [];
for (let t = 0; t < CONFIG.CONTENT_RETRY_TIMES; t++) {
docs = this.collectDocs(getMainDoc() || document);
video = this.findVideo(docs);
if (video) break;
log.debug(` [课件] 第${t + 1}/${CONFIG.CONTENT_RETRY_TIMES}次未发现视频,2s 后重试扫描`);
StatusPanel.update({ detail: `未发现视频,重试扫描 ${t + 1}/${CONFIG.CONTENT_RETRY_TIMES}` });
await sleep(2000);
}
log.debug(` [课件] 扫描 ${docs.length} 个文档,video=${!!video}`);
// 先加速学习时间(让服务器累计足够的学习时长)
if (CONFIG.TIME_ACCELERATE_SEC > 0) {
await this.accelerateTime(CONFIG.TIME_ACCELERATE_SEC);
}
if (video) {
return await this.learnVideo(video, itemId);
}
// 图文课件
return await this.learnDocument(docs);
} finally {
window.__qxjLearning = false;
}
},
// 视频学习:正常速度播放 → 持续续播/卡住重启 → 等待播放到结尾完成(不加速、不跳进度)
async learnVideo(video, itemId) {
try {
const docsFor = () => this.collectDocs(getMainDoc() || document);
// 启动:静音 + play() + 最高倍速 + 重试
const startPlay = () => {
try {
video.muted = StatusPanel.isMute();
if (video.paused) {
const p = video.play();
if (p && p.catch) p.catch(() => {
// 首次play被阻止 → 静音后重试(浏览器允许静音自动播放)
try { video.muted = true; video.play(); } catch (e2) {}
});
}
if (video.playbackRate < 2) {
video.playbackRate = 2;
}
} catch (e) {}
};
startPlay();
let dur = (video.duration && isFinite(video.duration)) ? video.duration : 0;
log.info(` 视频学习:时长 ${dur ? dur.toFixed(1) + 's' : '未知'},src=${(video.currentSrc || video.src || '').slice(0, 80)}`);
Diag.add('[视频] 开始 src=' + (video.currentSrc || video.src || '').slice(0, 100) + ' 时长=' + (dur ? dur.toFixed(1) + 's' : '未知') + ' t=' + (video.currentTime || 0).toFixed(1));
const maxMs = CONFIG.VIDEO_MAX_WAIT_SEC * 1000;
const interval = CONFIG.VIDEO_POLL_INTERVAL;
const start = Date.now();
// 正常速度播放,等待播放到结尾(平台按连续播放时长判定完成)
StatusPanel.update({ detail: `视频播放中(${dur ? Math.round(dur) + 's' : '未知'})` });
let lastT = video.currentTime || 0;
let lastProgressAt = Date.now();
let lastTry = 0;
let lastHeartbeat = 0;
let lastCheckTs = 0;
while (Date.now() - start < maxMs) {
// 暂停闸门
await StatusPanel.waitIfPaused();
// 视频元素被播放器替换/移除 → 按 src 重新获取
if (!video.isConnected) {
const same = docsFor().find(v => (v.currentSrc || v.src) === (video.currentSrc || video.src));
if (same) {
video = same;
dur = (same.duration && isFinite(same.duration)) ? same.duration : 0;
log.info(' 视频元素被播放器替换,已重新获取');
}
}
// 完成检测(只看该视频是否播到结尾)
if (this.isFinished(docsFor(), video)) {
log.info(` 视频已完成(ended=${video.ended} t=${(video.currentTime || 0).toFixed(1)}/${video.duration ? video.duration.toFixed(0) : '?'})`);
Diag.add('[视频] 完成 ended=' + video.ended + ' t=' + (video.currentTime || 0).toFixed(1) + '/' + (video.duration ? video.duration.toFixed(0) : '?'));
StatusPanel.update({ detail: '视频已完成,切下一课件' });
await sleep(800);
return true;
}
// 每 COMPLETION_CHECK_MIN 分钟检查一次平台完成状态:已完成 → 提前结束跳下一个
if (itemId && Date.now() - lastCheckTs >= CONFIG.COMPLETION_CHECK_MIN * 60 * 1000) {
lastCheckTs = Date.now();
StatusPanel.update({ detail: `每${CONFIG.COMPLETION_CHECK_MIN}分钟检查完成状态` });
const rec = await LearnRecord.read();
const rc = rec ? rec.find(x => x.itemId === itemId) : null;
if (rc && rc.done) {
log.info(` 周期检查:课件 ${itemId} 平台已标记完成,提前结束`);
Diag.add('[视频] 周期检查:课件 ' + itemId + ' 已完成,提前结束');
await sleep(800);
return true;
}
const cur = CoursewareList.read().find(x => x.itemId === itemId);
if (cur && cur.done) {
log.info(` 周期检查:课件 ${itemId} 已有对勾,提前结束`);
Diag.add('[视频] 周期检查:课件 ' + itemId + ' 有对勾,提前结束');
await sleep(800);
return true;
}
}
// 确保倍速不被播放器重置
if (video.playbackRate < 2) {
try { video.playbackRate = 2; } catch (e) {}
}
// 进度跟踪
const t = video.currentTime || 0;
if (!video.ended && t > lastT) {
lastT = t;
lastProgressAt = Date.now();
}
// 15 秒无进展:尝试重启播放
else if (Date.now() - lastProgressAt > 15000 && Date.now() - lastTry > 15000) {
lastTry = Date.now();
log.warn(` 视频未推进(t=${t.toFixed(1)},paused=${video.paused}),尝试启动`);
StatusPanel.update({ detail: '视频未推进,尝试重启' });
// 强制静音 + play + 刷新播放器
try { video.muted = true; } catch (e) {}
startPlay();
// 如果还是暂停,尝试重新加载
if (video.paused) {
try { video.load(); } catch (e) {}
await sleep(1000);
startPlay();
}
lastProgressAt = Date.now() - 10000;
}
// 心跳:每 30 秒记录一次进度,便于排障
if (Date.now() - lastHeartbeat > 30000) {
lastHeartbeat = Date.now();
Diag.add('[视频] 心跳 t=' + (video.currentTime || 0).toFixed(1) + '/' + (video.duration ? video.duration.toFixed(0) : '?') + ' paused=' + video.paused + ' ended=' + video.ended + ' connected=' + video.isConnected);
}
await sleep(interval);
}
log.warn(` 视频等待超时(${CONFIG.VIDEO_MAX_WAIT_SEC}s),强制结束`);
Diag.add('[视频] 等待超时');
StatusPanel.update({ detail: '视频等待超时,跳过' });
return false;
} catch (e) {
log.err('视频学习异常:', e);
Diag.add('[视频] 异常: ' + (e && e.stack || e));
return false;
}
},
// 图文/网页课件学习:停留设定时间(让服务器计满浏览时长),期间检测完成
async learnDocument(docs) {
const waitSec = Math.min(CONFIG.COURSEWARE_WATCH_SEC, 40);
log.info(` 图文课件,停留 ${waitSec}s`);
StatusPanel.update({ detail: `图文课件阅读 ${waitSec}s` });
const total = waitSec * 1000;
const step = 1000;
let waited = 0;
while (waited < total) {
await StatusPanel.waitIfPaused();
const seg = Math.min(step, total - waited);
await sleep(seg);
waited += seg;
// 期间检测完成
const allDocs = this.collectDocs(getMainDoc() || document);
if (this.isFinished(allDocs)) {
log.info(' 检测到已完成标记,提前结束');
StatusPanel.update({ detail: '图文已完成,切下一课件' });
return true;
}
}
return true;
},
};
/* ===================== 入口:按域名分流 ===================== */
const host = location.hostname;
log.info('脚本版本 1.7.4 已注入:', host, location.href);
Diag.add('[注入] 版本 1.7.4 host=' + host + ' url=' + location.href);
// 全局错误捕获:任何未处理异常都记入诊断(async 函数第一处 await 前抛错是静默 rejection)
window.addEventListener('error', (e) => {
try { Diag.add('[全局错误] ' + ((e && (e.message || (e.error && (e.error.stack || e.error.message)) || e)) || e)); } catch (err) {}
});
window.addEventListener('unhandledrejection', (e) => {
try { Diag.add('[未处理rejection] ' + ((e && e.reason && (e.reason.stack || e.reason.message || e.reason)) || e)); } catch (err) {}
});
// CMS 主站首页/登录页不启动(避免干扰登录、误关登录超时弹窗)
// 仅在"我的自选课"等业务页面启动;学习空间站则直接启动
function shouldStartOnCms() {
const p = location.pathname;
// 仅在课程相关页面启动
return /myCourse|student\/study|courseList|cms\/u\/student/.test(p + location.hash)
|| document.querySelector('a.btn-link.myCourse, #courseList, ul#courseList') !== null;
}
// 会话失效判定:当前页面是超时/错误/登录页(如 /error/timeout.htm)
// 此类页面绝不自动跳转,否则会形成 超时页↔课程页 无限循环
function isSessionErrorPage() {
const p = location.pathname || '';
return /\/error\/|timeout|sessionTimeout|loginExpired|signin|login\.(htm|html|action|jsp)/i.test(p);
}
// 会话失效综合判定:超时/错误/登录页,或页面出现"登录超时"弹窗/提示
function isSessionDead() {
if (isSessionErrorPage()) return true;
try {
const dlg = document.querySelector('.layui-layer-content');
if (dlg && /登录超时|登录已超时|请重新登录|会话已过期|登录已过期/.test(dlg.textContent || '')) return true;
const bt = (document.body && document.body.textContent || '').slice(0, 800);
if (/登录超时,为了您的信息安全,请重新登录/.test(bt)) return true;
} catch (e) {}
return false;
}
// 等待 body 就绪后挂载面板
(async () => {
await waitUntil(() => document.body, 10000);
if (host === CONFIG.CMS_HOST) {
// CMS 站:先判断是否在业务页面
if (!shouldStartOnCms()) {
log.info('CMS 首页/登录页,暂不启动面板与弹窗扫描');
// 监听 URL 变化,进入业务页面后再启动
let started = false;
const tryStart = () => {
if (started) return;
if (shouldStartOnCms()) {
started = true;
StatusPanel.mount();
MuteWatcher.start();
DialogDismisser.start();
VideoMonitor.start();
log.info('已进入 CMS 业务页面,面板与扫描已启动');
}
};
tryStart();
// URL 变化(SPA 路由)触发
let lastHref = location.href;
setInterval(() => {
if (location.href !== lastHref) {
lastHref = location.href;
tryStart();
}
}, 1000);
return;
}
}
// 学习空间站 / CMS 业务页面:直接启动
StatusPanel.mount();
MuteWatcher.start();
DialogDismisser.start();
VideoMonitor.start();
})();
// ===== 自动关闭弹窗(30分钟学习提醒等)=====
// 每 3 秒扫描一次(降低频率避免卡死主线程)
const DialogDismisser = {
timer: null,
// 只有弹窗内容匹配这些关键词才点击"确定"
MATCH_KEYWORDS: ['学了', '30分钟', '继续学习', '休息', '已经学', '学习时间', '分钟'],
// 弹窗内容含这些词则【绝不点击】(登录超时/会话失效等)
SKIP_KEYWORDS: [
'登录', '超时', '失效', '未登录', '重新登录', '重新登陆', '登录超时', '会话已过期', '登录已过期',
'已过期', '过期', '信息安全', '身份', '会话', 'session', 'timeout', 'login',
],
// 含这些词视为"登录/会话失效"类弹窗 → 暂停自动化,等待人工重新登录
LOGIN_KEYWORDS: ['登录超时', '登录已超时', '重新登录', '重新登陆', '信息安全', '会话', '超时', 'timeout', 'login'],
lastSkipLog: 0,
sessionTimeoutNotified: false,
observer: null,
observedDocs: new WeakSet(),
start() {
if (this.timer) return;
this.timer = setInterval(() => this.scan(), 3000);
// 不再使用 MutationObserver(视频播放时DOM频繁变化会导致疯狂触发scan,卡死主线程)
log.info('DialogDismisser 已启动(每3s扫描,不使用MutationObserver)');
},
// 用 MutationObserver 监听弹窗插入:
// 后台标签页里 setInterval 会被浏览器节流到 ~1次/分钟,而 DOM 变化回调不被节流,
// 弹窗一出现(平台 JS 插入 DOM)就能立即扫描关闭
installObserver(rootDoc) {
try {
if (!rootDoc || this.observedDocs.has(rootDoc)) return;
this.observedDocs.add(rootDoc);
const mo = new MutationObserver(() => this.scan());
mo.observe(rootDoc.body || rootDoc.documentElement, { childList: true, subtree: true });
if (!this.observer) this.observer = mo;
} catch (e) {}
},
scan() {
try {
// 仅检查顶层 + mainContent iframe(不递归,避免卡死)
const docs = [document];
const mc = $('#mainContent');
if (mc) {
try { if (mc.contentDocument) docs.push(mc.contentDocument); } catch (e) {}
}
for (const doc of docs) {
if (!doc) continue;
// 找所有 layui-layer 弹窗容器
const layers = doc.querySelectorAll('.layui-layer');
for (const layer of layers) {
if (!layer.style || layer.style.display === 'none') continue;
// 读弹窗内容(标题 + 正文 + 整体文本)
const content = layer.querySelector('.layui-layer-content');
const titleEl = layer.querySelector('.layui-layer-title');
const txt = [
(content && content.textContent) || '',
(titleEl && titleEl.textContent) || '',
(layer.textContent) || '',
].join(' ');
// 【第一步】SKIP:含登录/超时/失效等关键词 → 绝不点击
const skipHit = this.SKIP_KEYWORDS.find(k => txt.indexOf(k) >= 0);
if (skipHit) {
// 节流日志(5 秒一次,避免刷屏)
const now = Date.now();
if (now - this.lastSkipLog > 5000) {
this.lastSkipLog = now;
log.info('[弹窗] 保留弹窗(命中"' + skipHit + '"),不点确定:' + txt.substring(0, 50));
}
// 登录/会话失效类弹窗 → 立即暂停自动化,避免继续点击/跳转导致弹窗反复弹出
if (this.LOGIN_KEYWORDS.some(k => txt.indexOf(k) >= 0)) {
this.pauseOnSessionTimeout();
}
continue;
}
// 【第二步】MATCH:必须是学习提醒类弹窗才点
if (!this.MATCH_KEYWORDS.some(k => txt.indexOf(k) >= 0)) {
continue;
}
// 找该弹窗内的"确定"按钮
const btn = layer.querySelector('.layui-layer-btn0, .layui-layer-btn a');
if (btn) {
const btnTxt = (btn.textContent || '').trim();
if (btnTxt === '确定' || btnTxt === 'OK' || btnTxt === '继续学习' || btnTxt === '继续') {
log.info('[弹窗] 自动点击学习提醒弹窗「' + btnTxt + '」:' + txt.substring(0, 40));
try { btn.click(); } catch (e) {}
StatusPanel.update({ detail: '已关闭学习提醒弹窗' });
// 弹窗关闭后自动恢复视频播放(弹窗可能导致视频暂停)
setTimeout(() => {
try {
const docs = [document];
const mc = $('#mainContent');
if (mc) { try { if (mc.contentDocument) docs.push(mc.contentDocument); } catch (e) {} }
docs.forEach(d => {
d.querySelectorAll('video').forEach(v => {
if (v.paused && !v.ended) {
try { v.muted = StatusPanel.isMute(); } catch (e) {}
try { v.play(); } catch (e) {}
log.info('[弹窗] 弹窗关闭后恢复视频播放');
}
});
});
} catch (e) {}
}, 1000);
}
}
}
}
} catch (e) {
// 扫描异常忽略,避免中断定时器
}
},
// 检测到登录超时/会话失效:暂停全部自动化,等待人工重新登录(绝不点击该弹窗)
pauseOnSessionTimeout() {
if (this.sessionTimeoutNotified) return;
this.sessionTimeoutNotified = true;
try {
StatusPanel.setRunning(false);
StatusPanel.setPaused(true);
STORE.setSessionDead(true);
StatusPanel.update({ status: '登录超时', detail: '会话已失效,请重新登录后点「开始」' });
StatusPanel.render();
} catch (e) {}
log.warn('检测到登录超时弹窗:已暂停自动化,等待人工重新登录(绝不点击该弹窗)');
try { notify('登录超时', '请重新登录后再点「开始」继续学习'); } catch (e) {}
// 3 分钟后允许再次提醒
setTimeout(() => { this.sessionTimeoutNotified = false; }, 3 * 60 * 1000);
},
};
// 递归收集顶层 + 所有同源 iframe 的 document(供 DialogDismisser 用)
function collectAllDocs(rootDoc) {
const docs = [rootDoc];
try {
const iframes = rootDoc.querySelectorAll('iframe');
for (const f of iframes) {
try {
if (f.contentDocument) docs.push(...collectAllDocs(f.contentDocument));
} catch (e) {} // 跨域 iframe 跳过
}
} catch (e) {}
return docs;
}
// 抑制浏览器"是否离开网站"确认弹窗:
// 1) 平台 beforeunload 通过全局 customNeedCloseConfirm 开关控制 → 置 false
// 2) 清掉 onbeforeunload 属性处理器与 jQuery 注册的 beforeunload(本平台 handler 多挂在 $(window).bind 上)
function suppressLeaveConfirm() {
try { window.customNeedCloseConfirm = false; } catch (e) {}
try { window.onbeforeunload = null; } catch (e) {}
try { document.onbeforeunload = null; } catch (e) {}
// 覆盖 addEventListener 注册的 beforeunload:返回空值可阻止弹窗
try { window.addEventListener('beforeunload', function(e) { delete e.returnValue; }); } catch (e) {}
try {
const jq = window.jQuery || (window.$ && window.$.fn && window.$);
if (jq && jq._data) {
const e1 = jq._data(window, 'events');
if (e1 && e1.beforeunload) delete e1.beforeunload;
const e2 = jq._data(document, 'events');
if (e2 && e2.beforeunload) delete e2.beforeunload;
}
} catch (e) {}
}
// 关闭学习空间标签页
function closeCleanly() {
suppressLeaveConfirm();
try { window.customNeedCloseConfirm = false; } catch (e) {}
try { window.onbeforeunload = null; } catch (e) {}
// 多窗口模式:直接关闭标签页(不跳转CMS)
try {
if (GM_getValue('qxj_multi_window', false)) {
window.close();
return;
}
} catch (e) {}
// 单窗口模式:跳转回CMS
try {
document.open();
document.write('