// ==UserScript== // @name 【真·免费】2026年中小学智慧·暑期教师研修·自动播放/切换·保证满学时·0手动·教师福音·躺平神器 // @namespace https://bbs.tampermonkey.net.cn/ // @version 0.1.4 // @description 为了帮助老师们提升2026暑期研修学习效率,我们免费提供该脚本,可以帮助老师们自动播放视频并且自动切换课程。 // @author 教师小助手 // @resource SRIsecured http://assets.yixiyun.tech/qrcode/shoukuanma.png // @grant unsafeWindow // @grant GM_setValue // @grant GM_getValue // @grant GM_xmlhttpRequest // @match *://www.smartedu.cn/* // @match *://basic.smartedu.cn/* // @match *://auth.smartedu.cn/* // ==/UserScript== // 常量定义 const TRAINING_URL = 'https://basic.smartedu.cn/training/2026jjsqpx'; const TRAINING_PATH = '/training/2026jjsqpx'; const LOGIN_URL = 'https://auth.smartedu.cn/uias/login'; // 自动跳转白名单:匹配这些页面的,不自动跳转到研修页 // 每项为 { host: 包含该 host 字符串, path: 路径以该字符串开头 } const NO_REDIRECT_WHITELIST = [ { host: 'auth.smartedu.cn', path: '' }, // 登陆页 { host: 'basic.smartedu.cn', path: '/teacherTraining/courseIndex' }, // 课程目录页 { host: 'basic.smartedu.cn', path: '/teacherTraining/courseDetail' }, // 课程视频页 ]; // 跨页面持久化的状态键 const RUNNING_KEY = 'scriptRunning'; // 是否正在运行(boolean) const STATUS_KEY = 'scriptStatus'; // 状态栏展示文案(string) // 状态文案枚举 const STATUS = { STOPPED: '未运行', STARTING: '正在启动...', REDIRECTING: '正在跳转研修页...', CHECKING: '正在检查登陆状态...', NEED_LOGIN: '请先完成登陆', RUNNING: '运行中', }; /** * 日志函数,同时输出到控制台与状态栏日志区 * @param content */ function log(content) { console.debug(`=== ${content} ===`); setTimeout(() => { const el = document.getElementById('log_content'); if (el) el.innerText = `${content}`; }, 10); } /** * 读取运行状态 */ function isRunning() { return GM_getValue(RUNNING_KEY, false); } /** * 设置运行状态 */ function setRunning(running) { GM_setValue(RUNNING_KEY, running); } /** * 读取持久化的状态文案 */ function getStatus() { return GM_getValue(STATUS_KEY, STATUS.STOPPED); } /** * 设置状态文案(持久化并同步到状态栏) */ function setStatus(status) { GM_setValue(STATUS_KEY, status); const el = document.getElementById('script-status'); if (el) el.textContent = status; } /** * 注入底部状态栏(fixed 定位,bottom: 100px) */ function inject() { if (window.top != window.self) return; // 避免重复注入 if (document.getElementById('auto-play-status-bar')) return; const statusBar = document.createElement('div'); statusBar.id = 'auto-play-status-bar'; statusBar.innerHTML = `
`; // 「我想要」二维码弹窗 const wantModal = document.createElement('div'); wantModal.id = 'want-modal'; wantModal.innerHTML = ` `; // 「打赏」二维码弹窗 const donateModal = document.createElement('div'); donateModal.id = 'donate-modal'; donateModal.innerHTML = ` `; const style = document.createElement('style'); style.textContent = ` #auto-play-status-bar { position: fixed; bottom: 100px; left: 5vw; width: 90vw; box-sizing: border-box; background: linear-gradient(135deg, rgba(8, 10, 28, 0.92) 0%, rgba(28, 8, 48, 0.92) 100%); color: #e6f9ff; z-index: 9000; border: 1px solid rgba(0, 240, 255, 0.45); border-radius: 16px; box-shadow: 0 0 24px rgba(0, 240, 255, 0.35), 0 0 48px rgba(255, 0, 229, 0.22), inset 0 0 18px rgba(0, 240, 255, 0.06); backdrop-filter: blur(14px) saturate(1.2); overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; } /* 顶部流光条 */ #auto-play-status-bar::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px; background: linear-gradient(90deg, transparent, #00f0ff, #ff00e5, #00f0ff, transparent); background-size: 200% 100%; animation: shimmer 3s linear infinite; } @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } .status-content { position: relative; display: flex; flex-direction: row; align-items: center; gap: 16px; padding: 14px 20px; } .status-info { display: flex; align-items: center; gap: 10px; flex: 0 0 auto; } .status-text { font-size: 14px; font-weight: 500; color: #b8ecff; } .status-indicator { padding: 4px 12px; background: rgba(0, 240, 255, 0.12); border: 1px solid rgba(0, 240, 255, 0.4); border-radius: 20px; font-size: 12px; font-weight: 600; color: #00f0ff; text-shadow: 0 0 6px rgba(0, 240, 255, 0.7); animation: pulse 2s infinite; } @keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.6; } 100% { opacity: 1; } } #log_content { flex: 1 1 auto; text-align: center; font-size: 13px; font-weight: 500; color: #9bffe8; text-shadow: 0 0 6px rgba(0, 240, 255, 0.5); min-width: 0; min-height: 16px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .start-btn { flex: 0 0 auto; background: transparent; border: 1px solid #ff00e5; color: #ff6ef0; padding: 6px 18px; border-radius: 20px; cursor: pointer; font-size: 13px; font-weight: 600; text-shadow: 0 0 6px rgba(255, 0, 229, 0.6); box-shadow: 0 0 10px rgba(255, 0, 229, 0.45); transition: all 0.3s ease; } .promo-links { flex: 0 0 auto; display: flex; align-items: center; gap: 10px; border-left: 1px solid rgba(0, 240, 255, 0.25); padding-left: 16px; } .promo-link { position: relative; display: inline-block; background: linear-gradient(120deg, #ff00e5, #00f0ff, #ffd000, #ff00e5); background-size: 300% 100%; border: 1px solid rgba(255, 255, 255, 0.55); color: #ffffff; padding: 7px 16px; border-radius: 16px; font-size: 13px; font-weight: 700; letter-spacing: 0.5px; text-decoration: none; white-space: nowrap; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.55); box-shadow: 0 0 12px rgba(255, 0, 229, 0.55), 0 0 22px rgba(0, 240, 255, 0.35); animation: promoFlow 3s linear infinite, promoBreath 1.8s ease-in-out infinite; overflow: hidden; transition: transform 0.3s ease, box-shadow 0.3s ease; } /* 内部扫光高亮 */ .promo-link::after { content: ''; position: absolute; top: 0; left: -60%; width: 50%; height: 100%; background: linear-gradient(120deg, transparent, rgba(255, 255, 255, 0.65), transparent); transform: skewX(-20deg); animation: promoSheen 2.5s ease-in-out infinite; } @keyframes promoFlow { 0% { background-position: 0% 0; } 100% { background-position: 300% 0; } } @keyframes promoBreath { 0%, 100% { box-shadow: 0 0 12px rgba(255, 0, 229, 0.55), 0 0 22px rgba(0, 240, 255, 0.35); } 50% { box-shadow: 0 0 20px rgba(255, 0, 229, 0.9), 0 0 36px rgba(0, 240, 255, 0.6); } } @keyframes promoSheen { 0% { left: -60%; } 55%, 100% { left: 130%; } } .promo-link:hover { transform: translateY(-2px) scale(1.05); box-shadow: 0 0 24px rgba(255, 0, 229, 0.95), 0 0 44px rgba(0, 240, 255, 0.75); } .start-btn:hover { background: rgba(255, 0, 229, 0.15); color: #ffb3f3; transform: translateY(-1px); box-shadow: 0 0 20px rgba(255, 0, 229, 0.85); } .start-btn:disabled { background: rgba(120, 120, 140, 0.15); border-color: rgba(150, 150, 170, 0.4); color: rgba(200, 200, 220, 0.5); text-shadow: none; box-shadow: none; cursor: not-allowed; transform: none; } /* 未运行时的大号居中开始按钮 */ .start-btn-big { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); padding: 16px 72px; font-size: 22px; font-weight: 700; border-radius: 32px; letter-spacing: 4px; z-index: 10; box-shadow: 0 0 24px rgba(255, 0, 229, 0.7), 0 0 48px rgba(255, 0, 229, 0.35); animation: bigBtnBreath 2s ease-in-out infinite; } .start-btn-big:hover { transform: translate(-50%, -50%) scale(1.08); box-shadow: 0 0 36px rgba(255, 0, 229, 0.95), 0 0 64px rgba(255, 0, 229, 0.5); } @keyframes bigBtnBreath { 0%, 100% { box-shadow: 0 0 24px rgba(255, 0, 229, 0.7), 0 0 48px rgba(255, 0, 229, 0.35); } 50% { box-shadow: 0 0 36px rgba(255, 0, 229, 0.95), 0 0 72px rgba(255, 0, 229, 0.55); } } /* 停止按钮 */ .stop-btn { flex: 0 0 auto; display: none; background: transparent; border: 1px solid #ff4d4d; color: #ff8080; padding: 6px 18px; border-radius: 20px; cursor: pointer; font-size: 13px; font-weight: 600; text-shadow: 0 0 6px rgba(255, 77, 77, 0.6); box-shadow: 0 0 10px rgba(255, 77, 77, 0.45); transition: all 0.3s ease; } .stop-btn:hover { background: rgba(255, 77, 77, 0.15); color: #ffb3b3; transform: translateY(-1px); box-shadow: 0 0 20px rgba(255, 77, 77, 0.85); } /* 「我想要」按钮 */ .want-btn { flex: 0 0 auto; background: linear-gradient(120deg, #ffd000, #ff8a00, #ffd000); background-size: 200% 100%; border: 1px solid rgba(255, 255, 255, 0.5); color: #3a2400; padding: 6px 18px; border-radius: 20px; cursor: pointer; font-size: 13px; font-weight: 700; text-shadow: 0 1px 2px rgba(255, 255, 255, 0.4); box-shadow: 0 0 12px rgba(255, 180, 0, 0.6); animation: wantFlow 3s linear infinite; transition: transform 0.3s ease, box-shadow 0.3s ease; } @keyframes wantFlow { 0% { background-position: 0% 0; } 100% { background-position: 200% 0; } } .want-btn:hover { transform: translateY(-2px) scale(1.05); box-shadow: 0 0 22px rgba(255, 180, 0, 0.95); } /* 「打赏」按钮 */ .donate-btn { flex: 0 0 auto; background: linear-gradient(120deg, #ff4d6d, #ff8fa3, #ff4d6d); background-size: 200% 100%; border: 1px solid rgba(255, 255, 255, 0.5); color: #ffffff; padding: 6px 18px; border-radius: 20px; cursor: pointer; font-size: 13px; font-weight: 700; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35); box-shadow: 0 0 12px rgba(255, 77, 109, 0.65); animation: donateFlow 3s linear infinite; transition: transform 0.3s ease, box-shadow 0.3s ease; } @keyframes donateFlow { 0% { background-position: 0% 0; } 100% { background-position: 200% 0; } } .donate-btn:hover { transform: translateY(-2px) scale(1.05); box-shadow: 0 0 22px rgba(255, 77, 109, 0.95); } /* 打赏弹窗标题用红色调以区分 */ .want-modal-title-donate { color: #ff6b8a; text-shadow: 0 0 8px rgba(255, 77, 109, 0.6); } /* 「我想要」弹窗 */ #want-modal, #donate-modal { display: none; position: fixed; inset: 0; z-index: 9001; } #want-modal.show, #donate-modal.show { display: block; } .want-modal-overlay { width: 100%; height: 100%; background: rgba(0, 0, 0, 0.8); display: flex; justify-content: center; align-items: center; backdrop-filter: blur(5px); } .want-modal-content { position: relative; background: linear-gradient(135deg, #0a0c1f 0%, #1c0830 100%); border: 1px solid rgba(0, 240, 255, 0.5); border-radius: 16px; padding: 28px 32px 24px; max-width: 90vw; text-align: center; box-shadow: 0 0 30px rgba(0, 240, 255, 0.35), 0 0 50px rgba(255, 0, 229, 0.2); animation: wantModalIn 0.3s ease-out; } @keyframes wantModalIn { from { opacity: 0; transform: scale(0.85) translateY(20px); } to { opacity: 1; transform: scale(1) translateY(0); } } .want-modal-close { position: absolute; top: 10px; right: 14px; background: none; border: none; font-size: 26px; line-height: 1; cursor: pointer; color: #9bffe8; opacity: 0.7; transition: opacity 0.2s ease; } .want-modal-close:hover { opacity: 1; } .want-modal-title { margin: 0 0 16px; color: #ffd000; font-size: 20px; font-weight: 700; text-shadow: 0 0 8px rgba(255, 180, 0, 0.6); } .want-modal-qr { width: 240px; height: auto; border-radius: 12px; background: #fff; padding: 8px; box-shadow: 0 0 18px rgba(0, 240, 255, 0.3); } .want-modal-tip { margin: 16px 0 0; color: #b8ecff; font-size: 14px; line-height: 1.6; max-width: 280px; } `; document.head.appendChild(style); document.body.appendChild(statusBar); document.body.appendChild(wantModal); document.body.appendChild(donateModal); document.getElementById('start-script-btn').addEventListener('click', onStartClick); document.getElementById('stop-script-btn').addEventListener('click', onStopClick); document.getElementById('want-btn').addEventListener('click', () => { document.getElementById('want-modal').classList.add('show'); }); document.getElementById('donate-btn').addEventListener('click', () => { document.getElementById('donate-modal').classList.add('show'); }); document.getElementById('want-modal-close').addEventListener('click', closeWantModal); document.getElementById('donate-modal-close').addEventListener('click', closeDonateModal); // 点击遮罩关闭 document.querySelector('#want-modal .want-modal-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) closeWantModal(); }); document.querySelector('#donate-modal .want-modal-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) closeDonateModal(); }); // 根据当前运行状态同步 UI syncUI(); } /** * 关闭「我想要」弹窗 */ function closeWantModal() { document.getElementById('want-modal').classList.remove('show'); } /** * 关闭「打赏」弹窗 */ function closeDonateModal() { document.getElementById('donate-modal').classList.remove('show'); } /** * 根据运行状态同步按钮显隐与状态文案 */ function syncUI() { const btn = document.getElementById('start-script-btn'); const stopBtn = document.getElementById('stop-script-btn'); if (!btn) return; if (isRunning()) { btn.style.display = 'none'; if (stopBtn) stopBtn.style.display = 'inline-block'; } else { btn.style.display = 'inline-block'; if (stopBtn) stopBtn.style.display = 'none'; setStatus(STATUS.STOPPED); } } /** * 点击「开始」按钮 */ function onStartClick() { if (isRunning()) return; setRunning(true); setStatus(STATUS.STARTING); log('开始运行'); document.getElementById('start-script-btn').style.display = 'none'; const stopBtn = document.getElementById('stop-script-btn'); if (stopBtn) stopBtn.style.display = 'inline-block'; startFlow(); } /** * 点击「停止」按钮:停止脚本运行 */ function onStopClick() { if (!isRunning()) return; setRunning(false); setStatus(STATUS.STOPPED); log('已停止运行'); syncUI(); } /** * 判断当前是否在研修页 */ function onTrainingPage() { return window.location.pathname === TRAINING_PATH || window.location.href.startsWith(TRAINING_URL); } /** * 判断当前是否在登陆页 */ function onLoginPage() { return window.location.href.startsWith(LOGIN_URL); } /** * 判断当前页面是否在「不自动跳转」白名单中 */ function inNoRedirectWhitelist() { const host = window.location.hostname; const path = window.location.pathname; return NO_REDIRECT_WHITELIST.some(item => host.includes(item.host) && path.startsWith(item.path) ); } /** * 判断当前是否在课程目录页 * https://basic.smartedu.cn/teacherTraining/courseIndex?courseId=xxx */ function onCourseIndexPage() { return window.location.hostname.includes('basic.smartedu.cn') && window.location.pathname.startsWith('/teacherTraining/courseIndex'); } /** * 判断当前是否在课程视频页 * https://basic.smartedu.cn/teacherTraining/courseDetail?courseId=xxx */ function onCourseDetailPage() { return window.location.hostname.includes('basic.smartedu.cn') && window.location.pathname.startsWith('/teacherTraining/courseDetail'); } /** * 检查页面首个 index-module_user 元素是否包含「登录」「注册」 * @returns {boolean|null} true=需要登陆, false=已登陆, null=元素尚未出现 */ function checkLoginElement() { const el = document.querySelector('[class*="index-module_user"]'); if (!el) return null; const text = el.innerText || ''; return text.includes('登录') && text.includes('注册'); } /** * 主流程:根据当前页面与登陆状态推进 */ function startFlow() { if (!isRunning()) return; // 在登陆页:等待用户手动登陆,登陆成功后浏览器会自动跳回研修页 if (onLoginPage()) { setStatus(STATUS.NEED_LOGIN); log('请先完成登陆,登陆后将自动继续'); return; } // 不在研修页:白名单页面(课程页等)放行不跳转,其余跳转到研修页 if (!onTrainingPage()) { if (inNoRedirectWhitelist()) { // 课程目录页:执行报名/进入学习流程 if (onCourseIndexPage()) { setStatus(STATUS.RUNNING); log('进入课程目录页,准备开始学习'); handleCourseIndex(); } // 课程视频页:开始播放视频 if (onCourseDetailPage()) { setStatus(STATUS.RUNNING); log('进入课程视频页,准备播放视频'); playCourseVideo(); } return; } setStatus(STATUS.REDIRECTING); log('正在跳转到研修页'); window.top.location.href = TRAINING_URL; return; } // 已在研修页:等待页面加载后检查登陆状态 setStatus(STATUS.CHECKING); log('进入研修页,等待页面加载...'); waitAndCheck(5000); } /** * 等待指定时间后检查登陆状态;若元素未出现则继续等待 */ function waitAndCheck(delay) { setTimeout(() => { if (!isRunning()) return; const needLogin = checkLoginElement(); if (needLogin === null) { // 元素尚未渲染,继续等待 log('页面未加载完成,继续等待...'); waitAndCheck(2000); return; } if (needLogin) { // 未登陆,跳转登陆页 setStatus(STATUS.NEED_LOGIN); log('未检测到登陆状态,正在跳转登陆页'); window.top.location.href = LOGIN_URL; } else { // 已登陆,开始刷课流程 setStatus(STATUS.RUNNING); log('已登陆,准备继续后续流程'); startLearning(); } }, delay); } /** * 刷课主流程: * 1. 检测并点击「立即报名」按钮 * 2. 停留 3s 后读取进度 */ function startLearning() { if (!isRunning()) return; // 检测「立即报名」按钮 const enrollBtn = findEnrollButton(); if (enrollBtn) { log('检测到「立即报名」按钮,点击报名'); enrollBtn.click(); } // 停留 3s 后读取进度(读不到则轮询重试) setTimeout(() => { log('获取当前进度中'); pollProgress(); }, 3000); } /** * 轮询读取进度,直到读到为止 * 读取成功后先判断总学时是否已达标,达标则停止,未达标才查找未完成课程 */ function pollProgress() { if (!isRunning()) return; const progress = readProgress(); if (progress === null) { // 进度元素尚未渲染,2s 后重试 log('获取当前进度中'); setTimeout(pollProgress, 2000); return; } appendProgressToStatus(progress.done, progress.required); // 已认定学时 >= 要求学时,无需继续刷课 if (progress.done >= progress.required) { log(`总学时已达标(${progress.done.toFixed(2)}/${progress.required}),研修完成,停止运行`); setRunning(false); setStatus('研修已完成'); syncUI(); return; } log(`当前总进度:${progress.done.toFixed(2)}/${progress.required},开始查找未完成的课程`); findAndEnterCourse(); } /** * 课程目录页处理: * 1. 等待 2s,查找「开始学习/继续学习」按钮并点击 * 2. 再等 2s,若出现 fish-modal-wrap 弹窗,勾选协议并点击确认 */ function handleCourseIndex() { if (!isRunning()) return; // 1. 等待 2s 后查找并点击学习按钮 setTimeout(() => { if (!isRunning()) return; const btn = findLearnButton(); if (btn) { log(`检测到「${btn.innerText.trim()}」按钮,点击进入`); btn.click(); // 2. 再等 2s 处理可能出现的确认弹窗 setTimeout(() => { if (!isRunning()) return; handleEnrollModal(); }, 2000); } else { log('未找到学习按钮,继续等待'); setTimeout(handleCourseIndex, 2000); } }, 2000); } /** * 查找「开始学习」或「继续学习」按钮 * 类名包含 CourseIndex-module_course-btn,且 innerText 为「开始学习」或「继续学习」 * @returns {HTMLElement|null} */ function findLearnButton() { const candidates = document.querySelectorAll('[class*="CourseIndex-module_course-btn"]'); for (const el of candidates) { const text = (el.innerText || '').trim(); if (text === '开始学习' || text === '继续学习') { return el; } } return null; } /** * 处理报名确认弹窗: * 若存在 fish-modal-wrap,则勾选 fish-checkbox-wrapper, * 再点击 fish-modal-body 下最后一个 index-module_btn 前缀类名的 div */ function handleEnrollModal() { const modal = document.querySelector('.fish-modal-wrap'); if (!modal) { log('未出现确认弹窗,重新检测当前页面'); // 可能已跳转到 courseDetail 等页面,重新走主流程分发 startFlow(); return; } // 勾选协议 checkbox const checkbox = modal.querySelector('.fish-checkbox-wrapper'); if (checkbox) { checkbox.click(); log('已勾选确认协议,等待 16s 后确认'); } else { log('未找到协议勾选项,等待 16s 后尝试直接确认'); } // 勾选后等待 16s,再点击确认按钮 setTimeout(() => { if (!isRunning()) return; const body = modal.querySelector('.fish-modal-body'); if (body) { const btns = body.querySelectorAll('div[class*="index-module_btn"]'); if (btns.length > 0) { btns[btns.length - 1].click(); log('已点击确认按钮,等待页面跳转'); // SPA 路由跳转不会重新执行脚本,需要主动监测 URL 变化后重新分发 watchUrlChange(); return; } } log('未找到确认按钮'); }, 16000); } /** * 监测 URL 变化(SPA 路由跳转不会重新执行脚本) * 检测到 URL 变化后重新走主流程分发 * @param {string} [oldUrl] 基准 URL,默认为当前 URL * @param {number} [round] 当前轮次(内部使用) */ function watchUrlChange(oldUrl, round = 0) { if (!isRunning()) return; const base = oldUrl || window.location.href; // 超过 60 轮(约 60s)仍未跳转,放弃监测并重新分发 if (round >= 60) { log('等待页面跳转超时,重新检测当前页面'); startFlow(); return; } setTimeout(() => { if (!isRunning()) return; if (window.location.href !== base) { log('检测到页面跳转,重新执行主流程'); startFlow(); } else { watchUrlChange(base, round + 1); } }, 1000); } /** * 课程视频页主入口: * 1. 递归展开所有 aria-expanded=false 的目录节点 * 2. 展开完成后,进入 active 节点检查循环(已学完→切下一节;未学完→保持/触发播放) */ function playCourseVideo() { if (!isRunning()) return; log('进入课程视频页,开始展开目录'); expandAllMenus(() => { if (!isRunning()) return; log('目录已全部展开,开始检查当前学习节点'); checkActiveItem(); }); } /** * 递归展开所有未展开的课程目录节点 * 只匹配课程目录的折叠头部(fish-collapse-header),避免误伤页面其它折叠组件 * 展开后会动态渲染次级目录,需要持续检测直到没有待展开项 * @param {Function} done 全部展开完成后的回调 */ function expandAllMenus(done, round = 0) { if (!isRunning()) return; const collapsed = document.querySelectorAll('div.fish-collapse-header[aria-expanded="false"]'); if (collapsed.length === 0) { // 没有待展开项,完成 done(); return; } // 防止个别项点击后仍为 false 导致死循环,超过 15 轮强制进入下一步 if (round >= 15) { log('展开轮次过多,强制进入下一步'); done(); return; } log(`检测到 ${collapsed.length} 个待展开目录,正在展开`); collapsed.forEach(el => el.click()); // 等待次级目录渲染后继续检测 setTimeout(() => expandAllMenus(done, round + 1), 1500); } /** * 检查当前 active 学习节点(全部展开后调用): * 1. 等待 video 加载就绪 * 2. 找到带 resource-item-active 的节点 * 3. 若该节点已学完(含 ):点击下一个;无下一个则跳回研修页 * 4. 若未学完:已播放则保持,未播放则触发 play,并绑定 ended(播完跳回研修页) */ function checkActiveItem() { if (!isRunning()) return; // 1. 等待 video 加载(id 形如 vjs_video_1087_html5_api,数字会变) const video = document.querySelector('video[id^="vjs_video_"][id$="_html5_api"]') || document.querySelector('video'); if (!video) { log('等待视频加载...'); setTimeout(checkActiveItem, 2000); return; } // 2. 找当前 active 节点 const items = document.querySelectorAll('.resource-item.resource-item-train'); const activeIdx = Array.from(items).findIndex(el => el.classList.contains('resource-item-active')); if (activeIdx === -1) { log('未找到当前聚焦节点,继续等待'); setTimeout(checkActiveItem, 2000); return; } const active = items[activeIdx]; // 3. 已学完:切下一节或返回研修页 if (active.querySelector('i[title="已学完"]')) { if (activeIdx < items.length - 1) { log('当前节点已学完,切换到下一节'); items[activeIdx + 1].click(); setTimeout(() => { if (isRunning()) checkActiveItem(); }, 2000); } else { log('当前课程所有节点已学完,返回研修页'); setTimeout(() => { if (isRunning()) window.top.location.href = TRAINING_URL; }, 2000); } return; } // 4. 未学完:设倍速静音,未播放则触发播放 video.muted = true; video.playbackRate = 2; // 绑定 ended:播完直接跳回研修页(video 切换后 dataset 重置,会重新绑) if (!video.dataset.ysBound) { video.addEventListener('ended', () => { if (!isRunning()) return; log('视频播放完毕,返回研修页'); window.top.location.href = TRAINING_URL; }); video.dataset.ysBound = '1'; } if (video.paused) { log('视频未播放,触发播放(2倍速·静音)'); video.play().catch(err => { log('自动播放被阻止:' + err.message); }); } else { log('视频播放中(2倍速·静音)'); } // 启动视频状态看门狗,防止中途被暂停后不再恢复 startVideoWatchdog(); } /** * 视频状态看门狗:每 5s 巡检一次 * - 检测并自动关闭平台确认弹窗(如反挂机提示) * - 视频被意外暂停时自动恢复播放 * - 持续保持静音 + 2倍速(平台可能重置) * - 若 video 元素被替换,自动重新绑定 ended 事件 */ function startVideoWatchdog() { if (!isRunning()) return; setTimeout(() => { if (!isRunning()) return; // 检测平台确认弹窗(如“是否继续学习”等反挂机提示),自动点击确认 dismissConfirmModal(); const video = document.querySelector('video[id^="vjs_video_"][id$="_html5_api"]') || document.querySelector('video'); if (!video) { // 视频元素不存在(可能切换章节中),继续巡检 startVideoWatchdog(); return; } // 确保 ended 事件已绑定(video 元素可能被替换) if (!video.dataset.ysBound) { video.addEventListener('ended', () => { if (!isRunning()) return; log('视频播放完毕,返回研修页'); window.top.location.href = TRAINING_URL; }); video.dataset.ysBound = '1'; } // 保持静音 + 2倍速 video.muted = true; video.playbackRate = 2; // 暂停且未播完 → 自动恢复播放 if (video.paused && !video.ended) { log('检测到视频被暂停,自动恢复播放'); video.play().catch(() => {}); } startVideoWatchdog(); }, 5000); } /** * 检测并关闭平台确认弹窗: * 若存在 .fish-modal-confirm-body,则点击其下的 .fish-btn-primary 按钮 */ function dismissConfirmModal() { const modalBody = document.querySelector('.fish-modal-confirm-body-wrapper'); if (!modalBody) return; const btn = modalBody.querySelector('.fish-btn-primary'); if (btn) { btn.click(); log('检测到确认弹窗,已自动点击确认'); } } /** * 查找未完成的课程并进入 * 课程分两种类型: * 类型一:进度在 index-module_processC 前缀类名的 div 下,前两个 span 分别为已认定/要求课时 * (课程项是 index-module_items_ 容器的直接 div 子元素) * 类型二:进度在 index-module_phase_peroid 前缀类名的 div 中, * innerText 格式为「已认定 0.00 / 认定 3 学时」,未达标时从 * div.fish-tabs-content-holder 下的 index-module_box_ 前缀 div 提取 courseId * (类型二课程项通常是另一种 tab 布局,不一定在 items_ 容器内,因此整页扫描) * * 注意:平台点击课程会 window.open 新标签页,脚本触发的点击没有用户激活手势, * 会被浏览器拦截。因此改为从 DOM 提取 courseId 后同标签页跳转(不触发弹窗)。 */ function findAndEnterCourse() { if (!isRunning()) return; // ===== 类型一:index-module_items_ 容器内的课程 ===== const container = document.querySelector('div[class*="index-module_items_"]'); if (container) { const courses = Array.from(container.children).filter(el => el.tagName === 'DIV'); for (let i = 0; i < courses.length; i++) { const course = courses[i]; const result = checkTypeOneCourse(course); if (result) { log(`找到未完成课程(${result.done}/${result.required}),进入课程界面`); window.top.location.href = `https://basic.smartedu.cn/teacherTraining/courseIndex?courseId=${result.courseId}`; return; } } } else { log('未找到课程列表容器,尝试类型二课程'); } // ===== 类型二:index-module_phase_peroint 进度结构(整页扫描) ===== const phaseDivs = document.querySelectorAll('div[class*="index-module_phase_peroid"]'); for (let i = 0; i < phaseDivs.length; i++) { const phaseDiv = phaseDivs[i]; const result = checkTypeTwoCourse(phaseDiv); if (result) { log(`找到未完成课程(${result.done}/${result.required}),进入课程界面`); window.top.location.href = `https://basic.smartedu.cn/teacherTraining/courseIndex?courseId=${result.courseId}`; return; } } if (!container && phaseDivs.length === 0) { log('未找到任何课程,继续等待'); setTimeout(findAndEnterCourse, 2000); return; } log('所有课程均已达标'); setRunning(false); setStatus('研修已完成'); syncUI(); } /** * 检查类型一课程是否未完成 * @returns {{done:number, required:number, courseId:string}|null} 未完成返回信息,否则 null */ function checkTypeOneCourse(course) { const processDivs = course.querySelectorAll('div[class*="index-module_processC"]'); if (processDivs.length === 0) return null; const processDiv = processDivs[processDivs.length - 1]; const spans = Array.from(processDiv.children).filter(el => el.tagName === 'SPAN'); if (spans.length < 2) return null; const done = parseFloat(spans[0].innerText); const required = parseFloat(spans[1].innerText); if (isNaN(done) || isNaN(required) || done >= required) return null; const courseId = getCourseIdFromDom(course); if (!courseId) { log(`找到未完成课程(${done}/${required}),但无法读取 courseId,跳过`); return null; } return { done, required, courseId }; } /** * 检查类型二课程是否未完成 * @param {HTMLElement} phaseDiv 含 index-module_phase_peroid 类名的进度 div * @returns {{done:number, required:number, courseId:string}|null} */ function checkTypeTwoCourse(phaseDiv) { // innerText 格式:「已认定 0.00 / 认定 3 学时」(允许换行/多空白) const text = (phaseDiv.innerText || '').replace(/\s+/g, ' '); const match = text.match(/已认定\s*([\d.]+)\s*\/\s*认定\s*([\d.]+)/); if (!match) return null; const done = parseFloat(match[1]); const required = parseFloat(match[2]); if (isNaN(done) || isNaN(required) || done >= required) return null; // 从 fish-tabs-content-holder 下的 index-module_box_ div 提取 courseId // 先在 phaseDiv 最近的课程容器内找,找不到则向上找 const tabHolder = phaseDiv.closest('div.fish-tabs-content-holder') || document.querySelector('div.fish-tabs-content-holder'); let boxEl = null; if (tabHolder) { boxEl = tabHolder.querySelector('div[class*="index-module_box_"]'); } const courseId = getCourseIdFromDom(boxEl || phaseDiv); if (!courseId) { log(`找到未完成课程(${done}/${required}),但无法读取 courseId,跳过`); return null; } return { done, required, courseId }; } /** * 从课程 DOM 元素上提取 courseId(取 React Fiber 的 key) * @param {HTMLElement} el * @returns {string|null} */ function getCourseIdFromDom(el) { const fiberKey = Object.keys(el).find(p => p.startsWith('__reactFiber')); if (!fiberKey) return null; const fiber = el[fiberKey]; if (!fiber) return null; // 沿 fiber 向上找带 key 的节点(课程 key 通常挂在最近的宿主 fiber 上) let node = fiber; while (node) { if (node.key) return node.key; node = node.return; } return null; } /** * 查找「立即报名」按钮: * 类名包含 index-module_btnEnroll 且 innerText 包含「立即报名」 */ function findEnrollButton() { const candidates = document.querySelectorAll('[class*="index-module_btnEnroll"]'); for (const el of candidates) { if ((el.innerText || '').includes('立即报名')) { return el; } } return null; } /** * 读取进度: * 找第二个 class 以 index-module_topprocessCBBlock 开头的 div, * 取其下前两个 span:第一个=已认定学时,第二个=要求学时 * @returns {{done: number, required: number}|null} */ function readProgress() { const blocks = document.querySelectorAll('div[class^="index-module_topprocessCBBlock"]'); if (blocks.length < 2) { log('未找到进度信息,继续等待'); return null; } const spans = blocks[1].querySelectorAll('span'); if (spans.length === 0) { log('未找到进度数值,继续等待'); return null; } const done = parseFloat(spans[0].innerText); if (isNaN(done)) { log('进度数值解析失败:' + spans[0].innerText); return null; } // 要求学时:取第二个 span,读不到则默认 10 let required = 10; if (spans.length >= 2) { const r = parseFloat(spans[1].innerText); if (!isNaN(r) && r > 0) required = r; } return { done, required }; } /** * 在状态指示「运行中」后面追加进度显示 x.xx/要求学时 */ function appendProgressToStatus(done, required) { const el = document.getElementById('script-status'); if (!el) return; if (done === null || done === undefined) { el.textContent = STATUS.RUNNING; return; } el.textContent = `${STATUS.RUNNING} ${done.toFixed(2)}/${required}`; } (function () { 'use strict'; if (window.top != window.self) return; log('正在初始化脚本'); inject(); // 若处于运行中,则在每个页面加载后自动推进流程 if (isRunning()) { startFlow(); } })();