// ==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 =
''+
''+
''+
'
就绪
'+
'
'+
'
进度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();
})();