// ==UserScript== // @name 特教教材国家级示范培训 · 学习辅助面板(合规版) // @namespace http://tampermonkey.net/ // @version 1.2 // @description 面板显示运行状态与真实学习时间 / 一键开始连续播放 / 自动下一节 / 快捷键 / 本地笔记 | 不跳转不加速不伪造上报 // @author Helper // @match https://t.pep.com.cn/tj2025/* // @match https://t.pep.com.cn/tj2024/* // @match https://t.pep.com.cn/tj2022/* // @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); const m = Math.floor((sec / 60) % 60); const 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(); const st = getComputedStyle(el); return r.width > 0 && r.height > 0 && st.visibility !== 'hidden' && st.display !== 'none' && st.opacity !== '0'; } function waitForVideo(timeout = 30000) { return new Promise((resolve, reject) => { const start = Date.now(); const check = () => { const v = $(CONFIG.videoSelector); if (v) return resolve(v); if (Date.now() - start > timeout) return reject(new Error('未找到视频元素')); setTimeout(check, 400); }; check(); }); } function showTip(msg, ms = 2600) { 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; // 一键学习是否处于开启状态(仅用于界面状态) 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(video) { const now = performance.now(); const delta = (now - lastTick) / 1000; lastTick = now; if (!video) return; const playing = !video.paused && !video.ended && video.readyState >= 2; if (!playing) return; // 防止标签页休眠导致的超大跳变 if (delta <= 0 || delta > 5) return; sessionSeconds += delta; stats.total += delta; const dk = todayKey(); stats.daily[dk] = (stats.daily[dk] || 0) + delta; const pk = pageKey(); stats.pages[pk] = (stats.pages[pk] || 0) + delta; if (now - lastSave > 5000) { saveStats(); lastSave = now; } } setInterval(() => tickStats($(CONFIG.videoSelector)), 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; switch (e.key) { case ' ': e.preventDefault(); video.paused ? video.play().catch(() => {}) : video.pause(); break; case 'ArrowLeft': e.preventDefault(); video.currentTime = Math.max(0, video.currentTime - CONFIG.seekStep); break; case 'ArrowRight': e.preventDefault(); video.currentTime = Math.min(video.duration || 0, video.currentTime + CONFIG.seekStep); break; case 'ArrowUp': e.preventDefault(); video.volume = Math.min(1, video.volume + CONFIG.volumeStep); break; case 'ArrowDown': e.preventDefault(); video.volume = Math.max(0, video.volume - CONFIG.volumeStep); break; case 'm': case 'M': e.preventDefault(); video.muted = !video.muted; break; case 'n': case 'N': e.preventDefault(); addNoteAtCurrentTime(video); 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 = () => { const v = $(CONFIG.videoSelector); if (v) v.currentTime = parseFloat(el.dataset.t); }; }); } // ===================== 自动下一节 ===================== let nextTimer = null; let 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; } } const candidates = document.querySelectorAll('a, button, [role="button"], [class*="btn"], [class*="next"]'); for (const el of candidates) { 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) return; if (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 target = nextTarget; nextTarget = null; log('自动点击下一节'); try { target.click(); } catch (e) { log('点击失败:' + e.message); } } else { const cnt = toast.querySelector('.cnt'); if (cnt) cnt.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() { let video; try { video = await waitForVideo(15000); } catch { showTip('未找到视频元素,请确认已进入播放页'); return; } CONFIG.autoNext.enabled = true; syncAutoNextUI(); studyRunning = true; if (video.ended) { // 若已播完,直接进入下一节 triggerAutoNext(); return; } if (video.paused) { try { await video.play(); log('一键学习:已开始播放'); } catch (e) { showTip('浏览器拦截了自动播放,请先点击一次视频区域,再点“一键学习”'); return; } } } function stopStudy() { CONFIG.autoNext.enabled = false; syncAutoNextUI(); cancelPendingNext(); studyRunning = false; showTip('已停止自动连播(当前视频不会暂停)'); } // ===================== 面板刷新 ===================== let rafId = null; function refreshPanel(video) { const curEl = document.getElementById('curTime'); const durEl = document.getElementById('durTime'); const rateEl = document.getElementById('playRate'); const fillEl = document.getElementById('progressFill'); const stateEl = document.getElementById('runState'); const sessEl = document.getElementById('sessionTime'); const todayEl = document.getElementById('todayTime'); const pageEl = document.getElementById('pageTime'); if (video) { const cur = video.currentTime, dur = video.duration; if (curEl) curEl.textContent = fmtTime(cur); if (durEl) durEl.textContent = fmtTime(dur); if (rateEl) rateEl.textContent = (video.playbackRate || 1) + 'x'; if (fillEl) { const p = dur && isFinite(dur) ? Math.min(100, cur / dur * 100) : 0; fillEl.style.width = p.toFixed(1) + '%'; } if (stateEl) { let s = '未开始', c = '#8899bb'; if (video.ended) { s = '本节已结束'; c = '#ffa500'; } else if (video.paused) { s = '已暂停'; c = '#ffd700'; } else if (video.readyState >= 2) { s = studyRunning ? '🔴 学习中' : '播放中'; c = '#8f8'; } else { s = '加载中…'; c = '#8899bb'; } stateEl.textContent = s; stateEl.style.color = c; } } if (sessEl) sessEl.textContent = fmtDur(sessionSeconds); if (todayEl) todayEl.textContent = fmtDur(stats.daily[todayKey()] || 0); if (pageEl) pageEl.textContent = fmtDur(stats.pages[pageKey()] || 0); rafId = requestAnimationFrame(() => refreshPanel($(CONFIG.videoSelector))); } // ===================== 创建面板 ===================== function createPanel(video) { GM_addStyle(` #tj-helper-panel { position: fixed; bottom: 24px; right: 24px; width: 320px; background: linear-gradient(160deg, rgba(18,24,42,0.96), rgba(10,15,28,0.98)); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border: 1px solid rgba(255,215,0,0.12); border-radius: 16px; padding: 16px; z-index: 999999; font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; color: #e2e8f0; box-shadow: 0 16px 48px rgba(0,0,0,0.65); user-select: none; } #tj-helper-panel .hdr { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; cursor: move; padding-bottom: 8px; border-bottom: 1px solid rgba(255,255,255,0.07); } #tj-helper-panel .hdr .t { font-size: 14px; font-weight: 700; background: linear-gradient(135deg,#ffd700,#f5a623); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } #tj-helper-panel .hdr .tag { font-size: 10px; color: #ffd700; background: rgba(255,215,0,0.1); padding: 2px 8px; border-radius: 10px; } #tj-helper-panel .actions { display: flex; gap: 8px; margin-bottom: 12px; } #tj-helper-panel .btn-primary { flex: 1; padding: 11px 0; border: none; border-radius: 10px; background: linear-gradient(135deg,#f5af19,#f12711); color: #fff; font-size: 14px; font-weight: 700; cursor: pointer; transition: transform .15s, box-shadow .15s; box-shadow: 0 4px 16px rgba(241,39,17,0.25); } #tj-helper-panel .btn-primary:hover { transform: translateY(-1px); } #tj-helper-panel .btn-primary:active { transform: scale(.98); } #tj-helper-panel .btn-stop { flex: 0 0 90px; padding: 11px 0; border: 1px solid rgba(255,120,120,0.25); border-radius: 10px; background: rgba(255,120,120,0.08); color: #ff9999; font-size: 13px; cursor: pointer; } #tj-helper-panel .btn-stop:hover { background: rgba(255,120,120,0.16); } #tj-helper-panel .sec-title { font-size: 10px; color: #667799; letter-spacing: 1px; margin: 10px 0 6px; text-transform: uppercase; } #tj-helper-panel .row { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: #8899bb; padding: 4px 0; } #tj-helper-panel .row .v { color: #e2e8f0; font-weight: 600; font-variant-numeric: tabular-nums; } #tj-helper-panel .time-big { text-align: center; font-size: 28px; font-weight: 700; color: #ffd700; font-variant-numeric: tabular-nums; letter-spacing: 1px; line-height: 1.2; } #tj-helper-panel .time-lbl { text-align: center; font-size: 10px; color: #667799; margin-bottom: 6px; } #tj-helper-panel .bar { height: 5px; background: rgba(255,255,255,0.08); border-radius: 3px; overflow: hidden; margin: 4px 0 8px; } #tj-helper-panel .bar .fill { height: 100%; width: 0%; border-radius: 3px; background: linear-gradient(90deg,#ffd700,#f5a623); transition: width .3s; } #tj-helper-panel .next-row { display: flex; align-items: center; justify-content: space-between; background: rgba(0,0,0,0.22); border-radius: 10px; padding: 8px 12px; margin: 8px 0; } #tj-helper-panel .next-row .lbl { font-size: 12px; color: #c8d6e5; } #tj-helper-panel .next-row .sub { font-size: 10px; color: #556677; display: block; margin-top: 2px; } #tj-helper-panel .switch { position: relative; display: inline-block; width: 38px; height: 20px; flex: none; } #tj-helper-panel .switch input { opacity: 0; width: 0; height: 0; } #tj-helper-panel .slider { position: absolute; cursor: pointer; inset: 0; background: #2a3a5a; border-radius: 20px; transition: .2s; } #tj-helper-panel .slider::before { content: ''; position: absolute; width: 14px; height: 14px; left: 3px; top: 3px; background: #fff; border-radius: 50%; transition: .2s; } #tj-helper-panel .switch input:checked + .slider { background: #f5a623; } #tj-helper-panel .switch input:checked + .slider::before { transform: translateX(18px); } #tj-helper-panel .hint { font-size: 10px; color: #556677; text-align: center; line-height: 1.7; margin: 8px 0; } #tj-helper-panel .note-hdr { display: flex; justify-content: space-between; align-items: center; font-size: 11px; color: #88ddff; margin-bottom: 4px; } #noteList { max-height: 120px; overflow-y: auto; } #noteList::-webkit-scrollbar { width: 4px; } #noteList::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 4px; } #tj-next-toast { position: fixed; left: 50%; bottom: 30px; transform: translateX(-50%); background: rgba(20,28,50,0.96); border: 1px solid rgba(255,215,0,0.25); color: #e2e8f0; padding: 12px 20px; border-radius: 14px; font-family: system-ui; font-size: 14px; z-index: 1000000; display: flex; align-items: center; gap: 14px; box-shadow: 0 12px 36px rgba(0,0,0,0.6); } #tj-next-toast .cnt { color: #ffd700; font-size: 16px; } #tj-next-toast .cancel { background: rgba(255,255,255,0.08); border: none; color: #c8d6e5; padding: 6px 14px; border-radius: 8px; cursor: pointer; font-size: 13px; } .tj-tip { position: fixed; left: 50%; bottom: 100px; transform: translateX(-50%); background: rgba(20,28,50,0.96); border: 1px solid rgba(255,255,255,0.12); color: #e2e8f0; padding: 10px 18px; border-radius: 10px; font-family: system-ui; font-size: 13px; z-index: 1000000; box-shadow: 0 10px 30px rgba(0,0,0,0.5); } `); const panel = document.createElement('div'); panel.id = 'tj-helper-panel'; panel.innerHTML = `
📘 学习辅助 合规版 v1.2
运行情况
运行状态未开始
播放速率1x
学习时间(真实播放)
00:00:00
本次会话
今日累计00:00:00
本页累计00:00:00
当前视频
进度--:-- / --:--
🔁 自动下一节 仅在本节自然播放结束后切换
空格 播放/暂停 · ←/→ 快退/快进 ${CONFIG.seekStep}s · ↑/↓ 音量 · M 静音 · N 笔记
📝 时间点笔记 清空本页
`; document.body.appendChild(panel); // 按钮 document.getElementById('oneClickBtn').addEventListener('click', startOneClickStudy); document.getElementById('stopBtn').addEventListener('click', stopStudy); // 自动下一节开关 const toggle = document.getElementById('autoNextToggle'); toggle.checked = CONFIG.autoNext.enabled; toggle.addEventListener('change', e => { CONFIG.autoNext.enabled = e.target.checked; localStorage.setItem('tj_auto_next', e.target.checked ? '1' : '0'); if (!e.target.checked) { cancelPendingNext(); studyRunning = false; } }); // 拖动 const handle = document.getElementById('tjDragHandle'); let dragging = false, sx = 0, sy = 0, ox = 0, oy = 0; handle.addEventListener('mousedown', e => { e.preventDefault(); const rect = panel.getBoundingClientRect(); ox = rect.left; oy = rect.top; sx = e.clientX; sy = e.clientY; dragging = true; panel.style.transition = 'none'; }); document.addEventListener('mousemove', e => { if (!dragging) return; e.preventDefault(); const nx = Math.max(0, Math.min(ox + e.clientX - sx, innerWidth - panel.offsetWidth)); const ny = Math.max(0, Math.min(oy + e.clientY - sy, innerHeight - panel.offsetHeight)); panel.style.left = nx + 'px'; panel.style.top = ny + 'px'; panel.style.right = 'auto'; panel.style.bottom = 'auto'; }); document.addEventListener('mouseup', () => { if (dragging) { dragging = false; panel.style.transition = ''; } }); // 清空本页笔记 document.getElementById('clearNotesBtn').addEventListener('click', () => { if (!confirm('确定清空当前页面的所有笔记?')) return; saveNotes(loadNotes().filter(n => n.page !== location.href)); renderNotes(); }); renderNotes(); refreshPanel(video); } // ===================== 初始化 ===================== async function init() { let video; try { video = await waitForVideo(); } catch { log('未找到视频元素。如播放器不是原生 video,请调整 CONFIG.videoSelector。'); return; } log('视频元素已找到'); lastTick = performance.now(); bindShortcuts(video); bindEndedHandler(video); createPanel(video); // SPA 切换视频时重绑 const observer = new MutationObserver(() => { const v = $(CONFIG.videoSelector); if (v && v !== video) { if (rafId) cancelAnimationFrame(rafId); cancelPendingNext(); video = v; lastTick = performance.now(); bindShortcuts(video); bindEndedHandler(video); refreshPanel(video); } }); observer.observe(document.body, { childList: true, subtree: true }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();