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