// ==UserScript== // @name 我周五要吃德克士 // @namespace scu-ecourse-study-helper // @version 0.1.0 // @description 识别大川学堂视频、倍速、当前章节,并在正常播放结束后提示进入下一节(不伪造学习进度) // @match https://ecourse.scu.edu.cn/learn/course/detail/mooc/courseWare/* // @grant none // @run-at document-idle // ==/UserScript== (() => { 'use strict'; const CFG = { playbackRate: Number(localStorage.getItem('scu_helper_rate') || 1.5), tryAutoplay: true, muteForAutoplay: false, debug: true }; const log = (...args) => CFG.debug && console.log('[SCU Helper]', ...args); let boundVideo = null; let endHandler = null; let routeKey = ''; let panel = null; function getActiveVideo() { // 你提供的页面中,真正课程播放器为 #h5player 下的 video。 const primary = document.querySelector('#h5player video'); if (primary && Number.isFinite(primary.duration) && primary.duration > 0) return primary; // 兜底:排除页面中空的 controls video。 return [...document.querySelectorAll('video')] .find(v => (v.currentSrc || v.src) && Number.isFinite(v.duration) && v.duration > 0) || null; } function getSectionItems() { return [...document.querySelectorAll('a.section_item')] .filter(el => el.querySelector('.course_name')); } function getCurrentSectionItem() { const currentNode = document.querySelector('.el-tree-node.is-current'); return currentNode?.querySelector('a.section_item') || null; } function getSectionName(item) { return item?.querySelector('.course_name')?.textContent?.trim() || ''; } function getCurrentSectionIndex() { const items = getSectionItems(); const current = getCurrentSectionItem(); if (!current) return -1; return items.indexOf(current); } function getNextSectionItem() { const items = getSectionItems(); const index = getCurrentSectionIndex(); if (index >= 0 && index + 1 < items.length) { return items[index + 1]; } // 如果 Element UI 在切换时短暂丢失 is-current,则按顶部课程名匹配。 const title = document.querySelector('.course_title .course_name')?.textContent?.trim(); if (title) { const matched = items.findIndex(x => getSectionName(x) === title); if (matched >= 0 && matched + 1 < items.length) return items[matched + 1]; } return null; } function clickSection(item) { if (!item) return false; const name = getSectionName(item); log('准备进入章节:', name); // 使用站点本身 Vue/Element UI 已绑定的 click handler。 item.scrollIntoView({ behavior: 'smooth', block: 'center' }); setTimeout(() => item.click(), 250); return true; } // 🔥 修改点1:改为 showConfirm 参数,默认为 false(自动跳转) function goNextSection(showConfirm = false) { const next = getNextSectionItem(); if (!next) { alert('当前 DOM 中没有找到下一节。可能已到最后一节,或后续章节尚未展开。'); return; } const name = getSectionName(next); // 只有 showConfirm 为 true 时才弹出确认框 if (showConfirm) { if (!confirm(`当前视频已播放结束。\n\n进入下一节:\n${name}?`)) { return; } } log('自动进入下一节:', name); clickSection(next); } function applyRate(video) { if (!video) return; const rate = Math.max(0.5, Math.min(2, CFG.playbackRate)); video.playbackRate = rate; video.defaultPlaybackRate = rate; log('倍速:', rate); updatePanel(); } async function tryPlay(video) { if (!video || !CFG.tryAutoplay) return; try { if (CFG.muteForAutoplay) video.muted = true; applyRate(video); await video.play(); log('自动播放成功'); } catch (err) { log('浏览器阻止自动播放,需要点击一次页面后再播放:', err?.message || err); updatePanel('浏览器阻止自动播放,点击"播放"即可'); } } function bindVideo(video) { if (!video || video === boundVideo) return; if (boundVideo && endHandler) { boundVideo.removeEventListener('ended', endHandler); } boundVideo = video; applyRate(video); // 🔥 修改点2:视频结束自动跳转,不弹出确认框 endHandler = () => { log('视频 ended:', getSectionName(getCurrentSectionItem())); updatePanel('视频已结束,自动进入下一节...'); setTimeout(() => goNextSection(), 500); // 不传参,默认为 false }; video.addEventListener('ended', endHandler); video.addEventListener('loadedmetadata', () => { applyRate(video); updatePanel(); }); video.addEventListener('ratechange', () => updatePanel()); video.addEventListener('play', () => updatePanel()); video.addEventListener('pause', () => updatePanel()); video.addEventListener('timeupdate', throttle(updatePanel, 1000)); log('绑定课程视频:', { src: video.currentSrc || video.src, duration: video.duration }); tryPlay(video); updatePanel(); } function throttle(fn, wait) { let last = 0; return (...args) => { const now = Date.now(); if (now - last >= wait) { last = now; fn(...args); } }; } function fmt(sec) { if (!Number.isFinite(sec)) return '--:--'; const m = Math.floor(sec / 60); const s = Math.floor(sec % 60); return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`; } function createPanel() { if (document.getElementById('scu-helper-panel')) return; panel = document.createElement('div'); panel.id = 'scu-helper-panel'; panel.innerHTML = `
大川学堂学习辅助
等待识别课程视频…
`; const style = document.createElement('style'); style.textContent = ` #scu-helper-panel{ position:fixed;right:18px;bottom:18px;z-index:2147483647; width:290px;padding:12px 14px;border-radius:10px; background:rgba(25,25,25,.92);color:#fff; font:13px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif; box-shadow:0 6px 24px rgba(0,0,0,.25) } #scu-helper-panel .scu-title{font-weight:700;margin-bottom:6px} #scu-helper-panel #scu-status{opacity:.9;white-space:pre-line;margin-bottom:9px} #scu-helper-panel .scu-row{display:flex;gap:6px} #scu-helper-panel button,#scu-helper-panel select{ border:0;border-radius:6px;padding:6px 8px;cursor:pointer } #scu-helper-panel button{background:#fff;color:#333} #scu-helper-panel select{background:#fff;color:#333} `; document.head.appendChild(style); document.body.appendChild(panel); const rate = panel.querySelector('#scu-rate'); rate.value = String(CFG.playbackRate); panel.querySelector('#scu-play').addEventListener('click', async () => { const v = getActiveVideo(); if (!v) return alert('暂未找到课程视频'); applyRate(v); if (v.paused) { try { await v.play(); } catch (e) { alert('播放被浏览器阻止,请直接点击播放器。'); } } else { v.pause(); } updatePanel(); }); rate.addEventListener('change', () => { CFG.playbackRate = Number(rate.value); localStorage.setItem('scu_helper_rate', String(CFG.playbackRate)); applyRate(getActiveVideo()); }); // 🔥 修改点3:手动点击"下一节"按钮时弹出确认框 panel.querySelector('#scu-next').addEventListener('click', () => goNextSection(true)); } function updatePanel(extra = '') { if (!panel) return; const v = getActiveVideo(); const current = getCurrentSectionItem(); const items = getSectionItems(); const idx = getCurrentSectionIndex(); const status = panel.querySelector('#scu-status'); if (!v) { status.textContent = `章节:${getSectionName(current) || '未识别'}\n视频:等待加载…${extra ? '\n' + extra : ''}`; return; } status.textContent = `章节:${getSectionName(current) || document.querySelector('.course_title .course_name')?.textContent?.trim() || '未识别'}\n` + `章节位置:${idx >= 0 ? idx + 1 : '?'} / ${items.length || '?'}\n` + `视频:${fmt(v.currentTime)} / ${fmt(v.duration)} · ${v.paused ? '暂停' : '播放'} · ${v.playbackRate}×` + (extra ? `\n${extra}` : ''); } function scan() { createPanel(); const key = `${location.href}|${document.querySelector('.course_title .course_name')?.textContent?.trim() || ''}`; if (key !== routeKey) { routeKey = key; log('页面/章节变化:', key); } const video = getActiveVideo(); if (video) bindVideo(video); updatePanel(); } // Vue 单页应用切换章节时,video 节点和 is-current 都可能被重建。 const observer = new MutationObserver(throttle(scan, 300)); observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'src'] }); // 某些浏览器在用户首次交互之前会阻止 autoplay。 document.addEventListener('pointerdown', () => { const v = getActiveVideo(); if (v && v.paused && CFG.tryAutoplay) { setTimeout(() => tryPlay(v), 100); } }, { once: true, capture: true }); scan(); setInterval(scan, 2000); // 调试接口 window.SCUHelper = { getActiveVideo, getSectionItems, getCurrentSectionItem, getNextSectionItem, goNextSection, scan }; log('已启动。调试接口:window.SCUHelper'); })();