// ==UserScript== // @name 专技学习 // @namespace https://www.wlxy.org.cn/ // @version 2.16 // @description 自动展开目录、扫描课节,支持选择学习数量或全部学完。 // @author wwwww // @match https://www.wlxy.org.cn/* // @match https://www.wlxy.org.cn/#/* // @grant none // @run-at document-idle // @license MIT // ==/UserScript== (function () { 'use strict'; var DEBUG = true; function dbg(m) { if (DEBUG) console.log('[WLXY]', m); } var CFG = { POLL_MS: 800, STUCK_MS: 12000, VIDEO_WAIT_MS: 25000, INIT_MAX: 80, INIT_GAP: 80, }; var state = 'idle'; var allLessons = []; var currentIdx = -1; var learnTarget = 0; var learnedCount = 0; var lastProgressTime = 0; var pollTimer = null; var _lessonDone = false; // 防止 onEnded 与 checkCompletion 重复触发 var _completionCheckStart = 0; // checkCompletion 启用时间戳 var _lastDialogLog = ''; // v2.16: 弹窗日志去重,避免重复刷屏 // ========== helpers ========== function $(id) { return document.getElementById(id); } function getVideo() { return document.querySelector('video'); } function log(msg) { var a = $('wlxy-log-area'); if (!a) return; var t = new Date().toTimeString().slice(0,8); a.innerHTML += '
['+t+'] '+msg+'
'; a.scrollTop = a.scrollHeight; } function setUI(id, prop, val) { var el = $(id); if (!el) return; if (prop === 'dot') el.className = 'sd '+val; else if (prop === 'width') el.style.width = val; else el.textContent = val; } function showRow(id) { ['wlxy-r-idle','wlxy-r-choose','wlxy-r-learning','wlxy-r-done'].forEach(function(r){ var e = $(r); if (e) e.classList.add('hidden'); }); var t = $(id); if (t) t.classList.remove('hidden'); } // v2.16: 更新用户意图行,显示计划学习节数和预计耗时(每节按 40 分钟估算) function updateIntent(target) { var done = allLessons.filter(function(l) { return l.isCompleted; }).length; var remain = allLessons.length - done; var n = target > 0 ? target : remain; var hours = (n * 40 / 60).toFixed(1); setUI('wlxy-intent-text', null, '再学 '+n+' 节 预计 '+hours+' 小时'); } // ========== 展开目录 ========== function expandAllChapters() { var clicked = 0; var seen = {}; var all = document.querySelectorAll('*'); var candidates = []; for (var i = 0; i < all.length; i++) { var el = all[i]; var txt = (el.textContent || '').replace(/\s+/g, ''); var m = txt.match(/^\[(\d+)\]/); if (!m) continue; if (txt.length > 40 || el.children.length > 3) continue; candidates.push({ el: el, num: parseInt(m[1]), domIdx: i }); } // 按文档顺序排序,保证区间检查正确 candidates.sort(function(a, b) { return a.domIdx - b.domIdx; }); for (var j = 0; j < candidates.length; j++) { var c = candidates[j]; var key = 'ch_'+c.num; if (seen[key]) continue; seen[key] = true; // v2.15: 使用 dispatchEvent(MouseEvent) 模拟真实点击,触发 uni-app Vue 事件 try { c.el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); c.el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); c.el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); clicked++; } catch(e) {} } log('展开 ' + clicked + ' 个章节'); return clicked; } // ========== 扫描课节 ========== function scanLessons() { var raw = []; var all = document.querySelectorAll('*'); // --- 章节扫描:记录每个章节头位置,用于给课节标注所属章节 --- var chapterHeaders = []; for (var ci = 0; ci < all.length; ci++) { var cel = all[ci]; var ctxt = (cel.textContent || '').replace(/\s+/g, ' ').trim(); var cm = ctxt.match(/^\[(\d+)\]/); if (!cm) continue; if (ctxt.length > 40 || cel.children.length > 3) continue; chapterHeaders.push({ num: parseInt(cm[1]), domIdx: ci }); } for (var i = 0; i < all.length; i++) { var el = all[i]; var txt = (el.textContent || '').replace(/\s+/g, ' ').trim(); if (!/第\d+节/.test(txt)) continue; // v2.15: 遍历祖先链检测真实可见性(父容器 display:none 会导致子孙不可见) var hidden = false; for (var pp = el; pp && pp !== document.body; pp = pp.parentElement) { var cs = window.getComputedStyle(pp); if (cs.display === 'none' || cs.visibility === 'hidden') { hidden = true; break; } } if (hidden) continue; // 过滤 1:textContent 太长 → 容器元素 if (txt.length > 120) continue; // 过滤 2:含多个"第X节" → 容器(如整个目录面板) var cnt = (txt.match(/第\d+节/g) || []).length; if (cnt > 1) continue; // 提取节编号与名称 var m = txt.match(/第(\d+)节/); var num = parseInt(m[1]); var nameMatch = txt.match(/(第\d+节[\s\S]*?)(?=已完成|待学习|未学习|$)/); var name = nameMatch ? nameMatch[1].replace(/\s+/g, ' ').trim() : txt.slice(0, 60); // 截断尾标 name = name.replace(/已完成|待学习|未学习|未尝试/g, '').replace(/\s+/g, ' ').trim(); // 过滤 3:名称仅含"第X节"无实际课程名 → 编号标签(如 第1节),非课节行 if (name.replace(/第\d+节/, '').trim().length === 0) continue; // --- 完成判断:严格限定在当前元素自身 + 直接父级 --- var isCompleted = false; var ownText = (el.textContent || '').replace(/\s+/g, ' '); // 1) 自身文本含"已完成"(长度短说明是课节自身而非容器) if (/已完成/.test(ownText) && ownText.length < 150) isCompleted = true; // 2) 自身 class 含 completed/finish if (!isCompleted) { var cls = (el.className || '') + ''; if (/completed|finish|done/.test(cls)) isCompleted = true; } // 3) 直接父级(最多 2 层),父级文本不能太长 if (!isCompleted) { var p = el.parentElement; for (var j = 0; j < 2 && p; j++) { var pt = (p.textContent || '').replace(/\s+/g, ' '); if (pt.length > 50) { p = p.parentElement; continue; } if (/已完成/.test(pt)) { isCompleted = true; break; } var pcls = (p.className || '') + ''; if (/completed|finish|done/.test(pcls)) { isCompleted = true; break; } p = p.parentElement; } } raw.push({ el: el, num: num, name: name, isCompleted: isCompleted, txtLen: ownText.length, domIdx: i }); } // --- 去重:同一课节可能被多个 DOM 层级匹配到 --- // key = 节编号 + 名称前缀:同一课节不同层级合并;不同章节同编号的真实课节保留 var best = {}; for (var ri = 0; ri < raw.length; ri++) { var r = raw[ri]; var key = r.num + '|' + r.name.slice(0, 15); if (!best[key] || r.txtLen < best[key].txtLen || (r.txtLen === best[key].txtLen && r.isCompleted && !best[key].isCompleted) || (r.txtLen === best[key].txtLen && r.isCompleted === best[key].isCompleted && r.domIdx < best[key].domIdx)) { best[key] = r; } } // 排序章节头,保证区间查找正确 chapterHeaders.sort(function(a, b) { return a.domIdx - b.domIdx; }); // 转为数组,先分配章节号再排序 var lessons = []; var entries = []; for (var k in best) { if (best.hasOwnProperty(k)) entries.push(best[k]); } // 为每个条目分配所属章节 for (var ei = 0; ei < entries.length; ei++) { var b = entries[ei]; var chNum = 0; for (var cj = 0; cj < chapterHeaders.length; cj++) { if (chapterHeaders[cj].domIdx < b.domIdx) chNum = chapterHeaders[cj].num; else break; } b.chapter = chNum; } // 按(章节号,节编号)排序 — 确定性顺序,不依赖 DOM 位置 entries.sort(function(a, b) { if (a.chapter !== b.chapter) return a.chapter - b.chapter; return a.num - b.num; }); for (var ni = 0; ni < entries.length; ni++) { var b = entries[ni]; b.idx = lessons.length; lessons.push({ name: b.name, el: b.el, isCompleted: b.isCompleted, idx: b.idx, chapter: b.chapter }); } return lessons; } // 课节显示标签:带章节号 function lessonLabel(lesson) { return lesson.chapter ? '第'+lesson.chapter+'章 '+lesson.name : lesson.name; } // ========== 扫描流程 ========== function doScan() { allLessons = []; currentIdx = -1; state = 'scanning'; showRow('wlxy-r-idle'); setUI('wlxy-status-dot', 'dot', 'sdr'); setUI('wlxy-status-text', null, '扫描中...'); setUI('wlxy-intent-text', null, '-'); log('===== 开始扫描 ====='); // 登录检查 if (!/wlxy\.org\.cn/.test(window.location.href) && !/pages\/train/.test(window.location.href)) { log('未在培训页面,请先登录 wlxy.org.cn'); setUI('wlxy-status-dot', 'dot', 'sde'); setUI('wlxy-status-text', null, '未登录'); return; } log('已登录'); // 首次扫描需等页面 settle + 多轮展开确保 uni-app 异步渲染完成 setTimeout(function() { expandAllChapters(); setTimeout(function() { expandAllChapters(); setTimeout(function() { expandAllChapters(); // 第三轮,覆盖慢异步渲染 setTimeout(function() { allLessons = scanLessons(); var total = allLessons.length; var done = allLessons.filter(function(l) { return l.isCompleted; }).length; var pct = total > 0 ? Math.round((done / total) * 100) : 0; setUI('wlxy-progress-fill', 'width', pct+'%'); setUI('wlxy-progress-text', null, done+' / '+total+' 剩余 '+(total-done)+' 节未完成'); setUI('wlxy-status-dot', 'dot', total > 0 ? 'sdd' : 'sde'); setUI('wlxy-status-text', null, '已完成 '+done+'/'+total+' 节'); if (total === 0) { log('未检测到课节,请在培训目录已展开的页面重试'); showRow('wlxy-r-idle'); state = 'idle'; return; } log('共 '+total+' 节,已完成 '+done+' 节,剩余 '+(total-done)+' 节'); for (var i = 0; i < allLessons.length; i++) { var l = allLessons[i]; log(' ['+i+'] '+lessonLabel(l)+' - '+(l.isCompleted ? '已完成' : '待学习')); } state = 'ready'; // 输入框最大值 = 未完成节数 var remain = total - done; var inp = $('wlxy-input-num'); if (inp) { inp.max = remain; inp.min = 1; inp.value = remain; } // 扫描后重置当前课节显示 currentIdx = -1; setUI('wlxy-current-text', null, '-'); setUI('wlxy-video-text', null, '-'); showRow('wlxy-r-choose'); }, 1500); // 展开后等待 DOM 渲染 (v2.15: 延长给 uni-app 更多渲染时间) }, 1500); // 第三轮展开 }, 1500); // 第二轮展开 }, 500); // 首次页面 settle } // ========== 学习流程 ========== function startLearning() { state = 'learning'; showRow('wlxy-r-learning'); setUI('wlxy-status-dot', 'dot', 'sdr'); setUI('wlxy-status-text', null, '学习中'); // 从缓存找第一个未完成 var target = null; for (var i = 0; i < allLessons.length; i++) { if (!allLessons[i].isCompleted) { target = allLessons[i]; break; } } if (!target) { allDone(); return; } clickLesson(target); } function clickLesson(lesson) { currentIdx = lesson.idx; _lessonDone = false; log('► '+lessonLabel(lesson)); setUI('wlxy-current-text', null, lessonLabel(lesson)); setUI('wlxy-video-text', null, '加载中...'); // 多点尝试触发 uni-app 路由 try { lesson.el.click(); } catch(e) {} var kids = lesson.el.children; for (var i = 0; i < kids.length; i++) { try { kids[i].click(); } catch(e) {} } if (lesson.el.parentElement) { try { lesson.el.parentElement.click(); } catch(e) {} } waitForVideo(function() { log('视频就绪'); startMonitor(); }, function() { log('视频加载超时'); state = 'error'; setUI('wlxy-status-dot', 'dot', 'sde'); setUI('wlxy-status-text', null, '加载失败'); }); } function waitForVideo(ok, fail) { var v = getVideo(); if (v && v.readyState >= 2) return ok(); var t0 = Date.now(); var iv = setInterval(function() { var cv = getVideo(); if (cv && cv.readyState >= 2) { clearInterval(iv); ok(); return; } if (Date.now() - t0 > CFG.VIDEO_WAIT_MS) { clearInterval(iv); fail(); } }, 300); } var _antiPauseSetupDone = false; function ensureAntiPause(video) { // 拦截 video.pause():页面不可见时拒绝暂停 if (!video._antiPauseHooked) { video._antiPauseHooked = true; var origPause = video.pause.bind(video); video.pause = function() { if (document.hidden || !document.hasFocus()) { this.play().catch(function(){}); return; } return origPause(); }; } // 全局事件只注册一次 if (_antiPauseSetupDone) return; _antiPauseSetupDone = true; document.addEventListener('visibilitychange', function() { var cv = getVideo(); if (cv && cv.paused && !cv.ended) { cv.play().catch(function(){}); } }); window.addEventListener('blur', function() { var cv = getVideo(); if (cv && cv.paused && !cv.ended) { cv.play().catch(function(){}); } }); } // ========== 监控 ========== function startMonitor() { if (pollTimer) clearInterval(pollTimer); var v = getVideo(); if (v) { ensureAntiPause(v); lastProgressTime = Date.now(); _completionCheckStart = Date.now() + 5000; // 5 秒冷却期,防止平台「访问即标记」误判 v.play().catch(function(){}); v.addEventListener('timeupdate', onTimeUpdate); v.addEventListener('ended', onEnded); v.addEventListener('play', function() { setUI('wlxy-video-text', null, '播放中'); }); } pollTimer = setInterval(function() { handleDialogs(); var cv = getVideo(); if (cv && !cv.paused && !cv.ended && Date.now() - lastProgressTime > CFG.STUCK_MS) { cv.play().catch(function(){}); lastProgressTime = Date.now(); } checkCompletion(); }, CFG.POLL_MS); } function onTimeUpdate() { lastProgressTime = Date.now(); var v = getVideo(); if (v && v.duration) { setUI('wlxy-video-text', null, '播放中 '+Math.round(v.currentTime/v.duration*100)+'%'); } } function onEnded() { log('视频播放完毕'); setUI('wlxy-video-text', null, '已播完,等待确认'); // 视频播完是平台认定的真正完成信号 if (!_lessonDone) { _lessonDone = true; onLessonDone(); } } function checkCompletion() { // 冷却期内不检测 DOM:平台点击课节后侧边栏立即标记「已完成」,但视频未播 if (Date.now() < _completionCheckStart) return; if (_lessonDone) return; // onEnded 已触发 if (!allLessons[currentIdx]) return; var el = allLessons[currentIdx].el; if (!el || !el.parentElement) return; var isDone = false; var txt = (el.textContent || '').replace(/\s+/g, ' '); if (/已完成/.test(txt) && txt.length < 150) isDone = true; if (!isDone) { var cls = (el.className || '') + ''; if (/completed|finish|done/.test(cls)) isDone = true; } if (!isDone) { var p = el.parentElement; for (var j = 0; j < 2 && p; j++) { var pt = (p.textContent || ''); if (pt.length > 50) { p = p.parentElement; continue; } if (/已完成/.test(pt)) { isDone = true; break; } if (/completed|finish|done/.test((p.className||'')+'')) { isDone = true; break; } p = p.parentElement; } } if (isDone && !_lessonDone) { _lessonDone = true; onLessonDone(); } } function handleDialogs() { var btns = document.querySelectorAll('button,.btn,[class*="btn"],[class*="button"]'); for (var i = 0; i < btns.length; i++) { var b = btns[i]; var t = b.textContent.trim(); if (!/继续|确定|确认|是|好的|知道了|关闭|我知道了/.test(t)) continue; var p = b; for (var j = 0; j < 6; j++) { p = p.parentElement; if (!p) break; if (/(dialog|modal|popup|mask|overlay|toast)/.test((p.className||'')+'')) { b.click(); // v2.16: 弹窗日志去重,仅文本变化时才记录,避免相同弹窗反复刷屏 if (t !== _lastDialogLog) { log('处理弹窗: '+t); _lastDialogLog = t; } return; } } } } function onLessonDone() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } var v = getVideo(); if (v) { v.removeEventListener('timeupdate', onTimeUpdate); v.removeEventListener('ended', onEnded); } allLessons[currentIdx].isCompleted = true; learnedCount++; var done = allLessons.filter(function(l) { return l.isCompleted; }).length; var total = allLessons.length; var pct = Math.round((done/total)*100); log('✅ '+lessonLabel(allLessons[currentIdx])+' ('+done+'/'+total+')'); setUI('wlxy-progress-fill', 'width', pct+'%'); setUI('wlxy-progress-text', null, done+' / '+total+' 剩余 '+(total-done)+' 节未完成'); setUI('wlxy-status-text', null, '已完成 '+done+'/'+total); if (learnTarget > 0 && learnedCount >= learnTarget) { log('已学完 '+learnTarget+' 节'); allDone(); return; } var next = null; for (var i = 0; i < allLessons.length; i++) { if (!allLessons[i].isCompleted) { next = allLessons[i]; break; } } if (next) { setTimeout(function() { clickLesson(next); }, 2000); } else { allDone(); } } function allDone() { stopAll(); state = 'done'; setUI('wlxy-status-dot', 'dot', 'sdd'); setUI('wlxy-status-text', null, '全部完成!'); setUI('wlxy-progress-fill', 'width', '100%'); var done = allLessons.filter(function(l) { return l.isCompleted; }).length; setUI('wlxy-progress-text', null, done+' / '+allLessons.length+' 剩余 0 节未完成'); setUI('wlxy-current-text', null, '-'); setUI('wlxy-video-text', null, '-'); setUI('wlxy-intent-text', null, '-'); showRow('wlxy-r-done'); log('全部完成'); } function pauseLearning() { if (state !== 'learning') return; state = 'paused'; if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } // 暂停视频播放 var v = getVideo(); if (v) { v.pause(); setUI('wlxy-video-text', null, '已暂停'); } setUI('wlxy-status-dot', 'dot', 'sdp'); setUI('wlxy-status-text', null, '已暂停'); // 保留当前课节名 if (currentIdx >= 0 && allLessons[currentIdx]) { setUI('wlxy-current-text', null, lessonLabel(allLessons[currentIdx])); // 同步更新输入框上限 var done2 = allLessons.filter(function(l) { return l.isCompleted; }).length; var remain2 = allLessons.length - done2; var inp2 = $('wlxy-input-num'); if (inp2) { inp2.max = remain2; inp2.value = remain2; } } showRow('wlxy-r-choose'); log('已暂停'); } function stopAll() { state = 'idle'; if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } var v = getVideo(); if (v) { v.removeEventListener('timeupdate', onTimeUpdate); v.removeEventListener('ended', onEnded); } learnTarget = 0; learnedCount = 0; currentIdx = -1; } // ========== UI ========== function createPanel() { if ($('wlxy-auto-panel')) return; var p = document.createElement('div'); p.id = 'wlxy-auto-panel'; p.innerHTML = ''+ '
公需科目自动学习\u2715
'+ '
'+ '
就绪
'+ '
'+ '
进度0 / 0
'+ '
用户意图-
'+ '
当前-
'+ '
视频-
'+ // 按钮行 —— 使用更短的 id 避免字符串问题 '
'+ ''+ '
'+ ''+ ''+ ''+ '
'+ '
'; document.body.appendChild(p); // 拖拽 var dx, dy, dg = false; $('wlxy-panel-header').addEventListener('mousedown', function(e) { dg=true; dx=e.clientX-p.offsetLeft; dy=e.clientY-p.offsetTop; }); document.addEventListener('mousemove', function(e) { if(!dg) return; p.style.left=(e.clientX-dx)+'px'; p.style.top=(e.clientY-dy)+'px'; p.style.right='auto'; }); document.addEventListener('mouseup', function() { dg=false; }); $('wlxy-panel-close').addEventListener('click', function() { stopAll(); p.remove(); }); $('wlxy-btn-scan').addEventListener('click', doScan); $('wlxy-btn-all').addEventListener('click', function() { learnTarget=0; learnedCount=0; updateIntent(0); startLearning(); }); $('wlxy-btn-num').addEventListener('click', function() { var n = parseInt($('wlxy-input-num').value) || 1; var done4 = allLessons.filter(function(l) { return l.isCompleted; }).length; var max4 = allLessons.length - done4; if (n < 1) n = 1; if (n > max4) n = max4; if ($('wlxy-input-num')) $('wlxy-input-num').value = n; learnTarget = n; learnedCount = 0; updateIntent(n); log('还想学 '+n+' 节'); startLearning(); }); $('wlxy-btn-rescan').addEventListener('click', doScan); $('wlxy-btn-rescan2').addEventListener('click', doScan); $('wlxy-btn-pause').addEventListener('click', pauseLearning); $('wlxy-btn-more').addEventListener('click', function() { var done3 = allLessons.filter(function(l) { return l.isCompleted; }).length; var remain3 = allLessons.length - done3; var inp3 = $('wlxy-input-num'); if (inp3) { inp3.max = remain3; inp3.value = remain3; } state = 'ready'; showRow('wlxy-r-choose'); }); return p; } function createToggle() { if ($('wlxy-toggle-btn')) return; var b = document.createElement('div'); b.id = 'wlxy-toggle-btn'; b.textContent = '\uD83D\uDCDA'; b.title = '公需科目自动学习'; b.addEventListener('click', function() { var p = $('wlxy-auto-panel'); if (p) p.style.display = p.style.display === 'none' ? '' : 'none'; else createPanel(); }); document.body.appendChild(b); } // ========== SPA ========== function watchRoute() { window.addEventListener('hashchange', function() { createToggle(); if(!$('wlxy-auto-panel')) createPanel(); }); var op=history.pushState, or=history.replaceState; history.pushState=function(){ op.apply(this,arguments); createToggle(); if(!$('wlxy-auto-panel')) createPanel(); }; history.replaceState=function(){ or.apply(this,arguments); }; window.addEventListener('popstate', function() { createToggle(); if(!$('wlxy-auto-panel')) createPanel(); }); } function waitForBody(n) { n = n || 0; if (document.body) { createToggle(); if (!$('wlxy-auto-panel')) { createPanel(); log('脚本已就绪'); } return; } if (n > CFG.INIT_MAX) { console.error('[WLXY] body timeout'); return; } setTimeout(function() { waitForBody(n+1); }, CFG.INIT_GAP); } waitForBody(); watchRoute(); })();