// ==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 = '
暂无日志
'; return; } box.innerHTML = state.logs.map(entry => { const level = ['evt', 'warn', 'done'].includes(entry.level) ? entry.level : ''; return `
${escapeHtml(entry.message)}
`; }).join(''); box.scrollTop = box.scrollHeight; } function escapeHtml(text) { return String(text).replace(/[&<>"]/g, char => ( { '&': '&', '<': '<', '>': '>', '"': '"' }[char] )); } function clearLog() { state.logs = []; renderLog(); } function toast(message) { let element = document.getElementById('shou-helper-toast'); if (!element) { element = document.createElement('div'); element.id = 'shou-helper-toast'; document.body.appendChild(element); } element.textContent = message; element.classList.add('visible'); clearTimeout(element.hideTimer); element.hideTimer = setTimeout(() => element.classList.remove('visible'), 2600); logMessage(message, 'evt'); } function placePanel(panel, left, top, save = false) { const maxLeft = Math.max(8, window.innerWidth - panel.offsetWidth - 8); const maxTop = Math.max(8, window.innerHeight - panel.offsetHeight - 8); const safeLeft = Math.min(Math.max(8, left), maxLeft); const safeTop = Math.min(Math.max(8, top), maxTop); panel.style.setProperty('left', `${safeLeft}px`, 'important'); panel.style.setProperty('right', 'auto', 'important'); panel.style.setProperty('top', `${safeTop}px`, 'important'); if (save) storage.set(KEYS.panelPosition, JSON.stringify({ left: safeLeft, top: safeTop })); } function enablePanelDragging(panel) { const header = panel.querySelector('header'); let drag = null; header.addEventListener('pointerdown', event => { if (event.button !== 0 || event.target.closest('button')) return; const rect = panel.getBoundingClientRect(); drag = { pointerId: event.pointerId, offsetX: event.clientX - rect.left, offsetY: event.clientY - rect.top }; header.setPointerCapture(event.pointerId); panel.classList.add('dragging'); event.preventDefault(); }); header.addEventListener('pointermove', event => { if (!drag || event.pointerId !== drag.pointerId) return; placePanel(panel, event.clientX - drag.offsetX, event.clientY - drag.offsetY); }); const finish = event => { if (!drag || event.pointerId !== drag.pointerId) return; const rect = panel.getBoundingClientRect(); placePanel(panel, rect.left, rect.top, true); drag = null; panel.classList.remove('dragging'); }; header.addEventListener('pointerup', finish); header.addEventListener('pointercancel', finish); } function createPanel() { const panel = document.createElement('section'); panel.id = 'shou-helper-panel'; panel.innerHTML = `
上海开放大学助手
进度 0 项待开始
运行日志
暂无日志
`; document.body.appendChild(panel); try { const savedPosition = JSON.parse(storage.get(KEYS.panelPosition, 'null')); if (Number.isFinite(savedPosition?.left) && Number.isFinite(savedPosition?.top)) { placePanel(panel, savedPosition.left, savedPosition.top); } } catch (_) {} enablePanelDragging(panel); panel.classList.toggle('minimized', storage.get(KEYS.minimized, false) === true); setLearningActive(isLearningActive()); document.getElementById('shou-helper-start').addEventListener('click', toggleLearning); document.getElementById('shou-helper-expand').addEventListener('click', expandCatalog); document.getElementById('shou-helper-return').addEventListener('click', returnToCatalog); document.getElementById('shou-helper-log-clear').addEventListener('click', clearLog); document.getElementById('shou-helper-minimize').addEventListener('click', () => { panel.classList.toggle('minimized'); storage.set(KEYS.minimized, panel.classList.contains('minimized')); }); renderLog(); } function addStyles() { const style = document.createElement('style'); style.textContent = ` @keyframes shou-panel-in{from{opacity:0;transform:translateY(-10px) scale(.97)}to{opacity:1;transform:translateY(0) scale(1)}} @keyframes shou-glow{0%,100%{box-shadow:0 0 0 0 rgba(16,185,129,.35)}50%{box-shadow:0 0 0 5px rgba(16,185,129,0)}} #shou-helper-panel{position:fixed!important;right:18px!important;top:82px!important;bottom:auto!important;width:min(288px,calc(100vw - 24px))!important;z-index:2147483647!important;color:#1e293b!important;border:1px solid #d1fae5!important;border-radius:16px!important;box-shadow:0 12px 34px rgba(6,95,70,.14),0 2px 6px rgba(6,95,70,.06)!important;background:#ffffff!important;font:14px/1.5 system-ui,"Microsoft YaHei",sans-serif!important;overflow:hidden!important;animation:shou-panel-in .3s cubic-bezier(.2,.9,.3,1.05)!important} #shou-helper-panel header{position:relative;display:flex;justify-content:space-between;align-items:center;padding:12px 16px;background:linear-gradient(120deg,#059669,#10b981 60%,#06b6d4);color:#fff;font-size:15px;font-weight:700;letter-spacing:.2px;cursor:move;user-select:none;touch-action:none} #shou-helper-panel.dragging{transition:none!important;box-shadow:0 18px 44px rgba(6,95,70,.22)!important;cursor:grabbing} #shou-helper-panel button{cursor:pointer;border:1px solid #ccfbf1;border-radius:9px;background:#f0fdfa;color:#0d9488;padding:9px 10px;font-size:13.5px;font-weight:600;transition:transform .15s,background .18s,border-color .18s,box-shadow .18s;-webkit-tap-highlight-color:transparent} #shou-helper-panel button:hover{background:#e6fffa;border-color:#5eead4;box-shadow:0 3px 10px rgba(16,185,129,.16);transform:translateY(-1px)} #shou-helper-panel button:active{transform:translateY(0) scale(.97)} #shou-helper-panel button:disabled{cursor:not-allowed;opacity:.45;transform:none;box-shadow:none} #shou-helper-minimize{border:0!important;background:transparent!important;color:#fff!important;font-size:18px;line-height:1;padding:0 5px!important;opacity:.9;box-shadow:none!important} #shou-helper-minimize:hover{opacity:1;transform:none!important;background:rgba(255,255,255,.2)!important;border-radius:7px!important} .shou-helper-body{padding:14px 16px 16px}.minimized .shou-helper-body{display:none} .shou-helper-summary{display:flex;justify-content:space-between;align-items:center;margin-bottom:13px;color:#64748b;font-size:13px} #shou-helper-count{color:#065f46;font-weight:700} #shou-helper-status{padding:3px 10px;border-radius:999px;background:#ecfdf5;color:#059669;font-size:12px;font-weight:600;border:1px solid #a7f3d0} #shou-helper-status.active{background:#059669;color:#fff;border-color:#059669;animation:shou-glow 2.2s ease-in-out infinite} .shou-helper-actions{display:grid;grid-template-columns:1fr 1fr;gap:9px} #shou-helper-start{grid-column:1/-1;background:linear-gradient(120deg,#059669,#10b981)!important;border:0!important;color:#fff!important;font-weight:700;font-size:14.5px;letter-spacing:.3px;padding:11px!important;box-shadow:0 6px 16px rgba(16,185,129,.35)!important;background-size:160% 100%!important;transition:background-position .45s,transform .15s,box-shadow .2s!important} #shou-helper-start:hover{background-position:100% 0!important;transform:translateY(-1px);box-shadow:0 9px 22px rgba(16,185,129,.45)!important} .shou-helper-log-head{display:flex;justify-content:space-between;align-items:center;margin-top:14px;margin-bottom:6px;color:#64748b;font-size:12px;font-weight:600} #shou-helper-log-clear{border:0!important;background:transparent!important;color:#0d9488!important;font-size:11.5px;padding:0!important;font-weight:600;box-shadow:none!important} #shou-helper-log-clear:hover{background:transparent!important;text-decoration:underline;transform:none!important;box-shadow:none!important} #shou-helper-log{height:112px;overflow-y:auto;background:#f0fdf9;border:1px solid #d1fae5;border-radius:9px;padding:7px 9px;font:11.5px/1.55 "SFMono-Regular",Consolas,"Microsoft YaHei",monospace;color:#334155;scrollbar-width:thin} #shou-helper-log::-webkit-scrollbar{width:6px}#shou-helper-log::-webkit-scrollbar-thumb{background:#a7f3d0;border-radius:6px} .shou-helper-log-line{display:flex;gap:6px;padding:1px 0;word-break:break-all} .shou-helper-log-line time{color:#94a3b8;flex-shrink:0} .shou-helper-log-line.evt{color:#0d9488}.shou-helper-log-line.warn{color:#d97706}.shou-helper-log-line.done{color:#059669;font-weight:600} .shou-helper-log-empty{color:#94a3b8;text-align:center;padding-top:38px} .shou-helper-footer{margin-top:13px;padding-top:11px;border-top:1px dashed #d1fae5;color:#94a3b8;font-size:11.5px;line-height:1.7;text-align:center}.shou-helper-footer b{color:#059669;font-weight:700}.shou-helper-footer i{font-style:normal;color:#b6c0cc} #shou-helper-toast{position:fixed;left:50%;bottom:38px;z-index:2147483647;transform:translate(-50%,20px);background:#065f46;color:#fff;padding:11px 20px;border-radius:10px;font-weight:600;font-size:13.5px;box-shadow:0 10px 26px rgba(6,95,70,.3);opacity:0;pointer-events:none;transition:.26s cubic-bezier(.2,.9,.3,1.05)}#shou-helper-toast.visible{opacity:1;transform:translate(-50%,0)} `; (document.head || document.documentElement).appendChild(style); } function init() { if (!document.body || document.getElementById('shou-helper-panel')) return; try { if (!isAutomationSupportPage()) setLearningActive(false); addStyles(); createPanel(); scanTasks(); attachMandatoryVideoPlayback(); debugFlow('page:init'); console.info('%c上海开放大学助手%c v1.24.0 · 作者 孤影 · 反馈 QQ 2297311605', 'color:#059669;font-weight:700', 'color:#64748b', location.href); logMessage(`助手已启动 v1.24.0,识别到 ${state.tasks.length} 个任务`, 'done'); state.scanTimer = setInterval(() => { scanTasks(); checkCompletion(); attachMandatoryVideoPlayback(); }, CONFIG.rescanInterval); const observer = new MutationObserver(() => { clearTimeout(observer.debounceTimer); observer.debounceTimer = setTimeout(() => { scanTasks(); attachMandatoryVideoPlayback(); }, CONFIG.scanDelay); }); observer.observe(document.body, { childList: true, subtree: true }); } catch (error) { console.error('[上海开放大学助手] 启动失败', error); const badge = document.createElement('button'); badge.type = 'button'; badge.textContent = '学习助手启动失败,点击查看错误'; badge.style.cssText = 'position:fixed;right:18px;top:82px;z-index:2147483647;padding:12px;background:#c62828;color:#fff;border:0;border-radius:8px;cursor:pointer'; badge.addEventListener('click', () => alert(`上海开放大学助手启动失败:${error.message || error}`)); document.body.appendChild(badge); } } function boot() { if (document.body) init(); else document.addEventListener('DOMContentLoaded', init, { once: true }); } boot(); })();