// ==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 = `