// ==UserScript== // @name 湖南医药继续教育网课-弹窗自动确认+视频续播 // @namespace edu-edu-netclass-helper // @version 1.2.2 // @description (cws.edu-edu.com)网课助手:穿透iframe;自动确认弹窗;自动勾选“连续播放”;自动播放+静音兜底;自动记住播放位置并续播。 // @author doubao // @match *://cws.edu-edu.com/* // @match *://*.edu-edu.com/* // @run-at document-start // @grant none // @license MIT // ==/UserScript== (function () { 'use strict'; const VERSION = '1.2.2'; /* ================================================================ * 配置区(按需修改,改完保存后刷新课程页面生效) * ================================================================ */ const CONFIG = { // 1. 弹窗自动确认 autoConfirmNative: true, // 拦截 window.alert/confirm,自动点“确定”(慎关,部分确认框靠它) autoClickModal: true, // 自动点击页面弹窗里的“确定/确认/继续/知道了”等按钮 clickCooldownMs: 2500, // 点完一次按钮后冷却多久,避免重复点击 // 2. 视频续播 saveIntervalSec: 5, // 每多少秒保存一次播放位置 resumeMinSec: 20, // 播放超过该秒数才续播(小片段不跳) resumeTailSec: 30, // 距片尾该秒数内不续播(视为快看完,交给平台记进度) autoPlayNext: true, // 本视频结束后,尝试自动播放下一节(平台“连续播放”关闭时也尽量续上) // 3. 自动播放 + 连续播放 autoToggleContinuous: true, // 进入页面自动勾选“连续播放”开关 continuousMaxTry: 6, // 勾选“连续播放”最多重试次数(每3秒查一次) autoPlayVideo: true, // 进入页面自动播放当前视频 autoPlayMutedFallback: true, // 自动播放被浏览器阻止时,先静音播放,点击页面后恢复声音 // 4. 其他 autoOpenLast: true, // 页面加载后若无视频播放,自动打开上次未学完的章节 showBadge: true // 页面右下角显示运行状态角标 }; const TAG = '[网课助手]'; const STORE_KEY = 'qsc_netclass_helper_resume_v2'; const log = (...a) => console.log(TAG, ...a); /* ================================================================ * 工具函数 * ================================================================ */ const $ = (sel, root) => (root || document).querySelector(sel); const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel)); const norm = (s) => (s || '').replace(/\s+/g, '').trim(); const isVisible = (el) => { if (!el || !el.isConnected) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }; const fmt = (sec) => { sec = Math.floor(sec || 0); const m = Math.floor(sec / 60), s = sec % 60; return m + ':' + String(s).padStart(2, '0'); }; function getUid() { const m = location.hash.match(/[?&]uid=(\d+)/) || location.href.match(/[?&]uid=(\d+)/); return m ? m[1] : ''; } const uid = getUid(); /* ================================================================ * 本地存储(按 课程uid+章节名 记录播放位置,刷新后仍可续播) * ================================================================ */ function loadStore() { try { return JSON.parse(localStorage.getItem(STORE_KEY)) || {}; } catch (e) { return {}; } } function saveStore(s) { try { localStorage.setItem(STORE_KEY, JSON.stringify(s)); } catch (e) { /* 忽略 */ } } const getKeyFor = (u, name) => u + '::' + norm(name); /* ================================================================ * 功能1:弹窗自动确认(可作用于指定 window/document,穿透 iframe) * ================================================================ */ function blockNativeDialogs(win) { if (!win) return; try { win.__qsc_origAlert = win.alert; win.__qsc_origConfirm = win.confirm; win.alert = function (msg) { log('自动确认 alert:', msg); return undefined; }; win.confirm = function (msg) { log('自动确认 confirm:', msg); return true; }; } catch (e) { /* ignore */ } } const CONFIRM_RE = /(确定|确认|继续|知道了|好的|OK|同意|下一个|下一节|下一步|开始学习|关闭)/i; const CANCEL_RE = /(取消|暂不|不需要|不了|否|稍后|以后再说)/i; const MODAL_SELECTOR = [ '.el-dialog', '.ant-modal', '.layui-layer', '.modal', '.dialog', '[class*="dialog"]', '[class*="modal"]', '[class*="popup"]', '[class*="layer"]' ].join(','); let modalHandledCount = 0; // 已自动处理的弹窗次数 function clickConfirmBtn(doc) { if (!CONFIG.autoClickModal) return false; doc = doc || document; const now = Date.now(); if (now - clickConfirmBtn.last < CONFIG.clickCooldownMs) return false; let best = null; const modals = $$(MODAL_SELECTOR, doc).filter(isVisible); for (const m of modals) { const btns = $$('button, .btn, [role="button"], a.btn, [class*="btn"]', m).filter(isVisible); for (const b of btns) { const t = norm(b.innerText); if (!t || CANCEL_RE.test(t)) continue; if (CONFIRM_RE.test(t)) { best = b; break; } } if (best) break; } if (!best) { const all = $$('button, .btn, [role="button"]', doc).filter(isVisible); for (const b of all) { const t = norm(b.innerText); if (!t || CANCEL_RE.test(t)) continue; if (CONFIRM_RE.test(t)) { best = b; break; } } } if (best) { try { best.click(); clickConfirmBtn.last = Date.now(); modalHandledCount++; log('已自动点击弹窗按钮(累计 ' + modalHandledCount + ' 次):', norm(best.innerText) || best.className); return true; } catch (e) { /* ignore */ } } return false; } clickConfirmBtn.last = 0; /* ================================================================ * 功能2:视频断点续播 + 自动播放(穿透 iframe) * ================================================================ */ let lastKey = ''; let resumedKey = ''; // 已续播过的视频 key(防止同页重复跳) let lastTitle = ''; let lastSave = 0; let userInteracted = false; const seenVideos = new WeakSet(); const mutedTried = new WeakSet(); const everPlayed = new WeakSet(); // 标记“播放过”的视频,避免兜底逻辑打扰用户手动暂停 // 识别当前播放的章节名(优先取目录里的“选中项”,始终用顶层 document) function currentTitle() { let title = ''; const cands = $$('.active, .current, .cur, .on, [class*="active"], [class*="current"], [class*="playing"], [class*="selected"]'); for (const el of cands) { const t = norm(el.innerText); if (!t || t.length > 60) continue; if (/(视频|学时|分钟|第[一二三四五六七八九十\d]+节|:\d{2})/.test(t)) { title = t; break; } } if (!title) { const el = $('.active, .current, .cur'); if (el) title = norm(el.innerText); } if (!title) { const h = $('h1, h2, .course-name, .title'); if (h) title = norm(h.innerText); } // 去掉进度百分比和时长,保证 key 稳定 return (title || '') .replace(/已学\s*\d+%/g, '') .replace(/\d{1,2}:\d{2}(:\d{2})?/g, '') .replace(/\s+/g, ''); } function getCurrentKey(video) { const t = currentTitle(); const src = (video.currentSrc || video.src || '').split('?')[0]; const name = t || (src ? src.split('/').pop() : ''); return name ? getKeyFor(uid, name) : ''; } function savePosition(video) { const t = video.currentTime, d = video.duration || 0; if (!isFinite(t) || t <= 0) return; if (d && t >= d - 1) return; // 已播完不写续播点 const key = getCurrentKey(video); if (!key) return; const s = loadStore(); s[key] = { t: t, d: d, done: false, name: lastTitle || currentTitle(), at: Date.now() }; saveStore(s); } function tryResume(video) { const key = getCurrentKey(video); if (!key || resumedKey === key) return; const s = loadStore(); const rec = s[key]; if (!rec || rec.done) return; const d = video.duration || rec.d || 0; if (rec.t < CONFIG.resumeMinSec) return; if (d && rec.t > d - CONFIG.resumeTailSec) return; // 快看完了就不跳 try { video.currentTime = rec.t; log('视频续播 → ' + fmt(rec.t) + ' / 总 ' + fmt(d) + '(' + (rec.name || key) + ')'); resumedKey = key; if (CONFIG.autoPlayNext) { const p = video.play(); if (p && p.catch) p.catch(() => {}); } } catch (e) { /* ignore */ } } function tryPlay(video) { if (!CONFIG.autoPlayVideo) return; if (!video || video.ended || video.readyState < 2) return; if (!video.paused) return; const p = video.play(); if (p && p.catch) p.catch(() => { /* 自动播放被浏览器拦截,走静音兜底 */ }); } function tryPlayMuted(video) { if (!video || video.ended || video.readyState < 2) return; if (!video.paused) return; try { video.muted = true; const p = video.play(); if (p && p.catch) p.catch(() => {}); log('已静音自动播放(浏览器限制,点击页面后恢复声音)'); } catch (e) { /* ignore */ } } function handleVideo(video) { if (seenVideos.has(video)) return; seenVideos.add(video); // 记录播放状态:播放过则不再兜底强播;换源后重置(用于连续播放下一集) video.addEventListener('play', () => everPlayed.add(video), true); video.addEventListener('loadstart', () => everPlayed.delete(video), true); video.addEventListener('loadedmetadata', () => { tryResume(video); tryPlay(video); }, true); video.addEventListener('loadeddata', () => tryPlay(video), true); video.addEventListener('canplay', () => tryPlay(video), true); video.addEventListener('timeupdate', () => { const now = Date.now(); if (now - lastSave < CONFIG.saveIntervalSec * 1000) return; lastSave = now; savePosition(video); }, true); video.addEventListener('ended', () => { const key = getCurrentKey(video); if (key) { const s = loadStore(); s[key] = { t: 0, d: video.duration || 0, done: true, name: lastTitle || currentTitle(), at: Date.now() }; saveStore(s); log('本视频播放结束,已记录为完成'); } if (CONFIG.autoPlayNext) { setTimeout(() => { const v = $$('video').find(x => x.paused && x.readyState >= 2 && (x !== video || (x.currentSrc || x.src) !== (video.currentSrc || video.src)) ); if (v) { const p = v.play(); if (p && p.catch) p.catch(() => {}); log('尝试自动播放下一节'); } }, 2500); } }, true); tryResume(video); } // 周期性扫描指定 document 内的视频元素 function scan(doc) { doc = doc || document; const vids = $$('video', doc).filter(isVisible); if (!vids.length) return; for (const v of vids) handleVideo(v); const key = getCurrentKey(vids[vids.length - 1]); if (key && key !== lastKey) { lastKey = key; resumedKey = ''; // 换了新视频,允许再次续播 lastTitle = currentTitle(); } // 兜底自动播放:只对“从未播放过”的视频补播(避免打扰用户手动暂停); // 换源(loadstart)后会重置标记,保证连续播放下一集仍能自动起播 if (CONFIG.autoPlayVideo) { for (const v of vids) { if (v.paused && !v.ended && v.readyState >= 2 && !everPlayed.has(v)) tryPlay(v); } } } // 静音兜底:play() 被拒绝后尝试静音播放(每元素最多兜底一次,避免递归重试) const origPlay = HTMLMediaElement.prototype.play; if (CONFIG.autoPlayMutedFallback) { HTMLMediaElement.prototype.play = function () { const r = origPlay.apply(this, arguments); if (r && r.catch) { r.catch(() => { if (CONFIG.autoPlayMutedFallback && !userInteracted && !mutedTried.has(this)) { mutedTried.add(this); tryPlayMuted(this); } }); } return r; }; } /* ================================================================ * 初始化一个窗口上下文(顶层 + 每个同源 iframe 都跑一遍) * ================================================================ */ function setupWin(win, doc) { if (!win || !doc) return; // 拦截原生弹窗 if (CONFIG.autoConfirmNative) blockNativeDialogs(win); // 弹窗自动确认 if (CONFIG.autoClickModal) setInterval(() => clickConfirmBtn(doc), 1000); // 视频扫描 + 续播 + 自动播放 setInterval(() => scan(doc), 1500); new MutationObserver(() => scan(doc)).observe(doc.documentElement, { childList: true, subtree: true }); // 首次用户交互:恢复音量 + 继续播放 doc.addEventListener('click', () => { userInteracted = true; $$('video', doc).forEach(v => { if (v.muted) v.muted = false; if (v.paused && !v.ended) { const p = v.play(); if (p && p.catch) p.catch(() => {}); } }); }, { capture: true, once: true }); log('上下文已初始化(' + (win === window ? '顶层' : 'iframe') + ')'); } setupWin(window, document); // 穿透同源 iframe:播放器可能在 iframe 里 const seenFrames = new WeakSet(); setInterval(() => { $$('iframe').forEach(f => { try { if (seenFrames.has(f)) return; const win = f.contentWindow; const doc = win && win.document; if (!doc || !doc.body) return; // 还没加载完 seenFrames.add(f); setupWin(win, doc); } catch (e) { /* 跨域 iframe,忽略 */ } }); }, 2000); /* ================================================================ * 功能3:进入页面自动勾选“连续播放”(顶层) * ================================================================ */ // 返回 { el, type }:type='switch' 标准开关;type='text' 文字式“开/关”;type='label' 文字标签兜底 function findContinuousTarget() { if (!document.body) return null; // 找包含“连续播放”的文字(容忍冒号/空格,如“连续播放:”) const textNodes = []; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, { acceptNode(n) { const t = (n.textContent || '').replace(/[\s::]/g, ''); return t.includes('连续播放') ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; } }); let node; while ((node = walker.nextNode())) textNodes.push(node.parentElement); for (const el of textNodes) { // ① 优先:向上找 5 层,在容器内找标准开关控件 let cur = el; for (let i = 0; i < 5 && cur; i++) { const sw = cur.querySelector && cur.querySelector('input[type="checkbox"], [class*="switch"], [class*="toggle"], [class*="check"], [role="switch"], [class*="el-switch"]'); if (sw) return { el: sw, type: 'switch' }; cur = cur.parentElement; } // ② 兜底:在“连续播放”附近找独立的“开/关”文字(文字式开关) cur = el; for (let i = 0; i < 4 && cur; i++) { const txt = findOpenCloseText(cur); if (txt) return { el: txt, type: 'text' }; cur = cur.parentElement; } // ③ 最后兜底:点“连续播放”文字本身(部分开关整行可点) return { el: el, type: 'label' }; } return null; } // 在容器内找文本恰好为“开”或“关”的元素(叶子节点) function findOpenCloseText(container) { if (!container) return null; const w = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, { acceptNode(n) { const t = (n.textContent || '').replace(/\s+/g, ''); return (t === '开' || t === '关') ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; } }); let n; while ((n = w.nextNode())) { const p = n.parentElement; if (p) return p; } return null; } function isSwitchOn(sw) { if (!sw) return null; if (sw.tagName === 'INPUT') return sw.checked; const cls = String(sw.className || '') + ' ' + (sw.getAttribute('aria-checked') || ''); if (/is-checked|checked|active|switch-on/i.test(cls)) return true; if (/switch-off/i.test(cls)) return false; // 兜底看旁边文字“开/关” const box = (sw.closest && sw.closest('div,label,li,span')) || sw.parentElement; if (box) { const t = norm(box.textContent); if (/关/.test(t) && !/开/.test(t)) return false; if (/开/.test(t) && !/关/.test(t)) return true; } return null; } let continuousTried = 0; let continuousClickedAt = 0; let continuousDiagnosed = false; function ensureContinuousOn() { if (!CONFIG.autoToggleContinuous) return; if (continuousTried >= CONFIG.continuousMaxTry) return; const target = findContinuousTarget(); if (!target) { // 诊断日志:找不到时输出“连续播放”附近的结构,方便排查 if (!continuousDiagnosed) { continuousDiagnosed = true; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, { acceptNode(n) { const t = (n.textContent || '').replace(/[\s::]/g, ''); return t.includes('连续播放') ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; } }); let n, shown = 0; while ((n = walker.nextNode()) && shown < 2) { const el = n.parentElement; const parent = el && el.parentElement; log('未找到开关,' + (el ? el.tagName + '.' + (el.className || '') : '?') + ' 附近HTML:', (parent ? parent.outerHTML : el ? el.outerHTML : '').slice(0, 300)); shown++; } if (!shown) log('未找到“连续播放”文字元素'); } return; // 页面还没渲染出来,等下一轮 } const { el: sw, type } = target; // 文字式开关:看文字本身判断状态 if (type === 'text') { const t = norm(sw.textContent); if (t === '开') { continuousTried = CONFIG.continuousMaxTry; return; } // 已开启 if (t !== '关') return; // 状态不明,不冒险 } else if (type === 'label') { // 兜底:状态未知,只点一次即停(防止把“开”点回“关”) if (continuousClickedAt) { continuousTried = CONFIG.continuousMaxTry; return; } sw.click(); continuousClickedAt = Date.now(); continuousTried++; log('已点击“连续播放”(文字标签兜底)'); return; } else { const on = isSwitchOn(sw); if (on === true) { continuousTried = CONFIG.continuousMaxTry; return; } // 已是开启状态 // 未知状态时,仅当旁边文字显示“关”才点击,否则不冒险 if (on === null) { const box = (sw.closest && sw.closest('div,label,li,span')) || sw.parentElement; const t = box ? norm(box.textContent) : ''; if (!/关/.test(t) || /开/.test(t)) return; } } // 避免 3 秒内重复点同一个开关(防止来回切换) if (Date.now() - continuousClickedAt < 3500) return; try { sw.click(); continuousClickedAt = Date.now(); continuousTried++; log('已自动勾选“连续播放”(第 ' + continuousTried + ' 次尝试)'); } catch (e) { /* ignore */ } } if (CONFIG.autoToggleContinuous) setInterval(ensureContinuousOn, 2500); /* ================================================================ * 辅助:加载后自动打开上次未学完的章节 * ================================================================ */ function tryOpenLast() { if (!CONFIG.autoOpenLast) return; const vids = $$('video').filter(isVisible); if (vids.some(v => !v.paused && !v.ended && v.currentTime > 1)) return; // 已有播放中的视频 const s = loadStore(); const entries = Object.entries(s) .filter(([k, r]) => r && !r.done && r.t >= CONFIG.resumeMinSec && k.startsWith(uid + '::')) .sort((a, b) => (b[1].at || 0) - (a[1].at || 0)); if (!entries.length) return; const [key, rec] = entries[0]; const name = norm(rec.name || key.split('::')[1]); const items = $$('li, .node, [class*="chapter"], [class*="section"], [class*="item"], [class*="video-item"], [class*="menu"]'); for (const el of items) { const t = norm(el.innerText); if (!t || t.length > 80) continue; if (t.includes(name) && !/已学\s*100%/.test(t)) { el.click(); log('自动打开上次未学完章节:', name); return; } } } if (CONFIG.autoOpenLast) { setTimeout(tryOpenLast, 4000); setTimeout(tryOpenLast, 9000); } /* ================================================================ * 辅助:右下角状态角标 * ================================================================ */ function initBadge() { if (!CONFIG.showBadge) return; const div = document.createElement('div'); div.style.cssText = 'position:fixed;right:12px;bottom:12px;z-index:2147483647;' + 'background:rgba(0,0,0,.72);color:#7CFC9A;font:12px/1.6 Consolas,monospace;' + 'padding:4px 10px;border-radius:6px;pointer-events:none;user-select:none;'; div.textContent = '网课助手v' + VERSION + ' 运行中 · 弹窗 ' + modalHandledCount; (document.body || document.documentElement).appendChild(div); setInterval(() => { // 顶层和 iframe 里的视频都看 let v = $$('video').find(x => !x.paused && isVisible(x)); if (!v) { for (const f of $$('iframe')) { try { v = $$('video', f.contentDocument).find(x => !x.paused && isVisible(x)); } catch (e) { /* ignore */ } if (v) break; } } div.textContent = v ? '网课助手v' + VERSION + ' · 已播放 ' + fmt(v.currentTime) + ' · 弹窗 ' + modalHandledCount : '网课助手v' + VERSION + ' 运行中 · 弹窗 ' + modalHandledCount; }, 4000); } if (document.body) initBadge(); else document.addEventListener('DOMContentLoaded', initBadge, { once: true }); log('v' + VERSION + ' 已启动,uid=' + uid + ',穿透iframe + 弹窗自动确认 + 自动勾选连续播放 + 自动播放 + 视频续播 均可用'); })();