// ==UserScript==
// @name 气象培训平台自动学习
// @namespace cma.meteorology.autolearn
// @version 1.11.26
// @description 自动遍历"我的自选课""网络班级"中未完成课程,跳过已学完课程,进入学习空间学习课件;弹窗被拦截时暂停等待用户允许。
// @author you
// @match *://cmatcpx-cmatc.cma.cn/*
// @match *://pxkckj-cmatc.cma.cn/*
// @match *://kjcourse-cmatc.cma.cn/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @grant GM_notification
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// 供 setupIframeLoadWatcher 使用的模块级状态(必须在注入分发之前声明,否则 TDZ 报错)
let __iframeLoadWatcherInstalled = false;
let __lastIframeLoadTs = 0;
// CMS 学习窗口复用:同一窗口反复跳转,避免每次 window.open 被浏览器拦截
let sharedLearnWin = null;
// CMS 主循环重入保护:防止"详情页自动启动"与"用户点开始"并发多个循环(导致反复刷新/重复开窗)
let cmsLoopRunning = false;
// 班级详情页"全部刷完"轮次计数(防死循环)
let classRetryCount = 0;
// 手势预开的第一个窗口(多窗口模式把它算作第一个标签,避免 window.open 被拦截)
let preOpenedFirst = null;
/* ===================== 配置区 ===================== */
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));
// 网络班级上下文(班级列表页 #classList / 班级详情页):多窗口学习在此不可用,须禁用
function isClassContext() {
return !!(document.getElementById('classList') || document.getElementById('classDetailCourseId')
|| /classDetailNew|classCourseDetail/.test(location.pathname));
}
/* ===================== 跨标签页协调(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-retry').addEventListener('click', () => this.onRetry());
wrap.querySelector('.qxj-allfinish').addEventListener('click', () => this.onAllFinish());
wrap.querySelector('.qxj-accel').addEventListener('click', () => this.onAccel());
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 accelBtn = this.el.querySelector('.qxj-accel');
if (accelBtn) {
const accel = GM_getValue('qxj_accel', true) === true;
accelBtn.textContent = '加速:' + (accel ? '开' : '关');
accelBtn.dataset.on = accel ? '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 winInput = this.el.querySelector('#qxj-win-count');
const winLabel = this.el.querySelector('#qxj-win-label');
if (winInput) {
if (isClassContext()) {
winInput.disabled = true;
winInput.value = '1';
winInput.style.opacity = '.45';
winInput.style.cursor = 'not-allowed';
winInput.title = '网络班级课程不支持多窗口学习';
if (winLabel) { winLabel.textContent = '多窗口:禁止'; winLabel.style.color = '#8a93a5'; }
} else {
winInput.disabled = false;
winInput.style.opacity = '';
winInput.style.cursor = '';
winInput.removeAttribute('title');
if (winLabel) { winLabel.textContent = '窗口数:'; winLabel.style.color = '#9fb3c8'; }
}
}
},
onToggle() {
if (!this.isRunning() || this.isPaused()) {
// 主循环已在运行(班级详情页自动启动等):只解除暂停,不重启、不弹新窗、不刷新
if (cmsLoopRunning) {
this.setRunning(true);
this.setPaused(false);
this.update({ status: '运行中', detail: '已恢复运行(无需重复启动)' });
this.render();
log.info('主循环已在运行,仅恢复,不重复启动');
return;
}
// 启动/恢复
this.setRunning(true);
this.setPaused(false);
STORE.setSessionDead(false);
// 注:不再在点击时预开窗口——预开改为 qxj:start 事件内、获取全局运行锁成功后执行,
// 且使用唯一窗口名,避免把其它标签页已打开的学习窗口误跳成 about:blank
// 读取窗口数输入框(网络班级页多窗口禁用,强制单窗口)
const inClassCtx = isClassContext();
const winInput = document.getElementById('qxj-win-count');
const winCount = inClassCtx ? 1 : (winInput ? Math.max(1, Math.min(20, parseInt(winInput.value, 10) || 1)) : 1);
if (!inClassCtx) GM_setValue('qxj_win_count', winCount);
else GM_setValue('qxj_multi_window', false); // 班级页不使用多窗口标记
this.update({ status: '运行中', detail: inClassCtx ? '网络班级:多窗口已禁用(单窗口学习)' : `窗口数:${winCount}` });
log.info('用户点击【开始】' + (inClassCtx ? '(网络班级,多窗口禁用,单窗口学习)' : ',窗口数:' + 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());
},
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);
},
onAccel() {
const cur = GM_getValue('qxj_accel', true) === true;
GM_setValue('qxj_accel', !cur);
this.update({ detail: '时间加速:' + (!cur ? '开' : '关') + (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', 'qxj_cms_loop', 'qxj_accel'];
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;
// 每2秒扫描一次(强制静音需高频,对抗平台播放器重置声音)
this.timer = setInterval(() => this.scan(), 2000);
log.debug('静音观察器已启动(每2s,强制静音)');
},
scan() {
// 递归收集所有可访问文档(视频可能在 mainContent→mainFrame 深层嵌套 iframe 里)
const docs = [];
const seen = new Set();
const walk = (d) => {
if (!d || seen.has(d)) return;
try { seen.add(d); docs.push(d); } catch (e) { return; }
try {
d.querySelectorAll('iframe').forEach(f => {
try { if (f.contentDocument) walk(f.contentDocument); } catch (e) {}
});
} catch (e) {}
};
walk(document);
const paused = StatusPanel.isPaused();
docs.forEach(d => {
if (!d) return;
d.querySelectorAll('video').forEach(v => {
if (v.ended) return;
// 强制静音(muted + volume 双保险),不受静音开关影响
try { if (!v.muted) v.muted = true; if (v.volume > 0) v.volume = 0; } catch (e) {}
// 暂停时不干预播放;否则恢复播放
if (v.paused && !paused) {
const p = v.play();
if (p && p.catch) p.catch(() => {
try { v.muted = true; v.volume = 0; v.play(); } catch (e2) {}
});
}
});
});
},
};
// ===== 轻量视频监控 + 页面健康检测:每5秒检查,暂停续播 + 错误页自动退出 =====
const VideoMonitor = {
timer: null,
lastTime: new WeakMap(),
start() {
if (this.timer) return;
this.timer = setInterval(() => this.check(), 10000);
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→mainFrame 深层嵌套 iframe 里)
const docs = [];
const seen = new Set();
const walk = (d) => {
if (!d || seen.has(d)) return;
try { seen.add(d); docs.push(d); } catch (e) { return; }
try {
d.querySelectorAll('iframe').forEach(f => {
try { if (f.contentDocument) walk(f.contentDocument); } catch (e) {}
});
} catch (e) {}
};
walk(document);
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;
// 强制静音(muted + volume 双保险)
try { if (!v.muted) v.muted = true; if (v.volume > 0) v.volume = 0; } catch (e) {}
// 视频快播完时(剩余<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 {
if (!v.muted) { v.muted = true; if (v.volume > 0) v.volume = 0; }
} catch (e) {}
const p = v.play();
if (p && p.catch) p.catch(() => {
// 播放被阻止时保持静音重试
try { v.muted = true; v.volume = 0; 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;
},
// 尝试关闭欢迎弹窗(部分课程会出现,遮挡课件内容)
tryCloseWelcomePopup(fullScan) {
try {
// 递归收集所有可访问文档(含嵌套 iframe)——欢迎弹窗可能出现在 mainContent 深层 iframe 里
const docs = this.collectDocs(document);
// 点击后延迟验证弹窗是否真的被关闭(容器被隐藏/移除),便于确认消除是否成功
const verifyClosed = (cont, via) => {
setTimeout(() => {
try {
const still = cont && cont.isConnected && cont.offsetParent !== null;
if (still) {
log.warn(` [弹窗] 已点击"开始学习"(${via}),但弹窗容器仍可见,点击可能无效`);
StatusPanel.update({ detail: '已点击开始学习,但弹窗未消失' });
} else {
log.info(` [弹窗][成功] 欢迎弹窗已关闭(${via})`);
StatusPanel.update({ detail: '已自动关闭欢迎弹窗' });
}
} catch (e) {}
}, 500);
};
// 方法1:精确选择器(廉价,快速模式下也执行)
for (const doc of docs) {
if (!doc) continue;
const btn = doc.querySelector('.continue .user_choise');
if (btn) {
log.info(' [弹窗] 关闭欢迎弹窗(选择器)');
try { btn.click(); } catch (e) {}
verifyClosed(btn.closest('.continue') || doc.querySelector('.continue'), '选择器');
return true;
}
}
if (!fullScan) return false; // 性能优化:快速模式不做全文档文本遍历
// CMS 页面没有欢迎弹窗,且课程卡片含"开始学习/继续学习"按钮——
// 若在这里做文本匹配会把真按钮当弹窗点击,导致平台自己弹出重复学习窗口!必须跳过
if (location.hostname === CONFIG.CMS_HOST) return false;
// 方法2:文本匹配("开始学习/视频续播/继续播放/继续学习";
// 要求附近有 欢迎/续播 字样——这两个词只出现在弹窗里,课程卡片不会有)
for (const doc of docs) {
if (!doc) continue;
const allEls = doc.querySelectorAll('div, a, button, span, p');
for (const el of allEls) {
const txt = (el.textContent || '').trim();
if (!/^(开始学习|视频续播|继续播放|继续学习)$/.test(txt)) continue;
if (el.offsetParent === null || el.children.length > 0) continue;
let p = el.parentElement, near = false;
for (let i = 0; p && i < 3; i++, p = p.parentElement) {
if (/欢迎|续播/.test(p.textContent || '')) { near = true; break; }
}
if (!near) continue;
log.info(' [弹窗] 关闭弹窗(文本:' + txt + ')');
try { el.click(); } catch (e) {}
verifyClosed(el.closest('.continue') || el.parentElement, '文本');
return true;
}
}
// 方法3:仅在页面存在欢迎弹窗痕迹(.continue 容器)时才发送键盘事件,避免频繁干扰页面
for (const doc of docs) {
if (!doc || !doc.querySelector('.continue')) continue;
try {
doc.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
doc.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', code: 'Space', keyCode: 32, bubbles: true }));
log.info(' [弹窗] 检测到欢迎弹窗痕迹,发送键盘事件尝试关闭');
return true;
} catch (e) {}
}
} catch (e) {}
return false;
},
// 加速学习时间:直接调用服务器接口累加学习时长(跳过视频播放等待)
async accelerateTime(seconds) {
try {
// 沙箱内看不到页面的 var 全局变量,按 页面window → unsafeWindow → URL参数 依次取 courseId
let courseId = '';
try { if (typeof window.courseId !== 'undefined') courseId = window.courseId; } catch (e) {}
if (!courseId) { try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow.courseId) courseId = unsafeWindow.courseId; } catch (e) {} }
if (!courseId) {
const m = (location.href || '').match(/[?&]params\.courseId=([^&]+)/);
if (m) courseId = m[1];
}
courseId = String(courseId || '');
let limitId = '';
try { if (typeof window.limitId !== 'undefined') limitId = window.limitId; } catch (e) {}
if (!limitId) { try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow.limitId) limitId = unsafeWindow.limitId; } catch (e) {} }
if (!courseId) { log.debug('[加速] 未找到 courseId'); return; }
// 平台根路径:页面函数不可用时用 /learnspace(学习空间站点固定前缀)
const rootPath = (() => {
try { if (typeof getPlatformRootPath === 'function') return getPlatformRootPath(); } catch (e) {}
try { if (typeof unsafeWindow !== 'undefined' && typeof unsafeWindow.getPlatformRootPath === 'function') return unsafeWindow.getPlatformRootPath(); } catch (e) {}
return '/learnspace';
})();
// 用 GM_xmlhttpRequest 发 POST(沙箱内页面 jQuery 不可用;GM 请求自带会话 cookie)
const gmPost = (url, data) => new Promise((resolve) => {
try {
GM_xmlhttpRequest({
method: 'POST',
url,
data: Object.keys(data).map(k => encodeURIComponent(k) + '=' + encodeURIComponent(data[k])).join('&'),
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
timeout: CONFIG.WAIT_TIMEOUT,
onload: (res) => {
try { resolve(JSON.parse(res.responseText || '{}')); } catch (e) { resolve(null); }
},
onerror: () => resolve(null),
ontimeout: () => resolve(null),
});
} catch (e) { resolve(null); }
});
// 查询当前已累计的学习时长
let current_time = 0;
try {
const qResp = await gmPost(rootPath + '/course/study/learningTime_queryLearningTime.action', { courseId: courseId });
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 d = await gmPost(rootPath + '/course/study/learningTime_saveLearningTime.action', { courseId: courseId, studyTime: current_time, limitId: limitId });
log.debug(`[加速] 提交+${chunk}s,累计${current_time}s`);
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;
// 存在跨域课件 iframe 时,同域扫描永远找不到视频 → 直接进入跨域流程,不再重试
const hasCross = this.queryAll('iframe', docs).some(f => {
const s = f.src || '';
return s && !/^(about:|javascript:)/.test(s) && s.indexOf(location.hostname) < 0;
});
if (hasCross) {
log.debug(` [课件] 检测到跨域课件 iframe,跳过同域重试,直接走跨域流程`);
break;
}
// 未找到视频 → 可能是弹窗遮挡 → 尝试关闭弹窗(全量扫描)
this.tryCloseWelcomePopup(true);
log.debug(` [课件] 第${t + 1}/${CONFIG.CONTENT_RETRY_TIMES}次未发现视频,2s 后重试扫描`);
StatusPanel.update({ detail: `未发现视频,尝试关闭弹窗 ${t + 1}/${CONFIG.CONTENT_RETRY_TIMES}` });
await sleep(2000);
}
// 所有重试都失败 → 可能是弹窗挡住了,再试一次关闭弹窗(全量扫描)
if (!video) {
this.tryCloseWelcomePopup(true);
await sleep(1000);
docs = this.collectDocs(getMainDoc() || document);
video = this.findVideo(docs);
}
log.debug(` [课件] 扫描 ${docs.length} 个文档,video=${!!video}`);
// 检测跨域课件 iframe(如 kjcourse-cmatc.cma.cn):内部视频主脚本无法访问
let crossIframes = [];
try {
crossIframes = this.queryAll('iframe', docs).filter(f => {
const s = f.src || '';
return s && !/^(about:|javascript:)/.test(s) && s.indexOf(location.hostname) < 0;
});
if (crossIframes.length) {
log.warn(` [课件] ${crossIframes.length} 个跨域课件 iframe(${crossIframes[0].src.slice(0, 80)}),等待辅助脚本处理`);
StatusPanel.update({ detail: '跨域课件(web),等待辅助脚本' });
}
} catch (e) {}
// 跨域课件:等辅助脚本关闭弹窗并播放视频(辅助脚本通过 postMessage 上报)
if (!video && crossIframes.length) {
CwHelper.start();
const hv = await CwHelper.waitVideo(30000);
if (hv && hv.found) {
log.info(` [课件] 辅助脚本已找到并播放视频(${hv.duration ? Math.round(hv.duration) + 's' : '时长未知'})`);
Diag.add('[视频] 跨域视频由辅助脚本播放,duration=' + (hv.duration || '?'));
StatusPanel.update({ detail: `跨域视频播放中(${hv.duration ? Math.round(hv.duration) + 's' : '?'})` });
const maxMs = CONFIG.VIDEO_MAX_WAIT_SEC * 1000;
const start = Date.now();
let lastT = -1, lastProg = Date.now();
let missingSince = 0;
let maxT = 0; // 记录看过的最大播放位置(识别"播完一遍后自动重播")
while (Date.now() - start < maxMs) {
await StatusPanel.waitIfPaused();
const cur = CwHelper.video;
if (!cur || !cur.found) {
// 视频元素短暂消失(播放器重绘/重建的一瞬间)→ 容忍 10s,不轻易放弃,
// 否则视频播到一半会被误判为"消失"而切走(表现为"视频播放不完就刷新")
if (!missingSince) missingSince = Date.now();
if (Date.now() - missingSince > 10000) break;
await sleep(2000);
continue;
}
missingSince = 0;
if (cur.ended || (cur.duration && isFinite(cur.duration) && cur.currentTime >= cur.duration - 0.5)) {
log.info(` [课件] 跨域视频播放完成(t=${(cur.currentTime || 0).toFixed(1)}/${cur.duration ? cur.duration.toFixed(0) : '?'})`);
StatusPanel.update({ detail: '跨域视频播放完成' });
return true;
}
// 视频源无效(404/未加载):10s 内元数据都没出来,或明确无源 → 按图文处理
if (cur.readyState === 0 && (cur.networkState === 3 || Date.now() - start > 10000)) {
log.warn(' [课件] 跨域视频源无效(未加载出元数据),改按图文课件处理');
StatusPanel.update({ detail: '跨域视频源无效,按图文停留处理' });
break;
}
const t = cur.currentTime || 0;
if (t > maxT) maxT = t;
// 已播到较靠后,然后位置跳回开头附近 → 判定已完整播完一遍(平台自动重播)
if (maxT > 30 && t > 0 && t < 5 && t < maxT - 30) {
log.info(` [课件] 视频已播放到 ${Math.round(maxT)}s 后回到开头,判定已完整播放`);
StatusPanel.update({ detail: '跨域视频已完整播放' });
return true;
}
if (t > lastT) { lastT = t; lastProg = Date.now(); }
else if (Date.now() - lastProg > 15000) {
log.warn(' [课件] 跨域视频 15s 无推进,改按图文课件处理');
break;
}
await sleep(2000);
}
log.warn(' [课件] 跨域视频等待超时,按图文课件处理');
} else {
log.warn(' [课件] 辅助脚本未汇报到视频(可能纯图文),按图文课件处理');
StatusPanel.update({ detail: '跨域课件无视频,按图文停留处理' });
}
}
// 有视频:先正常播放(不预先加速——避免平台提前判定完成,导致学习窗口几分钟就切下一门)
if (video) {
const vok = await this.learnVideo(video, itemId);
if (vok) return true;
// 视频学习失败(损坏/网络中断)→ 时间加速(若开启)+ 图文兜底
log.warn(' [课件] 视频学习未完成,按图文课件兜底处理');
if (accelEnabled() && CONFIG.TIME_ACCELERATE_SEC > 0) {
await this.accelerateTime(CONFIG.TIME_ACCELERATE_SEC);
}
return await this.learnDocument(docs);
}
// 无视频(图文课件):先加速学习时长(若开启),再按图文停留
if (accelEnabled() && CONFIG.TIME_ACCELERATE_SEC > 0) {
await this.accelerateTime(CONFIG.TIME_ACCELERATE_SEC);
}
// 图文课件
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 = true;
if (video.volume > 0) video.volume = 0;
if (video.paused) {
const p = video.play();
if (p && p.catch) p.catch(() => {
// 播放被阻止 → 保持静音重试(浏览器允许静音自动播放)
try { video.muted = true; video.volume = 0; 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));
// 30 秒内加载不出元数据(时长未知)→ 视频源损坏/网络中断,判定为图文课件返回失败
if (!dur) {
const metaStart = Date.now();
while (Date.now() - metaStart < 30000) {
await sleep(1000);
try { if (video.ended) return true; } catch (e) {}
try { dur = (video.duration && isFinite(video.duration)) ? video.duration : 0; } catch (e) {}
if (dur) break;
}
if (!dur) {
log.warn(' 视频 30s 未加载出元数据(源损坏/网络中断),按图文课件兜底');
Diag.add('[视频] 30s 无元数据,按图文兜底');
return false;
}
}
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 = Date.now(); // 初始化为当前时间:首次周期检查不立即触发(否则视频刚开播就被"服务器已标记完成"打断)
let maxT = 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(' 视频元素被播放器替换,已重新获取');
}
}
// 强制静音(对抗平台播放器重置声音状态)
try { if (!video.muted) video.muted = true; if (video.volume > 0) video.volume = 0; } catch (e) {}
// 完成检测:视频播完后还要验证CMS页面是否确认完成
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) : '?'));
// 验证CMS页面是否确认完成(以第一页显示为准)
StatusPanel.update({ detail: '视频播完,验证CMS完成状态...' });
let cmsConfirmed = false;
for (let check = 0; check < 6; check++) { // 最多检查6次,每次等1分钟
const cmsCheck = await fetchCourseList();
const taskTitle = STORE.getTask();
const cmsCourse = cmsCheck ? cmsCheck.find(c => c.title && taskTitle && (c.title === taskTitle || c.title.indexOf(taskTitle) >= 0 || taskTitle.indexOf(c.title) >= 0)) : null;
if (cmsCourse && cmsCourse.done) {
log.info(` CMS确认完成:${taskTitle}`);
cmsConfirmed = true;
break;
}
// CMS显示未完成 → 多等1分钟让服务器确认
const progress = cmsCourse ? (cmsCourse.progress || '?') : '?';
log.info(` CMS显示${progress}%未完成,等待1分钟确认(第${check + 1}/6次)`);
StatusPanel.update({ detail: `CMS显示${progress}%,等待确认(${(6 - check)}分钟)` });
// 继续播放视频(可能还没被服务器记录完)
try { video.muted = true; video.volume = 0; video.play(); } catch (e) {}
await sleep(60000); // 等1分钟
}
if (cmsConfirmed) {
StatusPanel.update({ detail: 'CMS确认完成,切下一课件' });
return true;
} else {
// 6分钟后仍未完成 → 也结束(避免无限等待)
log.warn(` CMS 6分钟后仍未确认完成,按已完成处理`);
StatusPanel.update({ detail: 'CMS未确认,按已完成处理' });
return true;
}
}
// 每 COMPLETION_CHECK_MIN 分钟检查一次平台完成状态:已完成 → 提前结束跳下一个
if (itemId && Date.now() - lastCheckTs >= CONFIG.COMPLETION_CHECK_MIN * 60 * 1000) {
// 视频正在正常播放(近期有推进)→ 不提前结束,让它自然播完
// (否则服务器旧记录会让刚开播的视频几秒就被"标记完成"打断)
if (Date.now() - lastProgressAt < 45000) {
lastCheckTs = Date.now();
continue;
}
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 (t > maxT) maxT = t;
// 已播到较靠后,然后位置跳回开头附近 → 判定已完整播完一遍(平台自动重播)
if (maxT > 30 && t > 0 && t < 5 && t < maxT - 30) {
log.info(` 视频已播放到 ${Math.round(maxT)}s 后回到开头,判定已完整播放`);
Diag.add('[视频] 播完一遍后重播,判定完成 maxT=' + Math.round(maxT));
StatusPanel.update({ detail: '视频已完整播放' });
return true;
}
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; if (video.volume > 0) video.volume = 0; } 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;
},
};
/* ===================== 跨域课件辅助(kjcourse-cmatc.cma.cn) =====================
* 课件内容可能托管在跨域 iframe(如 kjcourse),主脚本无法直接访问其 DOM。
* 配套脚本"气象培训课件域辅助"在该域名内运行:关闭欢迎弹窗、播放视频,
* 并通过 postMessage 上报到顶层窗口,主脚本在此接收状态。
*/
const CwHelper = {
last: null,
video: null,
popupVisible: false,
_prevPopup: false,
listening: false,
start() {
if (this.listening) return;
this.listening = true;
window.addEventListener('message', (e) => {
try {
const d = e.data;
if (!d || d.source !== 'qxj_cw_helper') return;
this.last = d;
this.video = d.video || null;
this.popupVisible = !!d.popupVisible;
if (this._prevPopup && !this.popupVisible) {
log.info('[跨域] 欢迎弹窗已被辅助脚本关闭');
StatusPanel.update({ detail: '跨域欢迎弹窗已关闭' });
}
this._prevPopup = this.popupVisible;
log.debug('[跨域] 辅助脚本上报:video=' + (this.video && this.video.found ? '有' : '无') + ' popup=' + this.popupVisible);
} catch (err) {}
});
log.info('跨域辅助监听已启动');
},
// 等待辅助脚本汇报视频(弹窗关闭后视频可能需要几秒才出现)
waitVideo(timeoutMs) {
return new Promise((resolve) => {
const start = Date.now();
const check = () => {
if (this.video && this.video.found) return resolve(this.video);
if (Date.now() - start >= timeoutMs) return resolve(null);
setTimeout(check, 500);
};
check();
});
},
};
/* ===== 跨域课件域(kjcourse)辅助:单脚本集成 =====
* 课件内容可能托管在跨域 iframe(kjcourse-cmatc.cma.cn),主脚本无法跨域访问其 DOM。
* 该域内运行下方 runKjCourseHelper():点击"开始学习"触发播放器初始化 → 清理弹窗/灰色遮罩
* → 静音倍速播放视频 → postMessage 上报顶层(CwHelper 接收)。
*/
if (location.hostname === 'kjcourse-cmatc.cma.cn') {
runKjCourseHelper();
return;
}
// 非顶层框架(mainContent/mainFrame 等 iframe)不重复运行主逻辑(原 @noframes 行为)
if (window.top !== window.self) return;
// kjcourse 辅助实现(仅在该域名内被调用)
function runKjCourseHelper() {
const kjLog = (...a) => log.info('[kjcourse]', ...a);
// 暂停状态跨域共享(同一脚本的 GM 存储):暂停时不点击/不续播,避免课程自动刷新
const kjPaused = () => { try { return StatusPanel.isPaused(); } catch (e) { return false; } };
const collectDocs = (root) => {
const docs = [];
const seen = new Set();
const walk = (d) => {
if (!d || seen.has(d)) return;
try { seen.add(d); docs.push(d); } catch (e) { return; }
try {
d.querySelectorAll('iframe').forEach(f => {
try { if (f.contentDocument) walk(f.contentDocument); } catch (e) {}
});
} catch (e) {}
};
walk(root || document);
return docs;
};
// 灰色遮罩:只删"全屏 + 深色半透明 + 几乎无文字"的固定层
const removeShade = () => {
let removed = false;
for (const doc of collectDocs(document)) {
if (!doc) continue;
let winW = 0, winH = 0;
try { winW = doc.defaultView ? doc.defaultView.innerWidth : 0; winH = doc.defaultView ? doc.defaultView.innerHeight : 0; } catch (e) {}
if (!winW) continue;
doc.querySelectorAll('div').forEach(el => {
if (!el.isConnected) return;
let st = null;
try { st = getComputedStyle(el); } catch (e) {}
if (!st || st.position !== 'fixed') return;
const r = el.getBoundingClientRect();
if (r.width < winW * 0.7 || r.height < winH * 0.7) return;
const bg = st.backgroundColor || '';
const alpha = (st.opacity || '1') * 1;
const darkShade = /rgba\(\s*0,\s*0,\s*0,\s*(0?\.\d+)/.test(bg) || (bg === 'rgb(0, 0, 0)' && alpha < 1);
if (!darkShade) return;
if ((el.textContent || '').trim().length > 30) return;
try { el.remove(); } catch (e) { try { el.style.display = 'none'; } catch (e2) {} }
kjLog('已移除灰色遮罩层(' + String(el.className || '').slice(0, 40) + ')');
removed = true;
});
}
return removed;
};
const removePopup = () => {
let removed = false;
for (const doc of collectDocs(document)) {
if (!doc) continue;
const cont = doc.querySelector('.continue');
if (cont && cont.isConnected) {
try { cont.remove(); } catch (e) { try { cont.style.display = 'none'; } catch (e2) {} }
kjLog('已移除欢迎弹窗容器');
removed = true;
}
}
return removeShade() || removed;
};
const findVideo = () => {
let first = null;
for (const doc of collectDocs(document)) {
if (!doc) continue;
const vs = doc.querySelectorAll('video');
for (const v of vs) {
if (!first) first = v;
if (v.currentSrc || v.src) return v; // 优先有有效源的视频
}
}
return first;
};
// 上报状态到顶层(主逻辑 CwHelper 在顶层监听);paused=true 时仅上报、不干预播放
// 温和续播:视频暂停超过 4 秒才强制播放——避免每 1 秒一次的 play() 与平台播放器的
// 缩放/全屏/暂停逻辑冲突(一缩放就把视频"抢"到播放,导致缩放后不播放)
let lastForcePlay = 0;
const report = (paused) => {
try {
const v = findVideo();
const payload = { source: 'qxj_cw_helper', url: location.href.slice(0, 150) };
if (v) {
payload.video = {
found: true, paused: v.paused, muted: v.muted,
currentTime: v.currentTime, duration: v.duration, ended: v.ended,
readyState: v.readyState, networkState: v.networkState,
};
if (!paused) {
// 强制静音(muted + volume 双保险)
try { if (!v.muted) v.muted = true; if (v.volume > 0) v.volume = 0; } catch (e) {}
const now = Date.now();
if (v.paused && !v.ended && now - lastForcePlay > 4000) {
lastForcePlay = now;
const p = v.play();
if (p && p.catch) p.catch(() => { try { v.muted = true; v.volume = 0; v.play(); } catch (e2) {} });
}
// 倍速只在正常播放中恢复(缩放/全屏过程不干预)
if (!v.paused && v.playbackRate < 2 && now - lastForcePlay > 2000) {
try { v.playbackRate = 2; } catch (e) {}
}
}
} else {
payload.video = { found: false };
}
payload.popupVisible = document.querySelector('.continue') !== null;
try { window.top.postMessage(payload, '*'); } catch (e) {}
} catch (e) {}
};
// ===== 点击"开始学习"触发播放器初始化 =====
// 时机很重要:播放器框架可能晚于页面就绪。策略:等 1.5s 后开始,
// 每 2.5s 点一次,最多点 6 次(约 16s 窗口);视频一旦有了源立刻停止;
// 结束后才清理弹窗/遮罩(避免提前移除导致后续无法再点)。
let clickCount = 0;
let decided = false;
const videoHasSrc = () => {
try {
const v = findVideo();
if (!v) return false;
const s = v.currentSrc || v.src || '';
return /^https?:\/\//.test(s); // 必须是真实 http(s) 源,空/占位 src 不算
} catch (e) { return false; }
};
const tryClickOnce = () => {
if (kjPaused()) return false; // 暂停时不点击"开始学习",避免课程自动刷新
const BTN_TEXT = /^(开始学习|视频续播|继续播放|继续学习)$/;
for (const doc of collectDocs(document)) {
if (!doc) continue;
const btn = doc.querySelector('.continue .user_choise');
if (btn) {
clickCount++;
try { btn.click(); } catch (e) {}
kjLog('已点击"开始学习"(第' + clickCount + '次,触发播放器初始化)');
return true;
}
// 文本匹配:"开始学习/视频续播/继续播放"等变体按钮
const els = doc.querySelectorAll('div, a, button, span, p');
for (const el of els) {
const t = (el.textContent || '').trim();
if (!BTN_TEXT.test(t)) continue;
if (el.children.length > 0 || el.offsetParent === null) continue;
clickCount++;
try { el.click(); } catch (e) {}
kjLog('已点击"' + t + '"(第' + clickCount + '次)');
return true;
}
}
return false;
};
const finish = (why) => {
if (decided) return;
decided = true;
removePopup();
report(kjPaused());
kjLog(why);
};
// 点击阶段:先点"开始学习"(触发播放器初始化让视频源加载),点完/超时才清理弹窗/遮罩。
// 注意:点击阶段结束前绝不移除弹窗容器——移除了按钮就点不到,视频就加载不出来。
setTimeout(() => {
if (decided) return;
if (videoHasSrc()) return finish('视频源已加载,清理弹窗/遮罩');
tryClickOnce();
const iv = setInterval(() => {
if (decided) { clearInterval(iv); return; }
if (videoHasSrc()) { clearInterval(iv); return finish('视频源已加载,清理弹窗/遮罩'); }
if (clickCount >= 6) { clearInterval(iv); return finish('点击6次仍未加载视频源,按图文处理'); }
tryClickOnce();
}, 2500);
setTimeout(() => { if (!decided) { clearInterval(iv); finish('点击阶段超时,清理弹窗/遮罩'); } }, 30000);
}, 800);
// 常规轮询:持续上报;点击阶段结束(decided)后才清理可能重现的弹窗/遮罩
let lastAct = 0;
const act = () => {
const now = Date.now();
if (now - lastAct < 1000) return;
lastAct = now;
const paused = kjPaused();
try {
// 暂停时不清理(防课程自动刷新),仅上报状态
if (!paused && decided) {
if (document.querySelector('.continue')) removePopup();
}
} catch (e) {}
report(paused);
};
const mo = new MutationObserver(act);
mo.observe(document.documentElement, { childList: true, subtree: true }); // 去掉 attributes 减少触发
setInterval(act, 3000);
// 强制静音:每 1 秒对所有 video 应用静音(muted + volume 双保险,对抗平台播放器重置)
setInterval(() => {
try {
for (const doc of collectDocs(document)) {
if (!doc) continue;
doc.querySelectorAll('video').forEach(v => {
try { if (!v.muted) v.muted = true; if (v.volume > 0) v.volume = 0; } catch (e) {}
});
}
} catch (e) {}
}, 1000);
kjLog('跨域课件域辅助已启动');
}
/* ===================== 入口:按域名分流 ===================== */
const host = location.hostname;
log.info('脚本版本 1.11.26 已注入:', host, location.href);
Diag.add('[注入] 版本 1.11.26 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|getMyTrainingClass|classList|classDetail|classCourseDetail/.test(p + location.hash)
|| document.querySelector('a.btn-link.myCourse, a.btn-link.getMyTrainingClass, #courseList, ul#courseList, #classList, #classDetailCourseId') !== 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(), 5000);
// MutationObserver 专门监听欢迎弹窗(需要立即关闭,不能等5秒)
this.welcomeObserver = new MutationObserver(() => this.scanWelcome());
this.welcomeObserver.observe(document.documentElement, { childList: true, subtree: true });
// 递归监听所有可访问文档(含嵌套 iframe):欢迎弹窗可能出现在 mainContent 深层 iframe 里
this.installWelcomeObservers();
log.info('DialogDismisser 已启动(每5s扫描 + 欢迎弹窗实时监听)');
},
// 对每个可访问文档(含嵌套 iframe)安装欢迎弹窗监听;新 iframe 出现时补装
installWelcomeObservers() {
const seen = new WeakSet();
const install = (win) => {
try {
if (seen.has(win)) return;
seen.add(win);
const doc = win.document;
const mo = new MutationObserver(() => {
// 性能优化:先廉价检查本文档是否有弹窗痕迹,没有就不做全文档扫描
try {
if (StatusPanel.isPaused()) return; // 暂停时不点击(防课程自动刷新)
if (!doc.querySelector('.continue')) return;
} catch (e) { return; }
const now = Date.now();
if (now - (this._lastWelcomeScan || 0) > 500) {
this._lastWelcomeScan = now;
try { Courseware.tryCloseWelcomePopup(false); } catch (e) {}
}
});
mo.observe(doc.documentElement, { childList: true, subtree: true }); // 去掉 attributes:属性频繁变化会大量触发
doc.querySelectorAll('iframe').forEach(f => {
try { if (f.contentDocument) install(f.contentWindow); } catch (e) {}
});
} catch (e) {}
};
install(window);
if (this._welcomeObsTimer) clearInterval(this._welcomeObsTimer);
this._welcomeObsTimer = setInterval(() => install(window), 2000);
},
// 专门检测欢迎弹窗( MutationObserver 触发,立即响应)
scanWelcome() {
try {
if (StatusPanel.isPaused()) return; // 暂停时不关闭欢迎弹窗(防自动点击刷新课程)
if (!document.querySelector('.continue')) return; // 廉价过滤
if (Courseware.tryCloseWelcomePopup(false)) {
StatusPanel.update({ detail: '已关闭欢迎弹窗' });
}
} catch (e) {}
},
// 用 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 {
// 手动暂停时:仅继续识别登录超时弹窗(绝不点击),不再点击任何提醒/欢迎弹窗,
// 避免触发课程自动刷新/续播
const paused = StatusPanel.isPaused();
// 仅检查顶层 + 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 (paused) continue; // 暂停中不点击(防止课程自动刷新/续播)
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 = true; if (v.volume > 0) v.volume = 0; } catch (e) {}
try { v.play(); } catch (e) {}
log.info('[弹窗] 弹窗关闭后恢复视频播放');
}
});
});
} catch (e) {}
}, 1000);
}
}
}
}
// 欢迎学习弹窗(自定义HTML弹窗,非layui-layer):递归扫描所有可访问文档(5s一次全量,兜底文本变体)
if (!paused) {
try {
if (Courseware.tryCloseWelcomePopup(true)) {
StatusPanel.update({ detail: '已关闭欢迎弹窗' });
}
} catch (e) {}
}
} 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) {}
// 课程已学完:清除 GM 课程登记,允许 CMS 为"重刷"再次导航本窗口
try { GM_setValue('qxj_cur_course', ''); } catch (e) {}
// 多窗口模式:直接关闭标签页
try {
if (GM_getValue('qxj_multi_window', false)) {
window.close();
return;
}
} catch (e) {}
// 单窗口模式:不关闭标签页(供 CMS 复用)。用 document.open 替换文档(清掉所有
// beforeunload 处理器,不触发"离开此网站"确认框),显示"学习完成"页。
// 若 URL 已变化(CMS 已抢先导航到下一门课),则不动,让新课程正常加载。
try {
if (window.__qxjLearnUrl && location.href !== window.__qxjLearnUrl) return;
document.open();
document.write('