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 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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();
})();