// ==UserScript== // @name 杭州新干线继续教育自动过验证+自动连播 // @namespace http://tampermonkey.net/ // @version 2.0 // @description 自动过验证+自动连播+课程进度显示(优化防爬策略&修复逻辑Bug&修复SPA路由) // @author xiansi // @match https://learning.hzrs.hangzhou.gov.cn/* // @grant GM_xmlhttpRequest // @connect learning.hzrs.hangzhou.gov.cn // @license MIT // ==/UserScript== (function () { 'use strict'; // ═══════════════════════════════════════════════════════════════ // 环境判断 // 脚本会在主页面和 iframe 中各执行一次 // - 模块一(确认弹窗)、模块二(视频播放):所有页面均需要 // - 模块三(课程管理 UI):仅主页面需要 // ═══════════════════════════════════════════════════════════════ const IS_IFRAME = window.self !== window.top; // ═══════════════════════════════════════════════════════════════ // 全局配置 // ═══════════════════════════════════════════════════════════════ const CFG = Object.freeze({ // ---- 课程 API ---- COURSE_API: 'https://learning.hzrs.hangzhou.gov.cn/api/index/Course/index', API_BASE: 4 * 60_000, // 轮询基础间隔 4 min API_JITTER: 90_000, // 随机抖动 ±90 s → 实际 2.5 ~ 5.5 min API_TIMEOUT: 20_000, // 请求超时 20 s RETRY_BASE: 30_000, // 重试基础 30 s RETRY_MAX: 5 * 60_000, // 重试上限 5 min // ---- 视频检测 ---- VID_BASE: 5_000, // 基础 5 s VID_JITTER: 2_000, // ±2 s → 实际 3 ~ 7 s // ---- 课程切换随机延迟 ---- SW_MIN: 3_000, SW_MAX: 8_000, }); // ═══════════════════════════════════════════════════════════════ // 工具函数 // ═══════════════════════════════════════════════════════════════ /** 带随机抖动的间隔 */ const jitter = (base, range) => Math.max(1000, base + Math.round((Math.random() - 0.5) * 2 * range)); /** 随机整数 [min, max) */ const rand = (a, b) => a + Math.floor(Math.random() * (b - a)); /** 计算课程进度百分比 */ const pct = (c) => { const s = +c.validstudytime || 0, t = +c.coursetimes || 0; return t > 0 ? Math.min(Math.round((s / t) * 100), 100) : 0; }; /** 简易选择器 */ const $ = (s) => document.querySelector(s); // ═══════════════════════════════════════════════════════════════ // 模块一 · 自动确认弹窗 // MutationObserver 事件驱动,零轮询 // 运行环境:所有页面(主页面 + iframe) // ═══════════════════════════════════════════════════════════════ ;(function autoConfirm() { const LABELS = ['确定', '确认', '我知道了']; // 仅在主页面显示日志浮窗 let logEl = null; if (!IS_IFRAME) { logEl = Object.assign(document.createElement('div'), { textContent: '确认弹窗监听中…', }); logEl.style.cssText = 'position:fixed;top:10px;right:10px;z-index:99999;' + 'background:rgba(0,0,0,.75);color:#fff;padding:8px 12px;' + 'border-radius:6px;font:12px/1.5 sans-serif;max-width:260px;pointer-events:none;'; document.body.appendChild(logEl); } function scan(root) { for (const btn of root.querySelectorAll('button,.el-button,[role="button"]')) { const t = (btn.textContent || '').trim(); if (!LABELS.includes(t) || btn.dataset._ac) continue; btn.dataset._ac = '1'; // 仿人类随机延迟后点击 setTimeout(() => { btn.click(); if (logEl) logEl.textContent = '✅ 已点击「' + t + '」'; console.log('[确认] 已点击「' + t + '」'); setTimeout(() => delete btn.dataset._ac, 5000); }, rand(200, 1000)); return; } } // 初始扫描 scan(document.body); // 监听后续 DOM 变更 new MutationObserver(function (muts) { for (var i = 0; i < muts.length; i++) { var nodes = muts[i].addedNodes; for (var j = 0; j < nodes.length; j++) { if (nodes[j].nodeType === 1) scan(nodes[j]); } } }).observe(document.body, { childList: true, subtree: true }); })(); // ═══════════════════════════════════════════════════════════════ // 模块二 · 视频自动播放 // 固定 2s → 5s ± 2s 抖动 // 运行环境:所有页面,路由 #/class 时激活 // ═══════════════════════════════════════════════════════════════ ;(function autoPlay() { var playing = false, tid = null; function start() { if (!tid) { playing = false; loop(); } } function stop() { clearTimeout(tid); tid = null; playing = false; } function loop() { tid = setTimeout(function () { tick(); loop(); }, jitter(CFG.VID_BASE, CFG.VID_JITTER)); } function tick() { var v = document.getElementById('hls_html5_api'); if (!v) return; if (!v.paused) { playing = true; return; } // 静音后播放,降低被拦截概率 var vol = v._savedVol != null ? v._savedVol : v.volume; v._savedVol = vol; v.volume = 0; v.play().then(function () { playing = true; // 随机延迟恢复音量 setTimeout(function () { if (!v.paused) v.volume = vol; }, rand(2000, 6000)); }).catch(function () { v.click(); setTimeout(function () { if (!v.paused) playing = true; }, 1500); }); } // 路由感知 function onRoute() { if (location.hash.indexOf('#/class') === 0) start(); else stop(); } window.addEventListener('hashchange', onRoute); onRoute(); })(); // ═══════════════════════════════════════════════════════════════ // 模块三 · 课程自动连播管理(仅主页面) // ═══════════════════════════════════════════════════════════════ if (IS_IFRAME) return; ;(function courseManager() { // ---- 状态 ---- var done = new Set(); var courses = []; var cur = null; var running = false; var apiTid = null; var retryN = 0; var frameEl = null; var containerWatcher = null; var routeDebounce = null; var isLearn = function () { return location.hash.indexOf('#/learn') === 0; }; // ═══════════════════════════════════════════════════════════ // SPA 路由健壮检测 // 拦截 pushState / replaceState + hashchange / popstate // ═══════════════════════════════════════════════════════════ var _pushState = history.pushState; var _replaceState = history.replaceState; history.pushState = function () { _pushState.apply(this, arguments); onRouteChange('pushState'); }; history.replaceState = function () { _replaceState.apply(this, arguments); onRouteChange('replaceState'); }; window.addEventListener('hashchange', function () { onRouteChange('hashchange'); }); window.addEventListener('popstate', function () { onRouteChange('popstate'); }); function onRouteChange(source) { console.log('[课程] 路由变更(' + source + '): ' + location.hash); clearTimeout(routeDebounce); routeDebounce = setTimeout(function () { if (isLearn()) { waitForContainer(); } else { teardown(); } }, 300); } // ═══════════════════════════════════════════════════════════ // 等待目标容器(轮询 + MutationObserver 双保险) // ═══════════════════════════════════════════════════════════ function waitForContainer() { if ($('.cm-panel')) return; if ($('.el-table__inner-wrapper')) { renderPanel(); return; } // 方式 A:轮询等待 var pollCount = 0; var maxPolls = 20; var found = false; var pollTimer = setInterval(function () { pollCount++; if ($('.el-table__inner-wrapper')) { cleanup(); renderPanel(); } else if (pollCount >= maxPolls) { cleanup(); console.warn('[课程] 轮询超时,降级渲染到 body'); renderPanel(); } }, 800); // 方式 B:MutationObserver 实时检测 stopObserver(); containerWatcher = new MutationObserver(function () { if ($('.el-table__inner-wrapper')) { cleanup(); renderPanel(); } }); containerWatcher.observe(document.body, { childList: true, subtree: true }); function cleanup() { clearInterval(pollTimer); stopObserver(); } } function stopObserver() { if (containerWatcher) { containerWatcher.disconnect(); containerWatcher = null; } } // ═══════════════════════════════════════════════════════════ // 生命周期 // ═══════════════════════════════════════════════════════════ function teardown() { rmPanel(); rmFrame(); stopApi(); stopObserver(); running = false; cur = null; } // ═══════════════════════════════════════════════════════════ // API 调度(抖动 + 指数退避) // ═══════════════════════════════════════════════════════════ function stopApi() { clearTimeout(apiTid); apiTid = null; } function schedule() { var d = retryN > 0 ? Math.min(CFG.RETRY_BASE * Math.pow(2, retryN - 1), CFG.RETRY_MAX) : jitter(CFG.API_BASE, CFG.API_JITTER); console.log('[课程] 下次检测 ' + (d / 1000).toFixed(0) + 's 后'); apiTid = setTimeout(poll, d); } /** * 唯一的 API 调用入口 */ function poll() { if (!running) return; GM_xmlhttpRequest({ method: 'GET', url: CFG.COURSE_API, responseType: 'json', timeout: CFG.API_TIMEOUT, onload: function (resp) { var r = resp.response; if (!r || r.status === -1) { setStatus('⚠ 未登录或会话过期,请重新登录'); running = false; showBtn(); return; } retryN = 0; courses = (r.data && r.data.data) || []; renderList(); renderCur(); if (cur) { var latest = courses.find(function (c) { return c.courseid === cur.courseid; }); if (!latest || pct(latest) >= 100) { onDone(latest || cur); return; } } else { if (!pickNext()) return; } schedule(); }, onerror: function () { retryN++; schedule(); }, ontimeout: function () { retryN++; schedule(); }, }); } function pickNext() { var t = courses.find(function (c) { return !done.has(c.courseid) && pct(c) < 100; }); if (t) { openCourse(t); return true; } setStatus('🎉 所有课程已完成'); return false; } function onDone(c) { console.log('[课程] 「' + c.coursename + '」已完成'); rmFrame(); cur = null; setStatus('「' + c.coursename + '」已完成,切换下一个…'); renderList(); renderCur(); stopApi(); // 随机延迟后再拉取 apiTid = setTimeout(poll, rand(CFG.SW_MIN, CFG.SW_MAX)); } // ═══════════════════════════════════════════════════════════ // 课程 iframe 操作 // ═══════════════════════════════════════════════════════════ function openCourse(c) { done.add(c.courseid); cur = c; rmFrame(); var url = '#/class?courseId=' + c.courseid + '&coursetitle=' + encodeURIComponent(c.coursename); var wrap = document.createElement('div'); wrap.className = 'cm-frame-wrap'; // 标题栏 var hdr = document.createElement('div'); hdr.className = 'cm-frame-hdr'; var title = document.createElement('span'); title.textContent = c.coursename; title.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'; var closeBtn = document.createElement('button'); closeBtn.textContent = '✕ 关闭'; closeBtn.className = 'cm-frame-close'; closeBtn.addEventListener('click', rmFrame); hdr.appendChild(title); hdr.appendChild(closeBtn); // iframe var iframe = document.createElement('iframe'); iframe.src = url; iframe.className = 'cm-frame'; // 调整大小手柄 var resizeHandle = document.createElement('div'); resizeHandle.className = 'cm-resize-handle'; resizeHandle.title = '拖动调整高度'; var isResizing = false, startY = 0, startH = 0; resizeHandle.addEventListener('mousedown', function (e) { isResizing = true; startY = e.clientY; startH = iframe.offsetHeight; e.preventDefault(); document.body.style.cursor = 'ns-resize'; }); document.addEventListener('mousemove', function (e) { if (!isResizing) return; var nh = startH + (e.clientY - startY); iframe.style.height = Math.max(300, nh) + 'px'; }); document.addEventListener('mouseup', function () { if (isResizing) { isResizing = false; document.body.style.cursor = ''; } }); wrap.appendChild(hdr); wrap.appendChild(iframe); wrap.appendChild(resizeHandle); // 插入到面板之后 var panel = $('.cm-panel'); if (panel && panel.nextSibling) { panel.parentNode.insertBefore(wrap, panel.nextSibling); } else if (panel) { panel.parentNode.appendChild(wrap); } else { document.body.appendChild(wrap); } setTimeout(function () { wrap.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 200); frameEl = wrap; setStatus('▶ 播放中:' + c.coursename); renderList(); renderCur(); console.log('[课程] 已打开:ID=' + c.courseid + ' 名称=' + c.coursename); } function rmFrame() { if (!frameEl) return; var el = frameEl; el.style.transition = 'opacity .3s'; el.style.opacity = '0'; setTimeout(function () { el.remove(); }, 300); frameEl = null; } // ═══════════════════════════════════════════════════════════ // 启动 // ═══════════════════════════════════════════════════════════ function start() { running = true; hideBtn(); setStatus('运行中…'); poll(); } // ═══════════════════════════════════════════════════════════ // CSS 样式 // ═══════════════════════════════════════════════════════════ var STYLE_ID = 'cm-style'; function ensureCSS() { if ($('#' + STYLE_ID)) return; var s = document.createElement('style'); s.id = STYLE_ID; s.textContent = [ '.cm-panel{', ' width:100%;background:#fff;border-radius:8px;', ' box-shadow:0 2px 15px rgba(0,0,0,.12);padding:16px;', ' font-family:-apple-system,system-ui,sans-serif;', ' min-width:300px;margin-bottom:20px;', '}', '.cm-title{', ' font-size:16px;font-weight:600;color:#2d3748;', ' display:flex;justify-content:space-between;align-items:center;', ' margin-bottom:12px;', '}', '.cm-btn{', ' padding:6px 14px;border:none;border-radius:4px;', ' background:#4299e1;color:#fff;cursor:pointer;font-size:14px;', ' transition:background .2s;', '}', '.cm-btn:hover{background:#3182ce}', '.cm-list{max-height:500px;overflow-y:auto;margin:8px 0;padding-right:4px}', '.cm-item{', ' position:relative;', ' padding:10px;border-radius:6px;margin-bottom:6px;font-size:13px;', ' border:1px solid #edf2f7;cursor:pointer;transition:all .2s;', ' display:flex;flex-direction:column;gap:5px;', '}', '.cm-item:hover{border-color:#cbd5e0;background:#f7fafc}', '.cm-item.cm-now{border-color:#4299e1;background:#f0f7ff;font-weight:500}', '.cm-item.cm-now::before{', ' content:"当前播放";position:absolute;left:10px;top:-7px;', ' background:#4299e1;color:#fff;font-size:10px;padding:1px 6px;', ' border-radius:3px;', '}', '.cm-item.cm-done{opacity:.55;background:#fafafa}', '.cm-hdr{display:flex;align-items:center;gap:8px;width:100%}', '.cm-id{color:#718096;width:80px;flex-shrink:0;font-size:12px}', '.cm-name{', ' color:#2d3748;flex:1;white-space:nowrap;', ' overflow:hidden;text-overflow:ellipsis;min-width:0;', '}', '.cm-pbar-wrap{', ' width:100%;height:7px;background:#edf2f7;', ' border-radius:4px;overflow:hidden;', '}', '.cm-pbar{', ' height:100%;border-radius:4px;', ' background:linear-gradient(90deg,#4299e1,#38b2ac);', ' transition:width .6s ease;', '}', '.cm-ptxt{font-size:11px;color:#718096;text-align:right}', '.cm-status{', ' margin-top:10px;font-size:12px;color:#718096;', ' text-align:center;padding:4px 0;', '}', '.cm-cur{', ' background:#f0f7ff;border:1px solid #bee3f8;', ' border-radius:6px;padding:8px 10px;margin:8px 0;', '}', '.cm-cur .cm-name{font-weight:600;margin-bottom:4px}', '.cm-cur .cm-info{display:flex;align-items:center;gap:8px;font-size:11px}', '.cm-cur .cm-pbar-wrap{flex:1;height:5px}', '.cm-frame-wrap{', ' width:100%;margin:20px 0;border-radius:8px;overflow:hidden;', ' box-shadow:0 4px 16px rgba(0,0,0,.12);', '}', '.cm-frame-hdr{', ' background:#2d3748;color:#fff;padding:10px 16px;', ' display:flex;align-items:center;font-size:14px;', '}', '.cm-frame-close{', ' background:#e53e3e;color:#fff;border:none;border-radius:4px;', ' padding:4px 12px;cursor:pointer;font-size:12px;margin-left:12px;', ' transition:background .2s;', '}', '.cm-frame-close:hover{background:#c53030}', '.cm-frame{width:100%;height:480px;border:none;display:block;background:#000}', '.cm-resize-handle{', ' width:100%;height:8px;background:#4299e1;cursor:ns-resize;', ' display:flex;justify-content:center;align-items:center;', ' color:#fff;font-size:12px;', '}', '.cm-resize-handle:hover{background:#3182ce}', ].join('\n'); document.head.appendChild(s); } // ═══════════════════════════════════════════════════════════ // UI 渲染 // ═══════════════════════════════════════════════════════════ function renderPanel() { ensureCSS(); if ($('.cm-panel')) return; var panel = document.createElement('div'); panel.className = 'cm-panel'; // 标题 var title = document.createElement('div'); title.className = 'cm-title'; var titleText = document.createElement('span'); titleText.textContent = '继续教育自动播放'; var btn = document.createElement('button'); btn.textContent = '启动'; btn.id = 'cm-start'; btn.className = 'cm-btn'; btn.addEventListener('click', start); title.appendChild(titleText); title.appendChild(btn); panel.appendChild(title); // 当前播放 var curEl = document.createElement('div'); curEl.id = 'cm-cur'; curEl.className = 'cm-cur'; curEl.style.display = 'none'; panel.appendChild(curEl); // 课程列表 var listEl = document.createElement('div'); listEl.id = 'cm-list'; listEl.className = 'cm-list'; panel.appendChild(listEl); // 状态 var statusEl = document.createElement('div'); statusEl.id = 'cm-status'; statusEl.className = 'cm-status'; statusEl.textContent = '未启动,点击按钮开始'; panel.appendChild(statusEl); // 插入到容器中 var target = $('.el-table__inner-wrapper'); if (target) { target.parentNode.insertBefore(panel, target); } else { var main = $('main') || $('section') || document.body; main.insertBefore(panel, main.firstChild); } console.log('[课程] 控制面板已渲染'); } function renderList() { var el = $('#cm-list'); if (!el) return; el.innerHTML = ''; if (!courses.length) { el.innerHTML = '