// ==UserScript== // @name 河南工业职业技术学院继续教育学习平台-视频自动播放 // @namespace http://tampermonkey.net/ // @version 0.7.0 // @description 在页面内显示控制面板(可随意拖动、可最小化):读取播放列表、开始运行、停止运行。播放策略:优先播放有进度但未满100%的视频,每节确认进度达100%才播下一节,直至全部完成。为保证学习时长:视频真实完整自然播放、自动恢复暂停并取消静音、不跳转播放位置,确保系统读取到足够时长、数据正常对接。 // @author You // @match *://hnpihn.newzhihui.cn/* // @match *://hnpi.newzhihui.cn/* // @grant none // @run-at document-idle // ==/UserScript== (function () { 'use strict'; // ===== 配置 ===== const END_MARGIN = 0.5; // 播完判定裕量(秒):距结尾小于此值视为已播放完成 const REFRESH_INTERVAL = 20000; // 每20秒刷新一次各章节已学习进度 const PANEL_ID = 'zs-auto-video-panel'; // ===== 状态 ===== let running = false; let videoPlayer = null; let checkInterval = null; let progressTimer = null; // 定期刷新进度的定时器 let switching = false; // 是否正在切换章节(防重复触发) let listLoaded = false; // ---------- 工具 ---------- function $(sel, ctx) { return (ctx || document).querySelector(sel); } function $$(sel, ctx) { return Array.from((ctx || document).querySelectorAll(sel)); } // 读取单个章节的已学习进度 // 页面进度有两种表示:data-study-rate 属性(整数,如 "2")和 里的文本(如 "2%")。 // 优先取属性,取不到再从 badge 文本提取,双来源兜底,避免进度读不到。 function getProgressOf(el) { var r = el.getAttribute('data-study-rate'); if (r !== null && r !== '') return r; var b = el.querySelector('.badge'); if (b) { var m = b.innerText.match(/(\d+(?:\.\d+)?)/); if (m) return m[1]; } return '0'; } // 读取播放列表(页面真实结构:课程项为 .video-info,标题在 .video2_name,进度见 getProgressOf) function getPlaylist() { return $$('.video-info').map((el, i) => ({ index: i, title: (el.querySelector('.video2_name') || {}).innerText || ('第' + (i + 1) + '节'), rate: getProgressOf(el), el: el, isCurrent: el.classList.contains('current') })); } // ---------- 控制面板 ---------- let panelMinimized = false; // 面板是否最小化 function createPanel() { if ($('#' + PANEL_ID)) return; const panel = document.createElement('div'); panel.id = PANEL_ID; panel.style.cssText = 'position:fixed;top:90px;right:12px;z-index:2147483000;width:310px;' + 'background:rgba(20,26,38,.95);color:#e8ecf3;font:13px/1.6 "Microsoft YaHei",sans-serif;' + 'border:1px solid #3a4a63;border-radius:10px;box-shadow:0 6px 24px rgba(0,0,0,.45);' + 'padding:12px 14px;max-height:86vh;display:flex;flex-direction:column;' + 'user-select:none;'; // 禁止选中文本,避免拖动时干扰 panel.innerHTML = '
' + '▶ 自动播放控制' + '' + '
' + '
' + '
状态:未运行
' + '
当前:未加载
' + '
' + '' + '' + '' + '
' + '
' + '
播放列表:未读取
' + '
' + '
'; const style = document.createElement('style'); style.textContent = '#' + PANEL_ID + ' button{color:#fff;background:#33507a;border:0;border-radius:6px;padding:6px 8px;cursor:pointer;font-size:12px;}' + '#' + PANEL_ID + ' button:hover{filter:brightness(1.18);}' + '#' + PANEL_ID + ' .zs-item{cursor:pointer;padding:3px 6px;border-radius:5px;font-size:12px;color:#cdd7e6;margin:2px 0;display:flex;justify-content:space-between;}' + '#' + PANEL_ID + ' .zs-item:hover{background:#2a3a55;}' + '#' + PANEL_ID + ' .zs-item.cur{background:#1f4d6e;color:#fff;}' + '#' + PANEL_ID + ' .zs-item .r{color:#6fd17f;flex:none;margin-left:6px;}' + '#' + PANEL_ID + ' .zs-item .d{color:#8fa0b8;font-size:11px;flex:none;margin-left:8px;}' + '#' + PANEL_ID + ' .zs-item .t{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}'; document.head.appendChild(style); document.body.appendChild(panel); $('#zs-auto-min').onclick = toggleMin; makeDraggable(panel, $('#zs-auto-head')); $('#zs-auto-load').onclick = loadPlaylist; $('#zs-auto-start').onclick = startRun; $('#zs-auto-stop').onclick = stopRun; } // 面板拖动:按住标题栏可随意移动 function makeDraggable(panel, handle) { let dragging = false, startX = 0, startY = 0, origLeft = 0, origTop = 0; handle.addEventListener('mousedown', function (e) { if (e.target.closest('#zs-auto-min')) return; // 不拦截最小化按钮的点击 dragging = true; const rect = panel.getBoundingClientRect(); startX = e.clientX; startY = e.clientY; origLeft = rect.left; origTop = rect.top; // 拖动时从 right 定位切换为 left 定位,避免两者冲突 panel.style.right = 'auto'; panel.style.left = origLeft + 'px'; panel.style.top = origTop + 'px'; e.preventDefault(); }); document.addEventListener('mousemove', function (e) { if (!dragging) return; panel.style.left = (origLeft + e.clientX - startX) + 'px'; panel.style.top = (origTop + e.clientY - startY) + 'px'; }); document.addEventListener('mouseup', function () { dragging = false; }); } // 面板最小化/展开:最小化后仅保留标题栏 function toggleMin() { panelMinimized = !panelMinimized; const body = $('#zs-auto-body'); const panel = $('#' + PANEL_ID); if (panelMinimized) { body.style.display = 'none'; panel.style.width = 'auto'; $('#zs-auto-min').textContent = '+'; $('#zs-auto-min').title = '展开'; } else { body.style.display = 'block'; panel.style.width = '310px'; $('#zs-auto-min').textContent = '—'; $('#zs-auto-min').title = '最小化'; } } function setStatus(text) { $('#zs-auto-status').textContent = '状态:' + text; } function setNow(text) { $('#zs-auto-now').textContent = '当前:' + text; } // 读取并展示播放列表 function loadPlaylist() { const list = getPlaylist(); const box = $('#zs-auto-list'); if (!list.length) { box.innerHTML = '
未找到播放列表(.video-info),请确认处于视频学习页。
'; return; } let html = '
播放列表:共 ' + list.length + ' 节(点击某项可跳转)
'; list.forEach((item, i) => { html += '
' + '' + item.title + '' + item.rate + '%
'; }); box.innerHTML = html; listLoaded = true; box.querySelectorAll('.zs-item').forEach(row => { row.onclick = () => { list[+row.dataset.idx].el.click(); }; }); updateNow(); } // 秒数转 mm:ss 显示 function fmtTime(sec) { sec = Math.round(sec || 0); const m = Math.floor(sec / 60); const s = sec % 60; return m + ':' + (s < 10 ? '0' + s : s); } // 获取当前正在播放视频的总时长,填充到播放列表该行(此前为空时写入) function recordDuration() { if (!videoPlayer || !videoPlayer.duration) return; const items = $$('.video-info'); const cur = $('.video-info.current'); if (!cur || !listLoaded) return; const idx = items.indexOf(cur); const rows = $$('#zs-auto-list .zs-item'); const dEl = rows[idx] ? rows[idx].querySelector('.d') : null; if (dEl && !dEl.textContent) dEl.textContent = fmtTime(videoPlayer.duration); } let lastCurrentEl = null; // 记录上一次的当前项,避免频繁全量刷新列表高亮 // 更新“当前”信息与列表高亮 function updateNow() { const list = getPlaylist(); const cur = list.find(i => i.isCurrent); if (cur) { setNow((cur.index + 1) + '/' + list.length + ' ' + cur.title + ' ' + cur.rate + '%'); } // 仅当“当前项”真正变化时才重设高亮,避免每次DOM变化都全量刷新列表 if (listLoaded && cur && cur.el !== lastCurrentEl) { lastCurrentEl = cur.el; $$('#zs-auto-list .zs-item').forEach((row, i) => { row.classList.toggle('cur', list[i] && list[i].isCurrent); }); } } // ---------- 视频播放逻辑 ---------- function findVideo() { videoPlayer = document.querySelector('video'); if (videoPlayer) { bindVideo(); if (videoPlayer.readyState > 2) onCanPlay(); return true; } return false; } function bindVideo() { videoPlayer.removeEventListener('canplay', onCanPlay); videoPlayer.removeEventListener('ended', onEnded); videoPlayer.removeEventListener('error', onError); videoPlayer.addEventListener('canplay', onCanPlay); videoPlayer.addEventListener('ended', onEnded); videoPlayer.addEventListener('error', onError); } // 确保视频真实播放:若被暂停则恢复、并尝试取消静音。 // 平台会核验学习时长,静音或被暂停会导致时长不足/判定数据异常,因此必须保证有声且持续累计播放。 function ensurePlaying() { if (!videoPlayer) return; try { if (videoPlayer.muted) videoPlayer.muted = false; // 取消静音(可能受浏览器自动播放策略限制) if (videoPlayer.paused) videoPlayer.play().catch(function (e) { console.error('[自动播放] 恢复播放失败:', e); }); } catch (e) { /* 忽略 */ } } function onCanPlay() { if (!running) return; switching = false; // 新视频可播放,允许再次切换 ensurePlaying(); // 确保视频开始播放,时长开始累计 clearInterval(checkInterval); checkInterval = setInterval(checkProgress, 1000); // 每秒检查一次 } let confirmTimer = null; // 完成确认轮询定时器 let retryCount = 0; // 单节完成确认失败的重试次数 function checkProgress() { if (!videoPlayer || !running) { clearInterval(checkInterval); return; } recordDuration(); // 视频时长可读时,将总时长填入播放列表当前行 ensurePlaying(); // 保证真实播放时长持续累计:意外暂停自动恢复、尝试取消静音 const t = videoPlayer.currentTime; const d = videoPlayer.duration; // 播放完成判定:距结尾不足 END_MARGIN 秒,视为播完。 // 注意:这里不再强制跳转 currentTime 到总时长——那样会缩短真实播放时长, // 导致平台判定“学习时长不足,数据异常不予对接”。正常由 ended 事件自然触发 // 完成确认;此判定仅作为 ended 事件未触发的兜底,且不做跳转。 if (d > 0 && (d - t) <= END_MARGIN) { clearInterval(checkInterval); setStatus('视频即将播完,等待系统记录时长...'); confirmAndNext(); } } // 选择下一个要播放的视频(播放顺序策略): // 优先级:已学部分但未满100%(进度>0且<100)→ 尚未开始(进度=0)→ 无则全部完成 function pickNextTarget() { const list = getPlaylist(); const inProgress = list.filter(i => { const r = parseFloat(i.rate) || 0; return r > 0 && r < 100; }); if (inProgress.length) return inProgress[0]; const zero = list.filter(i => { const r = parseFloat(i.rate) || 0; return r === 0; }); if (zero.length) return zero[0]; return null; } // 切换到选中的目标视频播放 function goToNext() { const target = pickNextTarget(); if (target) { setNow('切换至:' + target.title); target.el.click(); updateNow(); } else { setStatus('全部视频已完成 100%,自动停止'); stopRun(); } } // 当前视频播放完成后:轮询确认其进度达到100%,确认后才播下一个 function confirmAndNext() { const curEl = $('.video-info.current'); if (!curEl) { goToNext(); return; } clearInterval(confirmTimer); setStatus('等待系统记录完成进度...'); let waited = 0; confirmTimer = setInterval(function () { waited += 2; const rate = parseFloat(getProgressOf(curEl)) || 0; if (rate >= 100) { clearInterval(confirmTimer); retryCount = 0; setStatus('该节已完成(100%),选择下一节'); goToNext(); } else if (waited >= 90) { // 最长等90秒 clearInterval(confirmTimer); retryCount++; if (retryCount <= 3) { setStatus('完成确认超时,重新播放该节(' + retryCount + '/3)'); curEl.click(); // 重新从头播放当前节,再走一次完成确认 } else { setStatus('多次确认失败,跳过该节'); retryCount = 0; goToNext(); // 跳过,避免死循环 } } }, 2000); } // 视频自然播放结束:进入完成确认,确认100%后再播下一个 function onEnded() { clearInterval(checkInterval); setStatus('视频播放结束,确认完成进度'); confirmAndNext(); } function onError(e) { console.error('[自动播放] 视频错误:', e); clearInterval(checkInterval); // 切换章节后旧 video 引用可能失效,稍后重新绑定 setTimeout(() => { if (running) findVideo(); }, 3000); } function startRun() { if (running) return; running = true; setStatus('运行中(优先补学未完成视频)'); // 启动定期刷新已学习进度:每20秒读取一次并更新面板 clearInterval(progressTimer); progressTimer = setInterval(refreshProgress, REFRESH_INTERVAL); const curEl = $('.video-info.current'); const curRate = curEl ? parseFloat(getProgressOf(curEl)) || 0 : 0; if (curEl && curRate >= 100) { // 当前已是完成状态,直接跳到第一个待完成视频 setNow('当前视频已完成,寻找待完成视频'); goToNext(); } else if (findVideo()) { setNow('开始播放'); updateNow(); } else { setStatus('未找到视频,3秒后自动重试'); setTimeout(() => { if (running) findVideo(); }, 3000); } } function stopRun() { running = false; clearInterval(checkInterval); clearInterval(progressTimer); clearInterval(confirmTimer); progressTimer = null; switching = false; retryCount = 0; setStatus('已停止'); } // 每20秒刷新一次各章节的已学习进度:重新读取并更新面板列表与当前信息 function refreshProgress() { recordDuration(); // 顺带把当前视频总时长填入列表(双保险) const list = getPlaylist(); if (!list.length) return; const rows = $$('#zs-auto-list .zs-item'); list.forEach((item, i) => { const r = rows[i] ? rows[i].querySelector('.r') : null; if (r) r.textContent = item.rate + '%'; if (rows[i]) rows[i].classList.toggle('cur', item.isCurrent); }); const cur = list.find(i => i.isCurrent); if (cur) setNow((cur.index + 1) + '/' + list.length + ' ' + cur.title + ' ' + cur.rate + '%'); } // ---------- 初始化 ---------- function init() { try { createPanel(); waitForList(); // 等待课程列表渲染完成后再读取,避免读到空列表 setupObserver(); } catch (e) { console.error('[自动播放] 初始化错误:', e); } } // 视频页的 .video-info 列表由 JS 异步渲染,脚本执行时可能尚未生成, // 这里轮询等待它出现(最长约 15 秒)后再加载播放列表;不在视频页时自动降级。 function waitForList(tries) { tries = tries || 0; if ($('.video-info')) { loadPlaylist(); return; } if (tries >= 50) { loadPlaylist(); return; } setTimeout(function () { waitForList(tries + 1); }, 300); } // MutationObserver:章节切换后刷新当前信息 / 重新绑定视频 // 注意:视频播放器(ckplayer)播放时DOM高频变化,若回调过重会导致网页卡顿。 // 这里做两点优化:1)忽略面板自身改动以阻断反馈循环;2)用节流合并高频变化。 let mutationTimer = null; function setupObserver() { const obs = new MutationObserver((muts) => { // 忽略面板自身改动,防止面板更新DOM反过来又触发observer形成死循环 if (muts.some(m => m.target.closest && m.target.closest('#' + PANEL_ID))) return; // 节流:把300ms内的所有DOM变化合并为一次处理,显著降低CPU占用 clearTimeout(mutationTimer); mutationTimer = setTimeout(() => { if (listLoaded) updateNow(); if (running && (!videoPlayer || !document.contains(videoPlayer))) { findVideo(); } }, 300); }); // attributes:false 减少因属性变更(如进度条样式)带来的触发,进一步降低开销 obs.observe(document.body, { childList: true, subtree: true, attributes: false }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { setTimeout(init, 600); } })();