Reddit Advanced Content Filter

Automatically hides posts in your Reddit feed based on keywords or subreddits you specify. Supports whitelist entries that override filtering.

// ==UserScript==
// @name         Reddit Advanced Content Filter
// @namespace    https://gf.qytechs.cn/en/users/567951-stuart-saddler
// @version      2.8
// @description  Automatically hides posts in your Reddit feed based on keywords or subreddits you specify. Supports whitelist entries that override filtering.
// @author       ...
// @license      MIT
// @icon         https://clipart-library.com/images_k/smoke-clipart-transparent/smoke-clipart-transparent-6.png
// @supportURL   https://gf.qytechs.cn/en/users/567951-stuart-saddler
// @match        *://www.reddit.com/*
// @match        *://old.reddit.com/*
// @run-at       document-end
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// ==/UserScript==

(async function () {
    'use strict';

    console.log('[DEBUG] Script started. Reddit Advanced Content Filter.');

    // -----------------------------------------------
    // Utility: Debounce function to prevent spam calls
    // -----------------------------------------------
    function debounce(func, wait) {
        let timeout;
        return (...args) => {
            clearTimeout(timeout);
            timeout = setTimeout(() => func.apply(this, args), wait);
        };
    }

    // -----------------------
    // Selectors & Script Vars
    // -----------------------
    // NOTE: .thing => old.reddit.com
    //       article, div[data-testid="post-container"], shreddit-post => new.reddit.com
    const postSelector = 'article, div[data-testid="post-container"], shreddit-post, .thing';

    let filteredCount = 0;
    let menuCommand = null; // track the menu command ID, so we can unregister if needed
    let processedPosts = new WeakSet();

    let blocklistSet = new Set();
    let keywordPattern = null;

    let whitelistSet = new Set();
    let whitelistPattern = null;

    let pendingUpdates = 0;

    // -----------------------------------
    // Attempt to (re)register the menu item
    // -----------------------------------
    function updateMenuEntry() {
        // If GM_registerMenuCommand is unavailable, just ensure fallback button is present
        if (typeof GM_registerMenuCommand !== 'function') {
            createFallbackButton();
            return;
        }

        // If it is available, let's try to unregister the old one (if supported)
        try {
            if (menuCommand !== null && typeof GM_unregisterMenuCommand === 'function') {
                GM_unregisterMenuCommand(menuCommand);
            }
        } catch (err) {
            // Some userscript managers might not support GM_unregisterMenuCommand at all
            console.warn('[DEBUG] Could not unregister menu command:', err);
        }

        // Register the new menu command with updated blocked count
        menuCommand = GM_registerMenuCommand(`Configure Filter (${filteredCount} blocked)`, showConfig);
    }

    // ----------------------------------------
    // Fallback Button (if menu is unsupported)
    // ----------------------------------------
    function createFallbackButton() {
        // Check if it’s already on the page
        if (document.getElementById('reddit-filter-fallback-btn')) {
            // Just update the label with the new count
            document.getElementById('reddit-filter-fallback-btn').textContent = `Configure Filter (${filteredCount} blocked)`;
            return;
        }

        // Otherwise create a brand new button
        const button = document.createElement('button');
        button.id = 'reddit-filter-fallback-btn';
        button.textContent = `Configure Filter (${filteredCount} blocked)`;
        button.style.cssText = 'position:fixed;top:10px;right:10px;z-index:999999;padding:8px;';
        button.addEventListener('click', showConfig);
        document.body.appendChild(button);
    }

    // ---------------------------------------------------------------------
    // Debounced function to update the menu/fallback button (blocking count)
    // ---------------------------------------------------------------------
    const batchUpdateCounter = debounce(() => {
        updateMenuEntry();
    }, 16);

    // -----------------
    // CSS for Hide Class
    // -----------------
    if (!document.querySelector('style[data-reddit-filter]')) {
        const style = document.createElement('style');
        style.textContent = `
            .content-filtered {
                display: none !important;
                height: 0 !important;
                overflow: hidden !important;
            }
        `;
        style.setAttribute('data-reddit-filter', 'true');
        document.head.appendChild(style);
    }

    // ---------------
    // Build Patterns
    // ---------------
    function getKeywordPattern(keywords) {
        if (keywords.length === 0) return null;
        const escapedKeywords = keywords.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
        // The trailing (s|es|ies)? is used to match common plurals
        return new RegExp(`\\b(${escapedKeywords.join('|')})(s|es|ies)?\\b`, 'i');
    }

    // --------------------------------------------
    // Show the Config Dialog for Block/Whitelist
    // --------------------------------------------
    async function showConfig() {
        const overlay = document.createElement('div');
        overlay.className = 'reddit-filter-overlay';
        Object.assign(overlay.style, {
            position: 'fixed',
            top: 0, left: 0, right: 0, bottom: 0,
            background: 'rgba(0,0,0,0.5)',
            zIndex: '999999'
        });

        const dialog = document.createElement('div');
        dialog.className = 'reddit-filter-dialog';
        Object.assign(dialog.style, {
            position: 'fixed',
            top: '50%', left: '50%',
            transform: 'translate(-50%, -50%)',
            background: 'white',
            padding: '20px',
            borderRadius: '8px',
            zIndex: '1000000',
            boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
            minWidth: '300px',
            maxWidth: '350px',
            fontFamily: 'Arial, sans-serif',
            color: '#333'
        });

        // Basic styling for elements inside the dialog
        dialog.innerHTML = `
            <h2 style="margin-top:0; color:#0079d3;">Reddit Filter: Settings</h2>
            <p><strong>Blocklist:</strong> One entry per line. Matching posts will be hidden.</p>
            <textarea spellcheck="false" id="blocklist" style="width:100%; height:80px; margin-bottom:10px;"></textarea>
            <p><strong>Whitelist:</strong> One entry per line. If matched, post is NOT hidden.</p>
            <textarea spellcheck="false" id="whitelist" style="width:100%; height:80px;"></textarea>
            <div style="display:flex; justify-content:flex-end; margin-top:10px; gap:10px;">
                <button id="cancel-btn" style="padding:6px 12px;">Cancel</button>
                <button id="save-btn" style="padding:6px 12px; background:#0079d3; color:white;">Save</button>
            </div>
        `;

        document.body.appendChild(overlay);
        document.body.appendChild(dialog);

        // Populate with existing data
        dialog.querySelector('#blocklist').value = Array.from(blocklistSet).join('\n');
        dialog.querySelector('#whitelist').value = Array.from(whitelistSet).join('\n');

        const closeDialog = () => {
            overlay.remove();
            dialog.remove();
        };

        // Cancel / overlay click => close
        dialog.querySelector('#cancel-btn').addEventListener('click', closeDialog);
        overlay.addEventListener('click', (e) => {
            // Close if user clicks the overlay, but not if user clicked inside the dialog
            if (e.target === overlay) {
                closeDialog();
            }
        });

        // Save => persist
        dialog.querySelector('#save-btn').addEventListener('click', async () => {
            const blocklistInput = dialog.querySelector('#blocklist').value;
            blocklistSet = new Set(
                blocklistInput
                    .split('\n')
                    .map(x => x.trim().toLowerCase())
                    .filter(x => x.length > 0)
            );
            keywordPattern = getKeywordPattern(Array.from(blocklistSet));
            await GM.setValue('blocklist', Array.from(blocklistSet));

            const whitelistInput = dialog.querySelector('#whitelist').value;
            whitelistSet = new Set(
                whitelistInput
                    .split('\n')
                    .map(x => x.trim().toLowerCase())
                    .filter(x => x.length > 0)
            );
            whitelistPattern = getKeywordPattern(Array.from(whitelistSet));
            await GM.setValue('whitelist', Array.from(whitelistSet));

            closeDialog();
            location.reload(); // easiest way to re-filter everything
        });
    }

    // -----------------------------------------
    // Process an Individual Post (Hide or Not)
    // -----------------------------------------
    function processPost(post) {
        if (!post || processedPosts.has(post)) return;
        processedPosts.add(post);

        const contentText = post.textContent.toLowerCase();

        // If whitelisted => skip
        if (whitelistPattern && whitelistPattern.test(contentText)) return;

        let shouldHide = false;

        // Old + New Reddit subreddit link
        // old.reddit => .tagline a.subreddit
        // new.reddit => a[data-click-id="subreddit"] or a.subreddit
        const subredditElement = post.querySelector('a[data-click-id="subreddit"], a.subreddit, .tagline a.subreddit');
        if (subredditElement) {
            const subName = subredditElement.textContent.trim().replace(/^r\//i, '').toLowerCase();
            if (blocklistSet.has(subName)) {
                shouldHide = true;
            }
        }

        // If not yet hidden => check keywords
        if (!shouldHide && keywordPattern) {
            if (keywordPattern.test(contentText)) {
                shouldHide = true;
            }
        }

        if (shouldHide) {
            hidePost(post);
        }
    }

    // ---------------
    // Hide Post Helper
    // ---------------
    function hidePost(post) {
        post.classList.add('content-filtered');

        const parentArticle = post.closest(postSelector);
        if (parentArticle) {
            parentArticle.classList.add('content-filtered');
        }

        filteredCount++;
        pendingUpdates++;
        batchUpdateCounter();
    }

    // -------------------------------------------
    // Process a Batch of Posts (in small chunks)
    // -------------------------------------------
    async function processPostsBatch(posts) {
        const batchSize = 5;
        for (let i = 0; i < posts.length; i += batchSize) {
            const batch = posts.slice(i, i + batchSize);
            // Use requestIdleCallback to keep page responsive
            await new Promise(resolve => requestIdleCallback(resolve, { timeout: 800 }));
            batch.forEach(processPost);
        }
    }

    const debouncedProcess = debounce((posts) => {
        processPostsBatch(Array.from(posts));
    }, 100);

    // ----------------------------
    // Initialization (load config)
    // ----------------------------
    async function init() {
        try {
            const loadedBlocklist = await GM.getValue('blocklist', []);
            blocklistSet = new Set(loadedBlocklist.map(x => x.toLowerCase()));
            keywordPattern = getKeywordPattern(Array.from(blocklistSet));

            const loadedWhitelist = await GM.getValue('whitelist', []);
            whitelistSet = new Set(loadedWhitelist.map(x => x.toLowerCase()));
            whitelistPattern = getKeywordPattern(Array.from(whitelistSet));

        } catch (err) {
            console.error('[DEBUG] Error loading saved data:', err);
        }

        // Try to create a menu entry or fallback button (zero blocked initially)
        updateMenuEntry();

        // On old Reddit, top-level posts appear under #siteTable
        // On new Reddit, there's .main-content
        const observerTarget = document.querySelector('.main-content')
            || document.querySelector('#siteTable')
            || document.body;

        const observer = new MutationObserver((mutations) => {
            const newPosts = new Set();
            for (const mutation of mutations) {
                for (const node of mutation.addedNodes) {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                        if (node.matches?.(postSelector)) {
                            newPosts.add(node);
                        }
                        node.querySelectorAll?.(postSelector).forEach(p => newPosts.add(p));
                    }
                }
            }
            if (newPosts.size > 0) {
                debouncedProcess(newPosts);
            }
        });

        observer.observe(observerTarget, { childList: true, subtree: true });

        // Process any existing posts on load
        const initialPosts = document.querySelectorAll(postSelector);
        if (initialPosts.length > 0) {
            debouncedProcess(initialPosts);
        }

        console.log('[DEBUG] Initialization complete. Now filtering posts...');
    }

    await init();
})();

QingJ © 2025

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