// ==UserScript== // @name 培训云平台自动学习助手5.4 // @namespace http://tampermonkey.net/ // @version 5.4.0 // @description 以v4.0.0稳定版为底:自动跳下一页、关闭弹窗、自动静音、自动切换章节、自动寻找未完成章节 // @author You // @match *://*.peixunyun.cn/* // @grant GM_setValue // @grant GM_getValue // @run-at document-idle // ==/UserScript== (function() { 'use strict'; const CONFIG = { debug: true, mainCheckInterval: 2000, // 与v4.0.0一致:2秒 popupCheckInterval: 500, videoEndDelay: 2000, nextVideoRetryDelay: 2000, maxNextVideoAttempts: 10 }; let state = { enabled: GM_getValue('enabled', true), autoNext: GM_getValue('autoNext', true), closePopup: GM_getValue('closePopup', true), autoMute: GM_getValue('autoMute', true), autoSwitchChapter: GM_getValue('autoSwitchChapter', true), autoFindUnfinished: GM_getValue('autoFindUnfinished', true), isSwitching: false, currentVideoSrc: '', noNextVideoAttempts: 0, lastUrl: location.href, completedChapterId: GM_getValue('completedChapterId', '') }; function log(...args) { if (CONFIG.debug) { console.log('[培训云助手]', ...args); try { const debugDiv = document.getElementById('peixunyun-debug-log'); if (debugDiv) { const text = args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '); const line = document.createElement('div'); line.style.cssText = 'padding:2px 5px;border-bottom:1px solid #333;font-size:11px;'; line.textContent = '[' + new Date().toLocaleTimeString() + '] ' + text; debugDiv.appendChild(line); if (debugDiv.children.length > 80) debugDiv.removeChild(debugDiv.firstChild); debugDiv.scrollTop = debugDiv.scrollHeight; } } catch (e) {} } } function saveConfig() { GM_setValue('enabled', state.enabled); GM_setValue('autoNext', state.autoNext); GM_setValue('closePopup', state.closePopup); GM_setValue('autoMute', state.autoMute); GM_setValue('autoSwitchChapter', state.autoSwitchChapter); GM_setValue('autoFindUnfinished', state.autoFindUnfinished); } // ========== 弹窗检测与关闭 ========== const POPUP_KEYWORDS = [ '离开学习', '确定离开', '是否离开', '继续学习', '确定要离开', '确认离开', '离开提示', '您确定要离开', '是否确定离开', '学习中断', '提示离开', '是否继续学习', '暂停学习', '温馨提示', '系统提示', '您确定要退出', '离开', '完成本章', '本章已结束', '本章学习完毕', '已完成本章' ]; function findAndClosePopup() { if (!state.enabled || !state.closePopup) return false; let closed = false; const modals = document.querySelectorAll('.modal'); for (const modal of modals) { if (!isModalVisible(modal)) continue; const text = (modal.textContent || '').trim(); const isLeavePopup = POPUP_KEYWORDS.some(kw => text.includes(kw)); if (!isLeavePopup) continue; log('检测到弹窗:', text.substring(0, 60)); // 特殊:"完成本章"弹窗 → 关弹窗 → 点返回 → 刷新 const isChapterEnd = text.includes('完成本章') || text.includes('本章的学习') || text.includes('本章已结束') || text.includes('本章学习完毕'); if (isChapterEnd && state.autoSwitchChapter) { log('检测到"完成本章"弹窗'); const chapterIdMatch = location.href.match(/chapterId=(\d+)/); if (chapterIdMatch) { state.completedChapterId = chapterIdMatch[1]; GM_setValue('completedChapterId', state.completedChapterId); GM_setValue('needAutoSwitchChapter', 'true'); log('已记录完成章节:', state.completedChapterId); } // 点关闭 const allBtns = modal.querySelectorAll('button, a, div, span'); for (const btn of allBtns) { const btnText = (btn.textContent || '').trim(); if (btnText === '关闭' || btnText.includes('关闭')) { if (isVisible(btn)) { btn.click(); log('已点击弹窗中的"关闭"按钮'); closed = true; break; } } } if (!closed) { modal.classList.remove('in'); modal.style.display = 'none'; modal.setAttribute('aria-hidden', 'true'); const backdrop = document.querySelector('.modal-backdrop'); if (backdrop) backdrop.remove(); log('强制关闭"完成本章"弹窗'); closed = true; } setTimeout(() => { clickBackToCourse(); // 不在视频页 reload,而是设置标志让主站自己刷新 GM_setValue('needMainSiteRefresh', 'true'); log('已设置 needMainSiteRefresh=true,等待主站检测'); }, 1000); return closed; } // 方式1: 关闭按钮 const closeBtns = modal.querySelectorAll('.close, [data-dismiss="modal"], .modal-header .close, button.close'); for (const btn of closeBtns) { if (isVisible(btn)) { btn.click(); log('已点击关闭按钮'); closed = true; break; } } // 方式2: 继续学习/取消 if (!closed) { const allBtns = modal.querySelectorAll('button, [role="button"], .btn, a'); const preferTexts = ['继续学习', '留在学习', '取消', '我再想想', '再看看']; for (const pText of preferTexts) { const btn = Array.from(allBtns).find(b => { const bText = (b.textContent || '').trim(); return bText === pText || bText.includes(pText); }); if (btn && isVisible(btn)) { btn.click(); log('已点击按钮:', (btn.textContent || '').trim()); closed = true; break; } } } // 方式3: footer第一个按钮 if (!closed) { const footerBtns = modal.querySelectorAll('.modal-footer button'); if (footerBtns.length > 0) { footerBtns[0].click(); log('已点击 footer 按钮:', (footerBtns[0].textContent || '').trim()); closed = true; } } // 方式4: 强制隐藏 if (!closed) { modal.classList.remove('in'); modal.style.display = 'none'; modal.setAttribute('aria-hidden', 'true'); const backdrop = document.querySelector('.modal-backdrop'); if (backdrop) backdrop.remove(); log('强制隐藏弹窗'); closed = true; } } if (closed) { setTimeout(() => { const video = findVideoElement(); if (video && video.paused && !video.ended) { tryPlayVideo(video); log('关闭弹窗后恢复视频播放'); } }, 300); } return closed; } function isModalVisible(modal) { if (modal.classList.contains('in')) return true; if (modal.style.display === 'block') return true; if (modal.style.display === 'flex') return true; const style = window.getComputedStyle(modal); return style.display !== 'none' && style.opacity !== '0'; } function isVisible(el) { if (!el) return false; if (el.offsetParent === null && el.tagName !== 'BODY') return false; const rect = el.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } // ========== 视频相关 ========== function findVideoElement() { const customVideo = document.querySelector('video.custom-video'); if (customVideo) return customVideo; const video = document.querySelector('video'); if (video) return video; return null; } // 使用 Web Audio API 静音:通过 GainNode 把音量设为 0,不修改 video.muted/volume // 平台检测的是 DOM 属性变化,Web Audio 路由不会触发反作弊 function ensureVideoMuted() { if (!state.enabled || !state.autoMute) return; const video = findVideoElement(); if (!video || video._peixunyunMuted) return; try { // 方案1:用 Web Audio API 拦截音频流(最安全,不碰 video 属性) if (!window._peixunyunAudioCtx) { window._peixunyunAudioCtx = new (window.AudioContext || window.webkitAudioContext)(); } const ctx = window._peixunyunAudioCtx; if (ctx.state === 'suspended') ctx.resume().catch(() => {}); const source = ctx.createMediaElementSource(video); const gainNode = ctx.createGain(); gainNode.gain.value = 0; source.connect(gainNode); gainNode.connect(ctx.destination); video._peixunyunMuted = true; log('Web Audio 静音已启用(GainNode gain=0,不修改 video.muted)'); } catch (e) { // 方案2:CORS 阻止时的兜底(极少见,如出现则放弃自动静音) log('Web Audio 静音失败(可能 CORS 限制):' + (e && e.message ? e.message : e)); video._peixunyunMuted = true; // 避免反复尝试 } } // ========== 自动跳转下一页 ========== function findNextPageButton() { const nextBtn = document.querySelector('.next-page-btn'); if (nextBtn && isVisible(nextBtn)) return nextBtn; const mobileNext = document.querySelector('.mobile-next-page-btn'); if (mobileNext && isVisible(mobileNext)) return mobileNext; const allBtns = document.querySelectorAll('div, button, a, span'); for (const btn of allBtns) { const text = (btn.textContent || '').trim(); if ((text === '下一页' || text === '下一节' || text === '下一个') && isVisible(btn)) { return btn; } } return null; } function clickNextPage() { const nextBtn = findNextPageButton(); if (nextBtn) { log('找到"下一页"按钮,点击跳转...'); nextBtn.click(); return true; } log('未找到"下一页"按钮'); return false; } function advanceToNext() { if (!state.enabled || !state.autoNext) { log('自动跳转已禁用'); return false; } return clickNextPage(); } function handleVideoEnded() { if (state.isSwitching) { log('已在切换中,忽略重复触发'); return; } state.isSwitching = true; log('===== 视频播放结束,准备跳转下一页 ====='); let attempts = 0; const maxAttempts = 5; function tryAdvance() { attempts++; const success = advanceToNext(); if (success) { log('已触发跳转,等待新页面加载...'); setTimeout(() => { state.isSwitching = false; }, 5000); } else if (attempts < maxAttempts) { log(`跳转失败,第 ${attempts + 1} 次重试...`); setTimeout(tryAdvance, CONFIG.nextVideoRetryDelay); } else { log('多次重试失败,放弃跳转'); state.isSwitching = false; state.noNextVideoAttempts++; if (state.noNextVideoAttempts >= CONFIG.maxNextVideoAttempts) { log('当前章节所有页面已播放完毕,准备切换下一章节'); handleChapterComplete(); } } } setTimeout(tryAdvance, CONFIG.videoEndDelay); } // ========== 章节完成处理 ========== function handleChapterComplete() { if (!state.autoSwitchChapter) { log('自动切换章节已禁用'); return; } const chapterIdMatch = location.href.match(/chapterId=(\d+)/); if (chapterIdMatch) { state.completedChapterId = chapterIdMatch[1]; GM_setValue('completedChapterId', state.completedChapterId); GM_setValue('needAutoSwitchChapter', 'true'); GM_setValue('needMainSiteRefresh', 'true'); log('已记录完成章节:', state.completedChapterId); } goBackToCourse(); } function clickBackToCourse() { log('点击左上角的"返回课程章节"按钮...'); const selectors = ['.back-btn', '.return-url', 'div.back-btn', '.control-btn.back-btn']; for (const sel of selectors) { const btn = document.querySelector(sel); if (btn && isVisible(btn)) { log('找到返回按钮:', sel); btn.click(); return true; } } const allBtns = document.querySelectorAll('div, a, button, span'); for (const btn of allBtns) { if (!isVisible(btn)) continue; const text = (btn.textContent || '').trim(); if (text.includes('返回课程章节') || text.includes('返回课程')) { log('找到返回按钮(文本匹配)'); btn.click(); return true; } } log('未找到返回按钮'); return false; } function goBackToCourse() { log('点击返回课程章节按钮...'); if (clickBackToCourse()) return true; log('未找到返回按钮,直接跳转主站...'); window.top.location.href = 'https://hngs.peixunyun.cn/#/class_study_detail'; return true; } function findAndClickNextChapter() { if (!state.enabled || !state.autoSwitchChapter) return false; log('查找下一章节...'); let chapterItems = document.querySelectorAll('.chapter-item'); log('找到章节项数量:', chapterItems.length); if (chapterItems.length === 0) { chapterItems = document.querySelectorAll('[id^="chapter"]'); log('备用查找章节项数量:', chapterItems.length); } if (chapterItems.length === 0) { log('未找到任何章节项'); return false; } chapterItems.forEach((item, i) => { log(`章节${i}: id=${item.id}, class=${(item.className || '').substring(0, 60)}`); }); let foundCurrent = false; let nextChapter = null; for (const item of chapterItems) { const itemId = item.id || ''; const itemClass = item.className || ''; if (state.completedChapterId && itemId.includes(state.completedChapterId)) { foundCurrent = true; log('找到刚完成的章节:', itemId); continue; } if (foundCurrent) { const isCompleted = itemClass.includes('completed') || itemClass.includes('finished'); if (!isCompleted) { nextChapter = item; log('找到下一章节:', itemId); break; } } } if (!nextChapter) { for (const item of chapterItems) { const itemClass = item.className || ''; const isCompleted = itemClass.includes('completed') || itemClass.includes('finished'); if (!isCompleted) { nextChapter = item; log('选择第一个未完成的章节:', item.id); break; } } } if (!nextChapter && chapterItems.length > 0) { nextChapter = chapterItems[0]; log('兜底:选择第一个章节'); } if (nextChapter) { log('点击章节...'); const clickTarget = nextChapter.querySelector('.chapter-name') || nextChapter.querySelector('.cursor') || nextChapter; ['mouseenter', 'mouseover', 'mousedown', 'mouseup', 'click'].forEach(type => { clickTarget.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window })); }); log('已点击下一章节'); return true; } log('未找到可点击的章节'); return false; } function findUnfinishedChapterByProgress() { if (!state.enabled || !state.autoFindUnfinished) return false; log('自动寻找未完成的章节(基于进度条和按钮)...'); const btns = document.querySelectorAll('button, a, .btn, [role="button"]'); for (const btn of btns) { if (!isVisible(btn)) continue; const text = (btn.textContent || '').trim(); if (text === '继续学习' || text === '开始学习') { log('找到按钮:' + text); btn.click(); return true; } } const rowSelectors = [ '.chapter-item', '[class*="chapter"]', '[class*="Chapter"]', '[class*="item"]', '[class*="list"] > div', 'div[class*="-row"]', 'tbody > tr' ]; for (const sel of rowSelectors) { const rows = document.querySelectorAll(sel); if (rows.length < 2) continue; log('扫描行选择器 ' + sel + ': 共' + rows.length + '行'); for (let i = 0; i < rows.length; i++) { const row = rows[i]; if (!isVisible(row)) continue; const text = (row.textContent || ''); const progMatch = text.match(/(\d{1,3})\s*%/); if (progMatch) { const pct = parseInt(progMatch[1], 10); log('第' + i + '行: 进度' + pct + '%'); if (pct < 100) { const btn = row.querySelector('button, a, .btn, [role="button"]'); if (btn) { const btnText = (btn.textContent || '').trim(); log('在进度' + pct + '%的行找到按钮: ' + btnText); btn.click(); return true; } } } else { const btn = Array.from(row.querySelectorAll('button, a, .btn, [role="button"]')) .find(b => isVisible(b) && /(继续学习|开始学习)/.test(b.textContent || '')); if (btn) { log('在第' + i + '行找到「' + (btn.textContent || '').trim() + '」按钮'); btn.click(); return true; } } } } log('按进度和按钮查找失败,fallback到章节查找'); return findAndClickNextChapter(); } // ========== 视频事件监听 ========== let videoEndListenerBound = false; // 统一的播放函数:先尝试正常播放,被浏览器阻止则用 muted=true 解锁 // 跨章节导航到新页面时,浏览器自动播放策略会阻止 play() // 但浏览器允许静音视频自动播放,所以 muted=true 后 play() 能成功 function tryPlayVideo(video) { if (!video || video.paused === false) return; const now = Date.now(); if (video._peixunyunPlayPending || (video._peixunyunLastPlayAttempt && now - video._peixunyunLastPlayAttempt < 1500)) return; video._peixunyunPlayPending = true; video._peixunyunLastPlayAttempt = now; video.play().then(() => { video._peixunyunPlayPending = false; }).catch(() => { log('自动播放被阻止,尝试静音解锁...'); if (!video.muted) video.muted = true; video.play().then(() => { video._peixunyunPlayPending = false; log('静音解锁播放成功'); if (!state.autoMute) { video.muted = false; } }).catch(() => { video._peixunyunPlayPending = false; log('静音播放也被阻止'); }); }); } function scheduleVideoPlayRetries(reason) { if (!state.enabled) return; if (state.playRetryTimer) clearInterval(state.playRetryTimer); let attempts = 0; const maxAttempts = 8; log('启动视频播放重试:', reason); function runRetry() { attempts++; const video = findVideoElement(); if (!video) { if (attempts >= maxAttempts) { clearInterval(state.playRetryTimer); state.playRetryTimer = null; log('播放重试结束:未找到视频元素'); } return; } if (!video.paused || video.ended) { clearInterval(state.playRetryTimer); state.playRetryTimer = null; log('播放重试结束:视频已播放或已结束'); return; } if (state.autoMute) ensureVideoMuted(); if (video.readyState === 0) { try { video.load(); } catch (_) {} log('播放重试等待视频加载,readyState=0'); } else { log('播放重试第' + attempts + '次,readyState=' + video.readyState); tryPlayVideo(video); } if (attempts >= maxAttempts) { clearInterval(state.playRetryTimer); state.playRetryTimer = null; log('播放重试达到上限'); } } setTimeout(runRetry, 500); state.playRetryTimer = setInterval(runRetry, CONFIG.mainCheckInterval); } function setupVideoEndListener() { const video = findVideoElement(); if (!video) return false; const src = video.currentSrc || video.src || ''; if (src && src === state.currentVideoSrc && videoEndListenerBound) { return true; } if (state.currentVideoSrc && state.currentVideoSrc !== src) { log('视频源变化,重置状态'); state.isSwitching = false; state.noNextVideoAttempts = 0; } state.currentVideoSrc = src; video.removeEventListener('ended', handleVideoEnded); video.addEventListener('ended', handleVideoEnded); videoEndListenerBound = true; // 关键:视频就绪事件监听(章节切换后视频源需要时间加载) // 用命名函数以便能 removeEventListener(重置时需要移除旧监听器) if (!video._peixunyunCanplayHandler) { video._peixunyunCanplayHandler = function onCanplay() { log('视频 canplay 事件触发,尝试播放...'); if (state.autoMute) ensureVideoMuted(); if (!video.paused) return; tryPlayVideo(video); }; video._peixunyunLoadedMetaHandler = function onLoadedMeta() { log('视频 loadedmetadata 事件触发'); if (state.autoMute) ensureVideoMuted(); if (!video.paused) return; tryPlayVideo(video); }; } // 如果标记被重置(URL变化时),先移除旧监听器再重新绑定 if (!video._peixunyunCanplayBound) { video.removeEventListener('canplay', video._peixunyunCanplayHandler); video.removeEventListener('loadedmetadata', video._peixunyunLoadedMetaHandler); video.addEventListener('canplay', video._peixunyunCanplayHandler); video.addEventListener('loadedmetadata', video._peixunyunLoadedMetaHandler); video._peixunyunCanplayBound = true; } // 对于已经 ready 的视频,立即尝试静音和播放 if (video.readyState >= 2) { if (state.autoMute) ensureVideoMuted(); if (video.paused && !video.ended) { log('视频已就绪(readyState=' + video.readyState + '),立即尝试播放'); tryPlayVideo(video); scheduleVideoPlayRetries('视频已就绪'); } } else if (video.readyState === 0 && !video._peixunyunLoadRetry) { video._peixunyunLoadRetry = true; log('视频 readyState=0,尝试 load()'); try { video.load(); } catch (_) {} scheduleVideoPlayRetries('视频等待加载'); } log('已绑定视频事件(ended/canplay/loadedmetadata)'); return true; } function ensureVideoPlaying() { if (state.isSwitching || !state.enabled) return; const video = findVideoElement(); if (!video || video.ended) return; if (video.paused) { const durOk = Number.isFinite(video.duration) && video.duration > 0; const curOk = Number.isFinite(video.currentTime); if (durOk && curOk && video.currentTime >= video.duration - 1) return; log('视频暂停,尝试恢复播放...'); tryPlayVideo(video); } // 视频正在播放时,不做任何操作 } function checkVideoProgress() { const video = findVideoElement(); if (!video || !Number.isFinite(video.duration) || video.duration <= 0) return; if (video.ended || state.isSwitching) return; if (video.currentTime >= video.duration - 0.5 && !video.paused) { log('视频接近结束(兜底检测)'); handleVideoEnded(); } } // ========== DOM 监听 ========== function setupMutationObserver() { const observer = new MutationObserver(function(mutations) { for (const mutation of mutations) { if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { for (const node of mutation.addedNodes) { if (node.nodeType === 1) { if (node.classList && (node.classList.contains('modal') || node.classList.contains('modal-backdrop'))) { setTimeout(findAndClosePopup, 100); } if (node.tagName === 'VIDEO' || (node.querySelector && node.querySelector('video'))) { // 减少延迟到100ms,尽快绑定事件 setTimeout(() => { setupVideoEndListener(); ensureVideoPlaying(); }, 100); // 1秒后再试一次(确保视频源已加载) setTimeout(() => { setupVideoEndListener(); ensureVideoPlaying(); }, 1000); // 3秒后最后再试一次 setTimeout(() => { setupVideoEndListener(); ensureVideoPlaying(); }, 3000); } } } } } }); observer.observe(document.body, { childList: true, subtree: true }); log('已启动 DOM 监测'); } // ========== 控制面板 ========== let panelVisible = false; function createControlPanel() { let panel = document.getElementById('peixunyun-helper-panel'); if (panel) { panelVisible = !panelVisible; panel.style.display = panelVisible ? 'block' : 'none'; return; } panelVisible = true; panel = document.createElement('div'); panel.id = 'peixunyun-helper-panel'; panel.style.cssText = 'display:block;position:fixed;top:20px;right:20px;width:280px;background:white;border-radius:12px;box-shadow:0 4px 20px rgba(0,0,0,0.15);font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Microsoft YaHei,sans-serif;z-index:2147483647;overflow:hidden;'; panel.innerHTML = `