// ==UserScript== // @name 寒暑假教师研修自动学习助手 // @namespace teacher-research-helper // @version 2.1.0 // @description 支持国家中小学智慧教育平台(smartedu.cn)、教师假期研修、和田/新疆专业技术人员继续教育等平台,自动播放视频、倍速学习、自动连播、弹窗处理、防挂机检测 // @author WorkBuddy // @match *://*.smartedu.cn/* // @match *://*.teacher.com.cn/* // @match *://*.xjpta.com/* // @match *://*.xjrs.gov.cn/* // @match *://*.ht.gov.cn/* // @match *://*.chinacde.edu.cn/* // @match *://*.jiaoshi.com.cn/* // @match *://*.jspx.com.cn/* // @match *://*.traintel.cn/* // @match *://*.gpstraining.cn/* // @match *://*.ceat.edu.cn/* // @match *://*.cde.edu.cn/* // @match *://*.ccen.com.cn/* // @match *://*.stu.com.cn/* // @match *://*.hxw.com.cn/* // @match *://*.qstbk.cn/* // @match *://*.htzyjs.com/* // @match *://*.xjzz.gov.cn/* // @grant unsafeWindow // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @run-at document-idle // @license MIT // @homepageURL https://github.com/teacher-research-helper // ==/UserScript== (function () { 'use strict'; /* * ============================================================ * 寒暑假教师研修自动学习助手 v2.1.0 * 支持平台: * 1. 国家中小学智慧教育平台 (smartedu.cn) * 2. 教师假期研修 (各省级平台) * 3. 和田/新疆专业技术人员继续教育 (xjpta.com 等) * 4. 其他基于视频的继续教育/研修平台 * * 核心功能: * - 视频自动播放(检测到暂停自动恢复) * - 可调倍速播放(1x ~ 16x) * - 自动连播(当前视频结束后自动切换下一集) * - 弹窗自动关闭( quiz弹窗、确认对话框等) * - 防挂机检测(绕过页面可见性检测,后台继续播放) * - 学习进度追踪与日志 * - 悬浮控制面板(可视化操作) * * 使用方法: * 1. 安装 Tampermonkey 浏览器扩展 * 2. 导入此脚本 * 3. 打开学习平台,脚本自动生效 * 4. 如平台域名不在 @match 列表中,手动添加即可 * ============================================================ */ // ==================== 配置管理 ==================== const CONFIG_KEYS = { autoPlay: 'trh_autoPlay', autoNext: 'trh_autoNext', playbackRate: 'trh_playbackRate', autoMute: 'trh_autoMute', skipDialog: 'trh_skipDialog', keepAlive: 'trh_keepAlive', skipQuiz: 'trh_skipQuiz', panelVisible: 'trh_panelVisible', speedBoost: 'trh_speedBoost', }; function getConfig(key, defaultValue) { try { const val = GM_getValue(key, defaultValue); return val !== undefined ? val : defaultValue; } catch (e) { return defaultValue; } } function setConfig(key, value) { try { GM_setValue(key, value); } catch (e) { // GM storage 不可用时忽略 } } // 全局运行状态 const STATE = { running: true, currentVideo: null, currentVideoTitle: '', completedCount: 0, startTime: Date.now(), lastAction: '', platform: 'unknown', log: [], }; // ==================== 平台检测 ==================== const PLATFORMS = { smartedu: { name: '国家中小学智慧教育平台', patterns: ['smartedu.cn'], selectors: { video: 'video', nextBtn: '.next-lesson, .next-btn, [class*="next"]', playBtn: '.vjs-big-play-button, .play-btn, button[class*="play"]', title: '.lesson-title, .course-title, h1, h2', progress: '.vjs-progress-holder, .progress-bar', quizDialog: '.quiz-dialog, .question-popup, [class*="quiz"], [class*="question"]', quizConfirm: '.quiz-confirm, .submit-btn, [class*="submit"]', }, }, teacher: { name: '全国教师继续教育网', patterns: ['teacher.com.cn', 'jspx.com.cn'], selectors: { video: 'video', nextBtn: '.next, .next-lesson, a[onclick*="next"]', playBtn: '.play, .vjs-big-play-button', title: '.title, .lesson-name, h3', progress: '.progress, .vjs-progress-holder', quizDialog: '.dialog, .modal, .popup', quizConfirm: '.ok, .confirm, .submit', }, }, xjpta: { name: '新疆/和田专业技术人员继续教育', patterns: ['xjpta.com', 'xjrs.gov.cn', 'ht.gov.cn', 'htzyjs.com', 'xjzz.gov.cn'], selectors: { video: 'video', nextBtn: '.next, .next-course, a[href*="next"], [class*="next"]', playBtn: '.play, .vjs-big-play-button, button[class*="play"]', title: '.course-title, .title, h2, h3', progress: '.progress, .vjs-progress-holder, .progress-bar', quizDialog: '.dialog, .modal, .layer, .layui-layer', quizConfirm: '.layui-layer-btn0, .confirm, .ok, .submit', }, }, generic: { name: '通用教育平台', patterns: [], selectors: { video: 'video', nextBtn: '.next, .next-lesson, .next-btn, [class*="next"]:not([class*="text"])', playBtn: '.vjs-big-play-button, .play-btn, .play, button[class*="play"]', title: 'h1, h2, h3, .title, .lesson-title, .course-title', progress: '.vjs-progress-holder, .progress-bar, .progress', quizDialog: '.modal, .dialog, .popup, .layer, [class*="quiz"], [class*="question"]', quizConfirm: '.confirm, .ok, .submit, .layui-layer-btn0, button[class*="confirm"]', }, }, }; function detectPlatform() { const host = location.hostname.toLowerCase(); for (const [key, platform] of Object.entries(PLATFORMS)) { if (key === 'generic') continue; for (const pattern of platform.patterns) { if (host.includes(pattern)) { STATE.platform = key; return platform; } } } STATE.platform = 'generic'; return PLATFORMS.generic; } const currentPlatform = detectPlatform(); // ==================== 日志系统 ==================== function log(message, level = 'info') { const time = new Date().toLocaleTimeString('zh-CN', { hour12: false }); const entry = { time, message, level }; STATE.log.push(entry); if (STATE.log.length > 200) STATE.log.shift(); const prefix = level === 'error' ? '[错误]' : level === 'warn' ? '[警告]' : '[信息]'; console.log(`[研修助手] ${time} ${prefix} ${message}`); updateLogDisplay(); } // ==================== 防挂机检测模块 ==================== function installAntiIdle() { if (!getConfig(CONFIG_KEYS.keepAlive, true)) return; try { // 覆盖 document.hidden 属性,始终返回 false Object.defineProperty(document, 'hidden', { get: () => false, configurable: true, }); // 覆盖 document.visibilityState 属性,始终返回 'visible' Object.defineProperty(document, 'visibilityState', { get: () => 'visible', configurable: true, }); // 覆盖 document.webkitHidden 属性(某些平台使用) if ('webkitHidden' in document) { Object.defineProperty(document, 'webkitHidden', { get: () => false, configurable: true, }); } // 拦截 visibilitychange 事件 document.addEventListener('visibilitychange', function (e) { e.stopImmediatePropagation(); e.preventDefault(); }, true); // 拦截 webkitvisibilitychange 事件 document.addEventListener('webkitvisibilitychange', function (e) { e.stopImmediatePropagation(); e.preventDefault(); }, true); // 拦截 blur 事件(部分平台用 blur 检测用户离开) window.addEventListener('blur', function (e) { e.stopImmediatePropagation(); }, true); // 拦截 mouseleave / mouseout(部分平台用鼠标离开检测) document.addEventListener('mouseleave', function (e) { e.stopImmediatePropagation(); }, true); // 定期模拟用户活动(鼠标移动 + 点击) setInterval(() => { if (!STATE.running) return; simulateActivity(); }, 30000); // 每30秒模拟一次 log('防挂机检测模块已启用'); } catch (e) { log('防挂机检测模块启用失败: ' + e.message, 'warn'); } } function simulateActivity() { try { // 模拟鼠标移动事件 const moveEvent = new MouseEvent('mousemove', { bubbles: true, clientX: Math.random() * window.innerWidth, clientY: Math.random() * window.innerHeight, }); document.dispatchEvent(moveEvent); // 模拟 keydown 事件(空格键,防止暂停检测) const keyEvent = new KeyboardEvent('keydown', { bubbles: true, key: ' ', code: 'Space', }); document.dispatchEvent(keyEvent); } catch (e) { // 忽略模拟事件错误 } } // ==================== 视频控制模块 ==================== function findVideoElement() { // 策略1:直接在 document 中查找 video 标签 let video = document.querySelector('video'); if (video && video.src) return video; // 策略2:在 iframe 中查找(同源情况下) try { const iframes = document.querySelectorAll('iframe'); for (const iframe of iframes) { try { const innerDoc = iframe.contentDocument || iframe.contentWindow.document; if (innerDoc) { video = innerDoc.querySelector('video'); if (video && video.src) return video; } } catch (e) { // 跨域 iframe,跳过 } } } catch (e) { // iframe 访问失败 } // 策略3:查找 video.js 或其他播放器容器内的 video const playerContainers = document.querySelectorAll( '.video-js, .vjs-tech, .video-player, .player-container, #player, .art-video-player' ); for (const container of playerContainers) { video = container.querySelector('video'); if (video && video.src) return video; } // 策略4:查找所有 video 标签(包括未设置 src 的) const allVideos = document.querySelectorAll('video'); if (allVideos.length > 0) return allVideos[0]; return null; } function controlVideo(video) { if (!video) return; STATE.currentVideo = video; // 自动播放 if (getConfig(CONFIG_KEYS.autoPlay, true)) { if (video.paused) { const playPromise = video.play(); if (playPromise !== undefined) { playPromise.then(() => { log('视频已自动播放'); }).catch(() => { // 浏览器策略阻止自动播放,尝试点击播放按钮 clickPlayButton(); }); } } } // 设置倍速 const rate = getConfig(CONFIG_KEYS.playbackRate, 2); setVideoSpeed(video, rate); // 自动静音 if (getConfig(CONFIG_KEYS.autoMute, true)) { video.muted = true; } // 监听视频结束事件(自动下一集) if (!video.dataset.trhBound) { video.dataset.trhBound = 'true'; video.addEventListener('ended', () => { log('当前视频播放结束'); STATE.completedCount++; updatePanelStats(); if (getConfig(CONFIG_KEYS.autoNext, true)) { setTimeout(() => goToNextLesson(), 1500); } }); video.addEventListener('pause', () => { if (!STATE.running) return; // 检查是否真的播放结束(ended 事件会先触发 pause) if (video.ended) return; // 延迟检查,可能是平台自动暂停 setTimeout(() => { if (video.paused && STATE.running && !video.ended) { log('检测到视频被暂停,尝试恢复播放'); const promise = video.play(); if (promise !== undefined) { promise.catch(() => { clickPlayButton(); }); } } }, 1000); }); // 监听速率变化(部分平台会重置速率) video.addEventListener('ratechange', () => { const targetRate = getConfig(CONFIG_KEYS.playbackRate, 2); if (Math.abs(video.playbackRate - targetRate) > 0.01 && STATE.running) { log(`播放速率被重置为 ${video.playbackRate}x,恢复为 ${targetRate}x`); video.playbackRate = targetRate; } }); // 监听 loadedmetadata(新视频加载时应用设置) video.addEventListener('loadedmetadata', () => { log('检测到新视频加载'); applyVideoSettings(video); }); // 监听 error 事件 video.addEventListener('error', () => { log('视频加载出错,尝试刷新', 'warn'); setTimeout(() => { if (getConfig(CONFIG_KEYS.autoNext, true)) { goToNextLesson(); } }, 3000); }); } // 获取视频标题 updateVideoTitle(video); } function applyVideoSettings(video) { if (!video) return; const rate = getConfig(CONFIG_KEYS.playbackRate, 2); setVideoSpeed(video, rate); if (getConfig(CONFIG_KEYS.autoMute, true)) { video.muted = true; } if (getConfig(CONFIG_KEYS.autoPlay, true) && video.paused) { const promise = video.play(); if (promise !== undefined) { promise.catch(() => { setTimeout(() => clickPlayButton(), 500); }); } } } function setVideoSpeed(video, rate) { if (!video) return; try { video.playbackRate = rate; // 某些平台需要同时设置 defaultPlaybackRate if ('defaultPlaybackRate' in video) { video.defaultPlaybackRate = rate; } log(`播放速率已设置为 ${rate}x`); } catch (e) { log(`设置播放速率失败: ${e.message}`, 'warn'); } } function clickPlayButton() { const selectors = currentPlatform.selectors.playBtn.split(', '); for (const sel of selectors) { try { const btn = document.querySelector(sel.trim()); if (btn) { btn.click(); log('已点击播放按钮'); return true; } } catch (e) { // 选择器无效,继续尝试 } } // 尝试点击视频区域中央 const video = STATE.currentVideo; if (video) { try { const rect = video.getBoundingClientRect(); const clickEvent = new MouseEvent('click', { bubbles: true, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, }); video.dispatchEvent(clickEvent); log('已点击视频区域'); return true; } catch (e) { // 点击失败 } } return false; } function goToNextLesson() { log('正在切换到下一集...'); // 策略1:查找并点击"下一集"按钮 const nextSelectors = currentPlatform.selectors.nextBtn.split(', '); for (const sel of nextSelectors) { try { const btn = document.querySelector(sel.trim()); if (btn && btn.offsetParent !== null) { btn.click(); log('已点击下一集按钮'); STATE.lastAction = '切换下一集'; updatePanelStats(); return true; } } catch (e) { // 选择器无效 } } // 策略2:查找课程列表中的下一个未完成项 const courseItems = document.querySelectorAll( '.lesson-item, .course-item, .chapter-item, .video-item, li[class*="lesson"], li[class*="chapter"]' ); for (const item of courseItems) { const isCompleted = item.classList.contains('completed') || item.classList.contains('finish') || item.querySelector('.completed, .finish, .done, [class*="complete"]'); if (!isCompleted) { item.click(); log('已点击下一个未完成课程'); STATE.lastAction = '切换下一集'; updatePanelStats(); return true; } } // 策略3:尝试从 URL 中推断下一集 const url = location.href; const match = url.match(/(\d+)(\D*)$/); if (match) { const num = parseInt(match[1]) + 1; const nextUrl = url.replace(/(\d+)(\D*)$/, num + match[2]); log(`尝试通过 URL 推断下一集: ${nextUrl}`); location.href = nextUrl; return true; } log('未找到下一集入口,请手动切换', 'warn'); return false; } function updateVideoTitle(video) { try { const titleSelectors = currentPlatform.selectors.title.split(', '); for (const sel of titleSelectors) { const el = document.querySelector(sel.trim()); if (el && el.textContent.trim()) { STATE.currentVideoTitle = el.textContent.trim().substring(0, 50); return; } } } catch (e) { // 忽略 } STATE.currentVideoTitle = '未知课程'; } // ==================== 弹窗/对话框处理模块 ==================== function handleDialogs() { if (!getConfig(CONFIG_KEYS.skipDialog, true)) return; // 处理 quiz 弹窗 const quizSelectors = currentPlatform.selectors.quizDialog.split(', '); for (const sel of quizSelectors) { try { const dialog = document.querySelector(sel.trim()); if (dialog && dialog.offsetParent !== null) { handleQuizDialog(dialog); } } catch (e) { // 选择器无效 } } // 处理 alert/confirm 弹窗(覆盖原生方法) // 已在初始化时覆盖 // 处理常见的"继续学习"确认框 const confirmTexts = ['继续学习', '继续播放', '确定', '我知道了', '关闭', '继续', 'OK', '确定']; const allButtons = document.querySelectorAll('button, a, span, input[type="button"], input[type="submit"]'); for (const btn of allButtons) { const text = btn.textContent.trim(); if (confirmTexts.includes(text) && btn.offsetParent !== null) { // 检查是否是弹窗中的按钮 const parent = btn.closest('.modal, .dialog, .popup, .layer, .mask, [class*="dialog"], [class*="modal"]'); if (parent && parent.offsetParent !== null) { btn.click(); log(`已自动点击弹窗按钮: "${text}"`); } } } } function handleQuizDialog(dialog) { if (!getConfig(CONFIG_KEYS.skipQuiz, true)) return; log('检测到 quiz 弹窗,尝试自动处理'); // 尝试选择答案(优先选择第一个选项) const options = dialog.querySelectorAll( 'input[type="radio"], input[type="checkbox"], .option, .answer-option, [class*="option"]' ); if (options.length > 0) { options[0].click(); log('已选择第一个选项'); } // 尝试点击提交/确认按钮 const confirmSelectors = currentPlatform.selectors.quizConfirm.split(', '); for (const sel of confirmSelectors) { try { const btn = dialog.querySelector(sel.trim()); if (btn) { setTimeout(() => { btn.click(); log('已提交 quiz 答案'); }, 500); return; } } catch (e) { // 忽略 } } // 尝试查找弹窗内的确认按钮 const submitBtns = dialog.querySelectorAll('button, a, input[type="button"], input[type="submit"]'); for (const btn of submitBtns) { const text = btn.textContent.trim(); if (['提交', '确定', '确认', '提交答案', 'OK', 'Submit'].includes(text)) { setTimeout(() => { btn.click(); log('已点击提交按钮'); }, 500); return; } } } // ==================== 主循环 ==================== let mainTimer = null; function mainLoop() { if (!STATE.running) return; // 查找视频元素 const video = findVideoElement(); if (video) { controlVideo(video); } // 处理弹窗 handleDialogs(); // 更新面板 updatePanelStats(); } function startMainLoop() { if (mainTimer) clearInterval(mainTimer); const interval = getConfig('trh_checkInterval', 3000); mainTimer = setInterval(mainLoop, interval); log(`主循环已启动,检查间隔 ${interval}ms`); } function stopMainLoop() { if (mainTimer) { clearInterval(mainTimer); mainTimer = null; } } // ==================== 原生方法覆盖 ==================== function overrideNativeMethods() { // 覆盖 alert unsafeWindow.alert = function (msg) { log(`[拦截alert] ${msg}`); }; // 覆盖 confirm(始终返回 true) unsafeWindow.confirm = function (msg) { log(`[拦截confirm] ${msg} → true`); return true; }; // 覆盖 prompt(返回空字符串) unsafeWindow.prompt = function (msg) { log(`[拦截prompt] ${msg} → ""`); return ''; }; log('原生弹窗方法已覆盖'); } // ==================== MutationObserver ==================== function setupMutationObserver() { const observer = new MutationObserver((mutations) => { if (!STATE.running) return; for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.nodeType !== 1) continue; // 检查新添加的节点是否是 video if (node.tagName === 'VIDEO' || node.querySelector?.('video')) { log('检测到新视频元素被添加'); const video = node.tagName === 'VIDEO' ? node : node.querySelector('video'); if (video) { setTimeout(() => controlVideo(video), 500); } } // 检查新添加的弹窗 if (getConfig(CONFIG_KEYS.skipDialog, true)) { const isDialog = node.matches?.( '.modal, .dialog, .popup, .layer, [class*="dialog"], [class*="modal"], [class*="quiz"]' ); if (isDialog) { log('检测到弹窗被添加'); setTimeout(() => handleDialogs(), 300); } } } } }); observer.observe(document.body, { childList: true, subtree: true, }); log('DOM 监听器已启动'); } // ==================== UI 控制面板 ==================== function createPanel() { if (!getConfig(CONFIG_KEYS.panelVisible, true)) return; // 防止重复创建 if (document.getElementById('trh-panel')) return; GM_addStyle(` #trh-panel { position: fixed; top: 15px; right: 15px; width: 300px; background: rgba(255, 255, 255, 0.98); border: 1px solid #e0e0e0; border-radius: 12px; box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12); z-index: 999999; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; font-size: 13px; color: #333; overflow: hidden; transition: all 0.3s ease; backdrop-filter: blur(10px); } #trh-panel.minimized { width: 48px; height: 48px; overflow: hidden; } #trh-panel.minimized .trh-panel-body, #trh-panel.minimized .trh-panel-footer { display: none; } #trh-panel.minimized .trh-panel-header { padding: 0; justify-content: center; border: none; } #trh-panel.minimized .trh-panel-header .trh-title-text, #trh-panel.minimized .trh-panel-header .trh-btn-minimize { display: none; } .trh-panel-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; cursor: move; user-select: none; } .trh-panel-header .trh-icon { font-size: 18px; margin-right: 6px; } .trh-panel-header .trh-title-text { font-weight: 600; font-size: 13px; flex: 1; } .trh-btn-minimize { background: rgba(255,255,255,0.2); border: none; color: #fff; cursor: pointer; border-radius: 4px; padding: 2px 8px; font-size: 16px; line-height: 1; } .trh-btn-minimize:hover { background: rgba(255,255,255,0.35); } .trh-panel-body { padding: 12px 14px; max-height: 380px; overflow-y: auto; } .trh-panel-body::-webkit-scrollbar { width: 5px; } .trh-panel-body::-webkit-scrollbar-thumb { background: #ccc; border-radius: 3px; } .trh-info-box { background: #f5f7fa; border-radius: 8px; padding: 8px 10px; margin-bottom: 10px; font-size: 12px; line-height: 1.6; } .trh-info-box .trh-info-row { display: flex; justify-content: space-between; margin-bottom: 2px; } .trh-info-box .trh-info-label { color: #888; } .trh-info-box .trh-info-value { color: #333; font-weight: 500; } .trh-info-box .trh-info-value.active { color: #52c41a; } .trh-info-box .trh-info-value.stopped { color: #f5222d; } .trh-control-group { margin-bottom: 10px; } .trh-control-row { display: flex; align-items: center; justify-content: space-between; padding: 4px 0; } .trh-control-label { font-size: 12px; color: #555; } .trh-toggle { position: relative; width: 36px; height: 20px; background: #d0d0d0; border-radius: 10px; cursor: pointer; transition: background 0.2s; flex-shrink: 0; } .trh-toggle.on { background: #667eea; } .trh-toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; background: #fff; border-radius: 50%; transition: left 0.2s; box-shadow: 0 1px 3px rgba(0,0,0,0.2); } .trh-toggle.on::after { left: 18px; } .trh-speed-control { display: flex; align-items: center; gap: 6px; margin-top: 6px; } .trh-speed-slider { flex: 1; -webkit-appearance: none; height: 4px; background: #e0e0e0; border-radius: 2px; outline: none; } .trh-speed-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; background: #667eea; border-radius: 50%; cursor: pointer; } .trh-speed-value { font-size: 12px; font-weight: 600; color: #667eea; min-width: 36px; text-align: right; } .trh-speed-presets { display: flex; gap: 4px; margin-top: 6px; flex-wrap: wrap; } .trh-speed-preset { padding: 2px 8px; font-size: 11px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer; background: #fff; color: #666; transition: all 0.2s; } .trh-speed-preset:hover { border-color: #667eea; color: #667eea; } .trh-speed-preset.active { background: #667eea; color: #fff; border-color: #667eea; } .trh-panel-footer { padding: 8px 14px; border-top: 1px solid #f0f0f0; display: flex; gap: 8px; } .trh-btn { flex: 1; padding: 6px 0; border: none; border-radius: 6px; font-size: 12px; cursor: pointer; transition: all 0.2s; font-weight: 500; } .trh-btn-primary { background: #667eea; color: #fff; } .trh-btn-primary:hover { background: #5568d3; } .trh-btn-danger { background: #f5222d; color: #fff; } .trh-btn-danger:hover { background: #d4380d; } .trh-btn-default { background: #f0f0f0; color: #333; } .trh-btn-default:hover { background: #e0e0e0; } .trh-log-box { background: #1e1e1e; color: #d4d4d4; border-radius: 6px; padding: 8px; font-size: 11px; font-family: "Consolas", "Monaco", monospace; max-height: 120px; overflow-y: auto; line-height: 1.5; margin-top: 8px; } .trh-log-box::-webkit-scrollbar { width: 4px; } .trh-log-box::-webkit-scrollbar-thumb { background: #555; border-radius: 2px; } .trh-log-entry { margin-bottom: 1px; word-break: break-all; } .trh-log-entry.error { color: #f44747; } .trh-log-entry.warn { color: #cca700; } .trh-log-entry.info { color: #d4d4d4; } .trh-log-entry.success { color: #4ec9b0; } `); const panel = document.createElement('div'); panel.id = 'trh-panel'; panel.innerHTML = `