Reddit Advanced Content Filter

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

当前为 2025-03-08 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Reddit Advanced Content Filter
// @namespace    https://greasyfork.org/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://greasyfork.org/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();
})();