BilibiliSponsorBlock

Bilibili SponsorBlock - 跳过B站恰饭片段

// ==UserScript==
// @name         BilibiliSponsorBlock
// @namespace    https://github.com/hanydd/BilibiliSponsorBlock
// @version      0.8.5
// @description  Bilibili SponsorBlock - 跳过B站恰饭片段
// @author       hanydd (https://github.com/hanydd)
// @license      GPL-3.0
// @match        https://*.bilibili.com/video/*
// @icon         https://www.bilibili.com/favicon.ico
// @grant        GM_xmlhttpRequest
// @connect      bsbsb.top
// @run-at       document-start
// ==/UserScript==

/**
 * BilibiliSponsorBlock Userscript
 *
 * This userscript provides sponsor segment skipping functionality for Bilibili videos
 * by implementing a cross-platform Tampermonkey script that works on Chrome, Firefox, Safari, and other browsers.
 *
 * Main Features:
 * - Automatically skips sponsor segments in Bilibili videos
 * - Supports multiple segment categories (sponsor, self-promo, interaction, etc.)
 * - Cross-platform compatibility through Tampermonkey
 *
 * Architecture:
 * - Uses GM_xmlhttpRequest for API communication
 * - Implements segment detection and automatic skipping
 * - Handles single-page application navigation
 */

