// ==UserScript== // @name 泰州师说 · 视频自动续播(防暂停) // @namespace local.tzss.autoplay // @version 1.4 // @description 视频暂停后自动续播;切到后台标签页不暂停;自动关闭"请确认仍在学习"类弹窗;播完自动进入下一节。视频真实播放,时长真实累计。 // @author local // @match http://jspx.tze.cn/* // @match https://jspx.tze.cn/* // @match http://www.tze.cn/* // @match https://www.tze.cn/* // @run-at document-idle // @grant none // @license MIT // ==/UserScript== (function () { "use strict"; // ============================================================ // 1. 让页面"以为"自己始终可见 // 多数培训平台的播放器会监听 visibilitychange / 读取 document.hidden, // 发现你切了标签页就暂停视频。这里把这几个属性伪装成"永远可见"。 // 注意:@noframes 已去掉,脚本会在每个 iframe 内各自生效。 // ============================================================ try { Object.defineProperty(document, "hidden", { get: function () { return false; }, configurable: true, }); Object.defineProperty(document, "visibilityState", { get: function () { return "visible"; }, configurable: true, }); Object.defineProperty(document, "webkitHidden", { get: function () { return false; }, configurable: true, }); Object.defineProperty(document, "webkitVisibilityState", { get: function () { return "visible"; }, configurable: true, }); } catch (e) { console.log("[自动续播] visibility 伪装失败(可能已被页面占用):", e); } // ============================================================ // 2. 从源头拦截"程序化暂停"(v1.4 核心) // 平台的反挂机逻辑是反复调用 video.pause()。"暂停后再续播"是被动挨打, // 这里直接改写 HTMLMediaElement.prototype.pause: // - 你本人的操作(点击/按键后 1 秒内发生的 pause)→ 放行,可以手动暂停 // - 其他一切代码触发的 pause() → 拦下,不生效,并打印是谁想暂停 // 对页面里所有 video 生效(包括换课后新建的 video 元素)。 // ============================================================ var lastUserInteraction = 0; // 最近一次真实用户操作的时间戳 ["pointerdown", "keydown", "touchstart"].forEach(function (evtName) { document.addEventListener(evtName, function (e) { if (e && e.isTrusted) lastUserInteraction = Date.now(); }, true); }); function isUserIntent() { return Date.now() - lastUserInteraction < 1000; } try { var origPause = HTMLMediaElement.prototype.pause; HTMLMediaElement.prototype.pause = function () { // 用户刚操作过(点播放器暂停键、按空格)→ 尊重,真暂停 if (isUserIntent()) { console.log("[自动续播] 检测到用户手动暂停,放行"); return origPause.apply(this, arguments); } // 真正播完了也放行(自然结束) try { if (this.ended) return origPause.apply(this, arguments); } catch (e) {} // 其余情况:平台代码想暂停 → 拦截 var who = ""; try { who = (new Error()).stack.split("\n")[2] || ""; who = who.trim().slice(0, 90); } catch (e) {} console.log("[自动续播] 已拦截平台发起的暂停调用 ←", who); return undefined; }; console.log("[自动续播] pause() 拦截器已安装"); } catch (e) { console.log("[自动续播] pause() 拦截器安装失败:", e); } // ============================================================ // 2.5 兜底看门狗:每 2 秒巡查一次 // 即使拦截被绕过(平台提前保存了原始 pause 引用等), // 只要视频处于"非结束的暂停"且不是用户刚操作的,就强制续播。 // ============================================================ setInterval(function () { if (isUserIntent()) return; // 用户可能正在操作播放器,别捣乱 var vids = document.querySelectorAll("video"); for (var i = 0; i < vids.length; i++) { var v = vids[i]; try { if (v.paused && !v.ended && v.currentSrc) { var p = v.play(); if (p && p.catch) p.catch(function () {}); } } catch (e) {} } }, 2000); // ============================================================ // 2.6 暂停事件后备续播(保留,作为双保险) // ============================================================ var RESUME_DELAY_MS = 300; // 暂停后多少毫秒内续播 var MAX_RESUMES = 10; // 30 秒窗口内最多自动续播次数 var resumeTimes = []; // 最近续播的时间戳 function resumeAllowed() { var now = Date.now(); resumeTimes = resumeTimes.filter(function (t) { return now - t < 30000; }); return resumeTimes.length < MAX_RESUMES; } function bindVideo(v) { if (v.__autoPlayBound) return; v.__autoPlayBound = true; console.log("[自动续播] 已绑定 video 元素", v); // 视频开始播放时学习"视频地址 → 章节"映射 learnSrcOnPlay(v); // 记录是否接近片尾,用于区分"真暂停"和"播完等下一步" var nearEnd = false; v.addEventListener("timeupdate", function () { try { var d = Number(v.duration) || 0; if (d > 0 && v.currentTime >= d - 1.5) nearEnd = true; else if (v.currentTime < d - 2) nearEnd = false; } catch (e) {} }); v.addEventListener("pause", function () { // 播到片尾附近暂停 → 视为"播完",走自动下一节,不强制续播(避免无限重播) if (v.ended || nearEnd) { tryAdvance(v); return; } if (!resumeAllowed()) { console.log("[自动续播] 短时间内暂停过于频繁,已停止续播(疑似反挂机检测)"); return; } resumeTimes.push(Date.now()); setTimeout(function () { if (!v.ended && v.paused && !nearEnd) { var p = v.play(); if (p && p.catch) p.catch(function () {}); } }, RESUME_DELAY_MS); }); v.addEventListener("ended", function () { tryAdvance(v); }); // 页面加载完尝试直接开始播放 var p = v.play(); if (p && p.catch) p.catch(function () {}); } function scanVideos() { var list = document.querySelectorAll("video"); for (var i = 0; i < list.length; i++) bindVideo(list[i]); } // ============================================================ // 3. 自动关闭"请确认仍在学习"类弹窗 // 只点击【可见弹层内部】且文案匹配白名单的按钮, // 避免误点页面上的普通按钮(如测验的提交)。 // ============================================================ var DIALOG_SELECTORS = [ ".el-dialog", // Element UI ".ant-modal", // Ant Design ".modal", ".modal-dialog", // Bootstrap / 通用 "[class*='dialog']", "[class*='Dialog']", "[class*='popup']", "[class*='Popup']", "[class*='confirm']", "[class*='Confirm']", ".layui-layer" // Layui ]; var BTN_TEXT_WHITELIST = [ "继续学习", "继续播放", "继续", "我在", "仍在", "确定", "好的", "我知道了", "知道了", "是" ]; function isElementVisible(el) { if (!el) return false; var rect = el.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return false; var style = window.getComputedStyle(el); return style.display !== "none" && style.visibility !== "hidden" && parseFloat(style.opacity || "1") > 0.05; } function dismissDialogs() { for (var d = 0; d < DIALOG_SELECTORS.length; d++) { var dialogs = document.querySelectorAll(DIALOG_SELECTORS[d]); for (var i = 0; i < dialogs.length; i++) { var dlg = dialogs[i]; if (!isElementVisible(dlg)) continue; var btns = dlg.querySelectorAll("button, .el-button, .ant-btn, .layui-layer-btn a, a.btn"); for (var j = 0; j < btns.length; j++) { var btn = btns[j]; if (!isElementVisible(btn)) continue; var text = (btn.textContent || "").trim(); var hit = false; for (var k = 0; k < BTN_TEXT_WHITELIST.length; k++) { if (text && text.indexOf(BTN_TEXT_WHITELIST[k]) >= 0 && text.length <= 8) { hit = true; break; } } if (hit) { console.log("[自动续播] 自动点击弹窗按钮:", text); btn.click(); return; // 每轮只点一个,避免连锁误操作 } } } } } // ============================================================ // 4. 自动播放下一节(v1.3:针对 jspx.tze.cn 定制 + 自学习定位) // 平台特征:章节列表 = ul.rowLists > li.stylesss;无"下一节"按钮; // 当前项无 active 类标记;video 元素 id="video-active"。 // 定位当前章节的优先级: // ① 文字匹配(用户点过 / 脚本自动点过后记住) // ② src 自学习映射(视频文件名 → 章节文字,重进课程也能定位) // ③ 计算样式少数派(高亮项与其他项颜色不同) // ④ 子元素结构少数派(当前项可能有额外图标) // ============================================================ var PLATFORM_LIST_ITEM = "ul.rowLists li.stylesss"; var currentChapterText = null; // 当前章节文字(SPA 重渲染后仍能定位) var srcToChapter = {}; // 视频文件名 → 章节文字(自学习) var chapterJustClicked = false; // 刚点击过章节,等待视频播放后记录映射 function srcFile(url) { try { return String(url || "").split("?")[0].split("/").pop(); } catch (e) { return ""; } } // --- 4.1 追踪用户点击了哪个章节 --- document.addEventListener("click", function (e) { var el = e.target; for (var depth = 0; el && depth < 6; depth++) { if (el.tagName === "LI" && el.classList && el.classList.contains("stylesss")) { var ul = el.parentElement; if (ul && ul.classList && ul.classList.contains("rowLists")) { currentChapterText = (el.textContent || "").trim(); chapterJustClicked = true; console.log("[自动续播] 记录当前章节:", currentChapterText.slice(0, 40)); } break; } el = el.parentElement; } }, true); // --- 4.2 视频开始播放时,学习"视频地址 → 章节"映射 --- function learnSrcOnPlay(v) { v.addEventListener("playing", function () { if (!chapterJustClicked || !currentChapterText) return; chapterJustClicked = false; var f = srcFile(v.currentSrc || v.src); if (f) { srcToChapter[f] = currentChapterText; console.log("[自动续播] 学习映射:", f.slice(-20), "→", currentChapterText.slice(0, 25)); } }); } // --- 4.3 定位当前章节(四层策略) --- function findMinorityIdx(keys) { if (keys.length < 3) return -1; var counts = {}; for (var i = 0; i < keys.length; i++) counts[keys[i]] = (counts[keys[i]] || 0) + 1; var normal = null, max = 0; for (var k in counts) { if (counts[k] > max) { max = counts[k]; normal = k; } } if (normal === null || max <= keys.length / 2) return -1; // 必须存在真正的多数派 for (var j = 0; j < keys.length; j++) if (keys[j] !== normal) return j; return -1; } function findCurrentIdx(items) { // ① 文字匹配 if (currentChapterText) { for (var i = 0; i < items.length; i++) { if ((items[i].textContent || "").trim() === currentChapterText) return i; } } // ② src 映射 var vids = document.querySelectorAll("video"); for (var v = 0; v < vids.length; v++) { var known = srcToChapter[srcFile(vids[v].currentSrc || vids[v].src)]; if (known) { for (var j = 0; j < items.length; j++) { if ((items[j].textContent || "").trim() === known) { console.log("[自动续播] ②视频地址定位:", known.slice(0, 30)); return j; } } } } // ③ 样式少数派(高亮色) var styleKeys = []; for (var s = 0; s < items.length; s++) { try { var cs = getComputedStyle(items[s]); styleKeys.push([cs.color, cs.backgroundColor, cs.fontWeight, cs.borderLeftColor].join("|")); } catch (e) { styleKeys.push("err" + s); } } var styleIdx = findMinorityIdx(styleKeys); if (styleIdx >= 0) { console.log("[自动续播] ③样式启发式定位:", (items[styleIdx].textContent || "").trim().slice(0, 30)); return styleIdx; } // ④ 结构少数派(子元素标签+类名组合) var structKeys = []; for (var t = 0; t < items.length; t++) { var parts = []; for (var c = 0; c < items[t].children.length; c++) { parts.push(items[t].children[c].tagName + "." + (items[t].children[c].className || "")); } structKeys.push(parts.join(",") || "leaf"); } var structIdx = findMinorityIdx(structKeys); if (structIdx >= 0) { console.log("[自动续播] ④结构启发式定位:", (items[structIdx].textContent || "").trim().slice(0, 30)); return structIdx; } return -1; } // --- 4.4 找到下一项并点击 --- function clickNextChapter() { // 策略一(本平台):ul.rowLists 里的 li.stylesss var items = document.querySelectorAll(PLATFORM_LIST_ITEM); if (items.length > 0) { var currentIdx = findCurrentIdx(items); if (currentIdx >= 0) { if (currentIdx + 1 < items.length) { var next = items[currentIdx + 1]; currentChapterText = (next.textContent || "").trim(); chapterJustClicked = true; // 合成 click 会触发 4.1 的监听,这里再兜底标记 console.log("[自动续播] 点击下一章[" + (currentIdx + 2) + "/" + items.length + "]:", currentChapterText.slice(0, 40)); next.click(); return true; } else { console.log("[自动续播] 已是最后一章(" + items.length + "/" + items.length + "),全部完成"); return false; } } else { console.log("[自动续播] 找到章节列表但无法定位当前项(请手动点一下正在看的章节)"); } } // 策略二(兜底):找"下一节"类按钮(其他页面可能存在) var NEXT_BTN_TEXTS = ["下一节", "下一章", "下一个", "下节", "下章", "next"]; var btns = document.querySelectorAll("button, a, .el-button, .ant-btn, [role='button']"); for (var b = 0; b < btns.length; b++) { var btn = btns[b]; if (!isElementVisible(btn)) continue; var t = (btn.textContent || "").trim().toLowerCase(); if (t.length === 0 || t.length > 12) continue; for (var k2 = 0; k2 < NEXT_BTN_TEXTS.length; k2++) { if (t.indexOf(NEXT_BTN_TEXTS[k2]) >= 0) { console.log("[自动续播] 点击下一节按钮:", btn.textContent.trim()); btn.click(); return true; } } } console.log("[自动续播] 未找到可自动进入的下一节"); return false; } function tryAdvance(v) { if (v.__advancing) return; v.__advancing = true; console.log("[自动续播] 检测到播放结束,等待平台连播或准备点击下一节…"); setTimeout(function () { // 等待期间若视频又开始播了(平台自己连播了),就别再点 try { if (!v.paused && !v.ended) { v.__advancing = false; console.log("[自动续播] 平台已自动连播,跳过手动点击"); return; } } catch (e) {} // 等待期间页面可能已跳走或换源 if (!document.body.contains(v)) { v.__advancing = false; return; } clickNextChapter(); // 冷却一段时间,避免新章节刚加载又触发 setTimeout(function () { v.__advancing = false; }, 5000); }, 1500); } // ============================================================ // 5. 启动:扫描视频 + 定时处理弹窗 // ============================================================ scanVideos(); setInterval(scanVideos, 2000); // 新出现的 video(换课时)自动接管 setInterval(dismissDialogs, 2500); // 定时关闭确认弹窗 // 动态插入的 video(SPA 页面切换章节)也兜底接管 try { var mo = new MutationObserver(function () { scanVideos(); }); mo.observe(document.documentElement, { childList: true, subtree: true }); } catch (e) {} console.log("[自动续播] v1.4 已启动:拦截程序化暂停 + 看门狗巡查 + 暂停后备续播 + 切后台不暂停 + 播完自动下一节"); console.log("[自动续播] 注意:请停用其他刷课脚本(面板脚本),两个脚本同时跑会冲突"); console.log("[自动续播] 验证是否生效:控制台应看到上方这行 + 'pause() 拦截器已安装';视频暂停时若出现'已拦截平台发起的暂停调用'即代表拦截生效"); })();