CleanGram

Hides Instagram posts that are suggested, sponsored, or prompt for "Follow" using a flexible configuration.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         CleanGram
// @namespace    http://tampermonkey.net/
// @version      0.0.3
// @description  Hides Instagram posts that are suggested, sponsored, or prompt for "Follow" using a flexible configuration.
// @author       JJJ
// @match        https://www.instagram.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=instagram.com
// @license      MIT
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function () {
    'use strict';

    // ==================== Configuration ====================
    const CONFIG = {
        timing: {
            clickDelay: 100,        // Reduced from 300ms - faster response to clicks
            scrollInterval: 250,    // Reduced from 500ms - more frequent scans during scroll
            scrollDebounce: 400,    // Reduced from 800ms - faster post-scroll cleanup
            periodicScan: 2000      // Reduced from 3000ms - more frequent fallback scans
        },
        selectors: {
            // Elements to scan (ARTICLE is typical Instagram post container)
            targetElements: 'ARTICLE',
            // Attribute used to mark hidden elements
            hiddenMarker: 'data-cleangram-hidden',
            // Common ad label spans (Instagram class names can vary; include a fallback)
            adSpans: 'span.x1fhwpqd, span[class*="x1fhwpqd"]',
            // Sponsored indicator (data attribute used by Instagram for some ads)
            sponsored: '[data-ad-preview="message"]',
            // Selector candidates for the "Suggested for you" label seen in the DOM
            // We intentionally include a few class combinations observed in the site HTML
            suggested: 'span.x193iq5w.xeuugli.x1fj9vlw, span.x1fhwpqd, span.xt0psk2'
        },
        patterns: {
            sponsored: 'sponsored',
            suggested: 'suggested for you',
            adLabel: ['Ad', 'Sponsored']
        }
    };

    // ==================== Logger Utility ====================
    const Logger = (() => {
        const styles = {
            info: 'color: #2196F3; font-weight: bold',
            warning: 'color: #FFC107; font-weight: bold',
            success: 'color: #4CAF50; font-weight: bold',
            error: 'color: #F44336; font-weight: bold'
        };
        const prefix = '[CleanGram]';
        const getTimestamp = () => new Date().toISOString().split('T')[1].slice(0, -1);
        const log = (level, msg) => {
            const method = level === 'error' ? console.error : level === 'warning' ? console.warn : console.log;
            method(`%c${prefix} ${getTimestamp()} - ${msg}`, styles[level]);
        };

        return {
            info: (msg) => log('info', msg),
            warning: (msg) => log('warning', msg),
            success: (msg) => log('success', msg),
            error: (msg) => log('error', msg)
        };
    })();

    // ==================== State Management ====================
    const State = {
        scanTimer: null,
        scrollTimer: null,
        isScrolling: false,
        observer: null
    };

    // ==================== Content Detection ====================
    const ContentDetector = {
        /**
         * Check if element text contains banned patterns
         * @param {string} text - Element text content
         * @returns {string|null} - Pattern found or null
         */
        checkTextPatterns(text) {
            if (text.length < 10) return null;

            const textLower = text.toLowerCase();

            if (textLower.includes(CONFIG.patterns.sponsored)) {
                return 'sponsored text';
            }

            if (textLower.includes(CONFIG.patterns.suggested)) {
                return 'suggested text';
            }

            return null;
        },

        /**
         * Check for ad label spans
         * @param {Element} element
         * @returns {boolean}
         */
        hasAdLabel(element) {
            const adSpans = element.querySelectorAll(CONFIG.selectors.adSpans);
            for (const span of adSpans) {
                const spanText = span.textContent.trim();
                if (CONFIG.patterns.adLabel.includes(spanText)) {
                    return true;
                }
            }
            return false;
        },

        /**
         * Check for sponsored content via selectors
         * @param {Element} element
         * @returns {boolean}
         */
        hasSponsoredSelector(element) {
            return !!element.querySelector(CONFIG.selectors.sponsored);
        },

        /**
         * Check for suggested header
         * @param {Element} element
         * @returns {boolean}
         */
        hasSuggestedHeader(element) {
            const header = element.querySelector(CONFIG.selectors.suggested);
            if (header) {
                const headerText = header.textContent.trim().toLowerCase();
                return headerText === CONFIG.patterns.suggested;
            }
            return false;
        },

        /**
         * Main detection method - determines if element should be hidden
         * @param {Element} element
         * @returns {boolean}
         */
        isBannedContent(element) {
            // Fast text-based checks first (no DOM queries)
            const textPattern = this.checkTextPatterns(element.textContent);
            if (textPattern) {
                Logger.warning(`Found ${textPattern}`);
                return true;
            }

            // Check for ad label
            if (this.hasAdLabel(element)) {
                Logger.warning('Found ad label');
                return true;
            }

            // Expensive DOM queries last
            if (this.hasSponsoredSelector(element)) {
                Logger.warning('Found sponsored selector');
                return true;
            }

            if (this.hasSuggestedHeader(element)) {
                Logger.warning('Found suggested header');
                return true;
            }

            return false;
        }
    };

    // ==================== Element Management ====================
    const ElementManager = {
        /**
         * Check if element is already hidden
         * @param {Element} element
         * @returns {boolean}
         */
        isHidden(element) {
            return element.dataset.cleangramHidden === 'true';
        },

        /**
         * Hide element non-destructively to not breake Instagram scroll detection
         * OPTIMIZED: Apply class first for instant CSS hide, then set attributes
         * @param {Element} element
         */
        hide(element) {
            if (this.isHidden(element)) return;

            // SPEED: Apply class first - CSS takes effect immediately
            element.classList.add('cleangram-hidden');
            element.dataset.cleangramHidden = 'true';

            // Apply inline styles as backup (in case CSS is removed)
            element.style.cssText = 'visibility:hidden!important;height:0!important;min-height:0!important;overflow:hidden!important;opacity:0!important;pointer-events:none!important';

            // Remove element from accessibility tree
            element.setAttribute('aria-hidden', 'true');
        },

        /**
         * Re-hide element if Instagram tries to show it again
         * @param {Element} element
         */
        ensureHidden(element) {
            if (this.isHidden(element)) {
                // Re-apply hiding styles if they were removed
                const visibility = element.style.getPropertyValue('visibility');
                if (visibility !== 'hidden') {
                    element.style.setProperty('visibility', 'hidden', 'important');
                    element.style.setProperty('height', '0', 'important');
                    element.style.setProperty('overflow', 'hidden', 'important');
                }
            }
        },

        /**
         * Get all unprocessed target elements
         * @returns {NodeList}
         */
        getUnprocessedElements() {
            return document.querySelectorAll(
                `${CONFIG.selectors.targetElements}:not([${CONFIG.selectors.hiddenMarker}="true"])`
            );
        },

        /**
         * Process and hide elements if they contain banned content
         * OPTIMIZED: Batch operations for better performance
         * @returns {number} - Count of hidden elements
         */
        processElements() {
            const elements = this.getUnprocessedElements();
            let hiddenCount = 0;

            // Process in batches for better performance with many elements
            for (let i = 0; i < elements.length; i++) {
                const element = elements[i];
                if (ContentDetector.isBannedContent(element)) {
                    this.hide(element);
                    hiddenCount++;
                }
            }

            return hiddenCount;
        },

        /**
         * Check and re-hide all marked elements (in case Instagram re-shows them)
         */
        reinforceHidden() {
            const hiddenElements = document.querySelectorAll(`[${CONFIG.selectors.hiddenMarker}="true"]`);
            hiddenElements.forEach(element => this.ensureHidden(element));
        }
    };

    // ==================== Scan Management ====================
    const ScanManager = {
        /**
         * Run cleanup scan
         */
        scan() {
            // First, reinforce already hidden elements
            ElementManager.reinforceHidden();

            // Then process new elements
            const hiddenCount = ElementManager.processElements();
            if (hiddenCount > 0) {
                Logger.success(`Hidden ${hiddenCount} element(s)`);
            }
        },

        /**
         * Start continuous scanning during scroll
         */
        startContinuous() {
            if (State.scanTimer) return;
            State.scanTimer = setInterval(
                () => this.scan(),
                CONFIG.timing.scrollInterval
            );
        },

        /**
         * Stop continuous scanning
         */
        stopContinuous() {
            if (State.scanTimer) {
                clearInterval(State.scanTimer);
                State.scanTimer = null;
            }
        },

        /**
         * Handle scroll events with debouncing
         */
        handleScroll() {
            if (!State.isScrolling) {
                State.isScrolling = true;
                this.startContinuous();
            }

            clearTimeout(State.scrollTimer);
            State.scrollTimer = setTimeout(() => {
                State.isScrolling = false;
                this.stopContinuous();
                this.scan(); // Final cleanup after scroll stops
            }, CONFIG.timing.scrollDebounce);
        }
    };

    // ==================== Observer Management ====================
    const ObserverManager = {
        /**
         * Process added nodes from mutations
         * @param {Node} node
         * @returns {number} - Count of hidden elements
         */
        processNode(node) {
            if (node.nodeType !== Node.ELEMENT_NODE) return 0;

            let hiddenCount = 0;

            // Check if node itself is a target element
            if (node.tagName === CONFIG.selectors.targetElements && !ElementManager.isHidden(node)) {
                if (ContentDetector.isBannedContent(node)) {
                    ElementManager.hide(node);
                    hiddenCount++;
                }
            }

            // Check child elements
            const children = node.querySelectorAll(
                `${CONFIG.selectors.targetElements}:not([${CONFIG.selectors.hiddenMarker}="true"])`
            );

            children.forEach(child => {
                if (ContentDetector.isBannedContent(child)) {
                    ElementManager.hide(child);
                    hiddenCount++;
                }
            });

            return hiddenCount;
        },

        /**
         * MutationObserver callback
         * OPTIMIZED: Faster iteration
         * @param {MutationRecord[]} mutations
         */
        callback(mutations) {
            let totalHidden = 0;

            // Use for loop instead of forEach for speed
            for (let i = 0; i < mutations.length; i++) {
                const mutation = mutations[i];

                // Handle new nodes being added
                if (mutation.type === 'childList' && mutation.addedNodes.length) {
                    for (let j = 0; j < mutation.addedNodes.length; j++) {
                        totalHidden += this.processNode(mutation.addedNodes[j]);
                    }
                }

                // Handle attribute changes (Instagram trying to re-show elements)
                else if (mutation.type === 'attributes' && mutation.target && ElementManager.isHidden(mutation.target)) {
                    ElementManager.ensureHidden(mutation.target);
                }
            }

            if (totalHidden > 0) {
                Logger.success(`Observer hidden ${totalHidden} element(s)`);
            }
        },

        /**
         * Initialize MutationObserver
         */
        initialize() {
            const targetNode = document.querySelector('main') || document.body;
            State.observer = new MutationObserver((mutations) => this.callback(mutations));
            // Watch for childList changes AND attribute changes (to catch style modifications)
            State.observer.observe(targetNode, {
                childList: true,
                subtree: true,
                attributes: true,
                attributeFilter: ['style', 'class']
            });
            Logger.success('Observer activated');
        }
    };

    // ==================== Event Handlers ====================
    const EventHandlers = {
        /**
         * Handle click events
         */
        onClick() {
            setTimeout(() => ScanManager.scan(), CONFIG.timing.clickDelay);
        },

        /**
         * Handle scroll events
         */
        onScroll() {
            ScanManager.handleScroll();
        },

        /**
         * Handle page load
         */
        onLoad() {
            ScanManager.scan();
        },

        /**
         * Register all event listeners
         */
        register() {
            window.addEventListener('scroll', () => this.onScroll(), { passive: true });
            document.addEventListener('click', () => this.onClick());
            window.addEventListener('load', () => this.onLoad());
            Logger.success('Event listeners registered');
        }
    };

    // ==================== Style Injection ====================
    const StyleManager = {
        /**
         * Inject CSS to ensure hidden elements stay hidden
         * Uses v0.0.2 gentle approach (no display:none) to preserve infinite scroll
         */
        injectStyles() {
            const style = document.createElement('style');
            style.id = 'cleangram-styles';
            style.textContent = `
                /* Force hide elements marked by CleanGram (v0.0.2 approach) */
                /* NOTE: We avoid display:none to keep infinite scroll working */
                [data-cleangram-hidden="true"],
                .cleangram-hidden {
                    visibility: hidden !important;
                    height: 0 !important;
                    min-height: 0 !important;
                    max-height: 0 !important;
                    overflow: hidden !important;
                    opacity: 0 !important;
                    pointer-events: none !important;
                }
            `;
            document.head.appendChild(style);
            Logger.success('Styles injected');
        }
    };

    // ==================== Application ====================
    const App = {
        /**
         * Initialize the application
         */
        initialize() {
            Logger.info('CleanGram initializing...');

            // Inject CSS styles first
            StyleManager.injectStyles();

            // Initial cleanup
            ScanManager.scan();

            // Setup MutationObserver
            ObserverManager.initialize();

            // Register event handlers
            EventHandlers.register();

            // Periodic fallback scan
            setInterval(() => ScanManager.scan(), CONFIG.timing.periodicScan);

            Logger.success('CleanGram fully initialized');
        },

        /**
         * Start the application when DOM is ready
         */
        start() {
            if (document.readyState === 'loading') {
                document.addEventListener('DOMContentLoaded', () => this.initialize());
            } else {
                this.initialize();
            }
        }
    };

    // ==================== Entry Point ====================
    App.start();
})();