// ==UserScript== // @name 气象培训平台自动学习 // @namespace cma.meteorology.autolearn // @version 1.11.27 // @description 自动遍历"我的自选课"未完成课程,跳过已学完课程,进入学习空间逐个学习课件;以课件页 s_point 对勾/completestate 判断完成状态并断点续学;登录超时弹窗绝不点击;会话失效时自动停止跳转;弹窗被拦截时暂停等待用户允许。已集成跨域课件域(kjcourse)辅助:自动关闭欢迎弹窗/遮罩、触发视频播放器并静音倍速播放。单脚本,适配 ScriptCat / Tampermonkey。 // @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)); } // 手动干预检测:用户自己点击课程/班级/课件/翻页/标签时,立即暂停自动流程并清除断点, // 尊重手动选择,避免脚本抢占跳转到它认为要学的课程(isTrusted 只认真实用户点击,脚本 click 不触发) function installManualTakeoverWatch() { document.addEventListener('click', (e) => { try { if (!e || e.isTrusted !== true) return; const t = e.target; if (!t || typeof t.closest !== 'function') return; const sel = 'a.btn-solid-primary, a.btn-theme, a.btn-link.myCourse, a.btn-link.getMyTrainingClass, ' + '.s_point, [completestate], li.learn-item, li.courseware-item, li.tree-node[data-itemid], ' + 'a.pg-link, a.pg-next, a.pg-prev, .tabs-link'; if (!t.closest(sel)) return; if (!StatusPanel.isRunning() || StatusPanel.isPaused()) return; StatusPanel.setPaused(true); STORE.clearProgress(); // 手动接管后,旧断点续学位置作废 StatusPanel.update({ status: '已暂停', detail: '手动选择,已暂停自动学习' }); StatusPanel.render(); log.info('检测到手动选择,已暂停自动学习(尊重手动操作;点「开始」恢复)'); notify('已暂停自动学习', '检测到手动操作,自动学习已暂停;点「开始」可恢复'); } catch (e) {} }, true); } /* ===================== 跨标签页协调(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;优先未结束的,绝不返回已结束的旧视频—— // 否则会把上个课件残留的 ended 视频当"本课件已学完") findVideo(docs) { const vs = this.queryAll('video', docs); const live = vs.filter(v => !v.ended && v.readyState >= 1); if (live.length) return live[0]; const pending = vs.filter(v => !v.ended); if (pending.length) return pending[0]; return 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 && (cur.duration && isFinite(cur.duration) && maxT >= cur.duration - 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 { // 跨域欢迎弹窗仍在/视频始终未出现 → 不能按图文算完成(视频其实没播) if (CwHelper.popupVisible) { log.warn(' [课件] 跨域欢迎弹窗未关闭且未发现视频,判定未完成(交由重学轮处理)'); StatusPanel.update({ detail: '跨域课件未完成,交由重学' }); return false; } 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; // 记录看过的最大播放位置(识别"播完一遍后自动重播") let sawPlayback = false; // 本课件是否真实播放推进过(杜绝"没学完却显示完成") let reloadCount = 0; // 视频 reload 次数(reload 会从头重载,必须限量,否则形成"一直刷新"死循环) let lastRateTs = 0; // 倍速强制时间戳(避免每秒干预播放器触发刷新) let recoveryTried = false; // 末尾收尾回退是否已尝试(只试一次,防止循环 seek) 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) {} // —— 完成检测:只认真实播放完成,杜绝"没学完却显示完成/学完不退出" —— // 1) 真正 ended;2) 已到末尾且本课件确实播放推进过(sawPlayback) const cT = video.currentTime || 0; const cDur = (video.duration && isFinite(video.duration)) ? video.duration : dur; const atEnd = !!cDur && cT >= cDur - 0.5; if (video.ended || (atEnd && sawPlayback)) { log.info(` 视频播放完成(ended=${video.ended} t=${cT.toFixed(1)}/${cDur ? cDur.toFixed(0) : '?'})`); Diag.add('[视频] 播放完成 ended=' + video.ended + ' t=' + cT.toFixed(1) + '/' + (cDur ? cDur.toFixed(0) : '?')); // 按 itemId 确认平台登记(服务器可能延迟几秒~几十秒),最多4次×15s。 // 不再抓 CMS 课程列表:学习空间 host 上 fetchCourseList 拿不到课程列表, // 且那里确认的是整门课而非本课件,只会白等 6 分钟 StatusPanel.update({ detail: '视频播完,确认平台登记...' }); let itemConfirmed = false; for (let check = 0; check < 4; check++) { let rc = null, cur = null; try { const rec = await LearnRecord.read(); rc = rec ? rec.find(x => x.itemId === itemId) : null; } catch (e) {} try { cur = itemId ? CoursewareList.read().find(x => x.itemId === itemId) : null; } catch (e) {} if ((rc && rc.done) || (cur && cur.done)) { log.info(` 平台已登记完成:${itemId}`); itemConfirmed = true; break; } log.info(` 平台尚未登记完成,15s后复查(第${check + 1}/4次)`); StatusPanel.update({ detail: `等待平台登记完成(${4 - check}次复查)` }); try { video.muted = true; video.volume = 0; video.play(); } catch (e) {} await sleep(15000); } if (itemConfirmed) { StatusPanel.update({ detail: '平台确认完成,切下一课件' }); return true; } else { log.warn(` 平台1分钟内未登记完成,按已完成处理(itemId=${itemId})`); StatusPanel.update({ detail: '平台未登记,按已完成处理' }); return true; } } // 加载即位于末尾(平台断点恢复到结尾)且本课件尚未播放推进 → 回退几秒真实播完, // 否则平台不会登记、下一轮又是"没学完"(只尝试一次,防止循环 seek) if (atEnd && !sawPlayback && !recoveryTried && cDur > 3) { recoveryTried = true; log.warn(` 视频加载即位于末尾(t=${cT.toFixed(1)}/${cDur.toFixed(0)}),回退收尾重播`); StatusPanel.update({ detail: '视频位于末尾,回退收尾重播' }); try { video.currentTime = Math.max(0, cDur - 2); video.muted = true; video.volume = 0; video.play(); } catch (e) {} await sleep(5000); sawPlayback = true; lastProgressAt = Date.now(); continue; } // 每 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; } } // 确保倍速不被播放器重置(每30秒才强制一次,避免频繁干预播放器触发视频刷新) if (Date.now() - lastRateTs > 30000 && video.playbackRate < 2) { lastRateTs = Date.now(); try { video.playbackRate = 2; } catch (e) {} } // 进度跟踪 const t = cT; if (t > maxT) maxT = t; // 已播到接近结尾、然后位置跳回开头附近 → 判定已完整播完一遍(平台自动重播)。 // 必须真的到达过接近结尾(maxT ≥ 时长-30s),避免中途 reload 重置到0被误判"已播完" if (maxT > 30 && t > 0 && t < 5 && t < maxT - 30 && cDur > 0 && maxT >= cDur - 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(); sawPlayback = true; } // 60 秒无进展:先尝试续播;reload 有副作用(视频从头重载),最多 2 次, // 否则会形成"视频一直刷新无法播放/学完不退出"的死循环;仍无进展则按图文兜底 else if (Date.now() - lastProgressAt > 60000 && Date.now() - lastTry > 60000) { lastTry = Date.now(); log.warn(` 视频未推进(t=${t.toFixed(1)},paused=${video.paused},已reload ${reloadCount}/2 次)`); StatusPanel.update({ detail: '视频未推进,尝试续播' }); // 强制静音 + play try { video.muted = true; if (video.volume > 0) video.volume = 0; } catch (e) {} startPlay(); if (video.paused && reloadCount < 2) { reloadCount++; log.warn(` 视频仍暂停,reload 视频(第${reloadCount}/2 次)`); StatusPanel.update({ detail: `视频无进展,reload(${reloadCount}/2)` }); try { video.load(); } catch (e) {} await sleep(1500); startPlay(); lastProgressAt = Date.now() - 30000; } else { // reload 已用完(或视频未暂停却无推进)→ 不再空等2小时,按图文兜底结束 log.warn(` 视频 ${reloadCount} 次 reload 后仍无进展,按图文课件兜底处理`); StatusPanel.update({ detail: '视频无进展,按图文兜底' }); return false; } } // 心跳:每 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.27 已注入:', host, location.href); Diag.add('[注入] 版本 1.11.27 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) {} }); // 手动干预检测:用户自己点击课程/课件时暂停自动流程(尊重手动选课) installManualTakeoverWatch(); // 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('

学习完成

正在自动继续…

