// ==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('

学习完成

正在自动继续…