DEOVRContentFilter

Filter videos by channel/keyword, with dropdown "Block Channel" button and menu commands to manage filter lists, plus import/export functionality.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         DEOVRContentFilter
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Filter videos by channel/keyword, with dropdown "Block Channel" button and menu commands to manage filter lists, plus import/export functionality.
// @author       Twine1481
// @match        https://deovr.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=deovr.com
// @grant        unsafeWindow
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @run-at       document-idle
// @license MIT
// ==/UserScript==

(function() {
    "use strict";

    const SCRIPT_PREFIX = "[DEOVRContentFilter]";
    console.log(`${SCRIPT_PREFIX} Script starting up...`);

    // -------------------------------------------------------------------------
    // 1) CONFIGURATION
    // -------------------------------------------------------------------------

    // Storage keys for persistence
    const STORAGE = {
        CHANNELS: "filteredChannels",
        KEYWORDS: "filteredKeywords"
    };

    // Default filter lists (content-agnostic)
    const DEFAULT_CONFIG = {
        // Add your default filtered channels here
        filteredChannels: [
            // Empty by default
        ],

        // Add your default filtered keywords here
        filteredKeywords: [
            // Empty by default
        ]
    };

    // -------------------------------------------------------------------------
    // 2) STATE MANAGEMENT
    // -------------------------------------------------------------------------

    /**
     * State management object for filter lists
     */
    const FilterState = {
        // Current filter lists
        filteredChannels: [],
        filteredKeywords: [],

        /**
         * Initialize filter state from storage
         */
        initialize() {
            // Load stored channels
            const storedChannels = GM_getValue(STORAGE.CHANNELS);
            const normalizedStoredChannels = Array.isArray(storedChannels)
                ? storedChannels.map(ch => ch.toLowerCase())
                : [];

            // Load stored keywords
            const storedKeywords = GM_getValue(STORAGE.KEYWORDS);
            const normalizedStoredKeywords = Array.isArray(storedKeywords)
                ? storedKeywords.map(kw => kw.toLowerCase())
                : [];

            // Merge default with stored values (removing duplicates)
            this.filteredChannels = [...new Set([
                ...DEFAULT_CONFIG.filteredChannels.map(ch => ch.toLowerCase()),
                ...normalizedStoredChannels
            ])];

            this.filteredKeywords = [...new Set([
                ...DEFAULT_CONFIG.filteredKeywords.map(kw => kw.toLowerCase()),
                ...normalizedStoredKeywords
            ])];

            // Log state
            this._logState();
        },

        /**
         * Add a channel to the filter list
         * @param {string} channel - Channel name to add
         * @returns {boolean} - Whether the channel was added
         */
        addChannel(channel) {
            if (!channel || typeof channel !== 'string') return false;

            const normalizedChannel = channel.toLowerCase().trim();
            if (!normalizedChannel) return false;

            if (this.filteredChannels.includes(normalizedChannel)) {
                return false; // Already in the list
            }

            this.filteredChannels.push(normalizedChannel);
            this._persistChannels();
            return true;
        },

        /**
         * Remove a channel from the filter list
         * @param {string} channel - Channel name to remove
         * @returns {boolean} - Whether the channel was removed
         */
        removeChannel(channel) {
            if (!channel || typeof channel !== 'string') return false;

            const normalizedChannel = channel.toLowerCase().trim();
            if (!normalizedChannel) return false;

            const initialLength = this.filteredChannels.length;
            this.filteredChannels = this.filteredChannels.filter(ch => ch !== normalizedChannel);

            if (this.filteredChannels.length !== initialLength) {
                this._persistChannels();
                return true;
            }

            return false;
        },

        /**
         * Add a keyword to the filter list
         * @param {string} keyword - Keyword to add
         * @returns {boolean} - Whether the keyword was added
         */
        addKeyword(keyword) {
            if (!keyword || typeof keyword !== 'string') return false;

            const normalizedKeyword = keyword.toLowerCase().trim();
            if (!normalizedKeyword) return false;

            if (this.filteredKeywords.includes(normalizedKeyword)) {
                return false; // Already in the list
            }

            this.filteredKeywords.push(normalizedKeyword);
            this._persistKeywords();
            return true;
        },

        /**
         * Remove a keyword from the filter list
         * @param {string} keyword - Keyword to remove
         * @returns {boolean} - Whether the keyword was removed
         */
        removeKeyword(keyword) {
            if (!keyword || typeof keyword !== 'string') return false;

            const normalizedKeyword = keyword.toLowerCase().trim();
            if (!normalizedKeyword) return false;

            const initialLength = this.filteredKeywords.length;
            this.filteredKeywords = this.filteredKeywords.filter(kw => kw !== normalizedKeyword);

            if (this.filteredKeywords.length !== initialLength) {
                this._persistKeywords();
                return true;
            }

            return false;
        },

        /**
         * Check if content should be filtered based on current filters
         * @param {string} text - Text content to check
         * @returns {Object} - Result with match details
         */
        shouldFilter(text) {
            if (!text || typeof text !== 'string') {
                return { shouldFilter: false };
            }

            const normalizedText = text.toLowerCase();

            // Check for channel match
            const channelMatch = this.filteredChannels.some(channel =>
                normalizedText.includes(channel));

            // Check for keyword match
            const keywordMatch = this.filteredKeywords.some(keyword =>
                normalizedText.includes(keyword));

            return {
                shouldFilter: channelMatch || keywordMatch,
                channelMatch,
                keywordMatch
            };
        },

        /**
         * Persist channels to storage
         * @private
         */
        _persistChannels() {
            GM_setValue(STORAGE.CHANNELS, this.filteredChannels);
        },

        /**
         * Persist keywords to storage
         * @private
         */
        _persistKeywords() {
            GM_setValue(STORAGE.KEYWORDS, this.filteredKeywords);
        },

        /**
         * Log current state to console
         * @private
         */
        _logState() {
            console.log(`${SCRIPT_PREFIX} Filtered Channels:`, this.filteredChannels);
            console.log(`${SCRIPT_PREFIX} Filtered Keywords:`, this.filteredKeywords);
        }
    };

    // -------------------------------------------------------------------------
    // 3) DOM INTERACTION
    // -------------------------------------------------------------------------

    /**
     * DOM utilities for filtering content and UI modifications
     */
    const DOMManager = {
        /**
         * Filter videos based on current filter state
         */
        filterContent() {
            console.log(`${SCRIPT_PREFIX} Filtering content...`);

            const articles = document.querySelectorAll("article");
            console.log(`${SCRIPT_PREFIX} Found ${articles.length} items to check.`);

            let filteredCount = 0;
            articles.forEach(article => {
                const articleText = article.textContent;
                const result = FilterState.shouldFilter(articleText);

                if (result.shouldFilter) {
                    article.style.display = "none";
                    filteredCount++;

                    console.log(
                        `${SCRIPT_PREFIX} Filtered item:`,
                        article,
                        `channelMatch=${result.channelMatch}`,
                        `keywordMatch=${result.keywordMatch}`
                    );
                }
            });

            console.log(`${SCRIPT_PREFIX} Filtered ${filteredCount} of ${articles.length} items.`);
        },

        /**
         * Inject "Block Channel" button into dropdown menu
         * @param {HTMLElement} dropdownMenu - The dropdown menu element
         */
        injectBlockButton(dropdownMenu) {
            // Avoid duplicates
            if (dropdownMenu.querySelector(".filter-option")) return;

            const listItem = document.createElement("li");
            listItem.classList.add("filter-option");
            listItem.innerHTML = `
                <div class="u-cursor--pointer u-fs--fo u-p--four u-lh--one u-block u-nowrap u-transition--base js-m-dropdown" data-qa="block-channel" style="display: flex; align-items: center;">
                    <span class="o-icon u-mr--three u-dg" style="width:18px;">
                        <svg class="o-icon" style="pointer-events: none; display: block; width: 100%; height: 100%;"
                             viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
                            <path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"></path>
                        </svg>
                    </span>
                    <span class="u-inline-block u-align-y--m u-mr--four u-ug u-fw--semibold u-uppercase hover:u-bl">Block Channel</span>
                </div>
            `;

            // Add click handler
            listItem.addEventListener("click", () => this._handleBlockChannelClick(dropdownMenu));

            // Add to dropdown
            dropdownMenu.appendChild(listItem);
            console.log(`${SCRIPT_PREFIX} Block button added to dropdown menu:`, dropdownMenu);
        },

        /**
         * Scan existing dropdowns for adding block buttons
         */
        scanExistingDropdowns() {
            console.log(`${SCRIPT_PREFIX} Scanning for existing dropdown menus...`);

            const dropdowns = document.querySelectorAll(
                "#content .m-dropdown.m-dropdown--grid-item .m-dropdown-content ul.u-list.u-l"
            );

            console.log(`${SCRIPT_PREFIX} Found ${dropdowns.length} existing dropdown menu(s).`);
            dropdowns.forEach(dropdown => this.injectBlockButton(dropdown));
        },

        /**
         * Set up observer for dynamically added dropdowns
         */
        setupDynamicObserver() {
            const contentElement = document.querySelector("#content") || document.body;

            const observer = new MutationObserver(mutations => {
                mutations.forEach(mutation => {
                    mutation.addedNodes.forEach(node => {
                        if (node.nodeType === Node.ELEMENT_NODE) {
                            // Check if the added node is a dropdown menu
                            if (node.matches && node.matches(".m-dropdown-content ul.u-list.u-l")) {
                                this.injectBlockButton(node);
                            } else {
                                // Check for dropdown menus within the added node
                                const nestedDropdowns = node.querySelectorAll ?
                                    node.querySelectorAll(".m-dropdown-content ul.u-list.u-l") :
                                    [];

                                nestedDropdowns.forEach(dropdown => this.injectBlockButton(dropdown));
                            }
                        }
                    });
                });
            });

            observer.observe(contentElement, { childList: true, subtree: true });
            console.log(`${SCRIPT_PREFIX} Dynamic observer set up for new dropdown menus.`);
        },

        /**
         * Handle click on "Block Channel" button
         * @private
         * @param {HTMLElement} dropdownMenu - The dropdown menu element
         */
        _handleBlockChannelClick(dropdownMenu) {
            console.log(`${SCRIPT_PREFIX} Block Channel button clicked.`);

            // Find parent article
            const article = dropdownMenu.closest("article");
            if (!article) {
                console.warn(`${SCRIPT_PREFIX} Could not find parent article.`);
                return;
            }

            // Find channel link
            const channelLink = article.querySelector('a[href^="/channel/"]');
            if (!channelLink) {
                console.warn(`${SCRIPT_PREFIX} No channel link found.`);
                return;
            }

            // Get channel name
            let channelName = channelLink.dataset.amplitudePropsChannel;
            if (!channelName || !channelName.trim()) {
                channelName = channelLink.textContent.trim();
            }
            channelName = channelName.toLowerCase();

            // Validate channel name
            if (channelName.length < 2) {
                if (!confirm(`Channel name is "${channelName}" (very short). Block anyway?`)) {
                    return;
                }
            }

            // Add to filter list
            if (FilterState.addChannel(channelName)) {
                alert(`Channel "${channelName}" added to block list.\nFiltering matching content...`);
                this.filterContent();
            } else {
                alert(`Channel "${channelName}" is already blocked.`);
            }
        }
    };

    // -------------------------------------------------------------------------
    // 4) USER INTERFACE - MENU COMMANDS
    // -------------------------------------------------------------------------

    /**
     * User interface for managing filter lists
     */
    const UserInterface = {
        /**
         * Register all Tampermonkey menu commands
         */
        registerMenuCommands() {
            GM_registerMenuCommand("Add Blocked Channel", () => this.addChannel());
            GM_registerMenuCommand("Remove Blocked Channel", () => this.removeChannel());
            GM_registerMenuCommand("Add Blocked Keyword", () => this.addKeyword());
            GM_registerMenuCommand("Remove Blocked Keyword", () => this.removeKeyword());
            GM_registerMenuCommand("Export Filter Lists", () => this.exportFilters());
            GM_registerMenuCommand("Import Filter Lists", () => this.importFilters());

            console.log(`${SCRIPT_PREFIX} Menu commands registered.`);
        },

        /**
         * Prompt user to add a channel to the filter list
         */
        addChannel() {
            const newChannel = prompt("Enter channel name to block:").trim();
            if (!newChannel) return;

            if (FilterState.addChannel(newChannel)) {
                alert(`Channel "${newChannel}" added. Reload the page to update.`);
            } else {
                alert(`Channel "${newChannel}" is already blocked.`);
            }
        },

        /**
         * Prompt user to remove a channel from the filter list
         */
        removeChannel() {
            if (FilterState.filteredChannels.length === 0) {
                alert("No blocked channels.");
                return;
            }

            const listStr = FilterState.filteredChannels.join("\n");
            const toRemove = prompt("Blocked channels:\n" + listStr + "\n\nEnter channel name to remove:");
            if (!toRemove) return;

            if (FilterState.removeChannel(toRemove)) {
                alert(`Channel "${toRemove}" removed. Reload the page to update.`);
            } else {
                alert(`Channel "${toRemove}" not found.`);
            }
        },

        /**
         * Prompt user to add a keyword to the filter list
         */
        addKeyword() {
            const newKeyword = prompt("Enter a keyword to block:").trim();
            if (!newKeyword) return;

            if (FilterState.addKeyword(newKeyword)) {
                alert(`Keyword "${newKeyword}" added. Reload the page to update.`);
            } else {
                alert(`Keyword "${newKeyword}" is already blocked.`);
            }
        },

        /**
         * Prompt user to remove a keyword from the filter list
         */
        removeKeyword() {
            if (FilterState.filteredKeywords.length === 0) {
                alert("No blocked keywords.");
                return;
            }

            const listStr = FilterState.filteredKeywords.join("\n");
            const toRemove = prompt("Blocked keywords:\n" + listStr + "\n\nEnter keyword to remove:");
            if (!toRemove) return;

            if (FilterState.removeKeyword(toRemove)) {
                alert(`Keyword "${toRemove}" removed. Reload the page to update.`);
            } else {
                alert(`Keyword "${toRemove}" not found.`);
            }
        },

        /**
         * Export filter lists to JSON file
         */
        exportFilters() {
            const data = {
                filteredChannels: FilterState.filteredChannels,
                filteredKeywords: FilterState.filteredKeywords
            };

            const json = JSON.stringify(data, null, 2);
            const blob = new Blob([json], { type: "application/json" });
            const url = URL.createObjectURL(blob);

            const downloadLink = document.createElement("a");
            downloadLink.href = url;
            downloadLink.download = "deovr_filter_lists.json";
            downloadLink.click();

            URL.revokeObjectURL(url);
        },

        /**
         * Import filter lists from JSON file
         * Uses a modal dialog approach to avoid browser security restrictions
         */
        importFilters() {
            console.log(`${SCRIPT_PREFIX} Starting import process with modal dialog...`);

            // Create modal container
            const modalOverlay = document.createElement('div');
            modalOverlay.style.cssText = `
                position: fixed;
                top: 0;
                left: 0;
                width: 100%;
                height: 100%;
                background-color: rgba(0, 0, 0, 0.5);
                z-index: 9999;
                display: flex;
                justify-content: center;
                align-items: center;
            `;

            // Create modal dialog
            const modalContent = document.createElement('div');
            modalContent.style.cssText = `
                background-color: white;
                padding: 20px;
                border-radius: 8px;
                max-width: 500px;
                width: 90%;
                box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
            `;

            // Create heading
            const heading = document.createElement('h2');
            heading.textContent = 'Import Filter Lists';
            heading.style.cssText = `
                margin-top: 0;
                margin-bottom: 15px;
                font-size: 18px;
            `;

            // Create description
            const description = document.createElement('p');
            description.textContent = 'Select a JSON file containing filter lists.';
            description.style.marginBottom = '20px';

            // Create file input
            const fileInput = document.createElement('input');
            fileInput.type = 'file';
            fileInput.accept = 'application/json';
            fileInput.style.display = 'block';
            fileInput.style.marginBottom = '15px';
            fileInput.style.width = '100%';

            // Create buttons container
            const buttonsContainer = document.createElement('div');
            buttonsContainer.style.cssText = `
                display: flex;
                justify-content: flex-end;
                gap: 10px;
            `;

            // Create cancel button
            const cancelButton = document.createElement('button');
            cancelButton.textContent = 'Cancel';
            cancelButton.style.cssText = `
                padding: 8px 16px;
                background-color: #f1f1f1;
                border: none;
                border-radius: 4px;
                cursor: pointer;
            `;

            // Create import button
            const importButton = document.createElement('button');
            importButton.textContent = 'Import';
            importButton.style.cssText = `
                padding: 8px 16px;
                background-color: #4285f4;
                color: white;
                border: none;
                border-radius: 4px;
                cursor: pointer;
            `;
            importButton.disabled = true;

            // Add event listener to enable import button when file is selected
            fileInput.addEventListener('change', () => {
                importButton.disabled = !fileInput.files || fileInput.files.length === 0;
            });

            // Add cancel button functionality
            cancelButton.addEventListener('click', () => {
                document.body.removeChild(modalOverlay);
            });

            // Add import button functionality
            importButton.addEventListener('click', () => {
                const file = fileInput.files[0];
                if (!file) return;

                console.log(`${SCRIPT_PREFIX} Reading file: ${file.name}`);

                const reader = new FileReader();

                // Handle file reading errors
                reader.onerror = error => {
                    console.error(`${SCRIPT_PREFIX} Error reading file:`, error);
                    alert(`Error reading file: ${error.message || "Unknown error"}`);
                    document.body.removeChild(modalOverlay);
                };

                // Process file contents when loaded
                reader.onload = e => {
                    console.log(`${SCRIPT_PREFIX} File read complete, processing content...`);

                    try {
                        const imported = JSON.parse(e.target.result);
                        console.log(`${SCRIPT_PREFIX} Parsed JSON:`, imported);

                        let updated = false;

                        // Support both new and old property names for backward compatibility

                        // Check for channels (new property name)
                        if (imported.filteredChannels && Array.isArray(imported.filteredChannels)) {
                            console.log(`${SCRIPT_PREFIX} Importing filtered channels:`, imported.filteredChannels);
                            FilterState.filteredChannels = imported.filteredChannels.map(ch => ch.toLowerCase());
                            FilterState._persistChannels();
                            updated = true;
                        }
                        // Check for channels (old property name from original script)
                        else if (imported.blockedChannels && Array.isArray(imported.blockedChannels)) {
                            console.log(`${SCRIPT_PREFIX} Importing blocked channels (legacy format):`, imported.blockedChannels);
                            FilterState.filteredChannels = imported.blockedChannels.map(ch => ch.toLowerCase());
                            FilterState._persistChannels();
                            updated = true;
                        }

                        // Check for keywords (new property name)
                        if (imported.filteredKeywords && Array.isArray(imported.filteredKeywords)) {
                            console.log(`${SCRIPT_PREFIX} Importing filtered keywords:`, imported.filteredKeywords);
                            FilterState.filteredKeywords = imported.filteredKeywords.map(kw => kw.toLowerCase());
                            FilterState._persistKeywords();
                            updated = true;
                        }
                        // Check for keywords (old property name from original script)
                        else if (imported.blockedWords && Array.isArray(imported.blockedWords)) {
                            console.log(`${SCRIPT_PREFIX} Importing blocked words (legacy format):`, imported.blockedWords);
                            FilterState.filteredKeywords = imported.blockedWords.map(kw => kw.toLowerCase());
                            FilterState._persistKeywords();
                            updated = true;
                        }

                        document.body.removeChild(modalOverlay);

                        if (updated) {
                            alert("Filter lists imported successfully. Reload the page to update.");
                        } else {
                            alert("No valid filter lists found in the imported file.");
                        }
                    } catch (error) {
                        console.error(`${SCRIPT_PREFIX} JSON parsing error:`, error);
                        alert(`Error importing JSON: ${error.message}`);
                        document.body.removeChild(modalOverlay);
                    }
                };

                // Start reading the file as text
                reader.readAsText(file);
            });

            // Assemble the modal
            buttonsContainer.appendChild(cancelButton);
            buttonsContainer.appendChild(importButton);

            modalContent.appendChild(heading);
            modalContent.appendChild(description);
            modalContent.appendChild(fileInput);
            modalContent.appendChild(buttonsContainer);

            modalOverlay.appendChild(modalContent);

            // Add modal to the page
            document.body.appendChild(modalOverlay);

            // Add click handler to close when clicking outside the modal
            modalOverlay.addEventListener('click', (event) => {
                if (event.target === modalOverlay) {
                    document.body.removeChild(modalOverlay);
                }
            });
        }
    };

    // -------------------------------------------------------------------------
    // 5) INITIALIZATION
    // -------------------------------------------------------------------------

    /**
     * Initialize the script
     */
    function initialize() {
        // Initialize state
        FilterState.initialize();

        // Register menu commands
        UserInterface.registerMenuCommands();

        // Initial content filtering
        DOMManager.filterContent();

        // Set up UI
        DOMManager.scanExistingDropdowns();
        DOMManager.setupDynamicObserver();

        console.log(`${SCRIPT_PREFIX} Script fully initialized!`);
    }

    // Start the script
    initialize();
})();