Block YouTube Videos by Titles, Keywords and Channels

Blocks videos by title keywords and channels (Right-click to select channel)

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Block YouTube Videos by Titles, Keywords and Channels
// @namespace    http://tampermonkey.net/
// @version      1.5
// @description  Blocks videos by title keywords and channels (Right-click to select channel)
// @author       Superflyin
// @match        *://www.youtube.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_addStyle
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // --- Configuration ---
    const STORAGE_KEY_TITLES = "blockedTitleKeywords";
    const STORAGE_KEY_CHANNELS = "blockedChannelNames";
    const HEADER_BUTTON_ID = "yt-blocker-header-btn";
    const SETTINGS_MODAL_ID = "yt-blocker-settings-overlay";

    let processedKeywordSets = [];
    let blockedChannelNames = [];
    let lastRightClickedName = null;

    // --- === SVG Icon === ---
    // This is the "block" icon
    const BLOCK_ICON_SVG = `
        <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="currentColor">
            <path d="M0 0h24v24H0V0z" fill="none"/>
            <path 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-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zM17 7.5L7.5 17 6 15.5 15.5 6 17 7.5z"/>
        </svg>
    `;

    // --- === New Settings Modal CSS === ---
    GM_addStyle(`
        #${SETTINGS_MODAL_ID} {
            position: fixed;
            top: 0;
            left: 0;
            width: 100vw;
            height: 100vh;
            background: rgba(0, 0, 0, 0.7);
            z-index: 2000;
            display: none;
            align-items: center;
            justify-content: center;
            font-family: "Roboto", "Arial", sans-serif;
        }
        #yt-blocker-settings-content {
            background: var(--yt-spec-brand-background-solid, #282828);
            color: var(--yt-spec-text-primary, #fff);
            border-radius: 12px;
            padding: 24px;
            width: 90%;
            max-width: 600px; /* Wider for two columns */
            z-index: 2001;
        }
        #yt-blocker-settings-content h2 {
            font-size: 20px;
            font-weight: 600;
            margin-top: 0;
            margin-bottom: 20px;
        }
        .yt-blocker-settings-section {
            display: flex;
            flex-direction: column;
            margin-bottom: 16px;
        }
        .yt-blocker-settings-section label {
            font-size: 16px;
            font-weight: 500;
            margin-bottom: 4px;
        }
        .yt-blocker-settings-section p {
            font-size: 13px;
            color: var(--yt-spec-text-secondary, #aaa);
            margin: 0 0 8px 0;
            line-height: 1.4;
        }
        .yt-blocker-settings-section p code {
            background-color: var(--yt-spec-badge-chip-background, #383838);
            color: var(--yt-spec-text-secondary, #aaa);
            padding: 2px 4px;
            border-radius: 4px;
            font-size: 0.9em;
        }
        .yt-blocker-textarea {
            width: 100%;
            height: 200px;
            background: var(--yt-spec-badge-chip-background, #383838);
            color: var(--yt-spec-text-primary, #fff);
            border: 1px solid var(--yt-spec-text-disabled, #717171);
            border-radius: 8px;
            padding: 10px;
            font-family: monospace;
            font-size: 14px;
            resize: vertical;
        }
        .yt-blocker-settings-buttons {
            display: flex;
            justify-content: flex-end;
            margin-top: 20px;
        }
        .yt-blocker-settings-btn {
            padding: 10px 20px;
            border: none;
            border-radius: 20px;
            cursor: pointer;
            font-weight: 600;
            margin-left: 10px;
        }
        .yt-blocker-btn-close {
            background: var(--yt-spec-badge-chip-background, #383838);
            color: var(--yt-spec-text-primary, #fff);
        }
        .yt-blocker-btn-save {
            background: var(--yt-spec-call-to-action, #3ea6ff);
            color: var(--yt-spec-always-black, #000);
        }

        /* Style for the new header button */
        #${HEADER_BUTTON_ID} {
            background: none;
            border: none;
            cursor: pointer;
            color: var(--yt-spec-text-primary, #fff);
            padding: 8px;
            margin: 0 8px;
            border-radius: 50%;
        }
        #${HEADER_BUTTON_ID}:hover {
            background-color: var(--yt-spec-badge-chip-background, #383838);
        }
    `);


    // --- === New Settings Modal Functions === ---

    function injectSettingsModal() {
        if (document.getElementById(SETTINGS_MODAL_ID)) return;

        const modalHTML = `
            <div id="${SETTINGS_MODAL_ID}">
                <div id="yt-blocker-settings-content">
                    <h2>Blocker Settings</h2>

                    <div class="yt-blocker-settings-section">
                        <label for="yt-blocker-titles-textarea">Block by Title Keywords</label>
                        <p>One entry per line. <br>
                           - <code>daily vlog</code> (no quotes): Blocks if title contains "daily" AND "vlog".<br>
                           - <code>"daily vlog"</code> (with quotes): Blocks if title contains the exact phrase "daily vlog".
                        </p>
                        <textarea id="yt-blocker-titles-textarea" class="yt-blocker-textarea"></textarea>
                    </div>

                    <div class="yt-blocker-settings-section">
                        <label for="yt-blocker-channels-textarea">Block by Channel Names</label>
                        <p>One channel name per line (e.g., <code>MrBeast</code>). Not case-sensitive. Do not include the <code>@</code> symbol.</p>
                        <textarea id="yt-blocker-channels-textarea" class="yt-blocker-textarea"></textarea>
                    </div>

                    <div class="yt-blocker-settings-buttons">
                        <button id="yt-blocker-settings-cancel" class="yt-blocker-settings-btn yt-blocker-btn-close">Cancel</button>
                        <button id="yt-blocker-settings-save" class="yt-blocker-settings-btn yt-blocker-btn-save">Save</button>
                    </div>
                </div>
            </div>
        `;
        document.body.insertAdjacentHTML('beforeend', modalHTML);

        // Add event listeners
        document.getElementById('yt-blocker-settings-cancel').addEventListener('click', closeSettingsModal);
        document.getElementById('yt-blocker-settings-save').addEventListener('click', saveSettings);
        document.getElementById(SETTINGS_MODAL_ID).addEventListener('click', (e) => {
            if (e.target.id === SETTINGS_MODAL_ID) {
                closeSettingsModal();
            }
        });
    }

    function openSettingsModal() {
        // Load current settings into textareas
        const currentTitles = GM_getValue(STORAGE_KEY_TITLES, []).join("\n");
        const currentChannels = GM_getValue(STORAGE_KEY_CHANNELS, []).join("\n");

        document.getElementById('yt-blocker-titles-textarea').value = currentTitles;
        document.getElementById('yt-blocker-channels-textarea').value = currentChannels;

        // Show modal
        document.getElementById(SETTINGS_MODAL_ID).style.display = 'flex';
    }

    function closeSettingsModal() {
        document.getElementById(SETTINGS_MODAL_ID).style.display = 'none';
    }

    function saveSettings() {
        // Save Title Keywords
        const newTitles = document.getElementById('yt-blocker-titles-textarea').value
            .split('\n')
            .map(k => k.trim()) // Keep original case for quote-checking
            .filter(k => k.length > 0);
        GM_setValue(STORAGE_KEY_TITLES, [...new Set(newTitles)]);

        // Save Channel Names
        const newChannels = document.getElementById('yt-blocker-channels-textarea').value
            .split('\n')
            .map(h => h.trim().toLowerCase()) // Always save as lowercase
            .filter(h => h.length > 0);
        GM_setValue(STORAGE_KEY_CHANNELS, [...new Set(newChannels)]);

        // Reload internal state and re-run blocker
        loadTitleKeywords();
        loadChannelNames();
        blockContent();

        // Close modal
        closeSettingsModal();
    }

    // --- === New Header Button Function === ---

    function injectHeaderButton() {
        // Check if button already exists
        if (document.getElementById(HEADER_BUTTON_ID)) {
            return;
        }

        // More robust injection logic for Firefox
        const headerButtonsContainer = document.querySelector('ytd-masthead #end');

        if (headerButtonsContainer) {
            // Create the button
            const newButton = document.createElement('button');
            newButton.id = HEADER_BUTTON_ID;
            newButton.title = "Blocker Settings";
            newButton.innerHTML = BLOCK_ICON_SVG;
            newButton.addEventListener('click', openSettingsModal);

            // Prepend the button to the container.
            // This makes it the first item in the top-right cluster.
            headerButtonsContainer.prepend(newButton);
        }
    }

    // --- === Load Keywords/Names === ---

    function loadTitleKeywords() {
        const storedKeywordEntries = GM_getValue(STORAGE_KEY_TITLES, []);
        // Add logic to support both "phrase" and "all words"
        processedKeywordSets = storedKeywordEntries.map(entry => {
            entry = entry.trim();
            // Check if entry is a phrase in quotes
            if (entry.startsWith('"') && entry.endsWith('"')) {
                // It's a phrase. Return it as a single-item array.
                // Remove quotes and convert to lowercase.
                return [entry.substring(1, entry.length - 1).toLowerCase()];
            } else {
                // It's a set of keywords. Split by space.
                return entry.toLowerCase().split(' ')
                    .map(k => k.trim())
                    .filter(k => k.length > 0);
            }
        });
    }

    function loadChannelNames() {
        const storedNames = GM_getValue(STORAGE_KEY_CHANNELS, []);
        blockedChannelNames = storedNames.map(h => h.toLowerCase());
    }

    // --- === Helper Function: Get Channel Name === ---
    function getChannelNameFromContainer(container) {
        // This selector list covers grid videos, list videos, suggested videos,
        // shorts, community posts, and the main video owner.
        const channelSelector = [
            'ytd-channel-name a', // Standard video meta
            'a.yt-core-attributed-string__link[href*="/@"]', // New handle link
            'a.yt-core-attributed-string__link[href*="/c/"]', // Old custom URL
            'a.yt-core-attributed-string__link[href*="/user/"]', // Old username URL
            'yt-formatted-string#channel-name', // Compact video (sidebar)
            'span#author-name', // Shorts
            'div#author-name', // Shorts (alternative)
            'a#author-text', // Community posts
            // Selector for old suggested video layout (from user snippet)
            'div.yt-content-metadata-view-model__metadata-row > span.yt-content-metadata-view-model__metadata-text'
        ].join(', ');

        const channelElement = container.querySelector(channelSelector);
        let channelName = null;

        if (channelElement) {
            let name = "";
            // Community posts have text in a child span
            if (channelElement.matches('a#author-text')) {
                const nameSpan = channelElement.querySelector('span.yt-core-attributed-string');
                name = nameSpan ? nameSpan.textContent : channelElement.textContent;
            }
            // Most other links/spans have the name as the first text node (to avoid "Verified" badges)
            else if (channelElement.childNodes.length > 0 && channelElement.childNodes[0].nodeType === Node.TEXT_NODE) {
                name = channelElement.childNodes[0].textContent;
            }
            // Fallback to all text content
            else {
                name = channelElement.textContent;
            }
            // Filter out view counts, etc. that might be caught by the new selector
            if (!name.match(/view/i) && !name.match(/ago/i)) {
                 channelName = name.trim().toLowerCase();
            }
        }
        return channelName;
    }


    // --- === Quick Block (Right-Click) Functions === ---

    function captureRightClick(event) {
        lastRightClickedName = null;
        let channelName = null;

        // Reverted the container query to only use custom element tag names.
        // This is more stable and prevents matching child elements.
        const container = event.target.closest(
            'yt-lockup-view-model, ytd-rich-grid-media, ytd-rich-item-renderer, ' +
            'ytd-video-renderer, ytd-grid-video-renderer, ytd-compact-video-renderer, ' +
            'ytd-playlist-panel-video-renderer, ytd-reel-item-renderer, ytd-playlist-renderer, ' +
            'ytd-ad-slot-renderer, ytd-promoted-video-renderer, ytd-display-ad-renderer, ytd-post-renderer'
        );

        if (container) {
            channelName = getChannelNameFromContainer(container);
        } else if (window.location.href.includes("/watch")) {
             // Main video page
             const ownerContainer = document.querySelector('ytd-video-owner-renderer');
             if (ownerContainer) {
                 channelName = getChannelNameFromContainer(ownerContainer);
             }
        }

        if (channelName && channelName.length > 0) {
            lastRightClickedName = channelName;
        }
    }

    function blockCurrentChannel() {
        if (!lastRightClickedName) {
             // Updated error message text
             showCustomAlert("Error: Could not find a channel to block.\n\n" +
                   "Please right-click *on the video or post* you wish to block, then select 'Block This Channel' from the Tampermonkey menu.");
             return;
        }

        const nameToBlock = lastRightClickedName;
        lastRightClickedName = null;

        const currentNames = GM_getValue(STORAGE_KEY_CHANNELS, []);
        if (currentNames.includes(nameToBlock)) {
            showCustomAlert("Channel '" + nameToBlock + "' is already on your blocklist.");
            return;
        }

        // Use a custom confirm instead of window.confirm
        showCustomConfirm("Block this channel: '" + nameToBlock + "'?\n\nThis will hide all videos from this channel.", () => {
            currentNames.push(nameToBlock);
            GM_setValue(STORAGE_KEY_CHANNELS, [...new Set(currentNames)]);
            loadChannelNames();
            blockContent();
        });
    }


    // --- === Main Blocker Function === ---

    function blockContent() {
        if (processedKeywordSets.length === 0 && blockedChannelNames.length === 0) {
            return;
        }

        // This list matches the right-click list.
        const containers = document.querySelectorAll(
            'yt-lockup-view-model, ytd-rich-grid-media, ytd-rich-item-renderer, ' +
            'ytd-video-renderer, ytd-grid-video-renderer, ytd-compact-video-renderer, ' +
            'ytd-playlist-panel-video-renderer, ytd-reel-item-renderer, ytd-playlist-renderer, ' +
            'ytd-ad-slot-renderer, ytd-promoted-video-renderer, ytd-display-ad-renderer, ytd-post-renderer'
        );

        containers.forEach(container => {
            if (container.style.display === 'none') { return; }
            let shouldBlock = false;

            // 1. Title Keyword Check
            if (processedKeywordSets.length > 0) {
                // --- MODIFICATION START (v8.8) ---
                // Re-ordered selectors to prioritize subscription/grid titles
                // before falling back to the generic 'a#video-title'.
                const titleElement = container.querySelector(
                    'a#video-title-link', // Grid view (subscriptions) link
                    'yt-formatted-string#video-title', // Grid view (subscriptions) text
                    'a#video-title', // Standard, compact, grid
                    '.yt-lockup-metadata-view-model__title', // Old lockup
                    '#ad-title', // Ads
                    '#title a', // Ads
                    '.title', // Ads
                    'span#content-text', // Community posts
                    '#post-text', // Community posts (alt)
                    '#content' // Community posts (alt)
                );
                // --- MODIFICATION END (v8.8) ---
                if (titleElement) {
                    const titleText = (titleElement.getAttribute('aria-label') || titleElement.title || titleElement.textContent || "").toLowerCase();
                    if (titleText) {
                        for (const keywordSet of processedKeywordSets) {
                            // This now checks if ALL keywords in the set are present
                            // This supports both "phrase" (1 item) and "all words" (N items)
                            if (keywordSet.every(keyword => titleText.includes(keyword))) {
                                shouldBlock = true;
                                break;
                            }
                        }
                    }
                }
            }

            // 2. Channel Name Check
            if (!shouldBlock && blockedChannelNames.length > 0) {
                const channelName = getChannelNameFromContainer(container);
                if (channelName && blockedChannelNames.includes(channelName)) {
                    shouldBlock = true;
                }
            }

            // 3. Block Action
            if (shouldBlock) {
                container.style.display = 'none';
            }
        });
    }

    // --- === Custom Alert/Confirm Modals === ---
    // (To avoid issues with browser alert/confirm)

    function injectAlertModal() {
        if (document.getElementById('yt-blocker-alert-overlay')) return;
        const alertHTML = `
            <div id="yt-blocker-alert-overlay" style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.7); z-index: 2002; justify-content: center; align-items: center;">
                <div id="yt-blocker-alert-content" style="background: var(--yt-spec-brand-background-solid, #282828); color: var(--yt-spec-text-primary, #fff); border-radius: 12px; padding: 24px; width: 90%; max-width: 450px; z-index: 2003; font-family: 'Roboto', 'Arial', sans-serif;">
                    <p id="yt-blocker-alert-text" style="font-size: 16px; margin: 0 0 20px 0; line-height: 1.5; white-space: pre-wrap;"></p>
                    <div id="yt-blocker-alert-buttons" style="display: flex; justify-content: flex-end;">
                        <button id="yt-blocker-alert-ok" class="yt-blocker-settings-btn yt-blocker-btn-save" style="margin-left: 0;">OK</button>
                    </div>
                    <div id="yt-blocker-confirm-buttons" style="display: none; justify-content: flex-end;">
                        <button id="yt-blocker-confirm-cancel" class="yt-blocker-settings-btn yt-blocker-btn-close">Cancel</button>
                        <button id="yt-blocker-confirm-ok" class="yt-blocker-settings-btn yt-blocker-btn-save">OK</button>
                    </div>
                </div>
            </div>
        `;
        document.body.insertAdjacentHTML('beforeend', alertHTML);

        document.getElementById('yt-blocker-alert-ok').addEventListener('click', closeCustomAlert);
        document.getElementById('yt-blocker-confirm-cancel').addEventListener('click', closeCustomAlert);
        document.getElementById('yt-blocker-alert-overlay').addEventListener('click', (e) => {
            if (e.target.id === 'yt-blocker-alert-overlay') {
                closeCustomAlert();
            }
        });
    }

    function showCustomAlert(message) {
        injectAlertModal();
        document.getElementById('yt-blocker-alert-text').textContent = message;
        document.getElementById('yt-blocker-alert-buttons').style.display = 'flex';
        document.getElementById('yt-blocker-confirm-buttons').style.display = 'none';
        document.getElementById('yt-blocker-alert-overlay').style.display = 'flex';
    }

    function showCustomConfirm(message, callback) {
        injectAlertModal();
        document.getElementById('yt-blocker-alert-text').textContent = message;
        document.getElementById('yt-blocker-alert-buttons').style.display = 'none';
        document.getElementById('yt-blocker-confirm-buttons').style.display = 'flex';

        // Remove old listener and add new one
        const confirmOk = document.getElementById('yt-blocker-confirm-ok');
        const newConfirmOk = confirmOk.cloneNode(true);
        confirmOk.parentNode.replaceChild(newConfirmOk, confirmOk);
        newConfirmOk.addEventListener('click', () => {
            closeCustomAlert();
            callback();
        });

        document.getElementById('yt-blocker-alert-overlay').style.display = 'flex';
    }

    function closeCustomAlert() {
        document.getElementById('yt-blocker-alert-overlay').style.display = 'none';
    }


    // --- --- Script Start --- ---

    // Inject all modals (they start hidden)
    injectSettingsModal();
    injectAlertModal();

    // Load blocklists from storage
    loadTitleKeywords();
    loadChannelNames();

    // Register ONLY the right-click menu command
    GM_registerMenuCommand("Block This Channel", blockCurrentChannel);

    // Add listener for right-clicks
    document.addEventListener('mousedown', (e) => {
        if (e.button === 2) { // 2 is for right-click
            captureRightClick(e);
        }
    }, true);

    // Run tasks repeatedly
    function runTasks() {
        injectHeaderButton(); // Constantly check if header button needs to be re-injected
        blockContent();       // Constantly check for new content to block
    }

    setInterval(runTasks, 500);

})();