// ==UserScript== // @name 教师教育网(px.teacheredu.cn) 倍速自动学习助手 // @namespace https://px.teacheredu.cn/ // @version 1.6 // @description v1.6 视频倍速(实测校验)+按平台真实目录结构自动下一节+文档课保活/到点提醒|仅加速真实播放,不伪造任何学习数据(0.5~16x 可调) / 自动播放 / 自动静音 / 防失焦暂停 / 播放器倍速锁定 / 播完自动下一节 / 弹窗自动处理 / 答题自动停车提醒。仅加速真实播放,不伪造任何学习进度上报。 // @author Yuanbao // @match *://px.teacheredu.cn/* // @match *://*.teacheredu.cn/* // @match *://*.polyv.net/* // @match *://*.polyv.com/* // @match *://*.alicdn.com/* // @match *://*.bokecc.com/* // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_setClipboard // @grant GM_registerMenuCommand // @run-at document-start // ==/UserScript== (function () { 'use strict'; /* ============================ 配置 ============================ */ const CFG_KEY = 'tedu_helper_cfg_v1'; const DEFAULTS = { rate: 1.5, // 倍速 autoplay: true, // 自动播放 mute: true, // 自动静音(浏览器要求静音才允许自动播放) antiPause: true, // 防暂停 / 防失焦暂停 lockRate: true, // 锁定倍速,播放器自己改不回去 nextAuto: true, // 播完自动下一节 autoDialog: true, // 自动处理普通弹窗 fakeVisible: true, // 让页面始终"可见",避免切后台被暂停 /* —— 文档课(PDF)辅助:只做「保住真实停留」与「到点提醒/自动切换」,绝不伪造停留时长 —— */ docTargetMin: 5, // 每篇文档目标停留分钟数(真实分钟,非加速) docAutoNext: true, // 真实挂满目标时长后,自动点「下一节/下一个」 docNotify: true, // 挂满时弹提醒 docKeepAlive: true, // 保活:鼠标长时间不动 / 切后台导致平台"停止计时"时,保住你真实开着页面的时间 docParallel: true, // 统计同时开着的页面数,方便多门课并行挂 brute: true, // 实测倍速不生效时,自动尝试播放器自带的倍速 API 兜底 stuckSeconds: 20, // 卡住多少秒尝试恢复 showPanel: true }; let cfg = Object.assign({}, DEFAULTS); try { Object.assign(cfg, JSON.parse(GM_getValue(CFG_KEY, '{}')) || {}); } catch (e) {} const saveCfg = () => { try { GM_setValue(CFG_KEY, JSON.stringify(cfg)); } catch (e) {} }; const IS_IFRAME = window.self !== window.top; const log = (...a) => console.log('%c[教师教育网助手]', 'color:#4c8bf5;font-weight:bold', ...a); /* ================== 1. 底层劫持(document-start 生效) ================== */ // 1.1 让页面永远"可见",破解"切后台/最小化就暂停" if (cfg.fakeVisible) { try { Object.defineProperty(Document.prototype, 'hidden', { configurable: true, get: () => false }); Object.defineProperty(Document.prototype, 'visibilityState', { configurable: true, get: () => 'visible' }); Object.defineProperty(Document.prototype, 'webkitHidden', { configurable: true, get: () => false }); } catch (e) {} } // 1.2 倍速锁定:播放器无论怎么改 playbackRate,实际播放速度都是我们设定的值 const MP = HTMLMediaElement.prototype; const rateDesc = Object.getOwnPropertyDescriptor(MP, 'playbackRate'); const defRateDesc = Object.getOwnPropertyDescriptor(MP, 'defaultPlaybackRate'); let lockRate = cfg.lockRate ? cfg.rate : null; if (rateDesc && rateDesc.set) { Object.defineProperty(MP, 'playbackRate', { configurable: true, enumerable: true, get() { return lockRate != null ? lockRate : rateDesc.get.call(this); }, set(v) { try { rateDesc.set.call(this, lockRate != null ? lockRate : v); } catch (e) {} } }); } if (defRateDesc && defRateDesc.set) { Object.defineProperty(MP, 'defaultPlaybackRate', { configurable: true, enumerable: true, get() { return lockRate != null ? lockRate : defRateDesc.get.call(this); }, set(v) { try { defRateDesc.set.call(this, lockRate != null ? lockRate : v); } catch (e) {} } }); } // 1.3 防暂停:接管过的视频,平台调用 pause() 直接无视 const managed = new WeakSet(); let userPaused = false; const origPause = MP.pause; MP.pause = function () { if (managed.has(this) && cfg.antiPause && !userPaused && !this.ended) return; return origPause.apply(this, arguments); }; /* ================== 2. 视频发现(含同源 iframe 递归) ================== */ function collectVideos() { const out = []; const walk = (doc, depth) => { if (!doc || depth > 4) return; try { doc.querySelectorAll('video').forEach(v => out.push(v)); } catch (e) {} try { doc.querySelectorAll('iframe').forEach(f => { try { if (f.contentDocument) walk(f.contentDocument, depth + 1); } catch (e) {} }); } catch (e) {} }; walk(document, 0); return out; } /* ================== 3. 播放控制 ================== */ const seen = new WeakSet(); const lastTime = new WeakMap(); const stuckAt = new WeakMap(); const endedDone = new WeakSet(); let quizBlocked = false; /** 用原生 setter 强制写入倍速(绕过被劫持的 getter/setter) */ function forceRate(v) { const want = cfg.rate; try { if (rateDesc && rateDesc.set) rateDesc.set.call(v, want); else v.playbackRate = want; if (defRateDesc && defRateDesc.set) defRateDesc.set.call(v, want); } catch (e) { try { v.playbackRate = want; } catch (e2) {} } } /** 读原生真实倍速(绕开被劫持的 getter),用于判断是否真的生效 */ function nativeRate(v) { try { if (rateDesc && rateDesc.get) return rateDesc.get.call(v); } catch (e) {} return null; } /* v1.3 新增:实测倍速 —— 比对「视频走了多少秒」vs「真实过了多少秒」 */ let accVid = 0, accWall = 0, lastC = null, lastW = null; let realRate = null, realRateState = '测量中…'; function updateMeasure(v) { if (!v || userPaused) { lastC = null; return; } const nowW = Date.now() / 1000; const c = v.currentTime; if (lastC != null && !v.paused && !v.ended && !v.seeking) { const dc = c - lastC, dw = nowW - lastW; // 排除 seek/跳变造成的异常采样 if (dc >= 0 && dc < 3 && dw > 0 && dw < 3) { accVid += dc; accWall += dw; } } lastC = c; lastW = nowW; if (accWall >= 8) { realRate = accWall > 0 ? accVid / accWall : null; accVid = 0; accWall = 0; const gap = Math.abs((realRate || 0) - cfg.rate); realRateState = (realRate != null && gap < 0.15) ? '✅ 倍速生效' : (realRate != null && Math.abs(realRate - 1) < 0.15 ? '❌ 播放器强制锁 1x' : '⚠ 部分生效'); // 不生效 → 尝试播放器自带 API 兜底 if (gap >= 0.15 && cfg.brute) tryPlayerAPI(cfg.rate); } } /* v1.3 新增:原生 playbackRate 压不进去时,尝试调用播放器自己的倍速 API */ let bruteTried = false; function tryPlayerAPI(rate) { if (bruteTried) return; bruteTried = true; const w = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window); const tried = []; const call = (obj, m, label) => { if (obj && typeof obj[m] === 'function') { try { obj[m](rate); tried.push(label + '.' + m); } catch (e) {} } }; try { if (w.videojs && w.videojs.players) { Object.keys(w.videojs.players).forEach(k => { const p = w.videojs.players[k]; ['playbackRate', 'setRate', 'rate'].forEach(m => call(p, m, 'videojs[' + k + ']')); }); } ['player', 'myPlayer', 'CCPlayer', 'ccPlayer', 'bokeccPlayer', '_player', 'videoPlayer', 'vp'].forEach(n => { const o = w[n]; if (!o) return; ['setRate', 'setPlaybackRate', 'setSpeed', 'playbackRate', 'rate', 'speed'].forEach(m => call(o, m, n)); }); } catch (e) {} if (tried.length) log('已尝试播放器自带倍速 API:', tried.join(', ')); else log('未找到播放器自带倍速 API(该播放器可能完全不支持变速)'); // 10 秒后允许再试 setTimeout(() => { bruteTried = false; }, 10000); } /* v1.5 新增:文档课保活 —— 只在【你确实开着页面】时防止平台因"空闲/后台"停止计时。 注意:它不会让平台多记哪怕 1 秒你没真实停留的时间;如果你人不在电脑前,请勿开启。 */ let docNotified = false, docNextTried = false; function docKeepAlive() { const pt0 = pageType(); if (!cfg.docKeepAlive || !(pt0 === 'pdf' || pt0 === 'doc' || pt0 === 'material')) return; try { const el = document.body || document.documentElement; if (!el) return; // 极轻量活动信号:位置不变的 mousemove,不触发任何点击或按钮 const ev = new MouseEvent('mousemove', { bubbles: true, cancelable: true, view: window, clientX: 1, clientY: 1, screenX: 1, screenY: 1 }); el.dispatchEvent(ev); document.dispatchEvent(ev); } catch (e) {} } /* v1.5 新增:多页面并行统计(localStorage 心跳,仅计数,不上报任何数据) */ const PARALLEL_KEY = 'tedu_parallel_tabs'; function parallelBeat() { if (!cfg.docParallel) return 0; try { const id = 'tab_' + (Math.random().toString(36).slice(2, 9)); let arr = []; try { arr = JSON.parse(localStorage.getItem(PARALLEL_KEY) || '[]'); } catch (e) {} const now = Date.now(); const mine = arr.find(x => x.id === location.pathname + '_' + Math.round(stayStart / 1000)); if (mine) mine.t = now; else arr.push({ id: location.pathname + '_' + Math.round(stayStart / 1000), u: location.pathname.slice(-28), t: now }); // 只保留 90 秒内有心跳的 arr = arr.filter(x => now - x.t < 90000); localStorage.setItem(PARALLEL_KEY, JSON.stringify(arr.slice(-20))); return arr.length; } catch (e) { return 1; } } /* v1.5 新增:文档课挂满检测 —— 到点提醒 / 自动切下一节 */ function docCheckDone() { const pt1 = pageType(); if (!(pt1 === 'pdf' || pt1 === 'doc' || pt1 === 'material')) return; const elapsedMin = (Date.now() - stayStart) / 60000; if (elapsedMin < cfg.docTargetMin) return; if (!docNotified && cfg.docNotify) { docNotified = true; toast(`✅ 本页已真实停留 ${cfg.docTargetMin} 分钟,可切换下一个`, 8000); log(`文档课已真实停留 ${cfg.docTargetMin} 分钟`); } // 自动切下一节:点的是真实存在的"下一节/下一个"按钮,属于把真实完成的动作自动化 if (cfg.docAutoNext && !docNextTried) { docNextTried = true; const hit = clickByText(['下一节', '下一章', '下一课', '下一个', '继续学习', '下一步'], { limit: 1 }); log('文档课自动切换:', hit > 0 ? '已点击下一节' : '未找到下一节按钮(可能导航在父页面,请手动切换)'); } } /* v1.4 新增:课件类型识别 —— 视频课 / 文档课(PDF) / 未知 */ let stayStart = Date.now(); function pageType() { const href = location.href.toLowerCase(); // v1.6:平台目录页的真实图标类(优先级最高,比猜 URL 准) if (document.querySelector('.font-syllabus-online-video')) return 'video'; // 视频 if (document.querySelector('.font-syllabus-page')) return 'doc'; // 文档/PDF if (document.querySelector('.font-syllabus-material')) return 'material'; // 材料 // 有 video 元素 → 视频课 if (document.querySelector('video')) return 'video'; // PDF.js 内置阅读器(教师网课件常见) if (/#viewercontainer|\.pdfviewer|web\/viewer\.html/i.test(href) || document.querySelector('#viewerContainer,.pdfViewer,.textLayer,[id="viewer"]')) return 'pdf'; if (/\/pdf\/|\/swf\/|\.pdf(\?|$)/i.test(href)) return 'pdf'; // 老课件:swf / 静态 htm if (/\/swf\/|\.swf(\?|$)|flash/i.test(href)) return 'flash'; if (/embed|object/i.test(href)) return 'flash'; return 'unknown'; } function stayText() { const s = Math.floor((Date.now() - stayStart) / 1000); return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; } function detectPlayer() { const w = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; if (w.polyv || w.PolyvPlayer) return '保利威 polyv'; if (w.Aliplayer) return '阿里云 Aliplayer'; if (w.videojs) return 'video.js'; if (w.TCPlayer || w.TcPlayer) return '腾讯云 TCPlayer'; if (w.XgPlayer || w.Player) return '西瓜 xgplayer'; if (w.CKobject || w.CKplayer) return 'CKplayer'; if (document.querySelector('.prism-player')) return '阿里云(prism)'; if (document.querySelector('.pv-play-wrap,.polyv-player')) return 'polyv(DOM)'; if (document.querySelector('video')) return '原生 video'; if (pageType() === 'pdf') return 'PDF 文档课件(无视频)'; return '未识别'; } function applyTo(v) { if (!seen.has(v)) { seen.add(v); managed.add(v); log('接管视频:', (v.currentSrc || v.src || '').slice(0, 90), '| 播放器:', detectPlayer()); // v1.3 关键修复:原判断 `Math.abs(v.playbackRate - lockRate)` 会被被劫持的 getter 骗过 // (getter 恒返回 lockRate,差值永远是 0,导致原生 playbackRate 从未被真正设置过) // 所以这里改为:只要发生 ratechange,无条件用原生 setter 重新压回去 v.addEventListener('ratechange', () => { forceRate(v); }, true); v.addEventListener('ended', () => onEnded(v), true); // 有些播放器会在 loadedmetadata / play / timeupdate 后重置倍速,一并兜住 ['loadedmetadata', 'canplay', 'playing', 'play'].forEach(ev => v.addEventListener(ev, () => setTimeout(() => forceRate(v), 0), true)); } // 静音 if (cfg.mute && !v.muted) { try { v.muted = true; } catch (e) {} } // 倍速:无条件强制同步原生 rate(不能再靠 getter 判断,否则永远判为"已生效") forceRate(v); // 自动播放 if (cfg.autoplay && !userPaused && v.paused && !v.ended) { const p = v.play(); if (p && p.catch) { p.catch(() => { // 被浏览器自动播放策略拦下 → 静音重试 try { v.muted = true; const p2 = v.play(); if (p2 && p2.catch) p2.catch(() => {}); } catch (e) {} }); } } } function tick() { if (quizBlocked) return; // 遇到答题,全自动停车等用户 const vids = collectVideos(); updateMeasure(vids[0]); // v1.3:实测倍速 docKeepAlive(); // v1.5:文档课保活(防空闲停止计时) docCheckDone(); // v1.5:挂满提醒 + 自动切下一个 if (!IS_IFRAME) updatePanel(vids[0]); vids.forEach(v => { applyTo(v); if (userPaused) return; const t = v.currentTime; const prev = lastTime.get(v); lastTime.set(v, t); // 卡死检测:进度不动且已缓冲好 if (prev != null && Math.abs(t - prev) < 0.05 && v.readyState >= 2 && !v.paused && !v.ended) { const since = stuckAt.get(v) || Date.now(); stuckAt.set(v, since); if ((Date.now() - since) / 1000 > cfg.stuckSeconds) { stuckAt.set(v, Date.now()); log('检测到卡顿,尝试恢复…'); try { v.play(); } catch (e) {} try { if (v.duration && isFinite(v.duration)) v.currentTime = Math.min(v.duration - 1, t + 0.5); } catch (e) {} } } else { stuckAt.set(v, Date.now()); } // 兜底判定结束(有些播放器 ended 事件不可靠) if (v.duration && isFinite(v.duration) && v.duration > 0 && t >= v.duration - 0.6 && !endedDone.has(v)) { onEnded(v); } }); if (!IS_IFRAME) { if (cfg.autoDialog) handleDialogs(); if (endedPending && cfg.nextAuto) tryNext(); } } /* ================== 4. 播完 → 自动下一节 ================== */ let endedPending = false, endedAt = 0, nextTries = 0; function onEnded(v) { if (endedDone.has(v)) return; endedDone.add(v); endedPending = true; endedAt = Date.now(); nextTries = 0; log('本节播放完成,准备切换下一节…'); if (!cfg.nextAuto) { toast('本节已完成(自动下一节已关闭)'); return; } setTimeout(tryNext, 800); } const NEXT_WORDS = ['下一节', '下一章', '下一课时', '下一课', '下一集', '下一个', '继续学习', '继续观看', '下一页', '下一步']; const CLOSE_WORDS = ['确定', '确认', '知道了', '好的', '继续', '关闭', '取消', '朕知道了']; const QUIZ_WORDS = ['答题', '测验', '考试', '问卷', '请回答', '单选题', '判断题', '多选题', '随堂', '闯关']; const isVisible = el => { if (!el) return false; const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0'; }; function clickByText(words, opts = {}) { const { root = document, limit = 1 } = opts; let hit = 0; const nodes = root.querySelectorAll( 'button,a,[role="button"],.el-button,.ant-btn,.btn,[class*="btn"],[class*="Btn"],' + 'li,.el-tree-node,[class*="item"],[class*="Item"],[class*="chapter"],[class*="catalog"],[class*="course"],[onclick],.el-dialog__footer button,.ant-modal-footer button' ); for (const el of nodes) { if (hit >= limit) break; if (!isVisible(el)) continue; if (el.disabled || el.getAttribute('aria-disabled') === 'true') continue; const txt = (el.innerText || el.textContent || '').replace(/\s+/g, '').trim(); if (!txt || txt.length > 40) continue; if (!words.some(w => txt === w || txt.includes(w))) continue; try { el.scrollIntoView({ block: 'center' }); el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); el.click(); hit++; log('点击:', txt); } catch (e) {} } return hit; } /* v1.6 新增:按真实目录结构切换下一节(用平台实际 DOM,比猜按钮文案准得多) 目录项:ul.lis-content li.lis-inside-content 当前项:含 #top_play 的 li 跳转:h2 的 onclick 里 window.location.href='...' */ function readCatalog() { const items = Array.from(document.querySelectorAll('ul.lis-content li.lis-inside-content')); if (!items.length) return null; const curIdx = items.findIndex(li => li.querySelector('#top_play')); const list = items.map((li, i) => { const h2 = li.querySelector('h2'); const btn = li.querySelector('button'); const onclick = h2 ? (h2.getAttribute('onclick') || '') : ''; const m = onclick.match(/window\.location\.href\s*=\s*['"]([^'"]+)['"]/); const prog = li.querySelector('.el-progress__text'); return { i, title: h2 ? (h2.textContent || '').trim().replace(/\s+/g, ' ') : '', url: m ? m[1] : '', btnText: btn ? (btn.textContent || '').trim() : '', done: prog ? /100/.test(prog.textContent || '') : false, isCurrent: i === curIdx }; }); return { curIdx, list }; } /** 找下一个未完成条目:优先跳当前项之后第一个未完成的;没有就跳第一个未完成的 */ function findNextCatalog() { const cat = readCatalog(); if (!cat) return null; const { curIdx, list } = cat; const after = list.slice(curIdx + 1).find(x => x.url && !x.done && !/待考试|考试/.test(x.btnText)); if (after) return after; const any = list.find(x => x.url && !x.done && !x.isCurrent && !/待考试|考试/.test(x.btnText)); return any || null; } function tryNextCatalog() { const next = findNextCatalog(); if (!next || !next.url) return false; log('目录切换到下一节:', next.title, '→', next.url.slice(0, 90)); toast(`➡ 下一节:${next.title.slice(0, 24)}`, 4000); window.location.href = next.url; return true; } function tryNext() { if (!endedPending) return; if (Date.now() - endedAt > 45000 || nextTries > 14) { // 超时放弃,交还给用户 endedPending = false; toast('⚠ 未自动找到下一节,请手动点击(按 D 导出诊断信息反馈适配)', 9000); return; } nextTries++; // 弹窗里的"确定/继续学习"优先 if (cfg.autoDialog && (clickByText(['继续学习', '继续观看', '知道了', '确定', '确认', '好的'], { limit: 1 }) > 0)) { setTimeout(() => { endedPending = false; }, 1500); return; } // v1.6:先按真实目录结构跳(最准) if (cfg.nextAuto && tryNextCatalog()) { setTimeout(() => { endedPending = false; locationReloadCheck(); }, 1500); return; } // 兜底:目录树 / 底部按钮里的"下一节" if (clickByText(NEXT_WORDS, { limit: 1 }) > 0) { setTimeout(() => { endedPending = false; locationReloadCheck(); }, 2000); return; } setTimeout(tryNext, 2500); } let lastHref = location.href; function locationReloadCheck() { lastHref = location.href; } /* ================== 5. 弹窗处理 ================== */ function handleDialogs() { const dialogs = document.querySelectorAll( '.el-dialog,.el-message-box,.el-overlay,.ant-modal,.ant-modal-wrap,.modal,.dialog,[class*="dialog"],[class*="Dialog"],[class*="modal"],[class*="Modal"],[class*="popup"],[class*="Popup"],[class*="mask"],[class*="tips"]' ); for (const d of dialogs) { if (!isVisible(d)) continue; const txt = (d.innerText || '').replace(/\s+/g, ''); if (!txt) continue; // 答题 / 考试类:全自动停车,交给用户 if (QUIZ_WORDS.some(w => txt.includes(w)) || d.querySelector('input[type=radio],input[type=checkbox],textarea,.el-radio,.ant-radio')) { if (!quizBlocked) { quizBlocked = true; collectVideos().forEach(v => { try { origPause.call(v); } catch (e) {} }); toast('🛑 检测到答题/测验弹窗,已暂停自动播放,请手动完成后刷新页面', 0); log('检测到答题弹窗,自动流程已停止。'); setPanelAlert(true); } return; } // 普通提示弹窗:点掉 if (clickByText(CLOSE_WORDS, { root: d, limit: 1 }) > 0) { log('关闭弹窗:', txt.slice(0, 30)); } } } /* ================== 6. 控制面板 ================== */ let panel, panelInfo, panelAlert = false; const PANEL_CSS = ` #teduPanel{position:fixed!important;top:70px!important;right:14px!important;z-index:2147483647!important; font:13px/1.5 -apple-system,"Microsoft YaHei",sans-serif!important; background:#1b1e26!important;color:#fff!important;border:2px solid #4c8bf5!important;border-radius:12px!important; padding:10px 12px!important;box-shadow:0 8px 28px rgba(0,0,0,.5)!important; min-width:208px!important;max-width:240px!important;user-select:none!important; opacity:1!important;visibility:visible!important;display:block!important;transition:transform .2s} #teduPanel.alert{background:#7a1f1f!important;border-color:#ff6b6b!important;animation:teduBlink 1s infinite} @keyframes teduBlink{50%{background:#a52a2a!important}} #teduPanel .t-title{font-size:12px;opacity:.8;margin-bottom:6px;display:flex;justify-content:space-between;gap:8px} #teduPanel .t-row{display:flex;align-items:center;gap:5px;margin:5px 0;flex-wrap:wrap} #teduPanel button{background:rgba(255,255,255,.16)!important;color:#fff!important;border:0!important;border-radius:7px!important; padding:4px 9px!important;cursor:pointer!important;font-size:12px!important;line-height:1.4!important} #teduPanel button:hover{background:rgba(255,255,255,.3)!important} #teduPanel button.on{background:#3d7eff!important} #teduPanel .t-rate{font-size:20px!important;font-weight:700!important;color:#6cf!important;margin:0 4px} #teduPanel .t-info{font-size:11px;opacity:.85;margin-top:6px;line-height:1.6;word-break:break-all;max-width:214px} #teduPanel .t-min{cursor:pointer;opacity:.7} #teduPanel.mini{transform:translateX(78%)} #teduToast{position:fixed!important;left:50%;top:120px;transform:translateX(-50%);z-index:2147483647!important; background:#1b1e26!important;color:#fff!important;padding:10px 18px;border-radius:10px!important;font-size:14px!important; box-shadow:0 6px 22px rgba(0,0,0,.45)!important;max-width:70vw} `; function injectCSS() { try { if (document.getElementById('teduStyle')) return; const s = document.createElement('style'); s.id = 'teduStyle'; s.textContent = PANEL_CSS; (document.head || document.documentElement || document.body).appendChild(s); } catch (e) {} try { if (typeof GM_addStyle === 'function') GM_addStyle(PANEL_CSS); } catch (e) {} } function buildPanel() { if (panel && document.contains && document.contains(panel)) return true; const host = document.body || document.documentElement; if (!host) return false; try { injectCSS(); panel = document.createElement('div'); panel.id = 'teduPanel'; panel.innerHTML = `
🎓 倍速学习助手${IS_IFRAME ? '(播放器层)' : ''}—
${cfg.rate}x
初始化…
`; host.appendChild(panel); } catch (e) { log('面板创建失败:', e && e.message); panel = null; return false; } const $ = id => panel.querySelector('#' + id); $('teduMinus').onclick = () => setRate(Math.max(0.5, +(cfg.rate - 0.25).toFixed(2))); $('teduPlus').onclick = () => setRate(Math.min(16, +(cfg.rate + 0.25).toFixed(2))); panel.querySelectorAll('[data-r]').forEach(b => b.onclick = () => setRate(+b.dataset.r)); $('teduPlay').onclick = () => { userPaused = !userPaused; $('teduPlay').textContent = userPaused ? '▶ 继续' : '⏸ 暂停'; collectVideos().forEach(v => { try { userPaused ? origPause.call(v) : v.play(); } catch (e) {} }); }; $('teduNext').onclick = e => { cfg.nextAuto = !cfg.nextAuto; e.target.classList.toggle('on', cfg.nextAuto); saveCfg(); }; $('teduAnti').onclick = e => { cfg.antiPause = !cfg.antiPause; e.target.classList.toggle('on', cfg.antiPause); saveCfg(); }; $('teduMute').onclick = e => { cfg.mute = !cfg.mute; e.target.classList.toggle('on', cfg.mute); saveCfg(); collectVideos().forEach(v => { try { v.muted = cfg.mute; } catch (err) {} }); }; $('teduMin').onclick = () => { panel.classList.add('mini'); setTimeout(() => panel.classList.remove('mini'), 1500); }; log('控制面板已显示'); return true; } // 面板自愈:被 SPA 重渲染删掉就重建,最多试 40 次(约 40 秒) let panelTries = 0; function ensurePanel() { if (!cfg.showPanel) return; if (panel && document.contains && document.contains(panel)) return; if (panelTries++ > 40) { if (panelTries === 41) console.warn('[教师教育网助手] 面板始终无法创建,请在控制台执行 diagnose() 并把结果反馈'); return; } buildPanel(); } function setPanelAlert(on) { panelAlert = on; if (panel) panel.classList.toggle('alert', on); } function setRate(r) { cfg.rate = r; saveCfg(); lockRate = cfg.lockRate ? r : null; const el = panel && panel.querySelector('#teduRate'); if (el) el.textContent = r + 'x'; // 用原生 setter 强制写入,并重置实测窗口 collectVideos().forEach(v => forceRate(v)); accVid = 0; accWall = 0; lastC = null; realRate = null; realRateState = '重新测量中…'; bruteTried = false; toast('倍速 ' + r + 'x'); } function updatePanel(v) { if (!cfg.showPanel) return; ensurePanel(); if (!panel) return; const info = panel.querySelector('#teduInfo'); if (!info) return; if (quizBlocked) { info.innerHTML = '🛑 答题中,已停车
完成后刷新页面继续'; return; } if (!v) { const pt = pageType(); if (pt === 'pdf' || pt === 'doc' || pt === 'material') { const el = (Date.now() - stayStart) / 60000; const tgt = cfg.docTargetMin; const pct = Math.min(100, tgt > 0 ? (el / tgt * 100) : 100); const done = el >= tgt; const mins = Math.floor(el), secs = Math.floor((el * 60) % 60); const leftM = Math.max(0, tgt - el); const barColor = done ? '#5ddc7a' : '#4c8bf5'; info.innerHTML = `📄 ${pt === 'material' ? '材料' : '文档'}课件(无视频)
` + `学时按真实停留算,倍速不适用
` + `⏱ ${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')} / 目标 ${tgt} 分` + `
` + `
` + (done ? `✅ 已挂满,可切下一个${cfg.docAutoNext ? '(已尝试自动切换)' : ''}` : `还需 ${leftM.toFixed(1)} 分钟 · N键设目标`) + (cfg.docKeepAlive ? `
🛡 保活中(防空闲停计)` : '') + (cfg.docParallel ? `
🗂 并行页面 ${parallelBeat()} 个` : ''); } else if (pt === 'flash') { info.innerHTML = `🧩 Flash/老式课件
` + `非 HTML5 播放器,脚本无法接管。
` + `⏱ 本页已停留 ${stayText()}`; } else { info.innerHTML = `当前页面未检测到视频
${detectPlayer()}
进入视频播放页即可生效
⏱ 本页停留 ${stayText()}`; } return; } const d = v.duration && isFinite(v.duration) ? v.duration : 0; const pct = d ? (v.currentTime / d * 100).toFixed(1) : '0'; const fmt = s => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(Math.floor(s % 60)).padStart(2, '0')}`; const nr = nativeRate(v); const rr = realRate == null ? '…' : realRate.toFixed(2) + 'x'; const okColor = (realRate != null && Math.abs(realRate - cfg.rate) < 0.15) ? '#5ddc7a' : '#ffb86c'; info.innerHTML = `${v.paused ? '⏸ 已暂停' : '▶ 播放中'} ${v.playbackRate}x ${cfg.mute ? '🔇' : '🔊'}
` + `${fmt(v.currentTime)} / ${d ? fmt(d) : '--:--'} (${pct}%)
` + `实测 ${rr} ${realRateState}` + (nr != null ? `
原生rate=${nr.toFixed(2)}` : '') + `
${detectPlayer()}${endedPending ? ' · 等待切换下一节' : ''}`; } let toastEl, toastTimer; function toast(msg, ms = 2600) { if (IS_IFRAME) return; if (!document.body) return; if (!toastEl) { toastEl = document.createElement('div'); toastEl.id = 'teduToast'; document.body.appendChild(toastEl); } toastEl.textContent = msg; toastEl.style.display = 'block'; clearTimeout(toastTimer); if (ms > 0) toastTimer = setTimeout(() => { toastEl.style.display = 'none'; }, ms); } /* ================== 7. 诊断信息(按 D 导出) ================== */ function diagnose() { const vids = collectVideos(); const lines = [ '=== 教师教育网助手 诊断报告 ===', 'URL: ' + location.href, '时间: ' + new Date().toLocaleString(), '播放器: ' + detectPlayer(), '配置: ' + JSON.stringify(cfg), '课件类型: ' + ({video:'视频课', pdf:'文档课(PDF·无视频)', flash:'Flash老课件', unknown:'未知'}[pageType()] || '?'), 'video 数量: ' + vids.length, '本页已停留: ' + stayText(), '目录项: ' + (() => { const c = readCatalog(); return c ? `${c.list.length} 项,当前第 ${c.curIdx} 项,已完成 ${c.list.filter(x => x.done).length} 项` : '未找到目录(ul.lis-content)'; })(), '下一节候选: ' + (() => { const n = findNextCatalog(); return n ? `[${n.i}] ${n.title}` : '无'; })(), '实测倍速: ' + (realRate == null ? '尚未测出(需播放 8 秒以上)' : realRate.toFixed(2) + 'x') + ' | 判定: ' + realRateState, '设定倍速: ' + cfg.rate + 'x' ]; vids.forEach((v, i) => { const nr = nativeRate(v); lines.push(` #${i} src=${(v.currentSrc || v.src || '').slice(0, 120)}`); lines.push(` dur=${v.duration} t=${v.currentTime.toFixed(1)} 劫持后rate=${v.playbackRate} 原生rate=${nr == null ? '?' : nr.toFixed(2)} paused=${v.paused} muted=${v.muted}`); }); lines.push('iframe 数量: ' + document.querySelectorAll('iframe').length); document.querySelectorAll('iframe').forEach((f, i) => lines.push(` iframe#${i} src=${(f.src || '').slice(0, 120)}`)); lines.push('--- 可点击元素文本(前 80 个)---'); const els = document.querySelectorAll('button,a,[role=button],.el-button,.ant-btn,li,.el-tree-node,[class*=item],[class*=btn]'); let n = 0; for (const el of els) { if (n++ >= 80) break; if (!isVisible(el)) continue; const t = (el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 40); if (t) lines.push(` <${el.tagName.toLowerCase()} class="${(el.className || '').toString().slice(0, 50)}"> ${t}`); } const report = lines.join('\n'); console.log(report); try { GM_setClipboard(report); toast('诊断报告已复制到剪贴板,可粘贴反馈'); } catch (e) { toast('诊断报告已输出到控制台(F12)'); } return report; } /* ================== 8. 快捷键 ================== */ document.addEventListener('keydown', e => { const tag = (e.target && e.target.tagName) || ''; if (/INPUT|TEXTAREA|SELECT/.test(tag) || e.target.isContentEditable) return; if (e.metaKey || e.ctrlKey || e.altKey) return; const k = e.key.toLowerCase(); if (k === 'd') { e.preventDefault(); diagnose(); } else if (k === '+' || k === '=') setRate(Math.min(16, +(cfg.rate + 0.25).toFixed(2))); else if (k === '-' || k === '_') setRate(Math.max(0.5, +(cfg.rate - 0.25).toFixed(2))); else if (k === '[') setRate(1); else if (k === ']') setRate(2); else if (k === 'p' || k === ' ') { e.preventDefault(); userPaused = !userPaused; collectVideos().forEach(v => { try { userPaused ? origPause.call(v) : v.play(); } catch (err) {} }); toast(userPaused ? '已暂停' : '继续播放'); } else if (k === 'h') { cfg.showPanel = !cfg.showPanel; saveCfg(); if (panel) panel.style.display = cfg.showPanel ? '' : 'none'; } // v1.5:文档课目标时长调节(真实分钟) else if (k === 'n') { const opts = [3, 5, 8, 10, 15, 20]; const i = opts.indexOf(cfg.docTargetMin); cfg.docTargetMin = opts[(i + 1) % opts.length]; docNotified = false; docNextTried = false; saveCfg(); toast('文档课目标停留:' + cfg.docTargetMin + ' 分钟(真实分钟)'); } else if (k === 'b') { cfg.docKeepAlive = !cfg.docKeepAlive; saveCfg(); toast('保活:' + (cfg.docKeepAlive ? '开' : '关')); } }, true); /* ================== 9. 启动 ================== */ GM_registerMenuCommand && GM_registerMenuCommand('📋 导出诊断报告', diagnose); GM_registerMenuCommand && GM_registerMenuCommand('🐢 恢复 1x 正常速度', () => setRate(1)); GM_registerMenuCommand && GM_registerMenuCommand('🏃 设为 2x', () => setRate(2)); GM_registerMenuCommand && GM_registerMenuCommand('🪟 强制重新显示面板', () => { cfg.showPanel = true; panelTries = 0; panel = null; saveCfg(); ensurePanel(); toast('已重新创建面板'); }); GM_registerMenuCommand && GM_registerMenuCommand('⏱ 设置文档课目标停留分钟数', () => { const v = prompt('每篇文档目标停留多少【真实】分钟?\n(仅用于到点提醒与自动切换,不会伪造任何上报数据)', cfg.docTargetMin); const n = parseFloat(v); if (!isNaN(n) && n > 0 && n <= 600) { cfg.docTargetMin = n; docNotified = false; docNextTried = false; saveCfg(); toast('目标停留:' + n + ' 分钟'); } }); GM_registerMenuCommand && GM_registerMenuCommand('🔄 恢复默认配置', () => { cfg = Object.assign({}, DEFAULTS); saveCfg(); location.reload(); }); // iframe 内(播放器层):做播放控制 + 也显示面板,状态上报给父页面 if (IS_IFRAME) { ensurePanel(); setInterval(() => { ensurePanel(); const v = collectVideos()[0]; if (v) { applyTo(v); updatePanel(v); try { parent.postMessage({ __tedu: 1, rate: v.playbackRate, t: v.currentTime, d: v.duration, paused: v.paused }, '*'); } catch (e) {} } }, 1000); log('iframe 播放器模式已启动'); return; } window.addEventListener('message', e => { const d = e.data; if (d && d.__tedu && panel) { const info = panel.querySelector('#teduInfo'); if (info) info.innerHTML = `▶ iframe 播放器 ${d.rate}x
${Math.floor(d.t || 0)}s / ${Math.floor(d.d || 0)}s${d.paused ? ' (暂停)' : ''}`; } }); const boot = () => { ensurePanel(); tick(); }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot(); setInterval(tick, 1000); setInterval(ensurePanel, 1500); // 面板被 SPA 重渲染冲掉时自动补回 setTimeout(ensurePanel, 300); setTimeout(ensurePanel, 1500); setTimeout(ensurePanel, 4000); // 暴露到全局,方便 F12 控制台手动排查 try { (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window).__tedu = { diagnose, ensurePanel, setRate, cfg, get panel() { return panel; } }; (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window).diagnose = diagnose; } catch (e) {} // 8 秒自检:面板还没出来就在控制台给出明确指引 setTimeout(() => { if (!panel && cfg.showPanel) { console.warn('%c[教师教育网助手] 面板未显示!', 'color:#f33;font-size:16px;font-weight:bold'); console.warn('请在控制台执行 __tedu.diagnose() 并把输出结果反馈;或点击油猴图标 → 本脚本 → 「🪟 强制重新显示面板」'); console.warn('当前 IS_IFRAME =', IS_IFRAME, '| document.body =', !!document.body, '| URL =', location.href); } }, 8000); // SPA 路由切换时重置结束标记 setInterval(() => { if (location.href !== lastHref) { lastHref = location.href; endedPending = false; quizBlocked = false; setPanelAlert(false); stayStart = Date.now(); // v1.4:换页重计停留时长、重置实测 accVid = 0; accWall = 0; lastC = null; realRate = null; realRateState = '测量中…'; docNotified = false; docNextTried = false; // v1.5:换页重置文档课状态 log('页面切换:', location.href.slice(0, 100)); } }, 1200); log('已启动 | 倍速', cfg.rate + 'x |', '快捷键: D诊断 / +−调速 / [1x / ]2x / P暂停 / H隐藏面板'); })();