'); document.close(); // 注意:不再 5s 后自动 window.close()——CMS 收 markDone 后还会复用本窗口导航到下一门课, // 提前关闭会导致下一门 window.open 非用户手势被拦截。整批学完由 CMS 流程统一关闭。 } catch (e) { try { window.stop(); } catch (e2) {} try { window.close(); } catch (e3) {} } } if (host === CONFIG.CMS_HOST) { runCmsBranch(); } else if (host === CONFIG.LEARN_HOST) { // 学习空间:轮询关闭欢迎弹窗(递归所有可访问文档,含嵌套 iframe) (function watchForWelcomePopup() { let attempts = 0; const timer = setInterval(() => { attempts++; // 性能优化:顶层有弹窗痕迹才扫描;文本变体由 5s 全量扫描兜底 try { // 暂停时不点击"开始学习"(避免课程被自动刷新/重播) if (StatusPanel.isPaused()) return; if (document.querySelector('.continue')) Courseware.tryCloseWelcomePopup(false); else if (attempts % 5 === 0) Courseware.tryCloseWelcomePopup(true); } catch (e) {} if (attempts > 180) clearInterval(timer); // 最多持续3分钟 }, 1000); })(); // 注入时自动启动一次 window.__qxjToken = (window.__qxjToken || 0) + 1; runLearnBranch(window.__qxjToken); // 监听"重启"事件(用户点「开始」按钮触发) window.addEventListener('qxj:restartLearn', () => { window.__qxjToken = (window.__qxjToken || 0) + 1; const token = window.__qxjToken; log.info('=== 学习空间重启(用户触发),token=' + token + ' ==='); StatusPanel.update({ status: '运行中', detail: '重新启动学习' }); runLearnBranch(token); }); } /* ===================================================================== * 分支一:CMS 站(cmatcpx-cmatc.cma.cn) * ===================================================================== */ /* ===== CMS 学习窗口复用:避免每次 window.open 被浏览器拦截 ===== */ // 弹窗被拦截时显示可见链接;点击是真实手势,能正常打开窗口,并自动恢复运行 function showOpenLink(hostOrCourse, url, winName) { try { const hostEl = (hostOrCourse && hostOrCourse.li) ? hostOrCourse.li : (hostOrCourse && hostOrCourse.appendChild) ? hostOrCourse : document.body; const link = document.createElement('a'); link.href = url; link.target = winName || 'qxj_learn_win'; link.textContent = '▶ 点击学习此课程'; link.className = 'qxj-open-link'; hostEl.appendChild(link); link.addEventListener('click', () => { try { StatusPanel.setPaused(false); StatusPanel.render(); } catch (e) {} }); StatusPanel.update({ detail: '弹窗被拦截,请点击蓝色链接' }); notify('弹窗被拦截', '请点击页面上的蓝色链接打开学习页面'); return link; } catch (e) { return null; } } // 从 URL 提取 courseId(兼容 courseId= 与 params.courseId= 两种格式,去掉 ___ 后缀) function extractCourseId(url) { try { const m = String(url || '').match(/[?&](?:params\.)?courseId=([^&]+)/); return m ? decodeURIComponent(m[1]).replace(/___$/, '') : ''; } catch (e) { return ''; } } // 打开学习窗口:优先复用已打开的窗口(固定 name,不会被拦截),失败则给可见链接并暂停 function openLearnWindow(url, course, winName) { winName = winName || 'qxj_learn_win'; try { if (sharedLearnWin && !sharedLearnWin.closed) { // 学习窗口已在目标课程(GM 精确登记)→ 只激活,不导航(防重复导航刷新) const targetId = extractCourseId(url); let gmCourse = ''; try { gmCourse = GM_getValue('qxj_cur_course', ''); } catch (e) {} if (gmCourse && targetId && targetId === gmCourse) { try { sharedLearnWin.focus(); } catch (e) {} log.info('[窗口] 学习窗口已在该课程(GM确认),仅激活'); return true; } // 8秒内已导航过 → 跳过(防刷新循环最后一道闸) try { const lastNav = GM_getValue('qxj_last_nav_ts', 0); if (Date.now() - lastNav < 8000) { log.info('[窗口] 8秒内已导航过学习窗口,跳过本次(防刷新循环)'); return true; } GM_setValue('qxj_last_nav_ts', Date.now()); } catch (e) {} log.info('[窗口] 导航学习窗口 → ' + String(url).slice(0, 130)); try { sharedLearnWin.location.href = url; sharedLearnWin.focus(); } catch (e) {} return true; } } catch (e) { sharedLearnWin = null; } let win = null; try { win = window.open(url, winName); } catch (e) { win = null; } if (win) { sharedLearnWin = win; try { GM_setValue('qxj_last_nav_ts', Date.now()); } catch (e) {} try { win.focus(); } catch (e) {} log.info('[窗口] 打开新学习窗口 → ' + String(url).slice(0, 130)); return true; } // 被拦截:暂停 + 可见链接 StatusPanel.setPaused(true); StatusPanel.render(); showOpenLink(course, url, winName); return false; } // ===== 全局主循环锁(GM 存储,跨标签页)===== // 保证整个浏览器同时只有一个 processAllCourses 在运行,防止多个标签页并发 // (自选课页 + 班级详情页 等)各自开窗/抢窗口 → 重复窗口、反复刷新。 function acquireCmsLoop() { try { if (GM_getValue('qxj_cms_loop', false) === true) { // 锁带时间戳:超过10分钟视为残留(浏览器崩溃/断电),允许接管,避免所有页面永远卡死 const ts = GM_getValue('qxj_cms_loop_ts', 0); if (Date.now() - ts < 10 * 60 * 1000) return false; log.warn('全局运行锁疑似残留(>10分钟),强制接管'); } GM_setValue('qxj_cms_loop', true); GM_setValue('qxj_cms_loop_ts', Date.now()); } catch (e) {} cmsLoopRunning = true; return true; } function releaseCmsLoop() { try { GM_setValue('qxj_cms_loop', false); GM_setValue('qxj_cms_loop_ts', 0); } catch (e) {} cmsLoopRunning = false; } // 时间加速开关(面板"加速"按钮控制):开=向服务器提交学习时长秒完成;关=只能靠真实观看 function accelEnabled() { try { return GM_getValue('qxj_accel', true) === true; } catch (e) { return true; } } // 等待"班级详情页整班学完"标记(由详情页循环结束时设置,用于网络班级上级页面) function waitClassDone(timeoutMs) { return new Promise((resolve) => { const started = Date.now(); (function check() { try { if (GM_getValue('qxj_class_done', false) === true) return resolve(true); } catch (e) {} if (Date.now() - started >= timeoutMs) return resolve(false); setTimeout(check, 1000); })(); }); } async function runCmsBranch() { // 标签页关闭/刷新时释放全局运行锁,避免锁卡死(其它页面永远等不到) window.addEventListener('pagehide', () => releaseCmsLoop()); // 会话失效(/error/*、登录页、页面出现"登录超时"弹窗):记录标记并静默停止,绝不自动跳转 // (在超时页上不做任何可见动作:不挂面板、不弹通知、不点弹窗) if (isSessionDead()) { STORE.setSessionDead(true); log.warn('检测到会话失效(' + location.pathname + '),静默停止自动跳转,等待人工重新登录'); return; } // 业务页(课程列表)正常渲染 → 会话有效,清除失效标记 if (shouldStartOnCms() && STORE.isSessionDead()) { STORE.setSessionDead(false); log.info('业务页正常渲染,会话有效,清除失效标记'); } // 存在失效标记(如刚登出回到首页)→ 不自动跳转,等人工重新登录 if (STORE.isSessionDead()) { log.info('存在会话失效标记,跳过自动跳转'); return; } // 每次进入课程列表页:重置所有脚本状态(干净启动) try { const resetKeys = ['learningDone', 'currentTask', 'autoStart', 'sessionDead', 'qxj_paused', 'qxj_mode', 'qxj_mute', 'qxj_running', 'qxj_retry']; // 保留 qxj_progress:断点续学要在整页刷新后恢复 resetKeys.forEach(k => { try { GM_deleteValue(k); } catch (e) {} }); 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('课程列表页加载,脚本状态已完全重置'); } catch (e) {} // 1. 检测当前页面类型(DOM + URL 双重判断) const isClassPage = !!$('#classList'); // 网络班级列表页 const isCoursePage = !!$('#courseList'); // 自选课页 const isClassDetail = !!$('#classDetailCourseId') || /classDetailNew|classCourseDetail/.test(location.pathname); // 班级详情页 if (isClassPage) log.info('检测到网络班级页面'); if (isCoursePage) log.info('检测到自选课页面'); if (isClassDetail) log.info('检测到班级详情页'); // 2. 仅在课程列表页时启动主控(班级详情页直接进入,不跳转) if (!isClassPage && !isCoursePage && !isClassDetail) { // 不在任何课程页 → 尝试导航到课程页 await goMyCourses(); // 导航后再次检测 const isClassPage2 = !!$('#classList'); const isCoursePage2 = !!$('#courseList'); const isClassDetail2 = !!$('#classDetailCourseId') || /classDetailNew|classCourseDetail/.test(location.pathname); if (!isClassPage2 && !isCoursePage2 && !isClassDetail2) { log.info('当前不在课程列表页,脚本待命。'); return; } } // 监听"开始"事件(用户点面板按钮触发;全局已有主循环运行时忽略,避免重复开窗/刷新) window.addEventListener('qxj:start', () => { if (!acquireCmsLoop()) { log.info('=== 已有学习流程在运行(本页或其它标签页),忽略本次「开始」 ==='); StatusPanel.update({ detail: '已有学习流程在运行,无需重复启动' }); return; } // 用户手势内(事件同步派发)预开学习窗口:直接打开第一个待学课程, // 避免先弹出 about:blank 空白页,也避免后续 window.open 被浏览器拦截。 // 所有模式都预开(多窗口模式把它算作第一个标签,不会多开窗口)。 // 用唯一窗口名:绝不复用/误跳其它标签页已打开的学习窗口(防止互相导航 → 刷新循环) try { if (!sharedLearnWin || sharedLearnWin.closed) { let firstUrl = ''; try { if (document.getElementById('classList')) { // 网络班级页:预开第一个班级详情 const firstCard = $$('#classList > li.cell')[0]; if (firstCard) { const btn = $('a.btn-solid-primary, a.btn-theme', firstCard); const onclick = btn ? (btn.getAttribute('onclick') || '') : ''; const m = onclick.match(/openClassByIp\(['"]([^'"]+)['"]\)/); if (m) firstUrl = 'https://' + CONFIG.CMS_HOST + '/cms/classDetailNew.htm?classId=' + m[1] + '&prePage=getMyTrainingClass'; } } else { // 自选课页:找第一个未完成课程 const firstLi = $$('#courseList > li.cell').find(li => { const proEl = li.querySelector('span[id^="pro_"]'); let progress = 0; if (proEl) { const t = (proEl.textContent || '').replace(/[%%]/g, '').trim(); progress = /^\d+$/.test(t) ? parseInt(t, 10) : 0; } return progress < 100; }); if (firstLi) { const btn = $$('a.btn-solid-primary', firstLi).find(a => /开始学习|继续学习/.test(a.textContent)) || $('a.btn-solid-primary', firstLi); if (btn) firstUrl = parseEnterCourseUrl(btn); } } } catch (e) {} const win = window.open(firstUrl || 'about:blank', 'qxj_pre_' + Date.now()); if (win) { sharedLearnWin = win; preOpenedFirst = { win, courseId: extractCourseId(firstUrl) }; } } } catch (e) {} const token = window.__qxjToken; log.info('=== CMS 主控启动(用户触发),token=' + token + ' ==='); notify('气象自动学习已启动', '将自动遍历课程并学习未完成项'); try { StatusPanel.setPaused(false); StatusPanel.render(); } catch (e) {} // 清除残留暂停 processAllCourses(token).then(() => { releaseCmsLoop(); // 仅当本次 token 仍是最新时才更新最终状态 if (window.__qxjToken !== token) { log.info('旧流程(token=' + token + ')已结束,不更新状态'); return; } log.info('=== 全部课程处理完毕 ==='); StatusPanel.update({ status: '已完成', detail: '所有课程处理完毕' }); StatusPanel.setRunning(false); StatusPanel.render(); notify('学习完成', '所有未完成课程已处理'); // 全部学完:关闭学习窗口 try { if (sharedLearnWin && !sharedLearnWin.closed) sharedLearnWin.close(); } catch (e) {} }).catch((e) => { releaseCmsLoop(); log.err('主控流程异常:', e); }); }); // 班级详情页:自动开始学习(重入保护:用户再点「开始」不会重复启动) const isDetailPage = !!$('#classDetailCourseId') || /classDetailNew|classCourseDetail/.test(location.pathname); if (isDetailPage) { log.info('班级详情页,等待课程列表加载...'); StatusPanel.update({ status: '运行中', detail: '等待课程列表加载...' }); StatusPanel.setRunning(true); StatusPanel.render(); // 等待课程列表加载 const loaded = await waitUntil(() => $$('.class2Li').length > 0, 15000); if (!loaded) { log.warn('课程列表未加载'); StatusPanel.update({ status: '异常', detail: '课程列表未加载' }); return; } const courseCount = $$('.class2Li').length; log.info(`班级详情页课程列表已加载,${courseCount}门课程`); if (courseCount === 0) { StatusPanel.update({ status: '异常', detail: '课程列表为空' }); return; } if (cmsLoopRunning || !acquireCmsLoop()) { // 本页已自动启动过,或其它标签页正在运行:等待锁释放后重试(最多60s) log.info('班级详情页:等待全局运行锁...'); StatusPanel.update({ detail: '等待其它页面的学习流程结束...' }); let got = false; for (let w = 0; w < 60 && !got; w++) { await sleep(1000); if (!cmsLoopRunning && acquireCmsLoop()) got = true; } if (!got) { log.warn('获取运行锁超时,班级详情页暂不启动'); StatusPanel.update({ status: '待命', detail: '其它页面正在运行,请稍后点「开始」' }); return; } } // 清除可能残留的暂停(旧自动暂停/弹窗拦截留下的 qxj_paused),避免主循环卡在"等待开始" try { StatusPanel.setPaused(false); StatusPanel.setRunning(true); StatusPanel.render(); } catch (e) {} window.__qxjToken = (window.__qxjToken || 0) + 1; const token = window.__qxjToken; STORE.clearDone(); StatusPanel.update({ status: '运行中', detail: '自动识别班级详情页,开始学习' }); await processAllCourses(token); try { GM_setValue('qxj_class_done', true); } catch (e) {} // 整班学完标记(网络班级上级页等待) releaseCmsLoop(); if (window.__qxjToken !== token) return; // 全部学完:关闭学习窗口(避免"学习完成"页一直挂着) try { if (sharedLearnWin && !sharedLearnWin.closed) sharedLearnWin.close(); } catch (e) {} StatusPanel.update({ status: '已完成', detail: '所有课程处理完毕' }); StatusPanel.setRunning(false); StatusPanel.render(); notify('学习完成', '所有课程已处理完毕'); return; } // 自选课页面:若之前已在运行则自动恢复 if (StatusPanel.isRunning() && !StatusPanel.isPaused()) { if (!acquireCmsLoop()) { log.info('全局运行锁被占用,跳过自动恢复'); return; } window.__qxjToken = (window.__qxjToken || 0) + 1; const token = window.__qxjToken; STORE.clearDone(); log.info('=== CMS 主控自动恢复,token=' + token + ' ==='); StatusPanel.update({ status: '运行中', detail: '自动恢复中' }); try { await processAllCourses(token); } finally { releaseCmsLoop(); } if (window.__qxjToken !== token) return; StatusPanel.update({ status: '已完成', detail: '所有课程处理完毕' }); StatusPanel.setRunning(false); StatusPanel.render(); notify('学习完成', '所有未完成课程已处理'); // 全部学完:关闭学习窗口 try { if (sharedLearnWin && !sharedLearnWin.closed) sharedLearnWin.close(); } catch (e) {} } else { StatusPanel.update({ status: '待命', detail: '点击「开始」启动学习' }); log.info('脚本已就绪,等待点击「开始」'); } } // 检查当前 token 是否仍为最新(旧流程自动退出) function isCurrentToken(token) { return window.__qxjToken === token; } async function goMyCourses() { // 已在自选课页或网络班级页 if ($('#courseList') || $('#classList')) return; if (STORE.isSessionDead() || isSessionErrorPage()) { log.warn('会话失效,不跳转'); return; } suppressLeaveConfirm(); // 找左侧菜单:优先"我的自选课",其次"我的网络班级" let link = $('a.btn-link.myCourse.active') || $('a.btn-link.myCourse') || $('a.btn-link.getMyTrainingClass.active') || $('a.btn-link.getMyTrainingClass'); if (link) { log.info('点击左侧:' + link.textContent.trim()); link.click(); await waitUntil(() => $('#courseList') || $('#classList'), CONFIG.WAIT_TIMEOUT); return; } // 兜底:跳转自选课 log.info('未找到按钮,直接访问自选课地址'); location.href = 'https://' + CONFIG.CMS_HOST + CONFIG.MY_COURSE_HREF; } // 点击"未完成"标签页,让服务器只返回未完成的课程(避免遍历大量已完成课程浪费时间) async function switchToUnfinishedTab() { const notFinishTab = $('#getNotFinish'); if (!notFinishTab) { log.debug('未找到"未完成"标签,跳过切换'); return; } // 检查当前是否已经在"未完成"标签(active 状态) const activeLink = notFinishTab.querySelector('.tabs-link.active'); if (activeLink) { log.info('已在"未完成"标签页,无需切换'); return; } log.info('普通模式:切换到"未完成"标签,过滤已完成课程'); Diag.add('[流程] 切换到"未完成"标签'); StatusPanel.update({ detail: '切换到"未完成"标签' }); try { // 多种方式尝试触发 getCourseNoFinish() // 1. 直接调用平台 JS 函数(最可靠) if (typeof getCourseNoFinish === 'function') { log.info('直接调用 getCourseNoFinish()'); getCourseNoFinish(); } else { // 2. 点击
  • 子元素(a.tabs-link) const link = notFinishTab.querySelector('a.tabs-link') || notFinishTab.querySelector('a'); if (link) { log.info('点击 标签触发'); link.click(); } else { // 3. 兜底:点击
  • notFinishTab.click(); } } // 等待 AJAX 刷新课程列表 await sleep(1000); // 验证标签是否切换成功(active 应在"未完成"上) const checkActive = notFinishTab.querySelector('.tabs-link.active'); if (checkActive) { log.info('标签切换成功:"未完成"已激活'); } else { log.warn('标签切换可能未成功,尝试兜底调用'); // 兜底:再次尝试直接调用 if (typeof getCourseNoFinish === 'function') { getCourseNoFinish(); await sleep(1000); } } await waitUntil(() => $('#courseList > li.cell'), CONFIG.WAIT_TIMEOUT); await sleep(500); log.info('已切换到"未完成"标签页,课程列表已刷新'); Diag.add('[流程] "未完成"标签切换完成'); } catch (e) { log.warn('切换"未完成"标签失败:', e); Diag.add('[流程] 标签切换异常:' + e); } } async function processAllCourses(token) { let page = 1; let totalLearned = 0; let totalAllCourses = 0; Diag.add('[流程] CMS 遍历开始(token=' + token + ')'); // 恢复断点续学进度 const saved = STORE.loadProgress(); let resumeCourse = ''; // 需要恢复的课程标题 let resumeIdx = 0; // 该课程在当前页的索引 if (saved && saved.page >= 1) { page = saved.page; totalLearned = saved.learned || 0; totalAllCourses = saved.total || 0; resumeCourse = saved.courseTitle || ''; resumeIdx = saved.courseIdx || 0; log.info(`【断点续学】恢复到第${page}页,已学${totalLearned}门,目标课程:${resumeCourse || '(从头)'}`); StatusPanel.update({ detail: `断点续学:第${page}页${resumeCourse ? ' → ' + resumeCourse.slice(0, 15) : ''}` }); } // === 网络班级页面:点击"未通过"→ 选择"培训中" → 进入班级 → 读取课程 === if ($('#classList')) { log.info('【网络班级】进入网络班级流程'); StatusPanel.update({ detail: '网络班级:准备切换标签' }); // 1. 点击"未通过"标签 const noPassTab = $('#getNoPassClass'); if (noPassTab) { log.info('点击"未通过"标签'); noPassTab.click(); await sleep(1500); } // 2. 选择"培训中"状态(code="1") const trainingStatus = $('span.code-1, span[code="1"]'); if (trainingStatus) { log.info('选择"培训中"状态'); trainingStatus.click(); await sleep(1500); } // 3. 读取班级列表 await waitUntil(() => $('#classList > li.cell'), CONFIG.WAIT_TIMEOUT); const classCards = $$('#classList > li.cell'); log.info(`【网络班级】找到 ${classCards.length} 个班级`); // 4. 逐个进入班级学习 for (const card of classCards) { if (!isCurrentToken(token)) return; await StatusPanel.waitIfPaused(); if (!isCurrentToken(token)) return; const titleEl = $('.title', card); const className = titleEl ? titleEl.textContent.trim() : '未知班级'; const enterBtn = $('a.btn-solid-primary, a.btn-theme', card); StatusPanel.update({ course: className, detail: '进入班级' }); log.info(`【网络班级】进入:${className}`); // 点击"进入班级" if (enterBtn) { const onclick = enterBtn.getAttribute('onclick') || ''; const classIdMatch = onclick.match(/openClassByIp\(['"]([^'"]+)['"]\)/); if (classIdMatch) { // 用 window.open 打开班级详情页 const classUrl = '/cms/classDetailNew.htm?classId=' + classIdMatch[1] + '&prePage=getMyTrainingClass'; const fullUrl = 'https://' + CONFIG.CMS_HOST + classUrl; STORE.clearDone(); STORE.setTask(className); try { GM_setValue('qxj_class_done', false); } catch (e) {} // 重置整班完成标记 if (!openLearnWindow(fullUrl, card, 'qxj_class_win')) { await StatusPanel.waitIfPaused(); if (!openLearnWindow(fullUrl, card, 'qxj_class_win')) { log.warn('仍无法打开学习窗口,跳过:' + className); continue; } } log.info(`[网络班级] 已打开班级:${className}`); // 等待班级详情页整班学完(详情页自动启动自己的循环并设置 qxj_class_done) await sleep(5000); // 等页面加载 releaseCmsLoop(); // 让班级详情页能获取运行锁、自动启动 const ok = await waitClassDone(CONFIG.CMS_WAIT_LEARN_SEC * 1000); if (!acquireCmsLoop()) { log.warn('运行锁被其它页面占用,网络班级流程停止'); return; } if (ok) log.info(`[网络班级] 班级完成:${className}`); else log.warn(`[网络班级] 班级超时:${className}`); STORE.clearDone(); } else { // 兜底:直接点击 enterBtn.click(); await sleep(5000); } } } log.info('【网络班级】所有班级处理完毕'); return; } // === 班级详情页:读取课程列表 === const isClassDetail = !!$('#classDetailCourseId') || /classDetailNew|classCourseDetail/.test(location.pathname); if (isClassDetail || /classDetailNew/.test(location.pathname)) { log.info('【班级详情】读取课程列表'); // 等待课程列表加载(可能需要几秒) await waitUntil(() => $$('.class2Li').length > 0, 15000); // 普通模式:切到"未完成"过滤(只显示未完成的课程,聚焦剩余课程、减少翻页) if (!StatusPanel.isReview()) { try { const notFinishTab = $('#courseStatusUnfinished') || document.querySelector('[onclick*="courseStatusUnfinished"]'); if (notFinishTab) { notFinishTab.click(); await sleep(1500); await waitUntil(() => $$('.class2Li').length > 0, CONFIG.WAIT_TIMEOUT); log.info('【班级详情】已切换到"未完成"过滤'); } } catch (e) {} } // 断页续学:若上次翻页触发整页刷新(脚本重跑回到第1页),跳回目标页 let classPage = 1; try { const wantPage = parseInt(GM_getValue('qxj_class_page', ''), 10) || 0; if (wantPage > 1) { const curPg = ((document.querySelector('a.pagin-link.pagin-cur, .pagin-cur') || {}).textContent || '').trim(); const pageLink = Array.from(document.querySelectorAll('a.pagin-link, a[onclick*="changePage"], a[href*="changePage"]')).find(a => parseInt(a.textContent.trim(), 10) === wantPage); if (pageLink && curPg !== String(wantPage)) { log.info(`【班级详情】检测到上次翻页到第${wantPage}页,先跳回`); pageLink.click(); await sleep(1500); } classPage = wantPage; } } catch (e) {} // 支持分页:逐页读取 class2Li 课程并学习(平台课程列表可能有多页) let emptyRetry = 0; while (classPage <= 50) { const finalCourses = $$('.class2Li'); if (finalCourses.length === 0) { emptyRetry++; log.warn(`【班级详情】第${classPage}页未找到课程(重试${emptyRetry}/3)`); if (emptyRetry >= 3) break; await sleep(3000); continue; } emptyRetry = 0; log.info(`【班级详情】第${classPage}页找到 ${finalCourses.length} 门课程`); totalAllCourses += finalCourses.length; for (const li of finalCourses) { if (!isCurrentToken(token)) return; await StatusPanel.waitIfPaused(); if (!isCurrentToken(token)) return; const titleEl = $('h2 a, .v2-title', li); const title = titleEl ? titleEl.textContent.trim() : '未知课程'; const proEl = li.querySelector('em[id^="pro_"]') || li.querySelector('span[id^="pro_"]'); let progress = 0; if (proEl) { const t = (proEl.textContent || '').replace(/[%%]/g, '').trim(); progress = /^\d+$/.test(t) ? parseInt(t, 10) : 0; } const btn = $('a.btn-theme, a.btn-solid-primary', li); const btnText = btn ? (btn.textContent || '').trim() : ''; if (!proEl && !progress) { if (btnText === '继续学习') progress = 50; else if (!btnText || btnText === '已学完') progress = 100; } // 普通模式跳过已完成 if (progress >= 100 && !StatusPanel.isReview()) { totalLearned++; StatusPanel.update({ learnedCourses: totalLearned, totalCourses: totalAllCourses }); continue; } // 保存断点 → 打开学习 STORE.saveProgress(0, totalLearned, totalAllCourses, title, 0); StatusPanel.update({ course: title, detail: '准备学习' }); // 解析学习URL const onclick = btn ? (btn.getAttribute('onclick') || '') : ''; const urlMatch = onclick.match(/enterCourse\(\s*['"]([^'"]+)['"]/); if (urlMatch) { const url = urlMatch[1].replace(/&/g, '&'); // 防重复开窗:本页没有可用学习窗口、且该课程已在其它窗口学习中 → 跳过 try { const gmCourse = GM_getValue('qxj_cur_course', ''); const targetId = extractCourseId(url); if ((!sharedLearnWin || sharedLearnWin.closed) && gmCourse && targetId && gmCourse === targetId) { log.info(`[班级课程] ${title} 已在其它窗口学习中,跳过避免重复开窗`); totalLearned++; continue; } } catch (e) {} STORE.clearDone(); STORE.setTask(title); if (!openLearnWindow(url, li)) { await StatusPanel.waitIfPaused(); if (!openLearnWindow(url, li)) { totalLearned++; continue; } } log.info(`[班级课程] 打开:${title}`); const ok = await waitLearnDone(CONFIG.CMS_WAIT_LEARN_SEC * 1000); if (ok) log.info(`[班级课程] 完成:${title}`); else log.warn(`[班级课程] 超时:${title}`); STORE.clearDone(); // 跳转缓冲:12 秒倒计时(给下一门视频加载留时间),面板显示倒计时 for (let s = 12; s > 0; s--) { StatusPanel.update({ detail: `准备下一门课程… ${s}s` }); await sleep(1000); } totalLearned++; StatusPanel.update({ learnedCourses: totalLearned, totalCourses: totalAllCourses }); } else { log.warn(`[班级课程] 未解析到URL:${title}`); totalLearned++; } } // 翻到下一页(平台分页组件:a.pagin-link / changePage 链接) const pageAnchors = Array.from(document.querySelectorAll('a.pagin-link, a[onclick*="changePage"], a[href*="changePage"]')); let nextLink = pageAnchors.find(a => /下一页|下页/.test(a.textContent || '')) || null; if (!nextLink) { const cur = document.querySelector('a.pagin-link.pagin-cur, .pagin-cur'); const curNum = cur ? parseInt(cur.textContent.trim(), 10) : 0; nextLink = pageAnchors.find(a => parseInt(a.textContent.trim(), 10) === curNum + 1) || null; } if (!nextLink || /disabled/i.test(nextLink.className || '')) { log.info(`【班级详情】没有下一页,共 ${classPage} 页`); try { GM_setValue('qxj_class_page', ''); } catch (e) {} break; } // 记录当前页第一条课程特征,用于确认翻页成功(AJAX 内容替换) const sigBefore = ($$('.class2Li')[0] || {}).outerHTML || ''; try { GM_setValue('qxj_class_page', String(classPage + 1)); } catch (e) {} // 整页刷新时跳回用 nextLink.click(); log.info(`【班级详情】点击翻页 → 第 ${classPage + 1} 页`); const pageOk = await waitUntil(() => { const first = $$('.class2Li')[0]; return first && first.outerHTML !== sigBefore; }, CONFIG.WAIT_TIMEOUT); if (!pageOk) { log.warn(`【班级详情】翻页后课程列表未变化,可能整页刷新,停止翻页`); try { GM_setValue('qxj_class_page', ''); } catch (e) {} break; } await sleep(500); classPage++; } // 全部刷完/重刷闭环:复查还有未完成的课程则再来一轮(带轮次上限,防死循环) try { const allFinish = GM_getValue('qxj_all_finish', false); const maxRounds = allFinish ? 5 : Math.min(3, StatusPanel.getRetry() + 1); if (classRetryCount < maxRounds) { // 重新切回"未完成"过滤,直接数页面上未完成的课程(默认列表第一页数不到第二页的未完成课) let left = 0; try { const tab = $('#courseStatusUnfinished') || document.querySelector('[onclick*="courseStatusUnfinished"]'); if (tab) { tab.click(); await sleep(1500); } left = $$('.class2Li').filter(li => { const proEl = li.querySelector('em[id^="pro_"]') || li.querySelector('span[id^="pro_"]'); if (!proEl) return false; const t = (proEl.textContent || '').replace(/[%%]/g, '').trim(); return /^\d+$/.test(t) && parseInt(t, 10) < 100; }).length; } catch (e) {} if (left > 0 && isCurrentToken(token)) { classRetryCount++; log.warn(`【班级详情】还有 ${left} 门未完成(第${classRetryCount}轮),继续学习`); StatusPanel.update({ detail: `还有${left}门未完成,第${classRetryCount}轮` }); await sleep(2000); return await processAllCourses(token); } } } catch (e) {} classRetryCount = 0; log.info('【班级详情】课程处理完毕'); return; } // === 自选课页面 === // 普通模式:先点击"未完成"标签 if (!StatusPanel.isReview() && isCurrentToken(token)) { await switchToUnfinishedTab(); if (!isCurrentToken(token)) return; } // 诊断:打印当前标签状态和课程列表信息 try { const activeTab = $('.tabs-link.active'); const activeTabText = activeTab ? activeTab.textContent.trim() : '(无)'; const courseCount = $$('#courseList > li.cell').length; const totalPages = getTotalPages(); log.info(`[诊断] 当前标签:${activeTabText},课程数:${courseCount},总页数:${totalPages}`); Diag.add('[诊断] 标签=' + activeTabText + ' 课程数=' + courseCount + ' 总页=' + totalPages); // 初始化面板总页数 StatusPanel.update({ totalPages: totalPages, learnedCourses: totalLearned, totalCourses: totalAllCourses }); } catch (e) {} // 断点续学:跳到保存的页码 if (page > 1) { log.info(`【断点续学】跳转到第${page}页`); for (let p = 1; p < page; p++) { if (!isCurrentToken(token)) return; if (!await gotoNextPage()) { log.warn('断点续学翻页失败,从第1页开始'); page = 1; break; } } } while (page <= CONFIG.MAX_PAGES) { // token 失效则退出(被新启动取代) if (!isCurrentToken(token)) { log.info('旧流程退出(token=' + token + ')'); return; } // 暂停闸门 await StatusPanel.waitIfPaused(); if (!isCurrentToken(token)) return; // 获取总页数(每页可能更新) const curTotalPages = getTotalPages(); StatusPanel.update({ page: `第${page}页`, totalPages: curTotalPages, detail: '读取课程列表' }); log.info(`==== 读取第 ${page} 页 ====`); const cards = readCardsOnPage(); totalAllCourses += cards.length; // 收集待学课程 let idx = 0; let resumed = !resumeCourse; const pending = []; for (const c of cards) { idx++; if (!resumed) { if (resumeCourse && (c.title === resumeCourse || c.title.indexOf(resumeCourse) >= 0 || resumeCourse.indexOf(c.title) >= 0)) { resumed = true; log.info(`【断点续学】找到目标:${c.title}`); } else { totalLearned++; continue; } } if (c.progress >= 100 && !StatusPanel.isReview()) { totalLearned++; continue; } pending.push(c); } if (pending.length === 0) continue; // 读取窗口数 const winCount = GM_getValue('qxj_win_count', 1); if (winCount > 1) { // === 多窗口模式 === GM_setValue('qxj_multi_window', true); // 标记多窗口模式(学习脚本据此决定关闭标签页而非跳转) log.info(`【多窗口】${winCount}个窗口,${pending.length}门待学`); let courseIdx = 0; const tabs = []; // {win, course, courseId} // 优先使用手势预开的第一个窗口(在用户点击内同步打开,不会被拦截),算作第一个标签 if (preOpenedFirst && preOpenedFirst.win && !preOpenedFirst.win.closed && courseIdx < pending.length) { const c = pending[courseIdx++]; const cid = preOpenedFirst.courseId || extractCourseId(parseEnterCourseUrl(c.btn)); tabs.push({ win: preOpenedFirst.win, course: c, courseId: cid }); StatusPanel.update({ detail: `打开1/${winCount}:${c.title}` }); log.info(`[多窗口] 使用预开窗口:${c.title}`); preOpenedFirst = null; } // 逐个打开剩余标签页,每个间隔10秒 for (let i = tabs.length; i < winCount && courseIdx < pending.length; i++) { if (!isCurrentToken(token)) return; const c = pending[courseIdx++]; const url = parseEnterCourseUrl(c.btn); if (!url) { totalLearned++; i--; continue; } STORE.clearDone(); STORE.setTask(c.title); let win = window.open(url, '_blank'); if (!win) { StatusPanel.setPaused(true); StatusPanel.render(); notify('弹窗被拦截', '请点击地址栏弹窗图标选择「始终允许」,然后点继续'); await StatusPanel.waitIfPaused(); win = window.open(url, '_blank'); if (!win) { totalLearned++; i--; continue; } } try { win.focus(); } catch (e) {} tabs.push({ win, course: c, courseId: extractCourseId(url) }); StatusPanel.update({ detail: `打开${i + 1}/${winCount}:${c.title}` }); log.info(`[多窗口] 打开并激活:${c.title}`); if (i < winCount - 1) await sleep(10000); } // 监控:轮流激活 + 检测完成 log.info(`[多窗口] ${tabs.length}个标签页已打开,开始监控`); while (tabs.length > 0) { if (!isCurrentToken(token)) return; await StatusPanel.waitIfPaused(); if (!isCurrentToken(token)) return; // 轮流激活每个标签页 for (let i = 0; i < tabs.length; i++) { try { tabs[i].win.focus(); } catch (e) {} await sleep(2000); // 激活2秒让视频播放 } // 检查完成(按课程ID匹配真正完成的那扇窗口,避免把全局完成标记归给错误的窗口 // ——否则一门课学完会把其它还在学的窗口误关/误跳) if (STORE.isDone()) { let doneCourse = ''; try { doneCourse = GM_getValue('qxj_done_course', ''); } catch (e) {} let doneIdx = -1; for (let i = 0; i < tabs.length; i++) { if (tabs[i].courseId && doneCourse && tabs[i].courseId === doneCourse) { doneIdx = i; break; } } if (doneIdx === -1) { // 完成标记可能来自已自行关闭的窗口 → 仅清除标记 STORE.clearDone(); try { GM_setValue('qxj_done_course', ''); } catch (e) {} continue; } const doneTab = tabs[doneIdx]; log.info(`[多窗口] 完成:${doneTab.course.title}`); // 多等1分钟让服务器确认(99%问题) for (let w = 60; w > 0; w -= 10) { StatusPanel.update({ detail: `等待服务器确认(${w}秒):${doneTab.course.title}` }); await sleep(10000); } totalLearned++; STORE.clearDone(); try { GM_setValue('qxj_done_course', ''); } catch (e) {} STORE.saveProgress(page, totalLearned, totalAllCourses, '', 0); StatusPanel.update({ learnedCourses: totalLearned, totalCourses: totalAllCourses }); if (courseIdx < pending.length) { // 还有课 → 跳转到下一门(窗口已自行关闭则开新窗口补课) const nc = pending[courseIdx++]; const nurl = parseEnterCourseUrl(nc.btn); if (nurl) { STORE.clearDone(); STORE.setTask(nc.title); if (doneTab.win.closed) { const nwin = window.open(nurl, '_blank'); if (nwin) { doneTab.win = nwin; try { nwin.focus(); } catch (e) {} } } else { try { doneTab.win.location.href = nurl; } catch (e) {} } doneTab.course = nc; doneTab.courseId = extractCourseId(nurl); log.info(`[多窗口] 补新课程:${nc.title}`); } } else { // 没有新课 → 关闭标签页 try { doneTab.win.close(); } catch (e) {} tabs.splice(doneIdx, 1); } } StatusPanel.update({ detail: `监控中(${tabs.length}个窗口)已完成${totalLearned}门` }); await sleep(10000); } GM_setValue('qxj_multi_window', false); // 清除多窗口标记 preOpenedFirst = null; log.info(`【多窗口】全部完成`); } else { // === 单窗口模式:逐个学习 === for (const c of pending) { if (!isCurrentToken(token)) return; await StatusPanel.waitIfPaused(); if (!isCurrentToken(token)) return; STORE.saveProgress(page, totalLearned, totalAllCourses, c.title, 0); StatusPanel.update({ page: `第${page}页`, totalPages: curTotalPages, course: c.title, detail: '准备学习' }); await studyOneCourseWithRetry(c, token); if (!isCurrentToken(token)) return; totalLearned++; await sleep(200); StatusPanel.update({ learnedCourses: totalLearned, totalCourses: totalAllCourses }); STORE.saveProgress(page, totalLearned, totalAllCourses, '', 0); } } // 翻页 const total = curTotalPages; if (page >= total) { log.info(`已是最后一页,共 ${total} 页。`); break; } if (!await gotoNextPage()) break; page++; } if (isCurrentToken(token)) { STORE.clearProgress(); const allFinish = GM_getValue('qxj_all_finish', false); const maxRetry = StatusPanel.getRetry(); // 检查未完成课程数 const finalCheck = await fetchCourseList(); const unfinished = finalCheck ? finalCheck.filter(x => !x.done).length : -1; if (unfinished > 0 && (allFinish || maxRetry > 0)) { // "全部刷完"开启 或 重刷次数>0 → 继续处理未完成课程 const maxRounds = allFinish ? 999 : maxRetry; // 全部刷完=无限循环 for (let r = 1; r <= maxRounds; r++) { if (!isCurrentToken(token)) return; log.info(`【继续学习】第${r}轮,剩余${unfinished}门未完成`); StatusPanel.update({ detail: `继续学习第${r}轮,剩余${unfinished}门` }); notify('继续学习', `还有${unfinished}门未完成,开始第${r}轮`); await sleep(2000); if (!isCurrentToken(token)) return; // 重新遍历 await processAllCourses(token); if (!isCurrentToken(token)) return; // 再次检查 const recheck = await fetchCourseList(); const remain = recheck ? recheck.filter(x => !x.done).length : -1; if (remain === 0) { log.info(`【继续学习】第${r}轮后全部完成`); break; } if (remain >= unfinished) { log.warn(`【继续学习】第${r}轮后数量未变(${remain}),停止`); break; } unfinished = remain; } } // 最终状态 const finalRecheck = await fetchCourseList(); const finalUnfinished = finalRecheck ? finalRecheck.filter(x => !x.done).length : -1; if (finalUnfinished === 0) { StatusPanel.update({ status: '已完成', detail: '所有课程已学完' }); notify('学习完成', '所有课程已处理完毕'); } else if (finalUnfinished > 0) { StatusPanel.update({ detail: `处理完毕,仍剩${finalUnfinished}门未完成` }); log.info(`处理完毕,仍剩${finalUnfinished}门未完成`); } else { StatusPanel.update({ detail: `已处理${totalLearned}门课程` }); } StatusPanel.setRunning(false); StatusPanel.render(); // 诚实汇报:还有哪些课未完成 try { const cards = await fetchCourseList(); const left = cards ? cards.filter(x => !x.done) : []; if (left.length) { log.warn('剩余未完成课程 ' + left.length + ' 门:' + left.map(x => x.title + '(' + (x.progress || '?') + '%)').join('、')); Diag.add('[课程] 剩余未完成:' + left.map(x => x.title).join('、')); } } catch (e) {} } } function readCardsOnPage() { const lis = $$('#courseList > li.cell'); return lis.map((li, i) => { const titleEl = $('.title', li); const title = (titleEl && (titleEl.getAttribute('title') || titleEl.textContent.trim())) || '未知课程'; const proEl = $('span[id^="pro_"]', li); let progress = 0; if (proEl) { const t = (proEl.textContent || '').replace(/[%%]/g, '').trim(); progress = /^\d+$/.test(t) ? parseInt(t, 10) : 0; } const btn = $$('a.btn-solid-primary', li).find(a => /开始学习|继续学习/.test(a.textContent)) || $('a.btn-solid-primary', li); const btnText = btn ? (btn.textContent || '').trim() : ''; // 进度数值缺失时用按钮文本兜底:"开始学习"=0%、"继续学习"=进行中(<100)、无按钮=已完成 if (!proEl && !progress) { if (btnText === '继续学习') progress = 50; else if (!btnText) progress = 100; } return { index: i, title, progress, li, btn, btnText }; }); } // 查找分页容器(兼容 #commonPage 和 .pg-btns 两种结构) function findPager() { // 空指针防护:.pg-btns 不存在时 $() 返回 null,直接 .closest 会抛异常导致主流程崩溃 const common = $('#commonPage'); if (common) return common; const pg = $('.pg-btns'); if (pg) return pg.closest('div') || pg; return null; } function getTotalPages() { // 优先用 pg-total .num(如"共 2 页") const totalNum = $('.pg-total .num'); if (totalNum && /^\d+$/.test(totalNum.textContent.trim())) { return parseInt(totalNum.textContent.trim(), 10); } // 兜底:数页码链接 const pager = findPager(); if (!pager) return 1; const nums = $$('a.pg-link', pager).map(a => a.textContent.trim()).filter(t => /^\d+$/.test(t)); return nums.length || 1; } async function gotoNextPage() { const next = $('a.pg-next'); if (!next) return false; if ((next.className || '').includes('disabled')) { log.info('下一页 disabled。'); return false; } const before = $('a.pg-link.active'); const beforeTxt = before ? before.textContent.trim() : ''; next.click(); log.info('点击『下一页』'); await waitUntil(() => { const cur = $('a.pg-link.active'); return (cur && cur.textContent.trim() !== beforeTxt) || !$('#courseList > li.cell'); }, CONFIG.WAIT_TIMEOUT); await waitUntil(() => $('#courseList > li.cell'), CONFIG.WAIT_TIMEOUT); await sleep(800); return true; } async function studyOneCourse(course) { log.info(`>>>>> 开始学习:${course.title}(进度 ${course.progress}%)`); if (!course.btn) { log.warn('未找到开始学习按钮,跳过'); return; } // 暂停闸门 await StatusPanel.waitIfPaused(); StatusPanel.update({ detail: '正在打开学习空间', progress: course.progress + '%' }); // 优先解析 onclick 中的学习空间 URL,强制新标签打开,保留 CMS 主控页 const url = parseEnterCourseUrl(course.btn); // 防重复开窗:本页没有可用学习窗口、且该课程已在其它窗口学习中 → 跳过 try { const gmCourse = GM_getValue('qxj_cur_course', ''); const targetId = extractCourseId(url); if ((!sharedLearnWin || sharedLearnWin.closed) && gmCourse && targetId && gmCourse === targetId) { log.info(`[课程] ${course.title} 已在其它窗口学习中,跳过避免重复开窗`); return; } } catch (e) {} STORE.clearDone(); STORE.setTask(course.title); Diag.add('[课程] 打开:' + course.title + '(进度' + course.progress + '%)url=' + (url ? '解析成功' : '未解析到')); if (url) { log.debug('打开学习空间:', url); if (!openLearnWindow(url, course)) { await StatusPanel.waitIfPaused(); if (!openLearnWindow(url, course)) { log.warn(`仍无法打开学习窗口,跳过:${course.title}`); return; } } } else { // 解析不到 URL:直接点击会导航走 CMS 主控页,学完无法继续下一门 → 安全跳过并提示 log.warn(`未解析到学习空间 URL,跳过:${course.title}`); StatusPanel.update({ detail: '未解析到学习空间 URL,跳过:' + course.title }); Diag.add('[课程] 未解析到 URL,跳过:' + course.title); notify('无法打开学习空间', '未解析到学习 URL,请手动学习:' + course.title); return; } // 轮询等待学习空间标签完成(期间持续响应暂停),超时自动跳下一门 log.info('等待学习空间完成……(超时 ' + CONFIG.CMS_WAIT_LEARN_SEC + 's 自动跳过)'); StatusPanel.update({ detail: '学习空间学习中……(超时自动跳过)' }); const ok = await waitLearnDone(CONFIG.CMS_WAIT_LEARN_SEC * 1000); if (ok) { log.info(`学习空间已完成:${course.title}`); } else { log.warn(`等待超时(${CONFIG.CMS_WAIT_LEARN_SEC}s),自动跳到下一门:${course.title}`); StatusPanel.update({ detail: '等待超时,跳下一门', progress: course.progress + '%' }); } STORE.clearDone(); // 跳转缓冲:12 秒倒计时(给下一门视频加载留时间),面板显示倒计时 for (let s = 12; s > 0; s--) { StatusPanel.update({ detail: `准备下一门课程… ${s}s` }); await sleep(1000); } } // 等待学习空间完成:轮询(1s) + storage 事件(后台标签也能即时收到)+ 回到前台立即复查 function waitLearnDone(timeoutMs) { return new Promise((resolve) => { const started = Date.now(); let settled = false; const cleanup = () => { window.removeEventListener('storage', onStorage); document.removeEventListener('visibilitychange', onVisible); window.removeEventListener('focus', onVisible); }; const finish = (ok) => { if (settled) return; settled = true; cleanup(); resolve(ok); }; const check = async () => { await StatusPanel.waitIfPaused(); if (STORE.isDone()) return finish(true); if (Date.now() - started >= timeoutMs) return finish(false); setTimeout(check, 600); }; const onStorage = (e) => { if (e.key === 'qxj_done' && e.newValue) finish(true); }; const onVisible = () => { if (!document.hidden) check(); }; window.addEventListener('storage', onStorage); document.addEventListener('visibilitychange', onVisible); window.addEventListener('focus', onVisible); check(); }); } // 用 GM_xmlhttpRequest 抓取课程列表最新 HTML,解析各卡片完成状态 // (按钮文本"开始学习/继续学习"=未完成;无学习按钮或"已学完/已完成"=已完成) function fetchCourseList() { return new Promise((resolve) => { const url = 'https://' + CONFIG.CMS_HOST + CONFIG.MY_COURSE_HREF; try { GM_xmlhttpRequest({ method: 'GET', url, timeout: CONFIG.WAIT_TIMEOUT, onload: (res) => { try { const doc = new DOMParser().parseFromString(res.responseText || '', 'text/html'); const cards = []; const lis = Array.from(doc.querySelectorAll('#courseList > li.cell')); for (const li of lis) { const titleEl = li.querySelector('.title'); const title = (titleEl && (titleEl.getAttribute('title') || titleEl.textContent.trim())) || ''; const proEl = li.querySelector('span[id^="pro_"]'); let progress = 0; if (proEl) { const t = (proEl.textContent || '').replace(/[%%]/g, '').trim(); if (/^\d+$/.test(t)) progress = parseInt(t, 10); } const btn = Array.from(li.querySelectorAll('a.btn-solid-primary')).find(a => /开始学习|继续学习/.test(a.textContent)) || li.querySelector('a.btn-solid-primary'); const btnText = btn ? (btn.textContent || '').trim() : ''; const done = progress >= 100 || (!btnText) || /已学完|已完成/.test(btnText); cards.push({ title, progress, btnText, done }); } resolve(cards); } catch (e) { resolve(null); } }, onerror: () => resolve(null), ontimeout: () => resolve(null), }); } catch (e) { resolve(null); } }); } // 学一门课 + 抓取课程列表复查:仍未完成(按钮仍"开始/继续学习")→ 按重刷次数重学 async function studyOneCourseWithRetry(course, token) { const maxRe = StatusPanel.getRetry(); for (let r = 0; r <= maxRe; r++) { await studyOneCourse(course); if (token !== undefined && !isCurrentToken(token)) return; const cards = await fetchCourseList(); if (!cards || !cards.length) return; // 抓不到列表就不复查 const mine = cards.find(c => c.title && course.title && (c.title === course.title || c.title.indexOf(course.title) >= 0 || course.title.indexOf(c.title) >= 0)); if (mine && mine.done) { log.info(` 【确认】${course.title} 已真正完成(${mine.progress || 100}%)`); Diag.add('[课程] 确认完成:' + course.title); return; } if (r < maxRe) { log.warn(` ${course.title} 学完复查仍未完成(第${r + 1}/${maxRe}遍),重学`); Diag.add('[课程] 复查未完成,重学:' + course.title); } else { log.warn(` ${course.title} 复查仍未完成(已达上限 ${maxRe}),留待人工处理`); Diag.add('[课程] 复查未完成(上限' + maxRe + '):' + course.title); } } } function parseEnterCourseUrl(btn) { // onclick 形如:enterCourse('https://pxkckj.../signLearn.action?...','courseId','1','','xxx','') // 逐级向上查找(onclick 可能挂在父级 a/li 上,直接点击会导航走主控页) let node = btn; for (let depth = 0; node && depth < 5; depth++, node = node.parentElement) { const oc = node.getAttribute && node.getAttribute('onclick'); if (oc) { const m = oc.match(/enterCourse\(\s*['"]([^'"]+)['"]/); if (m) return m[1].replace(/&/g, '&'); const w = oc.match(/(?:window\.open|openNewWindow|openWindow)\(\s*['"]([^'"]+)['"]/); if (w) return w[1].replace(/&/g, '&'); } } // 兜底:按钮本身就是学习空间链接 const href = btn.getAttribute('href') || ''; if (/^https?:\/\//.test(href) && href.indexOf('learnspace') >= 0) return href; // 整卡兜底:从卡片 HTML 里提取学习空间 URL const li = btn.closest('li'); if (li) { const html = li.outerHTML || ''; const u = html.match(/https?:\/\/pxkckj-cmatc\.cma\.cn\/learnspace\/[^"'\s]+/); if (u) return u[0].replace(/&/g, '&'); } return ''; } /* ===================================================================== * 分支二:学习空间站(pxkckj-cmatc.cma.cn) * ===================================================================== */ // 监听 mainContent iframe 加载:仅记录日志(不再自动学习,见下方注释) function setupIframeLoadWatcher() { if (__iframeLoadWatcherInstalled) return; __iframeLoadWatcherInstalled = true; const check = () => { const f = $('#mainContent'); if (!f) return; if (f.dataset.qxjWatched === '1') return; f.dataset.qxjWatched = '1'; f.addEventListener('load', () => { // 仅记录日志,不做任何自动学习/状态更新: // 主流程已负责学习,这里重复启动会导致"检测到内容刷新→视频反复刷新"死循环 log.debug('mainContent iframe 重新加载'); }); }; setInterval(check, 2000); check(); } async function runLearnBranch(token) { // 记录本次学习的入口 URL:closeCleanly 据此判断是否已被 CMS 导航到下一门课 window.__qxjLearnUrl = location.href; // 尽早抑制平台 beforeunload(避免学完跳转时弹"是否离开网站") suppressLeaveConfirm(); // 跨域课件辅助脚本(kjcourse)状态监听 CwHelper.start(); // —— 平台"单课程模式"防退出 + 刷新循环防护 —— try { const mCid = (location.href || '').match(/[?&](?:params\.)?courseId=([^&]+)/); const rawCid = mCid ? decodeURIComponent(mCid[1]) : ''; const cid = rawCid.replace(/___$/, ''); if (rawCid) { // 1) GM 登记当前课程(CMS 据此判断学习窗口是否已在目标课程,避免重复导航) try { GM_setValue('qxj_cur_course', cid); GM_setValue('qxj_cur_course_ts', Date.now()); } catch (e) {} // 2) 设置 learning-course cookie,防止平台 courseLimit 判定失败反复 quitLearning try { const mc = $('#mainContent'); const ssoEl = mc && mc.contentDocument ? mc.contentDocument.querySelector('#ssoUserId') : null; const ssoUserId = ssoEl ? (ssoEl.value || '') : ''; if (ssoUserId) document.cookie = 'learning-course=' + ssoUserId + '_' + rawCid + '; path=/'; } catch (e) {} // 3) 刷新循环检测:同一课程 30s 内加载 ≥4 次 → 仅告警(刷新已由导航保护根治, // 不再自动暂停——全局暂停会让 CMS 主循环卡住,反而断掉"刷下一门"的闭环) try { const prev = GM_getValue('qxj_reload', { c: '', n: 0, t: 0 }); const now = Date.now(); if (prev.c === cid && now - prev.t < 30000) { prev.n++; if (prev.n >= 4) { log.warn('检测到页面反复刷新(30s内' + prev.n + '次),请确认没有其它页面在重复打开本课程'); } } else { prev.c = cid; prev.n = 1; } prev.t = now; GM_setValue('qxj_reload', prev); } catch (e) {} } } catch (e) {} Diag.add('[流程] runLearnBranch 开始 token=' + token + ' top==self=' + (window.top === window.self)); try { if (window.top !== window.self) { Diag.add('[流程] 非顶层框架,退出'); return; } // 暂停闸门:学习页加载时若已暂停,先等待用户恢复,避免自动点击/刷新课程 await StatusPanel.waitIfPaused(); if (token !== undefined && !isCurrentToken(token)) { log.info('学习空间旧流程退出(token=' + token + ')'); Diag.add('[流程] token 失效,旧流程退出'); return; } try { StatusPanel.update({ status: '运行中', detail: '等待学习空间加载' }); Diag.add('[流程] updateOK'); } catch (e) { Diag.add('[流程] update异常: ' + (e && e.stack || e)); } try { setupIframeLoadWatcher(); Diag.add('[流程] watcherOK'); } catch (e) { Diag.add('[流程] watcher异常: ' + (e && e.stack || e)); } Diag.add('[流程] 等待 #learn-menu ...'); const menuOk = await waitUntil(() => $('#learn-menu'), CONFIG.WAIT_TIMEOUT); Diag.add('[流程] 菜单就绪=' + menuOk + ' token有效=' + isCurrentToken(token) + ' __qxjToken=' + window.__qxjToken); if (!isCurrentToken(token)) { log.info('学习空间旧流程退出(token=' + token + ')'); Diag.add('[流程] token 失效,旧流程退出'); return; } if (!menuOk) { log.err('学习空间页未加载出左侧菜单'); Diag.add('[流程] 菜单未加载,走宽限期'); StatusPanel.update({ status: '异常', detail: '菜单未加载,' + CONFIG.LEARN_GRACE_SEC + 's后自动跳过' }); await graceThenDone('学习空间菜单未加载', token); return; } log.info('=== 学习空间页加载,开始课件学习 ==='); Diag.add('[流程] 开始课件学习(token=' + token + ')'); } catch (e) { Diag.add('[流程] 整体异常: ' + (e && e.stack || e)); log.err('runLearnBranch 前置阶段异常:', e); } STORE.clearDone(); StatusPanel.update({ status: '运行中', course: STORE.getTask() || '-', detail: '学习空间已加载,准备点课件菜单', }); let learnedAll = false; try { learnedAll = await learnAllCoursewares(token); if (learnedAll) { log.info('该课程课件学习完成'); StatusPanel.update({ detail: '课件学习完成,即将关闭' }); } else { log.warn('课件未全部学完,宽限期后自动跳过'); StatusPanel.update({ status: '未完成', detail: '课件未全部学完,' + CONFIG.LEARN_GRACE_SEC + 's后自动跳过' }); } } catch (e) { log.err('学习空间流程异常:', e); StatusPanel.update({ status: '异常', detail: '异常:' + String(e && e.message || e) + ',' + CONFIG.LEARN_GRACE_SEC + 's后跳过' }); } if (!isCurrentToken(token)) { log.info('学习空间旧流程退出(token=' + token + ')'); return; } // 学完:markDone+关窗;未完成/异常:宽限期后 markDone+关窗,防止 CMS 卡死 if (learnedAll) { try { StatusPanel.setPaused(false); } catch (e) {} // 学完解除任何残留暂停,确保 CMS 继续下一门 // 登记本窗口完成的课程ID(多窗口模式按此匹配,避免误关其它窗口) try { GM_setValue('qxj_done_course', extractCourseId(location.href)); } catch (e) {} STORE.markDone(); notify('课件学习完成', STORE.getTask() || ''); await sleep(300); // 尽快写完"学习完成"页,缩小与 CMS 导航的竞态窗口 closeCleanly(); } else { await graceThenDone(learnedAll === false ? '课件未全部学完' : '学习流程异常', token); } } // 致命错误宽限期:等待 LEARN_GRACE_SEC 秒供人工查看,随后 markDone+关窗解除 CMS 阻塞 async function graceThenDone(reason, token) { log.warn(`【${reason}】${CONFIG.LEARN_GRACE_SEC}s 宽限期后自动 markDone+关窗`); for (let i = CONFIG.LEARN_GRACE_SEC; i > 0; i--) { if (token !== undefined && !isCurrentToken(token)) { log.info('宽限期旧流程退出'); return; } StatusPanel.update({ detail: `${reason},${i}s 后自动跳过并关闭` }); await StatusPanel.waitIfPaused(); if (token !== undefined && !isCurrentToken(token)) { log.info('宽限期旧流程退出'); return; } await sleep(1000); } if (token !== undefined && !isCurrentToken(token)) return; // 登记本窗口完成的课程ID(多窗口模式按此匹配) try { GM_setValue('qxj_done_course', extractCourseId(location.href)); } catch (e) {} STORE.markDone(); log.info('已通知 CMS 跳过本门'); closeCleanly(); } async function clickCoursewareMenu() { // 点击左侧『课件』菜单,绝不点击『学习进度』(#course_score) StatusPanel.update({ detail: '正在查找课件菜单' }); const menu = await waitForEl('#courseware_main_menu', CONFIG.WAIT_TIMEOUT); if (!menu || typeof menu.scrollIntoView !== 'function') { throw new Error('未找到课件菜单 #courseware_main_menu'); } menu.scrollIntoView({ block: 'center' }); StatusPanel.update({ detail: '正在点击课件菜单' }); await sleep(300); const link = $('a.learn-menu-cell-link', menu) || menu; link.click(); log.info('已点击『课件』菜单(未触碰学习进度)'); StatusPanel.update({ detail: '已点课件菜单,等待加载' }); await sleep(1200); } async function enterMainIframe() { StatusPanel.update({ detail: '等待课件内容加载' }); const ok = await waitUntil(() => { const f = $('#mainContent'); return f && (f.src || '').includes('courseware'); }, CONFIG.WAIT_TIMEOUT); if (!ok) { throw new Error('课件 iframe 未加载(mainContent src 不含 courseware)'); } log.info('mainContent iframe 已加载课件页'); StatusPanel.update({ detail: '课件内容已加载' }); } function getMainDoc() { const f = $('#mainContent'); if (!f) return null; try { return f.contentDocument || (f.contentWindow && f.contentWindow.document); } catch (e) { log.warn('访问 iframe 文档失败(可能跨域):', e); return null; } } function readCoursewares() { // 递归在顶层 + 所有同源嵌套 iframe 中查找课件列表 const docs = Courseware.collectDocs(getMainDoc() || document); const selectors = [ 'div.s_point[id^="s_point_"]', 'li.learn-item', 'li.courseware-item', 'div.learn-list li', 'ul.course-list li', 'li.tree-node[data-itemid]', 'a[data-itemid]', 'div.catalog li', 'li.chapter-item', "li a[href*='learnItemId']", "li a[onclick*='goCoursewareIndex']", 'li .title-text', '.courseware-list li', '.lesson-list li', '.section-list li', 'li.node', 'li[data-nodeid]', ]; for (const sel of selectors) { for (const d of docs) { try { const items = Array.from(d.querySelectorAll(sel)).filter(el => (el.textContent || '').trim()); if (items.length) { log.debug(`定位课件 ${items.length} 个(${sel},doc=${d.location ? d.location.pathname : '?'})`); return items; } } catch (e) {} } } // 兜底 for (const d of docs) { try { const allLi = Array.from(d.querySelectorAll('li')).filter(el => { const t = (el.textContent || '').trim(); return t && t.length < 80; }); if (allLi.length) { log.debug(`兜底定位课件 ${allLi.length} 个`); return allLi; } } catch (e) {} } return []; } function isItemFinished(item) { if (!item) return false; // 优先检测对勾标记(真正的完成标志) if (isCoursewareFinished(item)) return true; // 蓝色进度条满(width:100% 或 类名含 100) if (isProgressBarFull(item)) return true; // class 名 const cls = item.className || ''; if (/finished|completed|learned|done/i.test(cls)) return true; const txt = (item.textContent || ''); if (/已完成|已学完|已观看/.test(txt)) return true; // 进度数值 100% const m = txt.match(/(\d+(?:\.\d+)?)\s*%/); if (m && parseFloat(m[1]) >= 100) return true; return false; } // 检测蓝色进度条是否已满(width:100% 或 内联样式 100%) // 平台常见:.progress-bar / .progress-inner / .layui-progress-bar / .el-progress-bar__inner function isProgressBarFull(item) { if (!item) return false; const barSels = [ '.progress-bar', '.progress-inner', '.progress-bar-inner', '.layui-progress-bar', '.layui-progress-big', '.el-progress-bar__inner', '.ant-progress-bg', '.course-progress', '.learn-progress', '.study-progress', '.bar-inner', '.progress > .inner', '[class*="progress-bar"]', '[class*="progress-inner"]', ]; for (const sel of barSels) { try { const bars = item.querySelectorAll(sel); for (const bar of bars) { // 内联 width:100% const w = bar.style && (bar.style.width || ''); if (/100%/.test(w)) { log.debug(` [进度条满] 命中 ${sel} width=100%`); return true; } // 属性 aria-valuenow=100 const v = bar.getAttribute('aria-valuenow'); if (v && parseFloat(v) >= 100) { log.debug(` [进度条满] 命中 ${sel} aria-valuenow=${v}`); return true; } // class 含 100 if (/\b100\b/.test(bar.className || '')) { log.debug(` [进度条满] 命中 ${sel} class含100`); return true; } } } catch (e) {} } return false; } // 读取课件项的蓝色进度条百分比(0~100),无进度条返回 -1 function readItemProgress(item) { if (!item) return -1; const barSels = [ '.progress-bar', '.progress-inner', '.progress-bar-inner', '.layui-progress-bar', '.el-progress-bar__inner', '.ant-progress-bg', '.course-progress', '.learn-progress', '.study-progress', '[class*="progress-bar"]', '[class*="progress-inner"]', ]; for (const sel of barSels) { try { const bar = item.querySelector(sel); if (!bar) continue; // 内联 width:XX% const w = (bar.style && bar.style.width) || ''; const m = w.match(/(\d+(?:\.\d+)?)\s*%/); if (m) return parseFloat(m[1]); // aria-valuenow const v = bar.getAttribute('aria-valuenow'); if (v) return parseFloat(v); } catch (e) {} } // 文本百分比 const m = (item.textContent || '').match(/(\d+(?:\.\d+)?)\s*%/); if (m) return parseFloat(m[1]); return -1; } // 专门检测课件列表项的"对勾"完成标记 // 平台图标体系:i.coursespace icon-xxx;完成对勾常见于 icon-check/icon-finish/icon-complete // 同时支持对勾字符 ✓ √ ✔ 和各种通用 class function isCoursewareFinished(item) { if (!item) return false; // 0) 平台 s_point 完成标志:completestate="1" / done_icon_show 对勾 if (item.getAttribute && item.getAttribute('completestate') === '1') return true; try { if (item.querySelector('.done_icon_show')) return true; } catch (e) {} // 1) 对勾图标(i.coursespace icon-check 等) const checkSels = [ "i.coursespace[class*='check']", "i.coursespace[class*='finish']", "i.coursespace[class*='complete']", "i.coursespace[class*='Check']", "i.coursespace[class*='Finish']", "i.coursespace[class*='Complete']", "i.icon-check", "i.fa-check", "i.el-icon-check", "i.anticon-check", "i[class*='icon-check']", "i[class*='icon-finish']", "i[class*='icon-complete']", "i[class*='iconCheck']", "i[class*='iconFinish']", "i[class*='iconComplete']", ".check-icon", ".finish-icon", ".complete-icon", ".status-finish", ".status-complete", ".learned-icon", ".finished-icon", ".done-icon", ".node-status.finish", ".node-status.complete", ".node-status.done", ]; for (const sel of checkSels) { try { if (item.querySelector(sel)) { log.debug(` [对勾] 命中 ${sel}`); return true; } } catch (e) {} } // 2) 对勾字符 ✓ √ ✔ / 明确完成词(后不跟百分比/数字,避免"已完成60%""已观看3分钟"被误判完成) const txt = (item.textContent || ''); if (/[✓√✔]|已(?:完成|学完|观看)(?![%%\d])/.test(txt)) return true; // 3) 项整体被标记完成(class 含 finish/complete/learned/done) const cls = item.className || ''; if (/\b(finished|completed|learned|done|is-finish|is-complete|is-learned)\b/i.test(cls)) return true; // 4) 子元素有完成态 class try { if (item.querySelector('.finished, .completed, .learned, .done, .is-finish, .is-complete')) return true; } catch (e) {} return false; } async function learnAllCoursewares(token, pass) { pass = pass || 1; surveyLearnDom(); // 打印课件列表位置与可读性(排查用) // 主路径1:课件页 s_point 列表驱动(completestate / done_icon_show 对勾,最可靠的完成状态) // 仅首次(pass===1)点击课件菜单并进入 iframe,重试时菜单已打开无需重复点击 if (pass === 1) { try { await clickCoursewareMenu(); await enterMainIframe(); } catch (e) {} } await waitForEl(() => CoursewareList.collect().length > 0, CONFIG.WAIT_TIMEOUT) || await sleep(1000); await sleep(800); // 等列表完成状态渲染稳定 const items = CoursewareList.read(); if (items && items.length) { log.info(`[学习空间] 使用 s_point 课件列表路径:${items.length} 个课件`); Diag.add('[路径] s_point 课件列表:' + items.length + ' 个 | ' + items.map(x => x.itemId + (x.done ? '✓' : '✗')).join(' ')); items.forEach((x, i) => log.debug(` s_point[${i + 1}]: id=${x.itemId} title=${x.title} done=${x.done}`)); return await learnByList(token, items, pass); } log.warn('课件页未读到 s_point 课件列表'); Diag.add('[路径] s_point 未读到(0 个)'); // 主路径2:学习进度页驱动(GM_xmlhttpRequest 抓 HTML,跨域也能读) const items2 = await LearnRecord.read(); if (items2 && items2.length && isCurrentToken(token)) { log.info(`[学习空间] 使用学习进度页路径:${items2.length} 个课件`); return await learnByRecord(token, items2, pass); } log.warn('学习进度页未读到课件列表'); // 回退:课件列表驱动(旧逻辑) log.warn('课件列表与学习进度页均不可用,回退旧逻辑'); return await learnAllCoursewaresLegacy(token, pass); } // s_point 课件列表驱动的学习流程:跳过已完成(有对勾)→ 逐个学习未完成 → 全部学完后复查并重刷 async function learnByList(token, items, pass) { const total = items.length; const doneCount = items.filter(i => i.done).length; const overall = total ? Math.round(doneCount / total * 100) : 0; const review = StatusPanel.isReview(); log.info(`课件列表:${doneCount}/${total} 已完成(整体 ${overall}%),共 ${total} 个课件(第${pass}遍${review ? ',复习模式全部重学' : ''})`); StatusPanel.update({ progress: `${doneCount}/${total}`, detail: `课件列表:${doneCount}/${total} 已完成,断点续学` }); if (doneCount === total && !review) { log.info('所有课件均已学完(全部有对勾),无需学习'); StatusPanel.update({ detail: `全部课件已完成(${total}/${total})`, progress: `${total}/${total}` }); return true; } let learned = 0; for (let i = 0; i < total; i++) { if (token !== undefined && !isCurrentToken(token)) return false; // 暂停闸门 await StatusPanel.waitIfPaused(); if (token !== undefined && !isCurrentToken(token)) return false; const it = items[i]; // 学习前重新读取该课件的最新完成状态(防止初始读取时对勾尚未渲染) const cur = CoursewareList.read().find(x => x.itemId === it.itemId) || it; if (!review && cur.done) { log.info(` [${i + 1}/${total}] 已学完(有对勾),跳过:${cur.title || cur.itemId}`); Diag.add('[跳过] ' + it.itemId + ' 已有对勾,跳过'); continue; } const ok = await learnOneListItem(it, i, total); if (ok) learned++; await waitMainIframeStable(); } // 全部学完后确认真实完成状态:以服务器学习进度为准(页面 s_point 对勾可能不自动刷新, // 只看 DOM 会误判"整体 0%" → 反复重学) StatusPanel.update({ detail: '重新读取确认完成状态' }); await sleep(1500); let f2 = 0, t2 = 0; const items2 = CoursewareList.read(); if (items2 && items2.length) { t2 = items2.length; f2 = items2.filter(i => i.done).length; } try { const rec = await LearnRecord.read(); if (rec && rec.length) { const doneCount = rec.filter(r => r.done).length; if (doneCount >= f2) { f2 = doneCount; t2 = rec.length; } // 服务器更准确则采用 } } catch (e) {} const overall2 = t2 ? Math.round(f2 / t2 * 100) : 0; log.info(`课件列表确认:整体 ${overall2}%(${f2}/${t2})`); StatusPanel.update({ progress: `${f2}/${t2}`, detail: `课件列表确认:整体 ${overall2}%(${f2}/${t2})` }); const maxRetry = StatusPanel.getRetry(); if (overall2 < 100 && pass <= maxRetry) { log.warn(`整体完成度 ${overall2}% < 100%,重新学习未完成的课件(第${pass + 1}遍,上限${maxRetry})`); StatusPanel.update({ detail: `课程未完成(${overall2}%),重学第${pass + 1}遍` }); await sleep(2000); return await learnAllCoursewares(token, pass + 1); } if (overall2 < 100) { log.warn(`重学${pass}遍后整体完成度仍 ${overall2}% < 100%(上限${maxRetry}),请人工检查`); StatusPanel.update({ detail: `重学${maxRetry}次后仍未完成(${overall2}%),请人工检查` }); } return overall2 >= 100; // 诚实汇报:还有未完成课件时不让上层显示"课件学习完成" } // 学习单个课件:点击 s_point 打开课件 → 视频/图文学习 → 重新加载列表确认对勾 async function learnOneListItem(it, idx, count) { const txt = (it.title || '').slice(0, 30) || it.itemId; StatusPanel.update({ detail: `[${idx + 1}/${count}] 打开课件:${txt}` }); const ok = await CoursewareList.openItem(it); if (!ok) { // 兜底:改用学习进度页方式加载 log.warn(` [${idx + 1}/${count}] s_point 点击失败,改用原生加载:${it.itemId}`); await LearnRecord.goItem(it.itemId); } await waitUntil(() => { const f = $('#mainContent'); return f && /courseware_index/.test(f.src || ''); }, CONFIG.WAIT_TIMEOUT); await sleep(800); log.info(` [${idx + 1}/${count}] 开始学习:${txt}`); StatusPanel.update({ detail: `[${idx + 1}/${count}] 学习中:${txt}` }); const learnOk = await Courseware.learn(it.itemId); const confirmed = await confirmListItemFinished(it, idx, count, txt, learnOk); await sleep(CONFIG.SETTLE_SEC * 1000); return confirmed; } // 学完后检查课件列表中该课件是否出现对勾;优先读 DOM,未找到再点菜单刷新。 // learnOk=false(视频无法播放/学习未成功)时不再重学重载,避免视频反复刷新 async function confirmListItemFinished(it, idx, count, txt, learnOk) { // 学完后先耐心等平台确认(服务器更新可能延迟数十秒)。 // 直接读服务器学习进度(LearnRecord)确认真实状态,不看页面缓存—— // 避免"视频播完了但对勾还没出来 → 立即重学 → 页面刷新视频重播"的循环。 const CONFIRM_WAIT = 45000; const waitDone = (timeout) => new Promise((resolve) => { const started = Date.now(); (async function check() { let done = false; try { const cur = CoursewareList.read().find(x => x.itemId === it.itemId); done = !!(cur && cur.done); if (!done) { const rec = await LearnRecord.read(); const rc = rec ? rec.find(x => x.itemId === it.itemId) : null; done = !!(rc && rc.done); } } catch (e) {} if (done) return resolve(true); if (Date.now() - started >= timeout) return resolve(false); setTimeout(check, 4000); })(); }); let ok = await waitDone(CONFIRM_WAIT); if (ok) { log.info(` [${idx + 1}/${count}] 平台已确认完成:${txt}`); StatusPanel.update({ detail: `[${idx + 1}/${count}] 已完成:${txt}` }); return true; } // 学习未成功(视频无法播放等)时不重学,避免反复 reload 视频造成"一直刷新" if (!learnOk) { log.warn(` [${idx + 1}/${count}] 课件未学习成功(视频可能无法播放),跳过重学:${txt}`); StatusPanel.update({ detail: `[${idx + 1}/${count}] 未学成,跳过重学` }); return true; } // 45 秒仍未确认 → 刷新课件菜单再查一次,仍无才重学(最多2次) const RECONFIRM_RETRY = 2; for (let r = 1; r <= RECONFIRM_RETRY; r++) { log.warn(` [${idx + 1}/${count}] 学完45s仍未确认,第${r}/${RECONFIRM_RETRY}次重学:${txt}`); StatusPanel.update({ detail: `[${idx + 1}/${count}] 未确认完成,重学:${txt}` }); try { await clickCoursewareMenu(); } catch (e) {} await sleep(2000); const cur2 = CoursewareList.read().find(x => x.itemId === it.itemId); if (cur2 && cur2.done) { log.info(` [${idx + 1}/${count}] 刷新后对勾出现,确认完成:${txt}`); StatusPanel.update({ detail: `[${idx + 1}/${count}] 已完成(刷新确认):${txt}` }); return true; } await CoursewareList.openItem(cur2 || it); await waitUntil(() => { const f = $('#mainContent'); return f && /courseware_index/.test(f.src || ''); }, CONFIG.WAIT_TIMEOUT); await sleep(1500); await Courseware.learn(); ok = await waitDone(CONFIRM_WAIT); if (ok) { log.info(` [${idx + 1}/${count}] 重学后平台确认完成:${txt}`); StatusPanel.update({ detail: `[${idx + 1}/${count}] 已完成:${txt}` }); return true; } } log.warn(` [${idx + 1}/${count}] 重学${RECONFIRM_RETRY}次后仍未确认,按已完成处理:${txt}`); StatusPanel.update({ detail: `[${idx + 1}/${count}] 按已完成处理:${txt}` }); return true; } // 学习进度页驱动的学习流程:跳过已完成 → 逐个学习未完成 → 全部学完后复查并重刷 async function learnByRecord(token, items, pass) { const total = items.length; const doneCount = items.filter(i => i.done).length; const overall = total ? Math.round(doneCount / total * 100) : 0; const review = StatusPanel.isReview(); log.info(`学习进度页:${doneCount}/${total} 已完成(整体 ${overall}%),共 ${total} 个课件(第${pass}遍${review ? ',复习模式全部重学' : ''})`); StatusPanel.update({ progress: `${doneCount}/${total}`, detail: `学习进度:${doneCount}/${total} 已完成,断点续学` }); if (doneCount === total && !review) { log.info('所有课件均已学完,无需学习'); StatusPanel.update({ detail: `全部课件已完成(${total}/${total})`, progress: `${total}/${total}` }); return true; } let learned = 0; for (let i = 0; i < total; i++) { if (token !== undefined && !isCurrentToken(token)) return false; // 暂停闸门 await StatusPanel.waitIfPaused(); if (token !== undefined && !isCurrentToken(token)) return false; const it = items[i]; if (!review && it.done) { log.info(` [${i + 1}/${total}] 已学完,跳过:${it.title || it.itemId}`); continue; } // 学习前再核对课件页对勾(若 s_point 可读) const sp = CoursewareList.read().find(x => x.itemId === it.itemId); if (!review && sp && sp.done) { log.info(` [${i + 1}/${total}] s_point 有对勾,跳过:${it.title || it.itemId}`); continue; } const ok = await learnOneByItem(it, i, total); if (ok) learned++; await waitMainIframeStable(); } // 全部学完后,重新读取学习进度确认真实完成状态 StatusPanel.update({ detail: '重新读取学习进度确认' }); await sleep(1500); const items2 = await LearnRecord.read(); const f2 = items2 ? items2.filter(i => i.done).length : learned; const t2 = items2 ? items2.length : total; const overall2 = t2 ? Math.round(f2 / t2 * 100) : 0; log.info(`学习进度确认:整体 ${overall2}%(${f2}/${t2})`); StatusPanel.update({ progress: `${f2}/${t2}`, detail: `学习进度确认:整体 ${overall2}%(${f2}/${t2})` }); const maxRetry = StatusPanel.getRetry(); if (overall2 < 100 && pass <= maxRetry) { log.warn(`整体完成度 ${overall2}% < 100%,重新学习未完成的课件(第${pass + 1}遍,上限${maxRetry})`); StatusPanel.update({ detail: `课程未完成(${overall2}%),重学第${pass + 1}遍` }); await sleep(2000); return await learnAllCoursewares(token, pass + 1); } if (overall2 < 100) { log.warn(`重学${pass}遍后整体完成度仍 ${overall2}% < 100%(上限${maxRetry}),请人工检查`); StatusPanel.update({ detail: `重学${maxRetry}次后仍未完成(${overall2}%),请人工检查` }); } return overall2 >= 100; // 诚实汇报:还有未完成课件时不让上层显示"课件学习完成" } // 学习单个课件:跳转课件页 → 视频/图文学习 → 回读学习进度确认完成 async function learnOneByItem(it, idx, count) { const txt = (it.title || '').slice(0, 30) || it.itemId; StatusPanel.update({ detail: `[${idx + 1}/${count}] 加载课件:${txt}` }); const ok = await LearnRecord.goItem(it.itemId); if (!ok) { log.warn(` [${idx + 1}/${count}] 跳转课件失败:${it.itemId}`); return false; } await waitUntil(() => { const f = $('#mainContent'); return f && /courseware_index/.test(f.src || ''); }, CONFIG.WAIT_TIMEOUT); await sleep(800); log.info(` [${idx + 1}/${count}] 开始学习:${txt}`); StatusPanel.update({ detail: `[${idx + 1}/${count}] 学习中:${txt}` }); const learnOk = await Courseware.learn(it.itemId); // 回读学习进度页确认该课件已完成 const confirmed = await confirmRecordFinished(it, idx, count, txt, learnOk); await sleep(CONFIG.SETTLE_SEC * 1000); return confirmed; } // 学完后回读"学习进度"页确认该课件变为已完成;服务器延迟时先复查,仍未完成才重学。 // learnOk=false(学习未成功)时不再重学重载,避免视频反复刷新 async function confirmRecordFinished(it, idx, count, txt, learnOk) { const RECONFIRM_RETRY = 2; for (let r = 0; r <= RECONFIRM_RETRY; r++) { if (r > 0) await sleep(4000); const items = await LearnRecord.read(); const cur = items ? items.find(x => x.itemId === it.itemId) : null; if (cur && cur.done) { log.info(` [${idx + 1}/${count}] 学习进度确认完成:${txt}`); StatusPanel.update({ detail: `[${idx + 1}/${count}] 已完成(进度页确认):${txt}` }); return true; } if (r === 0) continue; // 首次未更新:等 4s 复查,不急着重学 // 学习未成功(视频无法播放等)时不重学,避免反复 reload 视频造成"一直刷新" if (!learnOk) { log.warn(` [${idx + 1}/${count}] 课件未学习成功(视频可能无法播放),跳过重学:${txt}`); StatusPanel.update({ detail: `[${idx + 1}/${count}] 未学成,跳过重学` }); return true; } if (r < RECONFIRM_RETRY) { log.warn(` [${idx + 1}/${count}] 学习进度仍显示未完成,第${r}/${RECONFIRM_RETRY}次重学:${txt}`); StatusPanel.update({ detail: `[${idx + 1}/${count}] 未确认完成,重学:${txt}` }); await LearnRecord.goItem(it.itemId); await waitUntil(() => { const f = $('#mainContent'); return f && /courseware_index/.test(f.src || ''); }, CONFIG.WAIT_TIMEOUT); await sleep(1500); await Courseware.learn(); } } log.warn(` [${idx + 1}/${count}] 重学后仍未完成,按已完成处理:${txt}`); StatusPanel.update({ detail: `[${idx + 1}/${count}] 按已完成处理:${txt}` }); return true; } // 旧逻辑:课件列表驱动(学习进度页不可用时的回退) async function learnAllCoursewaresLegacy(token, pass) { pass = pass || 1; // 第几遍学习(进度条满但课程未完成时重刷) let count = readCoursewares().length; log.info(`右侧共 ${count} 个课件,开始逐个学习(第${pass}遍)`); StatusPanel.update({ detail: `读取到课件 ${count} 个(第${pass}遍)`, progress: `0/${count}` }); if (!count) { log.warn('未定位到课件,可能选择器需调整(见 readCoursewares)'); StatusPanel.update({ detail: '未定位到课件,请人工检查选择器' }); return false; } // 跳转到第一个未学完的课件(蓝色进度条到达的位置) // 复习模式 / 重刷(pass>1)不跳过,从头开始学 let startIdx = 0; if (!StatusPanel.isReview() && pass === 1) { const items0 = readCoursewares(); let lastFinished = -1; // 最后一个已完成的位置 for (let i = 0; i < items0.length; i++) { if (isItemFinished(items0[i])) { lastFinished = i; } else { // 找到第一个未完成的,蓝色进度条就到这里 startIdx = i; break; } } // 状态栏报告蓝色进度条位置 const progAt = lastFinished >= 0 ? readItemProgress(items0[lastFinished]) : -1; if (startIdx > 0) { log.info(`跳转到第一个未学完的课件:第 ${startIdx + 1}/${count}(蓝色进度条到达位置,上个已完成项进度=${progAt}%)`); StatusPanel.update({ detail: `跳转到第 ${startIdx + 1}/${count}(蓝色进度条到达处)`, progress: `${startIdx}/${count}`, }); // 滚动到该课件 try { items0[startIdx].scrollIntoView({ block: 'center' }); } catch (e) {} await sleep(500); } else if (lastFinished === items0.length - 1) { // 全部已完成,普通模式无需再学 log.info(`所有 ${count} 个课件都已完成,无需跳转`); StatusPanel.update({ detail: `全部课件已完成(${count}/${count})`, progress: `${count}/${count}` }); } } let done = 0; for (let i = startIdx; i < count; i++) { if (token !== undefined && !isCurrentToken(token)) return false; // 暂停闸门 await StatusPanel.waitIfPaused(); if (token !== undefined && !isCurrentToken(token)) return false; // 每轮重新读取,避免元素失效 const items = readCoursewares(); if (i >= items.length) { log.info('课件列表已变化,结束'); break; } StatusPanel.update({ progress: `${i + 1}/${count}`, detail: `开始第${i + 1}/${count}个(第${pass}遍)` }); const ok = await learnOne(items[i], i, count); if (ok) done++; // 学完切回 mainContent iframe 上下文 await waitMainIframeStable(); } if (token !== undefined && !isCurrentToken(token)) return false; StatusPanel.update({ progress: `${done}/${count}`, detail: `课件处理完毕(${done}/${count},第${pass}遍)` }); // 学完所有课件后,读取"学习进度"页面确认真实完成状态 StatusPanel.update({ detail: '读取学习进度页面确认真实完成状态' }); const progress = await readLearnProgress(); if (progress) { log.info(`学习进度确认:整体 ${progress.overall}%(${progress.finished}/${progress.total})`); StatusPanel.update({ progress: `${progress.finished}/${progress.total}`, detail: `学习进度确认:整体 ${progress.overall}%(${progress.finished}/${progress.total})`, }); // 蓝色进度条已全满但课程整体未完成 → 重新刷一遍所有课件(最多重刷 getRetry() 次) const maxRetry = StatusPanel.getRetry(); if (progress.overall < 100 && pass <= maxRetry) { log.warn(`整体完成度 ${progress.overall}% < 100%,蓝色进度条已满但课程未完成,重新刷一遍(第${pass + 1}遍,上限${maxRetry})`); StatusPanel.update({ detail: `课程未完成(${progress.overall}%),重新刷一遍(${pass}/${maxRetry})` }); await sleep(2000); // 重试时菜单已打开,无需重复点击,直接重新学习 return await learnAllCoursewaresLegacy(token, pass + 1); } if (progress.overall < 100) { log.warn(`重刷${pass - 1}遍后整体完成度仍 ${progress.overall}% < 100%(上限${maxRetry}),可能需要人工检查`); StatusPanel.update({ detail: `重刷${maxRetry}次后仍未完成(${progress.overall}%),请人工检查` }); } } return progress ? progress.overall >= 100 : true; // 诚实汇报:未学完时不让上层显示"完成" } async function learnOne(item, idx, count) { try { item.scrollIntoView({ block: 'center' }); } catch (e) {} await sleep(400); const txt = (item.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 30); // 课件级别判断完成: // - 普通模式:跳过已完成的单个课件(只学未完成的) // - 复习模式:不跳过,全部重新学习 if (isItemFinished(item) && !StatusPanel.isReview()) { log.info(` [${idx + 1}/${count}] 已学完,跳过:${txt}`); StatusPanel.update({ detail: `[${idx + 1}/${count}] 已学完跳过:${txt}` }); return true; } log.info(` [${idx + 1}/${count}] 开始学习:${txt}`); StatusPanel.update({ detail: `[${idx + 1}/${count}] 点击课件:${txt}` }); // 优先用平台原生 goCoursewareIndex 方式加载课件(设置 mainContent.src 带 learnItemId) // 比单纯 DOM click 更可靠,fallback 到 click const loaded = await loadCoursewareNative(item); if (!loaded) { // fallback:DOM click let clicked = false; for (const sel of ['a', 'span.title-text', '.title', 'div']) { const sub = item.querySelector(sel); if (sub) { try { sub.click(); clicked = true; break; } catch (e) {} } } if (!clicked) { try { item.click(); } catch (e) {} } } // 等待 mainContent iframe 内容加载(用 waitForEl 等待 video/正文/页面主体) StatusPanel.update({ detail: `[${idx + 1}/${count}] 等待内容加载:${txt}` }); await waitForEl(() => { const doc = getMainDoc(); if (!doc) return null; if (doc.querySelector('video')) return doc.querySelector('video'); if (doc.body && doc.body.children.length > 0) return doc.body; return null; }, CONFIG.WAIT_TIMEOUT) || await sleep(CONFIG.SETTLE_SEC * 1000); await sleep(1000); // 学习当前课件(视频倍速播放等待完成,或图文停留) StatusPanel.update({ detail: `[${idx + 1}/${count}] 学习中:${txt}` }); const learnOk = await Courseware.learn(); // 学完后回读左侧课件列表项的"对勾"确认真正完成(不只看进度条) const confirmed = await confirmCoursewareFinished(item, idx, count, txt, learnOk); await sleep(CONFIG.SETTLE_SEC * 1000); return confirmed; } // 学完视频/图文后,回读课件列表项的对勾/完成标记确认真正完成 // 若未出现对勾,最多重试 RECONFIRM_RETRY 次重新学习;learnOk=false 时跳过重学,避免视频反复刷新 async function confirmCoursewareFinished(item, idx, count, txt, learnOk) { const RECONFIRM_RETRY = 2; for (let r = 0; r <= RECONFIRM_RETRY; r++) { // 等待对勾出现(列表项状态可能延迟更新) const waitOk = await waitUntil(() => { // 每轮重新读取课件项(DOM 可能重建) const cur = readCoursewares()[idx] || item; return isCoursewareFinished(cur); }, CONFIG.SETTLE_SEC * 1000 * 3, 1000); if (waitOk) { log.info(` [${idx + 1}/${count}] 对勾已出现,确认完成:${txt}`); StatusPanel.update({ detail: `[${idx + 1}/${count}] 已学完(对勾确认):${txt}` }); return true; } // 学习未成功(视频无法播放等)时不重学,避免反复 reload 视频造成"一直刷新" if (!learnOk) { log.warn(` [${idx + 1}/${count}] 课件未学习成功(视频可能无法播放),跳过重学:${txt}`); StatusPanel.update({ detail: `[${idx + 1}/${count}] 未学成,跳过重学` }); return true; } if (r < RECONFIRM_RETRY) { log.warn(` [${idx + 1}/${count}] 学完但未出现对勾,第${r + 1}/${RECONFIRM_RETRY}次重学:${txt}`); StatusPanel.update({ detail: `[${idx + 1}/${count}] 未确认完成,重学:${txt}` }); // 重新点击并学习(优先原生加载) try { const cur = readCoursewares()[idx] || item; const loaded = await loadCoursewareNative(cur); if (!loaded) { const sub = cur.querySelector('a') || cur.querySelector('.title-text') || cur; sub.click(); } } catch (e) {} await sleep(1500); await Courseware.learn(); } } log.warn(` [${idx + 1}/${count}] 重学后仍未出现对勾,按已完成处理:${txt}`); StatusPanel.update({ detail: `[${idx + 1}/${count}] 按已完成处理:${txt}` }); return true; } // ===== 用平台原生 goCoursewareIndex 方式加载课件(第二页源码逻辑)===== // 设置 #mainContent.src = courseware_index.action?courseId=xxx&learnItemId=xxx&itemOperation=xxx // 比单纯 DOM click 更可靠地触发课件加载 function getCourseId() { // 从课件菜单链接 href 提取 courseId const link = $('#courseware_main_menu a.learn-menu-cell-link') || $('#courseware_main_menu a'); if (link) { const m = (link.getAttribute('href') || '').match(/courseId=([^&]+)/); if (m) return m[1]; } // 从 mainContent 当前 src 提取 const f = $('#mainContent'); if (f) { const m = (f.src || '').match(/courseId=([^&]+)/); if (m) return m[1]; } return ''; } function extractLearnItemId(item) { // 从课件项的 data-itemid / data-learnitemid / onclick / href 提取 learnItemId const candidates = [ item.getAttribute('data-itemid'), item.getAttribute('data-learnitemid'), item.getAttribute('itemid'), item.getAttribute('nodeid'), ]; for (const c of candidates) { if (c) return c; } // id 形如 s_point_xxx(平台课件列表元素) const mId = (item.id || '').match(/^s_point_(.+)$/); if (mId) return mId[1]; const a = item.querySelector('a[href*="learnItemId"], a[onclick*="learnItemId"]'); if (a) { const m = (a.getAttribute('href') || a.getAttribute('onclick') || '').match(/learnItemId=([^&"']+)/); if (m) return m[1]; } // onclick 形如 goCoursewareIndex('xxx','1') const oc = item.getAttribute('onclick') || (item.querySelector('[onclick]') || {}).getAttribute && (item.querySelector('[onclick]').getAttribute('onclick')); if (oc) { const m = oc.match(/goCoursewareIndex\s*\(\s*['"]([^'"]+)['"]/); if (m) return m[1]; const m2 = oc.match(/openLearnResItem\s*\(\s*['"]([^'"]+)['"]/); if (m2) return m2[1]; } return ''; } async function loadCoursewareNative(item) { try { const itemId = extractLearnItemId(item); const courseId = getCourseId(); const f = $('#mainContent'); if (!f || !itemId || !courseId) { log.debug(' [原生加载] 缺少 itemId/courseId,回退 click', { itemId, courseId }); return false; } // 模板路径从当前 mainContent src 或课件菜单推断 let base = '/learnspace/learn/learn/templateeight/courseware_index.action'; const curSrc = f.src || ''; const m = curSrc.match(/(\/learnspace\/learn\/learn\/template\w+\/courseware_index\.action)/); if (m) base = m[1]; const newSrc = `${base}?params.courseId=${courseId}¶ms.learnItemId=${itemId}¶ms.itemOperation=1`; log.info(` [原生加载] 设置 mainContent.src(learnItemId=${itemId})`); StatusPanel.update({ detail: `原生加载课件(learnItemId=${itemId})` }); f.src = newSrc; return true; } catch (e) { log.debug(' [原生加载] 异常,回退 click:', e); return false; } } // ===== 学习进度页模块:真实的课件完成状态 + 断点续学 ===== // 平台"学习进度"页(learnRecord_stuLearnRecord.action)展示每个课件的 未完成/已完成 状态 // (见第二页过程.txt:tr[id^="item_"] + btn_unfinished/已完成 按钮 + toItem(itemId,...)), // 是判断"学到哪里"的唯一可靠来源(课件列表的蓝色进度条在不同模板下不可靠)。 // 用法:open() 打开进度页 → read() 解析 → goItem() 跳到指定课件学习。 const LearnRecord = { // 学习进度页 URL(从左侧菜单链接提取:& 解码 + 绝对化) getUrl() { const link = $('#course_score a.learn-menu-cell-link') || $('#course_score a'); if (link && link.getAttribute && link.getAttribute('href')) { const href = link.getAttribute('href').replace(/&/g, '&'); return /^https?:\/\//i.test(href) ? href : (location.origin + href); } return ''; }, // 用 GM_xmlhttpRequest 抓取学习进度页 HTML(不受 iframe 跨域限制,带会话 cookie) fetchHtml(url) { return new Promise((resolve) => { try { GM_xmlhttpRequest({ method: 'GET', url, timeout: CONFIG.WAIT_TIMEOUT, onload: (res) => resolve(res.responseText || ''), onerror: () => resolve(''), ontimeout: () => resolve(''), }); } catch (e) { resolve(''); } }); }, // 解析学习进度页 HTML → [{ itemId, title, done }],无课件行返回 null parseHtml(html) { const items = []; const doc = new DOMParser().parseFromString(html, 'text/html'); const rows = Array.from(doc.querySelectorAll('tr[id^="item_"]')); for (const tr of rows) { const itemId = (tr.id || '').replace(/^item_/, ''); if (!itemId) continue; const titleTd = tr.querySelector('td.mn3, td:first-child'); const title = titleTd ? (titleTd.textContent || '').trim().replace(/\s+/g, ' ') : ''; const btn = tr.querySelector('input[type="button"], button, a'); let done = false; if (btn) { const v = (btn.getAttribute('value') || btn.textContent || '').trim(); const cls = btn.className || ''; done = /已完成|已学完|已学习|finished|completed/.test(v + ' ' + cls); } else { done = /已完成|已学完|已学习/.test(tr.textContent || ''); } // 补充完成标志:对勾图标 / completestate / 勾选字符 if (!done) { done = tr.querySelector('.done_icon_show, [completestate="1"], i.icon-check, .item_done_icon.done_icon_show') !== null || /[✓√✔]/.test(tr.textContent || ''); } items.push({ itemId, title, done }); } return items.length ? items : null; }, // 读取学习进度:优先 GM_xmlhttpRequest 抓 HTML 解析(跨域可用),失败再读 iframe DOM async read() { const url = this.getUrl(); if (url) { const html = await this.fetchHtml(url); if (html && html.length > 500) { const items = this.parseHtml(html); if (items && items.length) { log.debug(`[学习进度] HTML 解析成功:${items.length} 个课件`); return items; } log.warn('[学习进度] HTML 已抓取但未解析到课件行'); } else { log.warn('[学习进度] 抓取学习进度页失败或内容为空'); } } // 兜底:iframe DOM const doc = getMainDoc(); if (doc) { try { const items = this.parseHtml(doc.documentElement.outerHTML); if (items && items.length) return items; } catch (e) {} } return null; }, // 打开学习进度页(人工查看用;主流程改用抓 HTML,不依赖 iframe 可读性) async open() { const url = this.getUrl(); const f = $('#mainContent'); if (f && url) { f.src = url; return true; } const menu = await waitForEl('#course_score', CONFIG.WAIT_TIMEOUT); if (!menu) return false; (menu.querySelector('a.learn-menu-cell-link') || menu).click(); return true; }, // 跳转到指定课件(等价于平台 goCoursewareIndex) async goItem(itemId) { if (!itemId) return false; await clickCoursewareMenu(); const f = $('#mainContent'); const courseId = getCourseId(); if (!f || !courseId) return false; let base = '/learnspace/learn/learn/templateeight/courseware_index.action'; const m = (f.src || '').match(/(\/learnspace\/learn\/learn\/template\w+\/courseware_index\.action)/); if (m) base = m[1]; f.src = base + '?params.courseId=' + courseId + '¶ms.learnItemId=' + itemId + '¶ms.itemOperation=1'; log.info('[学习进度] 跳转课件:' + itemId); return true; }, }; // ===== 课件列表模块:读课件页的 s_point 列表 ===== // 平台课件页每个课件是一个 div.s_point:id="s_point_",completestate="1" 表示已完成, // 完成时显示对勾 .done_icon_show,点击触发 openLearnResItem(itemId,...) 打开课件。 // 这是判断"学到哪里/是否完成"最可靠的结构(学习进度页对已完成课件可能只显示对勾/时长)。 const CoursewareList = { // 收集 s_point 元素:扫描顶层页面 + mainContent 及其所有同源嵌套 iframe // (不依赖 getMainDoc,即使 mainContent 主文档访问受限也能从其它 iframe 找到列表) collect() { const out = []; const seenDocs = new Set(); const roots = [document]; const mc = $('#mainContent'); if (mc) roots.push(mc); const walk = (d) => { if (!d || seenDocs.has(d)) return; seenDocs.add(d); try { d.querySelectorAll('div.s_point[id^="s_point_"], .s_point, [completestate]').forEach(el => out.push(el)); } catch (e) {} try { const ifs = d.querySelectorAll('iframe'); for (const f of ifs) { try { if (f.contentDocument) walk(f.contentDocument); } catch (e) {} } } catch (e) {} }; for (const r of roots) walk(r); return out; }, // 解析为 [{ itemId, title, done, el }](按 itemId 去重) read() { const seen = new Set(); const items = []; for (const el of this.collect()) { let itemId = ''; const mId = (el.id || '').match(/^s_point_(.+)$/); if (mId) itemId = mId[1]; if (!itemId) { const oc = el.getAttribute('onclick') || ''; const m = oc.match(/openLearnResItem\s*\(\s*['"]([^'"]+)['"]/); if (m) itemId = m[1]; } if (!itemId || seen.has(itemId)) continue; seen.add(itemId); const titleEl = el.querySelector('.s_pointti'); const title = (el.getAttribute('title') || (titleEl && titleEl.textContent) || '').trim().replace(/\s+/g, ' '); const done = el.getAttribute('completestate') === '1' || !!el.querySelector('.done_icon_show, .item_done_icon.done_icon_show, i[class*="check"], i[class*="Check"]') || /done_icon_show/.test(el.className || '') || /[✓√✔]|已(?:完成|学完)(?![%%\d])/.test(el.textContent || ''); items.push({ itemId, title, done, el }); } return items; }, // 点击 s_point 打开课件(平台原生 openLearnResItem) async openItem(item) { const el = item.el || document.querySelector('#s_point_' + item.itemId); if (!el) return false; try { el.click(); return true; } catch (e) { return false; } }, }; // ===== 学习空间 DOM 诊断:打印课件列表位置与可读性(排查"对勾读不到")===== function surveyLearnDom() { try { const f = $('#mainContent'); let mainOk = false, mainSrc = ''; if (f) { mainSrc = f.src || ''; try { mainOk = !!f.contentDocument; } catch (e) {} } const spoints = CoursewareList.collect().length; log.info(`[诊断] mainContent存在=${!!f} src=${mainSrc} 可访问=${mainOk} s_point数=${spoints}`); Diag.add('[诊断] mainContent存在=' + !!f + ' src=' + mainSrc + ' 可访问=' + mainOk + ' s_point数=' + spoints); if (spoints === 0 && f) { try { const d = f.contentDocument; if (d) { const ifs = d.querySelectorAll('iframe'); log.info(`[诊断] mainContent 内 iframe 数=${ifs.length},body子元素=${d.body ? d.body.children.length : 0}`); Diag.add('[诊断] mainContent 内 iframe 数=' + ifs.length + ',body子元素=' + (d.body ? d.body.children.length : 0)); const cands = d.querySelectorAll('[class*="point"],[class*="catalog"],[class*="directory"],[class*="tree"] li,[class*="list"] li'); log.info(`[诊断] mainContent 内目录候选元素=${cands.length}`); Diag.add('[诊断] mainContent 内目录候选元素=' + cands.length); for (let i = 0; i < Math.min(cands.length, 10); i++) { const c = cands[i]; try { log.debug(` 候选[${i}]: <${c.tagName} class="${String(c.className || '').slice(0, 50)}" id="${c.id}"> ${String(c.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 40)}`); } catch (e2) {} } } else { log.warn('[诊断] mainContent.contentDocument 不可访问(可能跨域)'); Diag.add('[诊断] mainContent.contentDocument 不可访问(可能跨域)'); } } catch (e) { log.warn('[诊断] mainContent 访问异常:', e); Diag.add('[诊断] mainContent 访问异常:' + e); } } } catch (e) { log.debug('[诊断] 异常:', e); } } // ===== 读取"学习进度"页面确认真实完成状态(第二页源码 #course_score)===== // 点击左侧『学习进度』菜单,等 mainContent 加载 learnRecord_stuLearnRecord 页面, // 读取每个课件的真实完成状态,返回已完成课件数/总数 async function readLearnProgress() { try { StatusPanel.update({ detail: '读取学习进度页面确认完成状态' }); log.info('=== 读取学习进度页面 ==='); // 点击『学习进度』菜单(#course_score) const menu = await waitForEl('#course_score', CONFIG.WAIT_TIMEOUT); if (!menu) { log.warn('未找到学习进度菜单 #course_score'); return null; } const link = $('a.learn-menu-cell-link', menu) || menu; link.click(); // 等 mainContent 加载学习进度页面 const ok = await waitUntil(() => { const f = $('#mainContent'); return f && /learnRecord|stuLearnRecord|study/.test(f.src || ''); }, CONFIG.WAIT_TIMEOUT); if (!ok) { log.warn('学习进度页面未加载'); return null; } await sleep(2000); // 等页面渲染 const doc = getMainDoc(); if (!doc) return null; // 读取完成状态:找进度条/百分比/对勾/完成文本 let total = 0, finished = 0; // 策略1:百分比文本 const items = Array.from(doc.querySelectorAll('tr, li, .learnRecord-item, .record-item, .study-item, .item')); for (const it of items) { const t = (it.textContent || '').trim(); if (!t || t.length > 200) continue; total++; const m = t.match(/(\d+(?:\.\d+)?)\s*%/); if (m && parseFloat(m[1]) >= 100) { finished++; continue; } if (/已完成|已学完|已完成学习/.test(t)) { finished++; continue; } } // 策略2:整页进度文本 if (!total) { const allText = (doc.body && doc.body.textContent) || ''; const mAll = allText.match(/(\d+(?:\.\d+)?)\s*%/g); if (mAll) { total = mAll.length; finished = mAll.filter(s => parseFloat(s) >= 100).length; } // 整体完成度 const mTotal = allText.match(/完成度[::]\s*(\d+(?:\.\d+)?)\s*%/); if (mTotal) { const overall = parseFloat(mTotal[1]); log.info(`学习进度页面整体完成度:${overall}%`); StatusPanel.update({ detail: `学习进度:整体完成度 ${overall}%` }); return { overall, total, finished }; } } log.info(`学习进度页面:${finished}/${total} 已完成`); StatusPanel.update({ detail: `学习进度确认:${finished}/${total} 已完成` }); return { overall: total ? (finished / total * 100) : 0, total, finished }; } catch (e) { log.err('读取学习进度页面异常:', e); return null; } } async function waitMainIframeStable() { // 课件点击后可能改变 mainContent src,等其重新加载稳定 await waitUntil(() => { const f = $('#mainContent'); return f && (f.src || '').includes('courseware'); }, CONFIG.WAIT_TIMEOUT); await sleep(800); } /* ===================== 通知 ===================== */ function notify(title, text) { try { if (typeof GM_notification === 'function') { GM_notification({ title, text, timeout: 4000 }); } } catch (e) {} } })();