// ==UserScript== // @name 上海开放大学助手 // @namespace https://l.shou.org.cn/ // @version 1.24.0 // @description 上海开放大学网上学习助手:自动识别课程任务、顺序导航、必看视频播放辅助,理清网课学习进度。仅供学习交流。 // @author 孤影 // @license All Rights Reserved // @copyright 2026 孤影 (QQ 2297311605) // @tag 上海开放大学 // @tag 网上学习 // @tag 学习助手 // @tag 网课 // @tag 学习进度 // @match https://l.shou.org.cn/* // @match http://l.shou.org.cn/* // @match https://*.shou.org.cn/* // @run-at document-start // @noframes // @grant GM_getValue // @grant GM_setValue // @grant GM_notification // ==/UserScript== /* * 上海开放大学助手 作者:孤影 反馈 QQ:2297311605 * * 【免责声明】 * 1. 本脚本为免费工具,仅用于辅助整理和定位课程学习任务,方便学习者规划进度。 * 2. 本脚本仅读取页面已显示的信息进行识别与导航,不向服务器写入完成状态、 * 不伪造播放/学习记录、不篡改任何平台数据。 * 3. 使用者应自行遵守所在学校及平台的相关规定,因使用本脚本产生的任何后果 * 由使用者自行承担,作者不承担任何责任。 * 4. 本脚本版权归作者「孤影」所有,仅供学习交流,禁止二次分发、商用或冒名发布。 */ (function () { 'use strict'; const CONFIG = { scanDelay: 800, taskSwitchDelay: 3000, navigationTimeoutMs: 15000, learningLeaseMs: 120000, rescanInterval: 8000, taskSelectors: [ '.sh-res', '.course_catalog .cell_info1 > a[href]', '[data-task-id]', '[data-activity-id]', '[class*="task"]', '[class*="activity"]', '[class*="catalog"] li', '[class*="chapter"] li', '.courseware_list li', '.course-list li', '.learn-list li', 'a[href*="directory.aspx"][href*="cellId="]', 'a[href*="directory.aspx"][href*="cellid="]', 'a[href*="learn"]', 'a[href*="study"]', 'a[href*="activity"]' ], completedWords: ['已完成', '完成', 'completed', 'finished', '100%'], incompleteWords: ['未完成', '未学习', '待完成', '进行中', '学习中', '未开始', '0%'], completionPageWords: ['学习完成', '任务已完成', '本节已完成', '恭喜完成'], catalogUrlPattern: /\/study\/(?:learnCatalogNew|directory)\.aspx/i, }; const KEYS = { position: 'shouHelper:lastPosition', autoReturn: 'shouHelper:autoReturn', smartMode: 'shouHelper:smartMode', minimized: 'shouHelper:minimized', sequence: 'shouHelper:sequencePosition', mandatoryVideoIds: 'shouHelper:mandatoryVideoIds', panelPosition: 'shouHelper:panelPosition', }; const state = { tasks: [], scanTimer: null, lastCompletionDetected: false, videoBound: new WeakSet(), autoplayNoticeShown: false, pageAdvanceScheduled: false, navigationStarted: false, navGuardTimer: null, navTargetKey: null, visitedKeys: null, queueKeys: null, logs: [], }; const LOG_LIMIT = 60; const RUN_SESSION_KEY = 'shouHelper:learningActiveUntil'; const VISITED_SESSION_KEY = 'shouHelper:visitedTasks'; const QUEUE_SESSION_KEY = 'shouHelper:taskQueue'; function isCourseDirectoryPage() { return /\/study\/directory\.aspx/i.test(location.pathname); } function isCourseCatalogPage() { return /\/study\/learnCatalogNew\.aspx/i.test(location.pathname); } function isAutomationSupportPage() { return isCourseCatalogPage() || isCourseDirectoryPage() || /\/study\/assignment-preview\.aspx/i.test(location.pathname); } const storage = { get(key, fallback) { try { const value = typeof GM_getValue === 'function' ? GM_getValue(key) : localStorage.getItem(key); if (value === undefined || value === null) return fallback; if (value === 'true') return true; if (value === 'false') return false; return value; } catch (_) { return fallback; } }, set(key, value) { try { if (typeof GM_setValue === 'function') return GM_setValue(key, value); localStorage.setItem(key, String(value)); } catch (_) { return undefined; } }, }; function isLearningActive() { return Number(storage.get(RUN_SESSION_KEY, 0)) > Date.now(); } function setLearningActive(active) { storage.set(RUN_SESSION_KEY, active ? Date.now() + CONFIG.learningLeaseMs : 0); const button = document.getElementById('shou-helper-start'); if (button) button.textContent = active ? '停止学习' : '开始学习'; const status = document.getElementById('shou-helper-status'); if (status) { status.textContent = active ? '学习中' : '待开始'; status.classList.toggle('active', active); } } function refreshLearningLease() { if (isLearningActive()) return storage.set(RUN_SESSION_KEY, Date.now() + CONFIG.learningLeaseMs); return undefined; } function taskVisitKey(task) { return cellIdFromUrl(task?.clickable?.href || '') || task?.identity || ''; } function visitedTaskKeys() { if (state.visitedKeys) return new Set(state.visitedKeys); try { const saved = JSON.parse(storage.get(VISITED_SESSION_KEY, 'null')); const keys = Array.isArray(saved) ? saved : saved?.keys; state.visitedKeys = new Set(Array.isArray(keys) ? keys : []); return new Set(state.visitedKeys); } catch (_) { state.visitedKeys = new Set(); return new Set(); } } function resetVisitedTasks() { state.visitedKeys = new Set(); return storage.set(VISITED_SESSION_KEY, '[]'); } function taskQueueKeys() { if (state.queueKeys) return [...state.queueKeys]; try { const saved = JSON.parse(storage.get(QUEUE_SESSION_KEY, 'null')); const keys = Array.isArray(saved) ? saved : saved?.keys; state.queueKeys = Array.isArray(keys) ? [...keys] : []; return [...state.queueKeys]; } catch (_) { state.queueKeys = []; return []; } } function saveTaskQueue(tasks) { const keys = tasks.map(taskVisitKey).filter(Boolean); const existing = taskQueueKeys(); if (existing.length > keys.length) return existing.length; state.queueKeys = [...keys]; storage.set(QUEUE_SESSION_KEY, JSON.stringify(keys)); return keys.length; } function clearTaskQueue() { state.queueKeys = []; return storage.set(QUEUE_SESSION_KEY, 'null'); } function markVisitedKey(key) { if (!key) return; const keys = visitedTaskKeys(); if (keys.has(key)) return; keys.add(key); state.visitedKeys = new Set(keys); return storage.set(VISITED_SESSION_KEY, JSON.stringify([...keys])); } function markTaskVisited(task) { return markVisitedKey(taskVisitKey(task)); } function markTasksThrough(task) { const index = state.tasks.findIndex(item => item.identity === task?.identity); if (index < 0) return markTaskVisited(task); return Promise.allSettled(state.tasks.slice(0, index + 1).map(markTaskVisited)); } function nextUnvisitedTask(tasks = state.tasks) { const visited = visitedTaskKeys(); const queue = taskQueueKeys(); if (queue.length) { const tasksByKey = new Map(tasks.map(task => [taskVisitKey(task), task])); for (const key of queue) { if (!visited.has(key) && tasksByKey.has(key)) return tasksByKey.get(key); } return null; } return tasks.find(task => { const key = taskVisitKey(task); return key && !visited.has(key); }) || null; } function debugFlow(event, extra = {}) { const current = currentSequenceTask(); const next = nextUnvisitedTask(); console.info('[助手流程]', { event, cellId: currentCellId(), current: current ? taskVisitKey(current) : null, next: next ? taskVisitKey(next) : null, queueLength: taskQueueKeys().length, visited: [...visitedTaskKeys()], active: isLearningActive(), scheduled: state.pageAdvanceScheduled, navigating: state.navigationStarted, ...extra, }); } function normalizedText(element) { return (element?.innerText || element?.textContent || '') .replace(/\s+/g, ' ').trim().toLowerCase(); } function isVisible(element) { if (!element?.isConnected) return false; const style = getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0; } function closestClickable(element) { return element.closest('a[href], button, [role="button"], [onclick]') || element.querySelector('a[href], button, [role="button"], [onclick]') || element; } function classify(element) { if (element.matches('.course_catalog .cell_info1 > a[href]')) return 'available'; if (element.matches('a[href*="directory.aspx"][href*="cellId="], a[href*="directory.aspx"][href*="cellid="]')) { return 'available'; } if (element.matches('.sh-res')) { const statusImages = [...element.querySelectorAll('img[src], img[title]')]; const statusTitles = statusImages.map(image => image.getAttribute('title') || '').join(' '); if (/已完成/.test(statusTitles)) return 'completed'; if (/未看|未完成|未学习/.test(statusTitles)) return 'incomplete'; if (element.querySelector('.sh-res-h > a[href], a[href*="cellId="]')) return 'available'; } const text = normalizedText(element); const classText = `${element.className || ''} ${element.getAttribute('title') || ''} ${element.getAttribute('aria-label') || ''}`.toLowerCase(); const combined = `${text} ${classText}`; const progressMatches = [...combined.matchAll(/(\d{1,3})\s*%/g)].map(match => Number(match[1])); if (progressMatches.some(value => value >= 100)) return 'completed'; if (CONFIG.incompleteWords.some(word => combined.includes(word.toLowerCase()))) return 'incomplete'; if (/\b(uncompleted|incomplete|pending|todo|doing)\b/.test(combined)) return 'incomplete'; if (CONFIG.completedWords.some(word => combined.includes(word.toLowerCase()))) return 'completed'; if (/\b(done|complete|completed|finished|success)\b/.test(combined)) return 'completed'; if (progressMatches.some(value => value >= 0 && value < 100)) return 'incomplete'; return 'unknown'; } function uniqueTaskElements() { const found = []; const seen = new Set(); document.querySelectorAll(CONFIG.taskSelectors.join(',')).forEach(element => { const isDirectoryLeaf = element.matches('.course_catalog .cell_info1 > a[href]'); if ((!isDirectoryLeaf && !isVisible(element)) || element.closest('#shou-helper-panel')) return; if (element.closest('.course_catalog') && !isDirectoryLeaf) return; const directoryItem = element.closest('.cell_info1'); const itemType = (directoryItem?.getAttribute('data-type') || '').toLowerCase(); const itemText = normalizedText(element); if (itemType === 'exercise' || itemType === 'assignment' || itemText.includes('在线练习') || itemText.includes('记分作业') || /[((]\s*练习\s*[))]/.test(itemText)) return; const clickable = closestClickable(element); const text = itemText; if (text.length < 2 || text.length > 500) return; const status = classify(element); if (status === 'unknown') return; const identity = clickable.href || clickable.getAttribute('onclick') || `${text.slice(0, 100)}|${status}`; if (seen.has(identity)) return; seen.add(identity); found.push({ element, clickable, text, status, identity }); }); return found; } function scanTasks(showMessage = false) { refreshLearningLease(); state.tasks = uniqueTaskElements(); rememberMandatoryVideoTasks(); if (isCourseDirectoryPage()) saveTaskQueue(state.tasks); releaseNavigationOnArrival(); renderStats(); if (showMessage) toast(state.tasks.length ? `已识别 ${state.tasks.length} 个任务` : '暂未识别到任务,请展开课程目录后重试'); scheduleSmartTaskFlow(); } function renderStats() { if (!isAutomationSupportPage()) return setText('shou-helper-count', '未在学习页'); const queue = taskQueueKeys(); const total = queue.length || state.tasks.length; if (!total) return setText('shou-helper-count', '0 项'); const visited = visitedTaskKeys(); const done = queue.length ? queue.filter(key => visited.has(key)).length : state.tasks.filter(task => visited.has(taskVisitKey(task))).length; setText('shou-helper-count', `已完成 ${done} / 共 ${total}`); } function releaseNavigationOnArrival() { if (!state.navigationStarted && !state.navGuardTimer) return; const arrived = state.navTargetKey && currentCellId() === state.navTargetKey; if (!arrived) return; clearNavigationGuard(); state.navigationStarted = false; state.pageAdvanceScheduled = false; state.navTargetKey = null; debugFlow('navigate:arrived', { cellId: currentCellId() }); } function setText(id, text) { const element = document.getElementById(id); if (element) element.textContent = text; } function saveTaskPosition(task) { return storage.set(KEYS.position, JSON.stringify({ url: location.href, identity: task.identity, text: task.text.slice(0, 120), savedAt: Date.now(), })); } async function goToTask(task) { if (!task || state.navigationStarted) return; // 切换任务前,把上一条视频进度「定格」为历史行(清掉 key,下个视频新建一条)。 const lastProgress = state.logs.find(entry => entry.key === 'video-progress'); if (lastProgress) lastProgress.key = null; state.navigationStarted = true; const index = state.tasks.findIndex(item => item.identity === task.identity); debugFlow('navigate:start', { target: taskVisitKey(task), targetIndex: index }); const writes = [ refreshLearningLease(), markTaskVisited(task), saveTaskPosition(task), storage.set(QUEUE_SESSION_KEY, JSON.stringify(taskQueueKeys())), storage.set(KEYS.sequence, JSON.stringify({ catalogUrl: sequenceScope(), identity: task.identity, index, savedAt: Date.now(), })), ]; await Promise.allSettled(writes.map(write => Promise.resolve(write))); debugFlow('navigate:persisted', { target: taskVisitKey(task), targetIndex: index }); task.element.scrollIntoView({ behavior: 'smooth', block: 'center' }); const targetKey = taskVisitKey(task); state.navTargetKey = targetKey; clearNavigationGuard(); state.navGuardTimer = setTimeout(() => { state.navGuardTimer = null; if (!state.navigationStarted) return; state.navigationStarted = false; state.pageAdvanceScheduled = false; debugFlow('navigate:timeout', { target: targetKey, targetIndex: index }); if (isLearningActive()) advanceToNextTask('上一次切换未生效,正在重试下一项'); }, CONFIG.navigationTimeoutMs); setTimeout(() => { const clickable = task.clickable; if (clickable.tagName === 'A') clickable.setAttribute('target', '_self'); debugFlow('navigate:click', { target: targetKey, targetIndex: index }); clickable.click(); }, 450); } function clearNavigationGuard() { if (state.navGuardTimer) { clearTimeout(state.navGuardTimer); state.navGuardTimer = null; } } function goToNext() { const tasks = state.tasks; if (!tasks.length) return toast('当前目录没有识别到可学习任务'); const next = nextUnvisitedTask(tasks); if (!next) return toast('当前目录任务已全部处理'); toast(`即将进入:${next.text.slice(0, 32)}`); goToTask(next); } function isMandatoryVideoTask(task) { const text = task?.text || ''; if (!text.includes('视频')) return false; if (text.includes('必看')) return true; const cellId = cellIdFromUrl(task?.clickable?.href || ''); return cellId ? mandatoryVideoIds().has(cellId) : false; } function mandatoryVideoIds() { try { const values = JSON.parse(storage.get(KEYS.mandatoryVideoIds, '[]')); return new Set(Array.isArray(values) ? values : []); } catch (_) { return new Set(); } } function rememberMandatoryVideoTasks() { const ids = mandatoryVideoIds(); let changed = false; state.tasks.forEach(task => { if (!task.text.includes('视频') || !task.text.includes('必看')) return; const cellId = cellIdFromUrl(task.clickable?.href || ''); if (cellId && !ids.has(cellId)) { ids.add(cellId); changed = true; } }); if (changed) storage.set(KEYS.mandatoryVideoIds, JSON.stringify([...ids])); } function cellIdFromUrl(urlValue) { try { const url = new URL(urlValue, location.href); for (const [key, value] of url.searchParams.entries()) { if (key.toLowerCase() === 'cellid') return value; } } catch (_) {} return ''; } function currentCellId() { return cellIdFromUrl(location.href); } function mandatoryVideoTasks() { return state.tasks.filter(isMandatoryVideoTask); } function currentTaskIndex(tasks) { const cellId = currentCellId(); if (!cellId) return -1; return tasks.findIndex(task => cellIdFromUrl(task.clickable?.href || '') === cellId); } function currentSequenceTask() { const index = currentSequenceIndex(); return index >= 0 ? state.tasks[index] : null; } function currentSequenceIndex() { const cellId = currentCellId(); if (cellId) { const byCellId = state.tasks.findIndex(task => cellIdFromUrl(task.clickable?.href || '') === cellId); if (byCellId >= 0) return byCellId; } let saved = null; try { saved = JSON.parse(storage.get(KEYS.sequence, 'null')); } catch (_) {} if (saved?.catalogUrl !== sequenceScope()) return -1; const savedIndex = Number(saved?.index); if (Number.isInteger(savedIndex) && savedIndex >= 0 && savedIndex < state.tasks.length) return savedIndex; return saved ? state.tasks.findIndex(task => task.identity === saved.identity) : -1; } function advanceToNextTask(message) { if (!isLearningActive() || state.navigationStarted) return; const next = nextUnvisitedTask(); if (!next) { storage.set(KEYS.sequence, 'null'); setLearningActive(false); return notify('当前目录任务已全部进入完毕。'); } debugFlow('advance', { target: taskVisitKey(next) }); toast(message || `切换到下一项:${next.text.slice(0, 32)}`); goToTask(next); } function scheduleSmartTaskFlow() { if (!isLearningActive() || storage.get(KEYS.smartMode, false) !== true) return; if (state.pageAdvanceScheduled || state.navigationStarted) return; const current = currentSequenceTask(); if (!current) { if (!state.tasks.length) return; const cellId = currentCellId(); if (cellId) markVisitedKey(cellId); if (!nextUnvisitedTask()) return; state.pageAdvanceScheduled = true; debugFlow('schedule:forward', { cellId, delay: CONFIG.taskSwitchDelay }); setTimeout(() => advanceToNextTask('定位不到当前任务,按队列进入下一项'), CONFIG.taskSwitchDelay); return; } markTaskVisited(current); if (isMandatoryVideoTask(current)) return; state.pageAdvanceScheduled = true; debugFlow('schedule', { current: taskVisitKey(current), delay: CONFIG.taskSwitchDelay }); setTimeout(() => advanceToNextTask(`已进入,切换下一项:${current.text.slice(0, 28)}`), CONFIG.taskSwitchDelay); } function sameOriginDocuments() { const documents = [document]; document.querySelectorAll('iframe').forEach(frame => { try { if (frame.contentDocument?.documentElement) documents.push(frame.contentDocument); } catch (_) {} }); return documents; } function attachMandatoryVideoPlayback() { if (!isLearningActive()) return; const mandatoryTasks = mandatoryVideoTasks(); if (currentTaskIndex(mandatoryTasks) < 0) return; sameOriginDocuments().forEach(doc => { doc.querySelectorAll('video.dplayer-video, .video_container video, video').forEach(video => { if (state.videoBound.has(video)) return; state.videoBound.add(video); const forceNormalSpeed = () => { if (video.playbackRate !== 1) video.playbackRate = 1; if (video.defaultPlaybackRate !== 1) video.defaultPlaybackRate = 1; }; forceNormalSpeed(); video.addEventListener('loadedmetadata', forceNormalSpeed); video.addEventListener('play', () => { forceNormalSpeed(); logMessage('视频开始播放', 'evt'); }); video.addEventListener('ratechange', forceNormalSpeed); // 播放进度实时写进日志(同一条原地更新,最多每 2 秒刷一次,不刷屏)。 video.addEventListener('timeupdate', () => { const now = Date.now(); if (now - (video._shouLastLog || 0) < 2000) return; video._shouLastLog = now; const duration = video.duration; if (!Number.isFinite(duration) || duration <= 0) return; const percent = Math.min(100, Math.round(video.currentTime / duration * 100)); logMessage(`视频播放 ${percent}%(${formatClock(video.currentTime)} / ${formatClock(duration)})`, 'info', 'video-progress'); }); video.addEventListener('ended', () => { logMessage('视频播放 100%,已看完', 'done', 'video-progress'); notify('视频已播放完成,正在切换下一项。'); setTimeout(() => advanceToNextTask(), CONFIG.taskSwitchDelay); }); const playResult = video.play(); if (playResult?.catch) { playResult.catch(() => { if (state.autoplayNoticeShown) return; state.autoplayNoticeShown = true; toast('浏览器阻止了自动播放,请手动点击一次视频播放按钮'); }); } }); }); } function sequenceScope() { const url = new URL(location.href); let courseOpenId = ''; for (const [key, value] of url.searchParams.entries()) { if (key.toLowerCase() === 'courseopenid') { courseOpenId = value; break; } } return courseOpenId ? `${url.origin}|${courseOpenId}` : `${url.origin}${url.pathname}`; } function expandCatalog() { const candidates = [...document.querySelectorAll('button, [role="button"], [onclick], .tree-node, .chapter, .folder')]; let count = 0; candidates.forEach(element => { const text = normalizedText(element); const classText = String(element.className || '').toLowerCase(); const collapsed = element.getAttribute('aria-expanded') === 'false' || /collapsed|close|fold/.test(classText) || /展开|章节|单元|目录/.test(text); if (collapsed && isVisible(element) && !element.closest('#shou-helper-panel')) { const childList = element.nextElementSibling || element.parentElement?.querySelector('ul, .children'); if (!childList || getComputedStyle(childList).display === 'none' || element.getAttribute('aria-expanded') === 'false') { element.click(); count += 1; } } }); document.querySelectorAll('.topic_condensation.am-icon-caret-right, .topic_condensation[class*="plus"]') .forEach(element => { if (isVisible(element)) { element.click(); count += 1; } }); document.querySelectorAll('.course_catalog .tr_topic').forEach(topic => { const children = topic.querySelector('.div_show_cell'); if (!children || getComputedStyle(children).display !== 'none') return; const toggle = topic.querySelector('.show_topic') || topic.querySelector('.show_cell_list'); if (!toggle || !isVisible(toggle)) return; toggle.click(); count += 1; }); setTimeout(() => scanTasks(), CONFIG.scanDelay); toast(count ? `已展开 ${count} 个折叠目录` : '目录已经全部展开'); } function notify(message) { logMessage(message, 'done'); if (typeof GM_notification === 'function') GM_notification({ title: '上海开放大学助手', text: message, timeout: 8000 }); else if ('Notification' in window && Notification.permission === 'granted') new Notification('上海开放大学助手', { body: message }); else alert(message); } function completionDetected() { const bodyText = normalizedText(document.body); return CONFIG.completionPageWords.some(word => bodyText.includes(word.toLowerCase())); } function checkCompletion() { const detected = completionDetected(); if (!detected || state.lastCompletionDetected) return; state.lastCompletionDetected = true; notify('检测到当前任务已完成。'); if (storage.get(KEYS.smartMode, false) === true) { return; } else if (storage.get(KEYS.autoReturn, false) === true) { setTimeout(returnToCatalog, 2500); } } function toggleLearning() { if (isLearningActive()) { setLearningActive(false); state.pageAdvanceScheduled = false; state.navigationStarted = false; clearNavigationGuard(); return toast('自动学习已停止'); } if (!isCourseCatalogPage() && !isCourseDirectoryPage()) return toast('请进入课程一级或二级目录后再开始学习'); const startsFromCatalog = isCourseCatalogPage(); storage.set(KEYS.sequence, 'null'); resetVisitedTasks(); if (startsFromCatalog) clearTaskQueue(); storage.set(KEYS.smartMode, true); storage.set(KEYS.autoReturn, true); setLearningActive(true); state.pageAdvanceScheduled = false; scanTasks(); const queueCount = taskQueueKeys().length; const current = currentSequenceTask(); if (current) { markTasksThrough(current); attachMandatoryVideoPlayback(); scheduleSmartTaskFlow(); } else if (state.tasks.length) { goToNext(); } const mandatoryCount = state.tasks.filter(task => task.text.includes('视频') && task.text.includes('必看')).length; debugFlow('manual:start', { startsFromCatalog, mandatoryCount, queueCount }); toast(startsFromCatalog ? `已记录 ${mandatoryCount} 个必看视频,正在进入第一项` : `已建立 ${queueCount} 项二级目录学习队列`); } function returnToCatalog() { if (CONFIG.catalogUrlPattern.test(location.href)) return scanTasks(true); let saved; try { saved = JSON.parse(storage.get(KEYS.position, 'null')); } catch (_) {} if (saved?.url && CONFIG.catalogUrlPattern.test(saved.url)) location.href = saved.url; else if (history.length > 1) history.back(); else toast('未保存课程目录地址,请手动返回目录'); } function logMessage(message, level = 'info', key = null) { const now = new Date(); const time = [now.getHours(), now.getMinutes(), now.getSeconds()] .map(value => String(value).padStart(2, '0')).join(':'); // 带 key 的日志用于「原地更新」,例如视频播放进度,避免刷屏。 if (key) { const existing = state.logs.find(entry => entry.key === key); if (existing) { existing.time = time; existing.message = message; existing.level = level; renderLog(); return; } } state.logs.push({ time, message, level, key }); if (state.logs.length > LOG_LIMIT) state.logs = state.logs.slice(-LOG_LIMIT); renderLog(); } function formatClock(seconds) { if (!Number.isFinite(seconds) || seconds < 0) return '00:00'; const total = Math.floor(seconds); const mm = String(Math.floor(total / 60)).padStart(2, '0'); const ss = String(total % 60).padStart(2, '0'); return `${mm}:${ss}`; } function renderLog() { const box = document.getElementById('shou-helper-log'); if (!box) return; if (!state.logs.length) { box.innerHTML = '