// ==UserScript== // @name 人才呀(博思)视频自动二倍速+自动下一集 // @namespace cx-free-helper // @version 4.7.0 // @description 人才呀(博思)网课视频助手:自动 2 倍速播放、自动跳过片尾、播放完毕自动切换下一集;提升起播可靠性、换集识别与运行安全。 // @author C-Y-T-258 // @license MIT // @supportURL https://greasyfork.org/zh-CN/scripts/585608 // @match http://rencaiya.vip/college/Video/* // @grant none // @run-at document-end // ==/UserScript== (function() { 'use strict'; console.log('[视频助手] 脚本已加载 v4.7.0(播完切换+安全版)'); // ========== 运行模式 ========== const RUN_MODE = { SPEED_2X: 'speed_2x', SKIP_TO_END: 'skip_to_end' }; // ========== 配置 ========== const CONFIG = { targetSpeed: 2.0, minPlayTime: 3, switchDelay: 2000, checkInterval: 500, skipToEndThreshold: 5, pageLoadWait: 3000 }; // ========== 状态 ========== let isRunning = false; let hasSwitched = false; let videoStartTime = 0; let isListOpen = false; let currentMode = RUN_MODE.SPEED_2X; let switchAttempts = 0; let lastVideoTitle = ''; let isPageLoading = false; let mainCheckInterval = null; let videoFinished = false; // 4.6.0:提到模块级,失败时可复位 let consecutiveFailures = 0; // 4.6.0:连续切换失败计数(上限 3) let isSwitching = false; // 4.6.0:切换进行中,防并发重复切换 let finishGraceUntil = 0; // 4.6.0:换集后宽限期,期间不判「播完」 let lastHref = ''; // 4.7.0:URL 变化作为换集信号 let mutedFallbackUsed = false; // 4.7.0:是否已用静音兜底起播 let warningScanTick = 0; // 4.7.0:预警文本扫描节流计数 // ========== 获取当前页面标题 ========== function getCurrentPageTitle() { const titleEl = document.querySelector('.ant-page-header-heading-title'); return titleEl ? titleEl.textContent.trim() : ''; } // ========== 获取视频元素 ========== function getVideo() { const video = document.querySelector('video'); return (video && video.duration > 0) ? video : null; } // ========== 点击"章节"按钮展开列表 ========== async function openChapterList() { if (isListOpen) return true; const existingPeriods = document.querySelectorAll('.period'); if (existingPeriods.length > 0) { isListOpen = true; return true; } const chapterBtn = document.querySelector('.chapter img[src*="chapter.png"]'); if (chapterBtn) { chapterBtn.click(); await new Promise(r => setTimeout(r, 1500)); isListOpen = true; return true; } const chapterText = document.querySelector('.chapter'); if (chapterText) { chapterText.click(); await new Promise(r => setTimeout(r, 1500)); isListOpen = true; return true; } return false; } // ========== 关闭章节列表 ========== function closeChapterList() { const chapterBtn = document.querySelector('.chapter img[src*="chapter.png"]'); if (chapterBtn && isListOpen) { chapterBtn.click(); isListOpen = false; } } // ========== 检测任务点是否已完成 ========== function isTaskCompleted(periodElement) { const progressImg = periodElement.querySelector('img.studyProgress'); if (progressImg) { const src = progressImg.src || ''; if (src.includes('full.png')) return true; if (src.includes('empty.png')) return false; if (src.includes('halfFull.png')) return false; } return false; } // ========== 判断是否是评测/任务项 ========== function isTestTask(periodElement) { // 4.6.0:补「练习」类课次(图标 videoCode.png),此前被当成视频课程点击 → 落到无 video 页 const taskIcon = periodElement.querySelector('img[src*="videoTask.png"], img[src*="videoCode.png"]'); if (taskIcon) return true; const text = periodElement.textContent || ''; if (text.includes('评测') || text.includes('考试') || text.includes('测试') || text.includes('测评')) { return true; } // 只认「练习:」标题前缀,避免「类型转换练习」这类课程名被误判 if (/^\s*练习\s*[::]/.test(getPeriodTitle(periodElement))) { return true; } return false; } // ========== 获取课时名称 ========== function getPeriodTitle(periodElement) { const titleSpan = periodElement.querySelector('.periodH span'); return titleSpan ? titleSpan.textContent.trim() : periodElement.textContent?.trim() || ''; } // ========== 获取可点击的元素 ========== function getClickableElement(periodElement) { const titleSpan = periodElement.querySelector('.periodH span'); if (titleSpan) return titleSpan; const spans = periodElement.querySelectorAll('span'); for (let span of spans) { const text = span.textContent.trim(); if (text && text.length > 5 && (text.includes('课程') || text.includes('评测'))) { return span; } } return periodElement; } // ========== 获取视频列表 ========== function getVideoList() { const videos = []; const periodElements = document.querySelectorAll('.period'); if (periodElements.length === 0) { return videos; } periodElements.forEach((period, index) => { const title = getPeriodTitle(period); if (!title || title.length < 2) return; if (title === '章节') return; if (title.includes('资源反馈')) return; const isTest = isTestTask(period); const isCourse = title.includes('课程') || title.match(/\d+-\d+/) || title.match(/^课程[::]/); const isCompleted = isTaskCompleted(period); if (isCourse || isTest) { videos.push({ index: index, element: period, clickableElement: getClickableElement(period), text: title, isTest: isTest, isCourse: isCourse, isCompleted: isCompleted }); } }); return videos; } // ========== 4.6.0:用站点自带的当前项标记定位(其余课次是 .periodH,当前项是 .periodI)========== function findCurrentPeriodElement() { const periods = document.querySelectorAll('.period'); for (let i = 0; i < periods.length; i++) { if (periods[i].querySelector('.periodI')) return periods[i]; } // 兜底:行内含「正在学」文本 for (let i = 0; i < periods.length; i++) { if ((periods[i].textContent || '').includes('正在学')) return periods[i]; } return null; } // ========== 查找当前视频在列表中的索引 ========== function findCurrentVideoIndex(videos) { // 策略0(4.6.0 新增,最可靠):DOM 当前项标记 → 按元素引用匹配 const currentPeriod = findCurrentPeriodElement(); if (currentPeriod) { for (let i = 0; i < videos.length; i++) { if (videos[i].element === currentPeriod) { console.log('[视频助手] 通过当前项标记定位到索引:', i, videos[i].text); return i; } } } const pageTitle = getCurrentPageTitle(); const pageMatch = pageTitle.match(/(\d+)-(\d+)/); if (pageMatch) { for (let i = 0; i < videos.length; i++) { const videoMatch = videos[i].text.match(/(\d+)-(\d+)/); if (videoMatch && pageMatch[1] === videoMatch[1] && pageMatch[2] === videoMatch[2]) { return i; } } } for (let i = 0; i < videos.length; i++) { const videoTitle = videos[i].text; if (pageTitle && videoTitle) { const pageSub = pageTitle.replace(/课程[::]?\s*/, '').substring(0, 20); const videoSub = videoTitle.replace(/课程[::]?\s*/, '').substring(0, 20); if (pageSub === videoSub || pageTitle.includes(videoTitle.substring(0, 15)) || videoTitle.includes(pageTitle.substring(0, 15))) { return i; } } } for (let i = 0; i < videos.length; i++) { if (videos[i].isCourse && !videos[i].isCompleted) { return i; } } for (let i = 0; i < videos.length; i++) { if (videos[i].isCourse) { return i; } } return -1; } // ========== 检查是否是测评/空页面 ========== function checkTestOrEmptyPage() { const pageTitle = getCurrentPageTitle(); const video = getVideo(); if (pageTitle === '#' || pageTitle === '' || !pageTitle) { return { shouldSkip: true }; } if (!video) { const pageContent = document.body.innerText || ''; if (pageContent.includes('测评') || pageContent.includes('考试') || pageContent.includes('测试') || pageTitle.includes('测评')) { return { shouldSkip: true }; } return { shouldSkip: true }; } if (pageTitle.includes('测评') || pageTitle.includes('考试') || pageTitle.includes('测试')) { return { shouldSkip: true }; } return { shouldSkip: false }; } // ========== 拖动进度条到底部 ========== function skipToEnd() { const video = getVideo(); if (!video || !video.duration) { return false; } const targetTime = Math.max(0, video.duration - CONFIG.skipToEndThreshold); video.currentTime = targetTime; console.log('[视频助手] 进度条已拖动到:', targetTime.toFixed(1), '秒'); return true; } // ========== 点击视频项 ========== async function clickVideoItem(videoItem) { console.log('[视频助手] 准备点击:', videoItem.text); notify('即将播放: ' + videoItem.text.substring(0, 25)); hasSwitched = true; isPageLoading = true; switchAttempts = 0; lastVideoTitle = getCurrentPageTitle(); setTimeout(() => { const clickTarget = videoItem.clickableElement || videoItem.element; clickTarget.click(); console.log('[视频助手] 已点击:', videoItem.text); setTimeout(() => { closeChapterList(); }, 800); setTimeout(() => { const titleNow = getCurrentPageTitle(); hasSwitched = false; isPageLoading = false; isListOpen = false; videoStartTime = Date.now(); switchAttempts = 0; // 4.6.0:校验点击是否真的生效,未生效则允许重试(避免永久卡死) if (titleNow === lastVideoTitle) { consecutiveFailures++; videoFinished = false; console.log('[视频助手] 点击后标题未变化,判定切换失败'); if (consecutiveFailures > 3) { isRunning = false; notify('自动运行已停止(连续切换失败 3 次)'); } } else { consecutiveFailures = 0; videoFinished = false; } }, CONFIG.pageLoadWait); }, CONFIG.switchDelay); return true; } // ========== 通过点击切换到下一个视频 ========== async function switchToNextByClick() { if (hasSwitched || isPageLoading) { return false; } const opened = await openChapterList(); if (!opened) { return false; } await new Promise(r => setTimeout(r, 2000)); let videos = getVideoList(); if (videos.length === 0) { await new Promise(r => setTimeout(r, 2000)); videos = getVideoList(); if (videos.length === 0) { return false; } } const currentIndex = findCurrentVideoIndex(videos); if (currentIndex === -1) { for (let i = 0; i < videos.length; i++) { if (videos[i].isCourse && !videos[i].isCompleted && !videos[i].isTest) { return await clickVideoItem(videos[i]); } } return false; } let nextIndex = currentIndex + 1; while (nextIndex < videos.length) { const next = videos[nextIndex]; if (next.isTest || next.isCompleted || !next.isCourse) { nextIndex++; continue; } return await clickVideoItem(next); } for (let i = 0; i < videos.length; i++) { const video = videos[i]; if (video.isCourse && !video.isCompleted && !video.isTest) { return await clickVideoItem(video); } } notify('🎉 所有课程已完成!'); isRunning = false; return false; } // ========== 切换到下一个视频 ========== async function switchToNext() { if (hasSwitched || isPageLoading || isSwitching) return false; switchAttempts++; if (switchAttempts > 50) { isRunning = false; notify('自动运行已停止(切换次数过多)'); return false; } console.log('[视频助手] 开始切换下一集...'); isSwitching = true; let clicked = false; try { clicked = await switchToNextByClick(); } catch (e) { // 4.6.0:主循环调用不 await,这里兜住异常,避免未处理的 Promise rejection console.log('[视频助手] 切换过程异常:', e && e.message ? e.message : e); clicked = false; } finally { isSwitching = false; } if (clicked) { // 是否真的切成功由 clickVideoItem 的 3s 标题校验判定;此处不能清零失败计数 return true; } // 4.6.0 失败处理:复位完成标志让主循环可重试;失败不计入熔断,改为连续失败计数 videoFinished = false; switchAttempts = Math.max(0, switchAttempts - 1); consecutiveFailures++; if (consecutiveFailures > 3) { isRunning = false; notify('自动运行已停止(连续切换失败 3 次)'); return false; } notify('切换失败,将自动重试(' + consecutiveFailures + '/3)'); return false; } // ========== 设置倍速 ========== function setSpeed() { const video = getVideo(); if (!video) return; if (video.paused) { ensurePlay(video); } video.playbackRate = CONFIG.targetSpeed; console.log('[视频助手] 倍速设置为:', CONFIG.targetSpeed); } // ========== 显示通知 ========== function notify(msg) { const old = document.getElementById('vh-notify'); if (old) old.remove(); const div = document.createElement('div'); div.id = 'vh-notify'; div.style.cssText = ` position: fixed; top: 20px; right: 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 15px 25px; border-radius: 8px; font-size: 14px; font-weight: bold; z-index: 999999; box-shadow: 0 4px 15px rgba(0,0,0,0.3); max-width: 300px; word-wrap: break-word; `; div.textContent = msg; document.body.appendChild(div); setTimeout(() => div.remove(), 3000); } // ========== 更新模式按钮样式 ========== function updateModeButtons() { const speedBtn = document.getElementById('vh-mode-speed'); const skipBtn = document.getElementById('vh-mode-skip'); const modeText = document.getElementById('vh-current-mode'); if (speedBtn && skipBtn) { if (currentMode === RUN_MODE.SPEED_2X) { speedBtn.style.background = '#4CAF50'; speedBtn.style.color = 'white'; skipBtn.style.background = '#555'; skipBtn.style.color = '#ccc'; } else { speedBtn.style.background = '#555'; speedBtn.style.color = '#ccc'; skipBtn.style.background = '#FF5722'; skipBtn.style.color = 'white'; } } if (modeText) { modeText.textContent = currentMode === RUN_MODE.SPEED_2X ? '二倍速' : '进度条跳过'; } } // ========== 切换模式 ========== function switchMode(mode) { currentMode = mode; updateModeButtons(); if (mode === RUN_MODE.SPEED_2X) { notify('已切换到:二倍速模式'); setSpeed(); } else { notify('已切换到:进度条跳过模式'); const video = getVideo(); if (video) video.playbackRate = 1.0; } } // ========== 创建控制面板 ========== function createPanel() { const panel = document.createElement('div'); panel.id = 'vh-panel'; panel.style.cssText = ` position: fixed; bottom: 20px; right: 20px; background: rgba(0,0,0,0.9); color: white; padding: 15px; border-radius: 10px; font-size: 12px; z-index: 999999; min-width: 260px; `; panel.innerHTML = `