// ==UserScript== // @name 特教教材国家级示范培训 · 学习辅助面板(合规版 v1.3) // @namespace http://tampermonkey.net/ // @version 1.3 // @description 面板始终显示;视频懒检测绑定;真实学习时间统计;自动下一节;快捷键;本地笔记 // @author Helper // @match https://t.pep.com.cn/* // @match https://wp.pep.com.cn/* // @run-at document-idle // @grant GM_addStyle // @license MIT // ==/UserScript== (function () { 'use strict'; // ===================== 配置 ===================== const CONFIG = { videoSelector: 'video', seekStep: 10, volumeStep: 0.1, noteStorageKey: 'tj_pep_notes', statsStorageKey: 'tj_study_stats', autoNext: { enabled: false, countdown: 3, selectors: [ 'a[class*="next"]', 'button[class*="next"]', '[class*="next-btn"]', '[class*="nextBtn"]', '.next', '.btn-next' ], texts: ['下一节', '下一个', '下一讲', '下一课', '下一集', '下一章'] } }; CONFIG.autoNext.enabled = localStorage.getItem('tj_auto_next') === '1'; // ===================== 工具 ===================== const $ = (sel, root = document) => root.querySelector(sel); const pad = n => String(n).padStart(2, '0'); function fmtTime(sec) { if (sec == null || isNaN(sec) || !isFinite(sec)) return '--:--'; const s = Math.floor(sec % 60), m = Math.floor(sec / 60 % 60), h = Math.floor(sec / 3600); return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`; } function fmtDur(sec) { sec = Math.max(0, Math.floor(sec || 0)); return `${pad(Math.floor(sec / 3600))}:${pad(Math.floor(sec % 3600 / 60))}:${pad(sec % 60)}`; } function log(msg) { console.log('[特教培训辅助]', msg); } function escapeHtml(str) { const d = document.createElement('div'); d.textContent = str; return d.innerHTML; } function isVisible(el) { if (!el || el.disabled) return false; const r = el.getBoundingClientRect(), st = getComputedStyle(el); return r.width > 0 && r.height > 0 && st.visibility !== 'hidden' && st.display !== 'none' && st.opacity !== '0'; } function showTip(msg, ms = 3000) { const el = document.createElement('div'); el.className = 'tj-tip'; el.textContent = msg; document.body.appendChild(el); setTimeout(() => el.remove(), ms); } // ===================== 学习时间统计(仅真实播放) ===================== const stats = loadStats(); let sessionSeconds = 0; let lastTick = performance.now(); let lastSave = 0; let studyRunning = false; let currentVideo = null; function todayKey() { const d = new Date(); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } function pageKey() { return location.pathname + location.search; } function loadStats() { try { const s = JSON.parse(localStorage.getItem(CONFIG.statsStorageKey) || '{}'); return { total: s.total || 0, daily: s.daily || {}, pages: s.pages || {} }; } catch { return { total: 0, daily: {}, pages: {} }; } } function saveStats() { try { localStorage.setItem(CONFIG.statsStorageKey, JSON.stringify(stats)); } catch {} } function tickStats() { const now = performance.now(); const delta = (now - lastTick) / 1000; lastTick = now; const v = currentVideo; if (!v || v.paused || v.ended || v.readyState < 2) return; if (delta <= 0 || delta > 5) return; sessionSeconds += delta; stats.total += delta; stats.daily[todayKey()] = (stats.daily[todayKey()] || 0) + delta; stats.pages[pageKey()] = (stats.pages[pageKey()] || 0) + delta; if (now - lastSave > 5000) { saveStats(); lastSave = now; } } setInterval(tickStats, 1000); window.addEventListener('beforeunload', saveStats); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') saveStats(); }); // ===================== 快捷键 ===================== function bindShortcuts(video) { if (video.__tjKeysBound) return; video.__tjKeysBound = true; document.addEventListener('keydown', function (e) { const tag = (e.target.tagName || '').toLowerCase(); if (tag === 'input' || tag === 'textarea' || e.target.isContentEditable) return; const v = currentVideo; if (!v) return; switch (e.key) { case ' ': e.preventDefault(); v.paused ? v.play().catch(() => {}) : v.pause(); break; case 'ArrowLeft': e.preventDefault(); v.currentTime = Math.max(0, v.currentTime - CONFIG.seekStep); break; case 'ArrowRight': e.preventDefault(); v.currentTime = Math.min(v.duration || 0, v.currentTime + CONFIG.seekStep); break; case 'ArrowUp': e.preventDefault(); v.volume = Math.min(1, v.volume + CONFIG.volumeStep); break; case 'ArrowDown': e.preventDefault(); v.volume = Math.max(0, v.volume - CONFIG.volumeStep); break; case 'm': case 'M': e.preventDefault(); v.muted = !v.muted; break; case 'n': case 'N': e.preventDefault(); addNoteAtCurrentTime(v); break; } }); } // ===================== 笔记 ===================== function loadNotes() { try { return JSON.parse(localStorage.getItem(CONFIG.noteStorageKey) || '[]'); } catch { return []; } } function saveNotes(notes) { localStorage.setItem(CONFIG.noteStorageKey, JSON.stringify(notes)); } function addNoteAtCurrentTime(video) { const t = video.currentTime; const text = prompt(`在 ${fmtTime(t)} 处记录笔记:`, ''); if (text == null) return; const trimmed = text.trim(); if (!trimmed) return; const notes = loadNotes(); notes.push({ time: t, timeLabel: fmtTime(t), text: trimmed, page: location.href, createdAt: new Date().toLocaleString() }); saveNotes(notes); renderNotes(); } function renderNotes() { const list = document.getElementById('noteList'); if (!list) return; const notes = loadNotes().filter(n => n.page === location.href).sort((a, b) => a.time - b.time); if (!notes.length) { list.innerHTML = '
暂无笔记,播放时按 N 添加
'; return; } list.innerHTML = notes.map(n => `
${n.timeLabel} ${escapeHtml(n.text)}
`).join(''); list.querySelectorAll('.note-time').forEach(el => { el.onclick = () => { if (currentVideo) currentVideo.currentTime = parseFloat(el.dataset.t); }; }); } // ===================== 自动下一节 ===================== let nextTimer = null, nextTarget = null; function findNextButton() { const panel = document.getElementById('tj-helper-panel'); for (const sel of CONFIG.autoNext.selectors) { for (const el of document.querySelectorAll(sel)) { if (panel && panel.contains(el)) continue; if (isVisible(el)) return el; } } for (const el of document.querySelectorAll('a, button, [role="button"], [class*="btn"], [class*="next"]')) { if (panel && panel.contains(el)) continue; const t = (el.textContent || '').replace(/\s+/g, '').trim(); if (!t || t.length > 12) continue; if (CONFIG.autoNext.texts.some(x => t === x || t.includes(x)) && isVisible(el)) return el; } return null; } function cancelPendingNext() { if (nextTimer) { clearInterval(nextTimer); nextTimer = null; } const toast = document.getElementById('tj-next-toast'); if (toast) toast.remove(); nextTarget = null; } function triggerAutoNext() { if (!CONFIG.autoNext.enabled || nextTimer) return; const btn = findNextButton(); if (!btn) { log('未找到“下一节”按钮'); showTip('视频已结束,未找到“下一节”按钮,请手动切换'); return; } nextTarget = btn; let remain = CONFIG.autoNext.countdown; const toast = document.createElement('div'); toast.id = 'tj-next-toast'; toast.innerHTML = `⏭ 视频已结束,${remain} 秒后自动播放下一节`; document.body.appendChild(toast); nextTimer = setInterval(() => { remain--; if (remain <= 0) { clearInterval(nextTimer); nextTimer = null; toast.remove(); const t = nextTarget; nextTarget = null; try { t.click(); } catch (e) { log('点击失败:' + e.message); } } else { const c = toast.querySelector('.cnt'); if (c) c.textContent = remain; } }, 1000); toast.querySelector('.cancel').onclick = () => { cancelPendingNext(); log('已取消自动跳转'); }; } function bindEndedHandler(video) { if (video.__tjEndedBound) return; video.__tjEndedBound = true; video.addEventListener('ended', () => { log('视频自然播放结束'); triggerAutoNext(); }); video.addEventListener('loadstart', cancelPendingNext); } // ===================== 一键学习 / 停止 ===================== function syncAutoNextUI() { const t = document.getElementById('autoNextToggle'); if (t) t.checked = CONFIG.autoNext.enabled; } async function startOneClickStudy() { const v = currentVideo || $(CONFIG.videoSelector); if (!v) { showTip('未检测到视频。请确认已进入播放页,或按 F12 检查 video / iframe'); return; } CONFIG.autoNext.enabled = true; syncAutoNextUI(); studyRunning = true; if (v.ended) { triggerAutoNext(); return; } if (v.paused) { try { await v.play(); log('一键学习:已开始播放'); } catch { showTip('浏览器拦截了自动播放,请先点击一次视频区域再点“一键学习”'); return; } } } function stopStudy() { CONFIG.autoNext.enabled = false; syncAutoNextUI(); cancelPendingNext(); studyRunning = false; showTip('已停止自动连播(当前视频不会暂停)'); } // ===================== 视频检测与绑定 ===================== function attachVideo(video) { if (!video || video === currentVideo) return; currentVideo = video; bindShortcuts(video); bindEndedHandler(video); lastTick = performance.now(); log('已绑定视频元素'); updateVideoStatus(); } function findAndAttachVideo() { const v = $(CONFIG.videoSelector); if (v) attachVideo(v); updateVideoStatus(); } function updateVideoStatus() { const el = document.getElementById('videoStatus'); if (!el) return; if (currentVideo && document.contains(currentVideo)) { el.textContent = '✅ 已检测'; el.style.color = '#8f8'; el.title = '已绑定视频元素'; } else { el.textContent = '⚠️ 未检测'; el.style.color = '#ffa500'; el.title = detectHint(); } const hintEl = document.getElementById('videoHint'); if (hintEl) hintEl.textContent = currentVideo ? '' : detectHint(); } function detectHint() { const iframes = [...document.querySelectorAll('iframe')].filter(f => f.src); if (iframes.length) { const src = iframes.map(f => f.src).slice(0, 2).join(' | '); return `未找到 video。页面含 iframe:${src} —— 视频可能在 iframe 内,需把该域名加入 @match。`; } return '未找到 video 元素。播放器可能不是原生