// ==UserScript== // @name 雨课堂视频连续播放(1倍速) // @namespace local.yuketang.video-only // @version 3.0.0 // @description 从课程目录选择未完成视频,正常1倍速播放,结束后返回目录继续。 // @match https://*.yuketang.cn/* // @match https://yuketang.cn/* // @run-at document-idle // @grant unsafeWindow // @noframes // @license MIT // ==/UserScript== /* 使用前禁用旧脚本。仅适配 pro/lms 目录和 ai-workspace 视频流程。 * 不提交作业、讨论、考试,不请求题库,不修改/伪造平台学习进度。 * 两份源文件没有运行时 DOM,下面目录选择器需要在实际页面验证。 */ (() => { 'use strict'; const KEY = 'ykt-video-only-v3'; const ROWS = '.leaf-detail,[data-leaf-id],a[href*="/video/"]'; const COURSE = /\/pro\/lms\/[^/]+\/(\d+)\/studycontent\/?$/; const VIDEO = /\/(?:lms-graph|lms)\/(?:[^/]+\/)?(\d+)\/video\/([^/?#]+)/; const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); const text = el => (el?.innerText || el?.textContent || '').trim(); const load = () => { try { return JSON.parse(sessionStorage.getItem(KEY)) || {}; } catch { return {}; } }; let state = load(); let busy = false; let media = null; let mediaSource = ''; let endedAt = 0; let playAttempt = 0; let lastMove = Date.now(); let lastTime = -1; let routeStarted = Date.now(); let lastRoute = location.href; let panel, label; const save = () => sessionStorage.setItem(KEY, JSON.stringify(state)); const log = message => { if (label && label.textContent !== message) { label.textContent = message; console.info('[雨课堂视频]', message); } }; const stop = message => { state.running = false; save(); media?.pause(); log(message); }; function docs(root = document, seen = new Set()) { if (seen.has(root)) return []; seen.add(root); const result = [root]; for (const frame of root.querySelectorAll('iframe')) { try { if (frame.contentDocument) result.push(...docs(frame.contentDocument, seen)); } catch { /* 跨域 iframe 无法读取,超时后提示。 */ } } return result; } function visible(el) { return el?.isConnected && el.getClientRects().length > 0; } function routeOf(doc) { try { return doc.defaultView.location.href; } catch { return ''; } } function safeVideoURL(raw, base) { try { const url = new URL(raw, base); if (url.origin !== location.origin || !VIDEO.test(url.pathname)) return ''; if (state.course && url.pathname.match(VIDEO)?.[1] !== state.course) return ''; return url.href; } catch { return ''; } } function rows() { const result = []; for (const doc of docs()) { for (const el of doc.querySelectorAll(ROWS)) { // 链接常嵌在 leaf-detail 中,只保留完整单元行。 if (!el.matches('.leaf-detail') && el.closest('.leaf-detail')) continue; if (el.parentElement?.closest('[data-leaf-id]') && !el.matches('.leaf-detail')) continue; const anchor = el.matches('a') ? el : el.querySelector('a[href]'); const href = safeVideoURL(anchor?.getAttribute('href') || el.getAttribute('data-href') || '', routeOf(doc)); const icons = [...el.querySelectorAll('i,svg,use,[class*="icon"]')] .map(n => `${n.getAttribute('class') || ''} ${n.getAttribute('href') || n.getAttribute('xlink:href') || ''}`).join(' '); const kind = `${el.getAttribute('data-type') || ''} ${icons}`; // 不通过标题包含“视频”来猜类型,防止误点“视频作业”等。 const video = !!href || /shipin|(?:^|[\s_#-])video(?:$|[\s_-])/i.test(kind); const nonVideo = /zuoye|kaoshi|taolun|homework|exam|discussion|exercise/i.test(kind); if (!video || nonVideo) continue; const status = text(el.querySelector('.progress-wrap,.progress-text,.status,.leaf-status')) || text(el); const done = !/未完成|未学完|未学习/.test(status) && (/(?:^|\D)100(?:\.0+)?\s*%/.test(status) || /已完成|已学完|已看完/.test(status)); const id = el.getAttribute('data-leaf-id') || href.match(VIDEO)?.[2] || el.getAttribute('data-id'); const title = text(el.querySelector('.leaf-name,.name,.title')) || text(el).split('\n')[0]; // 无 ID 时使用目录顺序和标题;不会把标题单独当成唯一标识。 result.push({ el, href, title, done, key: id ? `id:${id}` : `row:${result.length}:${title}` }); } } return result; } function bindVideo(v) { const source = v.currentSrc || v.src; if (v === media && source === mediaSource) return; media = v; mediaSource = source; endedAt = 0; playAttempt = 0; lastTime = -1; lastMove = Date.now(); // 定时器同时检查 ended,处理脚本绑定前就已结束的情况。 v.addEventListener('ended', () => { if (v === media && v.ended) endedAt ||= Date.now(); }); } async function finish() { if (busy || !state.pending || !state.running) return; busy = true; try { log('视频自然结束,等待平台同步后返回目录…'); await sleep(6000); if (!state.running || !media?.ended) return; state.watched ||= []; if (!state.watched.includes(state.pending.key)) state.watched.push(state.pending.key); // watched 仅表示本次任务自然播放结束,不代表服务端已记为100%。 state.pending = null; save(); media = null; if (location.href !== state.catalog) location.assign(state.catalog); else routeStarted = Date.now(); } finally { busy = false; } } async function openItem(item) { busy = true; state.pending = { key: item.key, title: item.title, url: item.href, opened: Date.now() }; save(); // 必须在点击/整页导航前保存。 routeStarted = Date.now(); log(`打开未完成视频:${item.title}`); try { if (item.href) { location.assign(item.href); return; } // Vue 单元可能没有 href,通过原点击处理器获取正确路由。 // 将此次点击的 window.open 收敛到当前页,避免第二次打开被浏览器拦截。 const win = item.el.ownerDocument.defaultView; const page = win === window ? unsafeWindow : win; const original = page.open; const redirect = function(url) { const target = safeVideoURL(String(url || ''), routeOf(item.el.ownerDocument)); if (target) { state.pending.url = target; save(); location.assign(target); } else stop('目录尝试打开无法确认的视频地址,已停止。请提供运行后的目录 DOM。'); return null; }; try { page.open = redirect; item.el.click(); await sleep(1500); } finally { if (page.open === redirect) page.open = original; } } finally { busy = false; } } async function tick() { if (busy || !state.running) return; if (lastRoute !== location.href) { lastRoute = location.href; routeStarted = Date.now(); media = null; } const documents = docs(); const expected = state.pending?.url?.match(VIDEO)?.[2]; const vdoc = documents.find(d => { const match = new URL(routeOf(d) || location.href).pathname.match(VIDEO); return match && match[1] === state.course && (!expected || match[2] === expected) && [...d.querySelectorAll('video')].some(visible); }); const v = vdoc && [...vdoc.querySelectorAll('video')].find(visible); if (v && state.pending) { bindVideo(v); if (v.playbackRate !== 1) v.playbackRate = 1; v.defaultPlaybackRate = 1; if (v.error) { stop(`播放器错误 ${v.error.code},已停止,请手动检查。`); return; } if (v.ended) { endedAt ||= Date.now(); await finish(); return; } if (v.currentTime !== lastTime) { lastTime = v.currentTime; lastMove = Date.now(); } if (Date.now() - lastMove > 120000) { stop('视频两分钟未推进:请检查缓冲、弹题或验证,处理后点“继续”。'); return; } if (v.paused && Date.now() - playAttempt > 10000) { playAttempt = Date.now(); try { await v.play(); } catch { // 浏览器可能要求用户手动播放;保留声音设置。 log('浏览器阻止自动播放,请在播放器点一次播放;脚本会继续接管。'); return; } } log(`1倍速播放:${state.pending.title}(${Math.floor(v.currentTime)} / ${Math.floor(v.duration || 0)} 秒)`); return; } if (state.pending) { if (Date.now() - state.pending.opened > 60000) { stop('60秒内未找到对应视频。可能是跨域 iframe、异步新窗口或选择器不匹配;请提供运行时 DOM。'); } else log(`等待视频播放器:${state.pending.title}`); return; } if (!COURSE.test(location.pathname)) { stop('请回到课程目录页后点击“重新扫描”。'); return; } // 等待异步目录加载;避免返回目录瞬间扫描到上一页残留节点。 if (Date.now() - routeStarted < 4000) return; const list = rows(); if (!list.length) { log('等待可识别的视频目录…请展开章节;若持续无结果,需要运行时目录 DOM。'); return; } const next = list.find(item => !item.done && !(state.watched || []).includes(item.key) && visible(item.el)); if (next) await openItem(next); else { stop(`当前已加载目录没有待播视频(识别到 ${list.length} 项)。如有折叠章节,请展开后继续扫描。`); } } function start(reset = false) { const course = location.pathname.match(COURSE); if (course) { if (reset || state.catalog !== location.href) state = { watched: [] }; state.course = course[1]; state.catalog = location.href; if (reset) state.pending = null; } if (!state.catalog) { log('请从课程目录页开始。'); return; } state.running = true; if (state.pending) state.pending.opened = Date.now(); lastMove = Date.now(); save(); log('正在扫描目录/等待播放器…'); } function init() { panel = document.createElement('div'); panel.style.cssText = 'position:fixed;right:16px;top:80px;z-index:2147483647;width:300px;padding:12px;background:#fff;color:#222;border:1px solid #aaa;border-radius:8px;box-shadow:0 3px 16px #0003;font:14px/1.6 sans-serif'; const title = document.createElement('strong'); title.textContent = '雨课堂 · 视频连续播放 · 1倍速'; label = document.createElement('div'); panel.append(title, label); for (const [name, action] of [ ['继续', () => start()], ['停止', () => stop('已停止。')], ['重新扫描', () => start(true)] ]) { const button = document.createElement('button'); button.textContent = name; button.style.cssText = 'margin:8px 6px 0 0;cursor:pointer'; button.onclick = action; panel.append(button); } document.body.append(panel); if (COURSE.test(location.pathname) && state.catalog !== location.href) start(true); else if (COURSE.test(location.pathname) && state.running === undefined) start(); else log(state.running ? '恢复视频任务…' : '已停止;从目录页点击“继续”开始。'); setInterval(() => tick().catch(error => stop(`脚本异常:${error.message}`)), 1000); } if (COURSE.test(location.pathname) || VIDEO.test(location.pathname) || state.catalog) init(); })();