(function () {
    "use strict";

    // =============================================================================
    // HTTP REQUEST WRAPPER
    // =============================================================================

    /**
     * HTTP request wrapper using GM_xmlhttpRequest
     * Provides fetch-like interface for making API requests
     * @param {string} method - HTTP method (GET, POST, etc.)
     * @param {string} url - Request URL
     * @param {Object|string} data - Request data
     * @returns {Promise} Promise resolving to response object
     */
    function makeRequest(method, url, data) {
        return new Promise((resolve, reject) => {
            const details = {
                method: method,
                url: url,
                onload: function (response) {
                    // Create fetch-like response object
                    resolve({
                        ok: response.status >= 200 && response.status < 300,
                        status: response.status,
                        text: () => Promise.resolve(response.responseText),
                        json: () => Promise.resolve(JSON.parse(response.responseText)),
                    });
                },
                onerror: function (error) {
                    reject(error);
                },
            };

            if (method.toUpperCase() !== "GET" && data) {
                details.data = typeof data === "string" ? data : JSON.stringify(data);
                details.headers = {
                    "Content-Type": "application/json",
                };
            }

            GM_xmlhttpRequest(details);
        });
    }

    // =============================================================================
    // CONFIGURATION SYSTEM
    // =============================================================================

    /**
     * Configuration management class
     * Handles userscript settings and category preferences
     */
    class UserscriptConfig {
        constructor() {
            this.loadDefaults();
        }

        /**
         * Load default configuration values
         * Sets up server address and category skip preferences
         */
        loadDefaults() {
            // Configuration with server address and category skip settings
            this.config = {
                // Server configuration
                serverAddress: "https://bsbsb.top",

                // Category skip settings - determines which segment types to skip
                categorySelections: {
                    sponsor: { skip: true }, // 赞助商片段
                    selfpromo: { skip: true }, // 自我推广
                    interaction: { skip: true }, // 互动提醒
                    intro: { skip: false }, // 开场
                    outro: { skip: false }, // 结尾
                    preview: { skip: false }, // 预览
                    music_offtopic: { skip: false }, // 音乐片段
                    filler: { skip: false }, // 填充内容
                },
            };
        }

        /**
         * Get configuration value
         * @param {string} key - Configuration key
         * @returns {*} Configuration value
         */
        get(key) {
            return this.config[key];
        }
    }

    // Initialize global configuration instance
    const Config = new UserscriptConfig();

    // =============================================================================
    // PAGE DETECTION UTILITIES
    // =============================================================================

    /**
     * Extract Bilibili video ID from current URL
     * @returns {string|null} Video ID (BVID) or null if not found
     */
    function getBilibiliVideoID() {
        // Match different Bilibili URL patterns
        const patterns = [
            /\/video\/([^/?#]+)/, // /video/BV1234567890
            /\/BV([^/?#]+)/, // /BV1234567890
            /bvid=([^&]+)/, // ?bvid=BV1234567890
        ];

        const url = window.location.href;
        for (const pattern of patterns) {
            const match = url.match(pattern);
            if (match) {
                let videoId = match[1];
                // Ensure BV prefix
                if (!videoId.startsWith("BV")) {
                    videoId = "BV" + videoId;
                }
                return videoId;
            }
        }
        return null;
    }

    // =============================================================================
    // HASHING UTILITY
    // =============================================================================

    /**
     * SHA-256 hash function using Web Crypto API
     * @param {string} value - String to hash
     * @returns {Promise<string>} Hash string in hexadecimal format
     */
    async function sha256Hash(value) {
        try {
            // Check if crypto.subtle is available (required for hash functions)
            if (!crypto || !crypto.subtle) {
                console.error("[BSB] crypto.subtle is not available. Hash functions will not work.");
                return value; // Fallback to original value
            }

            const encoder = new TextEncoder();
            const data = encoder.encode(value);
            const hashBuffer = await crypto.subtle.digest("SHA-256", data);
            const hashArray = Array.from(new Uint8Array(hashBuffer));
            return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
        } catch (error) {
            console.error("[BSB] Error hashing value:", error);
            return value; // Fallback to original value
        }
    }

    /**
     * Get video ID hash for API requests
     * @param {string} videoID - Video ID to hash
     * @returns {Promise<string>} Hashed video ID
     */
    async function getVideoIDHash(videoID) {
        return await sha256Hash(videoID);
    }

    // =============================================================================
    // SEGMENT SKIPPING FUNCTIONALITY
    // =============================================================================

    /**
     * Main class for handling segment detection and skipping
     * Manages video segment loading, detection, and automatic skipping
     */
    class SegmentSkipper {
        constructor() {
            console.log("[BSB] SegmentSkipper instance created.");
            this.segments = []; // Array of skip segments for current video
            this.currentVideo = null; // Current video ID being processed
            this.videoElement = null; // Reference to HTML video element
            this.lastCheckedTime = 0; // Last time we checked for skipping (performance optimization)
        }

        /**
         * Load skip segments for a specific video from the API
         * @param {string} videoID - Bilibili video ID (BVID)
         */
        async loadSegments(videoID) {
            if (!videoID) return;

            console.log(`[BSB] Loading segments for video: ${videoID}`);
            try {
                // Hash the video ID and get first 4 characters for API request
                const videoIDHash = await getVideoIDHash(videoID);
                const hashPrefix = videoIDHash.slice(0, 4);

                console.log(`[BSB] Using hash prefix: ${hashPrefix} for video: ${videoID}`);

                // Make API request to get skip segments using hash prefix
                const response = await makeRequest(
                    "GET",
                    `${Config.get("serverAddress")}/api/skipSegments/${hashPrefix}`
                );

                if (response.ok) {
                    const allSegments = await response.json();
                    console.log("[BSB] API response received:", allSegments);

                    // Filter segments for this specific video ID
                    this.segments = [];
                    for (const segmentResponse of allSegments) {
                        if (segmentResponse.videoID === videoID && segmentResponse.segments) {
                            // Filter segments by enabled categories and action types
                            const filteredSegments = segmentResponse.segments.filter((segment) => {
                                const categorySettings = Config.get("categorySelections")[segment.category];
                                const enabledActionTypes = ["skip", "poi"]; // ActionType.Skip, ActionType.Poi

                                return (
                                    categorySettings &&
                                    categorySettings.skip &&
                                    enabledActionTypes.includes(segment.actionType)
                                );
                            });

                            this.segments = filteredSegments;
                            break;
                        }
                    }

                    console.log("[BSB] Segments loaded and filtered:", this.segments);
                } else {
                    console.error(`[BSB] Failed to load segments. Status: ${response.status}`);
                    this.segments = [];
                }
            } catch (error) {
                console.error("[BSB] Error loading segments:", error);
                this.segments = [];
            }
        }

        /**
         * Check if current video time is within any skip segment
         * Called repeatedly during video playback via timeupdate event
         */
        checkForSkip() {
            // Don't check if no video element or segments available
            if (!this.videoElement || this.segments.length === 0) return;

            const currentTime = this.videoElement.currentTime;

            // Performance optimization: only check every 0.5 seconds
            // This prevents excessive processing while maintaining accuracy
            if (Math.abs(currentTime - this.lastCheckedTime) < 0.5) return;
            this.lastCheckedTime = currentTime;

            // Check each segment to see if we should skip
            for (const segment of this.segments) {
                const [startTime, endTime] = segment.segment;
                const category = segment.category;

                // Check if user has enabled skipping for this category
                const categorySettings = Config.get("categorySelections")[category];
                if (!categorySettings?.skip) continue;

                // Check if current playback time is within this segment
                if (currentTime >= startTime && currentTime < endTime) {
                    console.log(
                        `[BSB] Skipping ${category} segment: ${this.formatTime(startTime)} - ${this.formatTime(
                            endTime
                        )}`
                    );
                    this.skipToTime(endTime);
                    break; // Only skip one segment at a time
                }
            }
        }

        /**
         * Skip video to specified time
         * @param {number} time - Time in seconds to skip to
         */
        skipToTime(time) {
            if (this.videoElement) {
                this.videoElement.currentTime = time;
                console.log(`[BSB] Skipped to: ${this.formatTime(time)}`);
            }
        }

        /**
         * Format time duration in MM:SS format
         * @param {number} seconds - Duration in seconds
         * @returns {string} Formatted time string
         */
        formatTime(seconds) {
            const min = Math.floor(seconds / 60);
            const sec = Math.floor(seconds % 60);
            return `${min}:${sec.toString().padStart(2, "0")}`;
        }

        /**
         * Initialize the segment skipper
         * Sets up video element detection and event listeners
         */
        init() {
            console.log("[BSB] Initializing segment skipper...");
            // Function to check for video element periodically
            const checkForVideo = () => {
                // Try multiple video selectors to find the video element
                this.videoElement =
                    document.querySelector("video") ||
                    document.querySelector(".bpx-player-video-area video") ||
                    document.querySelector(".bilibili-player video");

                if (this.videoElement) {
                    console.log("[BSB] Video element found, setting up event listeners.");
                    // Remove existing event listeners to avoid duplicates
                    this.videoElement.removeEventListener("timeupdate", this.checkForSkip);

                    // Add timeupdate event listener to check for segments to skip
                    this.videoElement.addEventListener("timeupdate", () => {
                        this.checkForSkip();
                    });

                    // Load segments when video changes
                    const videoID = getBilibiliVideoID();
                    if (videoID && videoID !== this.currentVideo) {
                        this.currentVideo = videoID;
                        this.loadSegments(videoID);
                    }
                } else {
                    // Video element not found, try again in 1 second
                    setTimeout(checkForVideo, 1000);
                }
            };

            // Start checking for video element
            checkForVideo();
        }
    }

    // =============================================================================
    // MAIN INITIALIZATION
    // =============================================================================

    /**
     * Initialize the userscript
     * Main entry point that sets up all functionality
     */
    function init() {
        console.log("[BSB] BilibiliSponsorBlock userscript starting...");
        console.log("[BSB] Current URL:", window.location.href);
        console.log("[BSB] Configuration:", Config.config);

        // Extract video ID from current page
        const videoID = getBilibiliVideoID();
        console.log(`[BSB] Video ID detected: ${videoID}`);

        // Test crypto availability
        if (!crypto || !crypto.subtle) {
            console.error("[BSB] WARNING: crypto.subtle not available. Userscript may not work correctly.");
        } else {
            console.log("[BSB] crypto.subtle available.");
        }

        // Initialize segment skipper
        const skipper = new SegmentSkipper();
        skipper.init();

        // Handle page navigation for Single Page Application (SPA)
        let currentURL = window.location.href;

        const handleNavigation = () => {
            if (window.location.href !== currentURL) {
                currentURL = window.location.href;
                console.log(`[BSB] Navigation detected. New URL: ${currentURL}`);
                // Reinitialize skipper after navigation
                setTimeout(() => {
                    skipper.init();
                }, 1000);
            }
        };

        // Listen for popstate events (back/forward navigation)
        window.addEventListener("popstate", handleNavigation);

        // Check for URL changes periodically for SPA navigation
        setInterval(handleNavigation, 3000);
    }

    // =============================================================================
    // USERSCRIPT STARTUP
    // =============================================================================

    // Wait for DOM to be ready before initializing
    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", init);
    } else {
        // DOM is already ready, initialize immediately
        init();
    }
})();

QingJ © 2025

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