B站根据弹幕提醒跳过广告

智能检测弹幕中广告提醒,提取时间戳并自动跳转播放器进度

当前为 2025-07-15 提交的版本,查看 最新版本

// ==UserScript==
// @name         B站根据弹幕提醒跳过广告
// @namespace    https://greasyfork.org/zh-CN/scripts/542541
// @version      1.05
// @description  智能检测弹幕中广告提醒,提取时间戳并自动跳转播放器进度
// @author       chemhunter
// @match        https://www.bilibili.com/video/*
// @grant        none
// @run-at       document-end
// @license      MIT
 
// ==/UserScript==

(function () {
    'use strict';

    const timeRegexList = [
        { regex: /\b(?!\d[::]0\b)(\d{1,2})[::](\d{1,2})\b/, isFuzzy: false }, // 5:14
        { regex: /(\d{1,2}|[一二三四五六七八九十]{1,3})分(\d{1,2}|[零一二三四五六七八九十]{1,3})秒/, isFuzzy: false },
        { regex: /(\d{1,2})\.(\d{1,2})[郎朗]/, isFuzzy: false },
        { regex: /(\d{1,2}|[一二三四五六七八九十]{1,3})分(\d{1,2}|[零一二三四五六七八九十]{1,3})/, isFuzzy: false },
        { regex: /(?<!\d)(\d{1,2})\.(\d{1,2})(?![\d郎秒分:])/, isFuzzy: true } // 模糊时间戳:纯数字 5.14
    ];

    const cnNumMap = {
        "零": 0, "一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9, "十": 10
    };

    function parseChineseNumber(ch) {
        if (ch === "十") return 10;
        if (ch.length === 1) return cnNumMap[ch] || 0;
        if (ch.length === 2 && ch[0] === "十") return 10 + (cnNumMap[ch[1]] || 0);
        if (ch.length === 2 && ch[1] === "十") return (cnNumMap[ch[0]] || 0) * 10;
        if (ch.length === 3 && ch[1] === "十") return (cnNumMap[ch[0]] || 0) * 10 + (cnNumMap[ch[2]] || 0);
        return 0;
    }

    const pendingDanmaku = [];
    const timestampCounter = new Map();
    const processedTimestamps = new Set();
    const fuzzyCandidates = []; // { timestamp, timeAdded }

    const TIME_GROUP_THRESHOLD = 10;
    const FUZZY_TIMEOUT = 10;
    const MIN_COUNT_TO_LOG = 2;
    let lastJumpTime = 0;

    function extractTimestamps(text) {
        const cleanText = text.replace(/\s+/g, '');
        for (let i = 0; i < timeRegexList.length; i++) {
            const { regex, isFuzzy } = timeRegexList[i];
            const match = regex.exec(cleanText);
            if (match) {
                let minutes = /[一二三四五六七八九十]/.test(match[1]) ? parseChineseNumber(match[1]) : parseInt(match[1]) || 0;
                let seconds = /[一二三四五六七八九十]/.test(match[2]) ? parseChineseNumber(match[2]) : parseInt(match[2]) || 0;
                const ts = minutes * 60 + seconds;
                if (!isNaN(ts)) {
                    return {
                        timestamp: ts,
                        hasLang: /[郎朗]/.test(text),
                        isFuzzy
                    };
                }
            }
        }
        return null;
    }

    function recordTimestamp(ts) {
        for (const [existingTs, count] of timestampCounter.entries()) {
            if (Math.abs(existingTs - ts) <= TIME_GROUP_THRESHOLD) {
                const newKey = Math.min(existingTs, ts);
                timestampCounter.set(newKey, count + 1);
                if (existingTs !== newKey) timestampCounter.delete(existingTs);
                return;
            }
        }
        timestampCounter.set(ts, 1);
    }

    function processPendingDanmaku() {
        const now = Date.now();

        // 先记录本轮新的明确时间戳(用于模糊匹配)
        for (const { text, timestamp: ts, hasLang } of pendingDanmaku) {
            if (hasLang) {
                timestampCounter.set(ts, MIN_COUNT_TO_LOG);
            } else {
                recordTimestamp(ts);
            }
        }

        // 处理模糊候选
        for (let i = fuzzyCandidates.length - 1; i >= 0; i--) {
            if (now - fuzzyCandidates[i].timeAdded >= FUZZY_TIMEOUT*1000) {
                console.log('[模糊丢弃]', fuzzyCandidates[i].timestamp);
                fuzzyCandidates.splice(i, 1);
            }
        }

        for (let i = fuzzyCandidates.length - 1; i >= 0; i--) {
            const fuzzy = fuzzyCandidates[i];
            for (const ts of timestampCounter.keys()) {
                if (Math.abs(fuzzy.timestamp - ts) <= TIME_GROUP_THRESHOLD) {
                    console.log('[模糊转正]', fuzzy.timestamp, '因匹配到', ts);
                    recordTimestamp(fuzzy.timestamp);
                    fuzzyCandidates.splice(i, 1);
                    break;
                }
            }
        }

        pendingDanmaku.length = 0;

        //跳转逻辑
        for (const [ts, count] of timestampCounter) {
            if (ts > 0 && count >= MIN_COUNT_TO_LOG && !processedTimestamps.has(ts)) {
                if (Date.now() - lastJumpTime >= 20000) {
                    console.log(`[跳转提示] 发现广告时间戳 ${formatTime(ts)},出现次数:${count}`);
                    jumpToTimestamp(ts);
                    showJumpNotice(ts);
                    processedTimestamps.add(ts);
                    lastJumpTime = Date.now();
                } else {
                    console.log('[跳转抑制] 离上次跳转不足20秒');
                }
            }
        }

        timestampCounter.clear();
    }


    function formatTime(ts) {
        const min = Math.floor(ts / 60);
        const sec = String(ts % 60).padStart(2, '0');
        return `${min}:${sec}`;
    }


    function showJumpNotice(ts) {
        const container = document.querySelector('.bpx-player-video-wrap');
        if (!container) return;
        const box = document.createElement('div');
        box.innerText = '跳转广告 ⏩ ' + formatTime(ts);
        Object.assign(box.style, {
            position: 'absolute',
            top: '10px',
            left: '50%',
            transform: 'translateX(-50%)',
            padding: '6px 12px',
            backgroundColor: 'rgba(0, 0, 0, 0.4)',
            color: '#fff',
            fontSize: '16px',
            fontWeight: 'bold',
            borderRadius: '8px',
            zIndex: '9999',
            pointerEvents: 'none',
            opacity: '0',
            transition: 'opacity 0.3s ease'
        });

        container.style.position = 'relative';
        container.appendChild(box);
        requestAnimationFrame(() => {
            box.style.opacity = '1';
        });
        setTimeout(() => {
            box.style.opacity = '0';
            setTimeout(() => box.remove(), 500);
        }, 3000);
    }

    function jumpToTimestamp(ts) {
        const video = document.querySelector('video');
        if (!video || video.duration < ts + 60 || video.currentTime >= ts || video.currentTime < 20) return;
        video.currentTime = ts;
        console.log(`[跳转成功] 已跳转至 ${formatTime(ts)}`);
    }

    function handleDanmakuMutations(mutationsList) {
        for (const mutation of mutationsList) {
            for (const node of mutation.addedNodes) {
                if (node._danmakuHandled) continue;
                node._danmakuHandled = true;
                const text = node.textContent.trim();
                if (text.length === 0 ||text === '9麤') continue;
                //console.log('>>>', text);
                //console.log('[弹幕节点识别]', text, 'from', node);
                const result = extractTimestamps(text);
                if (result) {
                    console.log('识别弹幕:', text, result.timestamp, result.isFuzzy ? '[模糊]' : '');
                    if (result.isFuzzy) {
                        fuzzyCandidates.push({ timestamp: result.timestamp, timeAdded: Date.now() });
                    } else {
                        pendingDanmaku.push({ text, timestamp: result.timestamp, hasLang: result.hasLang });
                    }
                }
            }
        }
    }

    function ObserveDanmaku(container) {
        console.log('[监听启动] 弹幕容器绑定成功');
        const observer = new MutationObserver(handleDanmakuMutations);
        observer.observe(container, { childList: true, subtree: true });
        setInterval(processPendingDanmaku, 2500);
    }

    function startObserveDanmakuOnceReady() {
        const check = setInterval(() => {
            const container = document.querySelector('div.bpx-player-render-dm-wrap > div.bpx-player-dm-mask-wrap > div.bpx-player-row-dm-wrap');
            if (container) {
                clearInterval(check);
                ObserveDanmaku(container);
            }
        }, 1000);
    }

    startObserveDanmakuOnceReady();
})();