Reddit Advanced Content Filter (Phrase Fix)

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

当前为 2025-07-11 提交的版本,查看 最新版本

// ==UserScript==
// @name         Reddit Advanced Content Filter (Phrase Fix)
// @namespace    https://gf.qytechs.cn/en/users/567951-stuart-saddler
// @version      2.9
// @description  Automatically hides posts in your Reddit feed based on keywords or subreddits you specify. Supports whitelist entries that override filtering. Improved phrase matching.
// @author       Stuart Saddler
// @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 blocklistArr = [];
    let whitelistSet = new Set();
    let whitelistArr = [];

    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);
    }

    // ----------------
    // Regex Escaper
    // ----------------
    function escapeRegex(str) {
        return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    }

    // ---------------
    // Block/Whitelist Logic
    // ---------------
    function shouldBlock(contentText, blocklist) {
        for (const entry of blocklist) {
            if (entry.includes(' ')) {
                // Phrase match (case-insensitive)
                if (contentText.includes(entry)) return true;
            } else {
                // Single word match with word boundaries and plurals
                const regex = new RegExp(`\\b${escapeRegex(entry)}(s|es|ies)?\\b`, 'i');
                if (regex.test(contentText)) return true;
            }
        }
        return false;
    }

    function shouldWhitelist(contentText, whitelist) {
        for (const entry of whitelist) {
            if (entry.includes(' ')) {
                if (contentText.includes(entry)) return true;
            } else {
                const regex = new RegExp(`\\b${escapeRegex(entry)}(s|es|ies)?\\b`, 'i');
                if (regex.test(contentText)) return true;
            }
        }
        return false;
    }

    // --------------------------------------------
    // 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)
            );
            blocklistArr = Array.from(blocklistSet);
            await GM.setValue('blocklist', blocklistArr);

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

            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 (shouldWhitelist(contentText, whitelistArr)) 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/phrases
        if (!shouldHide && shouldBlock(contentText, blocklistArr)) {
            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()));
            blocklistArr = Array.from(blocklistSet);

            const loadedWhitelist = await GM.getValue('whitelist', []);
            whitelistSet = new Set(loadedWhitelist.map(x => x.toLowerCase()));
            whitelistArr = 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或关注我们的公众号极客氢云获取最新地址