B站大学课程辅助器

让你自律地看多集视频

// ==UserScript==
// @name         B站大学课程辅助器
// @namespace    http://tampermonkey.net/
// @version      3.0
// @description  让你自律地看多集视频
// @author       zhuangjie
// @match        https://www.bilibili.com/video/**
// @icon         https://www.bilibili.com/favicon.ico
// @grant        GM_setValue
// @grant        GM_getValue
// @license MIT
// ==/UserScript==
 
(async function() {
    'use strict';

    // ========== 公共工具函数区 ==========

    // 【url改变监听器】
    function onUrlChange(fun,isImmediately = false) {
        let initUrl = window.location.href.split("#")[0];
        function urlChangeCheck() {
            let currentUrl = window.location.href.split("#")[0];
            if (initUrl != currentUrl) {
                // 新的=>旧的
                initUrl = currentUrl;
                fun();
            }
        }
        if(isImmediately) fun();
        setInterval(urlChangeCheck, 460);
    }

    // 数据缓存器
    let cache = {
        get(key) {
            return GM_getValue(key);
        },
        set(key, value) {
            GM_setValue(key, value);
        },
        jGet(key) {
            let value = GM_getValue(key);
            if (value == null) return value;
            return JSON.parse(value);
        },
        jSet(key, value) {
            value = JSON.stringify(value);
            GM_setValue(key, value);
        },
        remove(key) {
            GM_deleteValue(key);
        },
        cookieSet(cname, cvalue, exdays) {
            var d = new Date();
            d.setTime(d.getTime() + exdays);
            var expires = "expires=" + d.toGMTString();
            document.cookie = cname + "=" + cvalue + "; " + expires;
        },
        cookieGet(cname) {
            var name = cname + "=";
            var ca = document.cookie.split(';');
            for (var i = 0; i < ca.length; i++) {
                var c = ca[i].trim();
                if (c.indexOf(name) == 0) return c.substring(name.length, c.length);
            }
            return "";
        }
    };

    // 防抖函数
    function debounce(func, delay) {
        let timeoutId;
        return function() {
            const context = this;
            const args = arguments;
            clearTimeout(timeoutId);
            timeoutId = setTimeout(function() {
                func.apply(context, args);
            }, delay);
        };
    }

    // 获取视频的ID
    function getVideoId() {
        let regex = /.*?video\/([^?\/]*).*/; // 匹配 /video/ 后面的字符,直到遇到 /
        let match = window.location.href.match(regex); // 使用正则表达式匹配
        if (match && match[1]) {
            let videoId = match[1];
            return videoId;
        } else {
            return null;
        }
    }

    // 获取指定属性data-开头的属性名-返回数组
    function getDataAttributes(element) {
        var dataAttributes = [];
        if (element && element.attributes) {
            var attributes = element.attributes;
            for (var i = 0; i < attributes.length; i++) {
                var attributeName = attributes[i].name;
                if (attributeName.startsWith('data-')) {
                    dataAttributes.push(attributeName);
                }
            }
        }
        return dataAttributes;
    }

    // 判断当前是否在iframe里面,
    function currentIsIframe() {
        if (self.frameElement && self.frameElement.tagName == "IFRAME") return true;
        if (window.frames.length != parent.frames.length) return true;
        if (self != top) return true;
        return false;
    }

    // ========== 程序业务函数区 ==========

    // 播放状态修改
    function getPlayStatus() { // 播放 true,暂停 false
        var element = document.querySelector('.bpx-player-state-play');
        var computedStyle = getComputedStyle(element);
        var display = computedStyle.getPropertyValue('display');
        var visibility = computedStyle.getPropertyValue('visibility');
        var isVisible = (display !== 'none' && visibility !== 'hidden');
        return !isVisible;
    }

    // 修改视频播放状态
    function play(isPlay = false) {
        if (getPlayStatus() == isPlay) return;
        // 如果状态不一致,让状态一致
        var button = document.getElementsByClassName("bpx-player-ctrl-play")[0];
        // 创建并初始化一个点击事件
        var clickEvent = new MouseEvent("click", {
            bubbles: true,
            cancelable: true
        });
        // 派发(click)触发点击事件
        button.dispatchEvent(clickEvent);
    }

    // 监听某个元素内容变化
    let elementChange = {
        existCheck(select, timeout = 6000) {
            return new Promise((resolve, reject) => {
                let timer = null;
                timer = setInterval(() => {
                    let element = document.querySelector(select);
                    if (element != null) {
                        resolve(element);
                        clearInterval(timer);
                    }
                }, 200);
                setTimeout(() => { clearInterval(timer); }, timeout);
            });
        },
        hasContentCheck(select, count = 1, timeout = 6000) {
            return new Promise((resolve, reject) => {
                let timer = null;
                timer = setInterval(() => {
                    let element = document.querySelector(select);
                    let isHasContent = false;
                    if (element == null) return;
                    let innerText = element.innerText;
                    isHasContent = element.childNodes.length >= count && innerText != "" && !/^\s*<!--[^<>]*-->\s*$/.test(innerText);
                    if (isHasContent) {
                        resolve(element);
                        clearInterval(timer);
                    }
                }, 200);
                setTimeout(() => { clearInterval(timer); }, timeout);
            });
        }
    };

    // 全局变量,用于存放视图及缓存相关信息
    let pList = null;
    let TP_CACHE_KEY = null;
    let WHEN_SAVING_P_CACHE_KEY = null;
    let currentEpisodes = null;
    let controlElement = null; // 视图节点对象

    // 页面改变需要修改的元素选择器
    let pageElementSelector = {
        pListBox: ".rcmd-tab", // 集数列表盒子,脚本控制器会放在它上面
        p2: ".video-pod__body > div > div:nth-child(2)", //也决定了是否为多集视频
        currentP: ".amt" // innerHtml应是(n/m)这种才能解析,否则需要修改逻辑
    };

    // 刷新视频信息
    function refreshVideoInfo() {
        // 存放集列表的盒子,如果有证明是多集视频(page-change-change)
        // 脚本控制器将放在这上面,且证明是否多集
        pList = document.querySelector(pageElementSelector.pListBox);
        let oldVideoId = TP_CACHE_KEY;
        let currentVideoId = TP_CACHE_KEY = getVideoId();
        WHEN_SAVING_P_CACHE_KEY = TP_CACHE_KEY + ":WHEN_SAVING_P_CACHE_KEY";
        let isVideoChange = oldVideoId != currentVideoId;
    }

    // 视图初始化
    function initView() {
        // 之前的集数
        let tp = cache.get(TP_CACHE_KEY) ?? 0;
        let inputStyle = `
           height: 20px;
           border-radius: 5px;
           border: 1.5px solid pink;
           padding: 2px 5px;
           box-sizing: border-box;
           max-width: 60px;
        `;

        // 创建新的 <div> 元素
        controlElement = document.createElement('div');
        // 视图容器样式
        controlElement.style = `
               margin: 10px 0px;
               line-height:25px;
               color:#FB7299;
               font-weight: 500;
            `;

        // 加data就可以让内容可以选中使用,不然都不能选中,如input如何聚焦编辑
        let dataAttrName = getDataAttributes(pList)[0];
        controlElement.innerHTML = `
              <span >当前P<span id="current_episodes">--</span> , 本次目标P</span>
              <input type="number" style="${inputStyle}" value="${tp}" id="tp_input"  ${dataAttrName} />
              <span id="tp_msg">--</span>
        `;
        // 在目标元素前插入新的兄弟元素
        setTimeout(()=>{
            // 这里必须等待页面,否则页面将功能异常(由向页面插入元素引起)
            pList.before(controlElement);

            // 使用防抖修改内容
            let tpInput = document.querySelector('#tp_input');
            let refresh = debounce(() => {
                // 在这里编写输入值改变事件的处理逻辑
                cache.set(TP_CACHE_KEY, parseInt(tpInput.value));
                cache.set(WHEN_SAVING_P_CACHE_KEY, currentEpisodes);
                refreshViewState();
            }, 1000);
            refreshViewState();
            tpInput.addEventListener('input', () => refresh());
        },2000)
    }

    function refreshControlVisibility() {
        const p2 = document.querySelector(pageElementSelector.p2)
        if (p2 == null && controlElement != null) {
            console.log("1.1   refreshControlVistor")
            // 多集视频 -> 单视频  执行
            controlElement?.remove();
            controlElement = null;
        } else if(p2 != null && controlElement == null){
            console.log("1.2  refreshControlVistor")
            // 单视频 -> 多集视频时 执行
            initView();
        }
    }
    // 更新视图状态
    async function refreshViewState() {
        refreshVideoInfo();
        refreshControlVisibility();
        // 当前集数
        currentEpisodes = await new Promise((resolve, reject) => {
            let timer = null;
            timer = setInterval(() => {
                let activeItem = document.querySelector(pageElementSelector.currentP);
                if (activeItem == null) return;
                const text = activeItem.innerText;
                const regex = /(\d+)\/(\d+)/; // 提取分子和分母
                const match = text.match(regex);
                if (!match) return;

                const current = parseInt(match[1], 10); // 当前进度
                const total = parseInt(match[2], 10); // 总进度
                console.log(`当前进度: ${current}, 总进度: ${total}`);

                if (activeItem == null) {
                    clearInterval(timer);
                    resolve(null);
                    return;
                }
                if (current != null && current >= 1) {
                    clearInterval(timer);
                    resolve(current);
                }
            }, 200);
        });
        let tpInput = document.querySelector('#tp_input');
        let tp = cache.get(TP_CACHE_KEY) ?? 0;
        let tpMsg = document.querySelector('#tp_msg');
        let currentEpisodesElement = document.querySelector('#current_episodes');
        let residueP = tp - currentEpisodes;
        let whenSavingP = cache.get(WHEN_SAVING_P_CACHE_KEY);
        let sumP = whenSavingP === undefined ? "--" : (tp - whenSavingP + 1);
        let viewed = (typeof sumP === "string") ? "--" : (sumP - residueP - 1);
        if (tpInput == null) return;
        tpInput.value = tp;
        currentEpisodesElement.innerHTML = `${currentEpisodes}`;
        let statusMsg = (viewed >= sumP)
        ? (tp == 0 ? "第一步设置目标!" : "太棒了,任务完成了!")
        : "看完当前+1";
        tpMsg.innerHTML = ` , 进度 ${viewed}/${sumP}集!${statusMsg}`;
        debugger

        // 检查
        if (tp == 0) return; // 没有设置目标值
        if (currentEpisodes >= tp + 1) {
            setTimeout(() => {
                play(false);
                alert(`你已经达到本次任务!${currentEpisodes > tp + 1 ? "请更新目标" : ""}`);
            }, 100); // 设置状态为暂停
        }
    }

    // === 扩展功能-暂停与自动播放控制 ===
    function setupVisibilityChangeListener() {
        // 当页面失去焦点时暂停,活动时播放(前提是自动关闭的)
        document.addEventListener("visibilitychange", function() {
            if (document.visibilityState === "visible") {
                // 活动
                if (isIntervene) { // 只有干预过,才可自动恢复播放
                    play(true);
                    isIntervene = false; // 重置为未干预
                }
            } else if (getPlayStatus()) {
                // 不活动 & 在播放时
                isIntervene = true; // 设置为已干预
                play(false);
            }
        });
    }

    // 全局变量,用于扩展功能控制
    let isIntervene = false;

    // ========== 主函数区 ==========
    function main() {
        // 如果处于iframe内则不执行脚本
        if (currentIsIframe()) return;

        // 程序入口:等待集数目录加载完成-初始化视图
        window.onload = function() {
            // 集数目录加载完时,执行初始化视图(如果视图比集数目录显示在前面,可能集数行内容空白)
            onUrlChange(() => refreshViewState(),true)
        };

        // 设置扩展功能
        setupVisibilityChangeListener();
    }

    // 调用主函数启动程序
    main();

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址