Declutter Reddit

Remove clutter from Reddit: search telemetry links, ads, promoted posts, related content sections, and homepage search

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Declutter Reddit
// @namespace    August4067
// @version      0.0.1
// @description  Remove clutter from Reddit: search telemetry links, ads, promoted posts, related content sections, and homepage search
// @author       August4067
// @license      MIT
// @match        https://www.reddit.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @run-at       document-idle
// @icon         https://www.reddit.com/favicon.ico
// ==/UserScript==

(function () {
  "use strict";

  // Configuration
  const CONFIG = {
    selectors: {
      searchTracker: "search-telemetry-tracker",
      searchLink: 'a[href^="/search/"]',
      relatedAnswers: 'aside[id^="answers-suggested-queries"]',
      promotion: 'aside[id="right-rail-experience-root"]',
      relatedPosts: 'aside[aria-label="Related Posts Section"]',
      commentTreeAd: "shreddit-comment-tree-ad",
      adPost: "shreddit-ad-post",
      sidebarAd: "shreddit-async-loader[bundlename='sidebar_ad']",
      recentPosts: "recent-posts",
      searchHero: "#search-hero",
      resourcesSection: "faceplate-expandable-section-helper",
      advertiseButton: "advertise-button",
    },
    debug: true,
  };

  // Settings with defaults
  const Settings = {
    get removeRelatedAnswers() {
      return GM_getValue("removeRelatedAnswers", true);
    },
    set removeRelatedAnswers(value) {
      GM_setValue("removeRelatedAnswers", value);
    },
    get removeTopPosts() {
      return GM_getValue("removeTopPosts", true);
    },
    set removeTopPosts(value) {
      GM_setValue("removeTopPosts", value);
    },
    get removeRelatedPosts() {
      return GM_getValue("removeRelatedPosts", true);
    },
    set removeRelatedPosts(value) {
      GM_setValue("removeRelatedPosts", value);
    },
    get removeSearchHero() {
      return GM_getValue("removeSearchHero", true);
    },
    set removeSearchHero(value) {
      GM_setValue("removeSearchHero", value);
    },
    get removeRecentPosts() {
      return GM_getValue("removeRecentPosts", true);
    },
    set removeRecentPosts(value) {
      GM_setValue("removeRecentPosts", value);
    },
  };

  // Utility: Debug logger
  function debug(message, ...args) {
    if (CONFIG.debug) {
      console.log(`[Declutter Reddit] ${message}`, ...args);
    }
  }

  // Core declutter functions
  const Declutterer = {
    /**
     * Remove search telemetry tracker and replace with plain text
     */
    removeSearchLink(tracker) {
      const link = tracker.querySelector(CONFIG.selectors.searchLink);

      if (!link) return false;

      // Extract text content (excluding SVG icon)
      const textContent = Array.from(link.childNodes)
        .filter((node) => node.nodeType === Node.TEXT_NODE)
        .map((node) => node.textContent)
        .join("")
        .trim();

      if (!textContent) return false;

      // Replace tracker with plain text
      const textNode = document.createTextNode(textContent);
      tracker.parentNode.replaceChild(textNode, tracker);

      debug(`Removed search link: "${textContent}"`);
      return true;
    },

    /**
     * Process all search telemetry trackers on the page
     */
    processSearchLinks() {
      const trackers = document.querySelectorAll(
        CONFIG.selectors.searchTracker,
      );
      let count = 0;

      trackers.forEach((tracker) => {
        if (this.removeSearchLink(tracker)) {
          count++;
        }
      });

      if (count > 0) {
        debug(`Processed ${count} search link(s)`);
      }

      return count;
    },

    /**
     * Remove Related Answers section
     */
    removeRelatedAnswers(element) {
      element.remove();
      debug(`Removed Related Answers section`);
      return true;
    },

    /**
     * Process all Related Answers sections on the page
     */
    processRelatedAnswers() {
      if (!Settings.removeRelatedAnswers) return 0;

      const sections = document.querySelectorAll(
        CONFIG.selectors.relatedAnswers,
      );
      let count = 0;

      sections.forEach((section) => {
        if (this.removeRelatedAnswers(section)) {
          count++;
        }
      });

      if (count > 0) {
        debug(`Processed ${count} Related Answers section(s)`);
      }

      return count;
    },

    /**
     * Remove Top Posts section
     */
    removeTopPosts(element) {
      element.remove();
      debug(`Removed Top Posts section`);
      return true;
    },

    /**
     * Process all Top Posts sections on the page
     */
    processTopPosts() {
      if (!Settings.removeTopPosts) return 0;

      // Find Top Posts sections by finding h2 with "Top Posts" text
      const headers = Array.from(document.querySelectorAll("h2")).filter((h2) =>
        h2.textContent.trim().toUpperCase().includes("TOP POSTS"),
      );

      let count = 0;

      headers.forEach((header) => {
        // Find the parent container (should be a div.px-md or similar)
        const container = header.closest("div");
        if (container && this.removeTopPosts(container)) {
          count++;
        }
      });

      if (count > 0) {
        debug(`Processed ${count} Top Posts section(s)`);
      }

      return count;
    },

    /**
     * Remove Promotion section
     */
    removePromotion(element) {
      element.remove();
      debug(`Removed Promotion section`);
      return true;
    },

    /**
     * Process all Promotion sections on the page
     */
    processPromotions() {
      const sections = document.querySelectorAll(CONFIG.selectors.promotion);
      let count = 0;

      sections.forEach((section) => {
        if (this.removePromotion(section)) {
          count++;
        }
      });

      if (count > 0) {
        debug(`Processed ${count} Promotion section(s)`);
      }

      return count;
    },

    /**
     * Remove Related Posts section
     */
    removeRelatedPosts(element) {
      element.remove();
      debug(`Removed Related Posts section`);
      return true;
    },

    /**
     * Process all Related Posts sections on the page
     */
    processRelatedPosts() {
      if (!Settings.removeRelatedPosts) return 0;

      const sections = document.querySelectorAll(CONFIG.selectors.relatedPosts);
      let count = 0;

      sections.forEach((section) => {
        if (this.removeRelatedPosts(section)) {
          count++;
        }
      });

      if (count > 0) {
        debug(`Processed ${count} Related Posts section(s)`);
      }

      return count;
    },

    /**
     * Remove Comment Tree Ad
     */
    removeCommentTreeAd(element) {
      element.remove();
      debug(`Removed Comment Tree Ad`);
      return true;
    },

    /**
     * Process all Comment Tree Ads on the page
     */
    processCommentTreeAds() {
      const ads = document.querySelectorAll(CONFIG.selectors.commentTreeAd);
      let count = 0;

      ads.forEach((ad) => {
        if (this.removeCommentTreeAd(ad)) {
          count++;
        }
      });

      if (count > 0) {
        debug(`Processed ${count} Comment Tree Ad(s)`);
      }

      return count;
    },

    /**
     * Remove Ad Post
     */
    removeAdPost(element) {
      element.remove();
      debug(`Removed Ad Post`);
      return true;
    },

    /**
     * Process all Ad Posts on the page
     */
    processAdPosts() {
      const ads = document.querySelectorAll(CONFIG.selectors.adPost);
      let count = 0;

      ads.forEach((ad) => {
        if (this.removeAdPost(ad)) {
          count++;
        }
      });

      if (count > 0) {
        debug(`Processed ${count} Ad Post(s)`);
      }

      return count;
    },

    /**
     * Remove Sidebar Ad
     */
    removeSidebarAd(element) {
      element.remove();
      debug(`Removed Sidebar Ad`);
      return true;
    },

    /**
     * Process all Sidebar Ads on the page
     */
    processSidebarAds() {
      const ads = document.querySelectorAll(CONFIG.selectors.sidebarAd);
      let count = 0;

      ads.forEach((ad) => {
        if (this.removeSidebarAd(ad)) {
          count++;
        }
      });

      if (count > 0) {
        debug(`Processed ${count} Sidebar Ad(s)`);
      }

      return count;
    },

    /**
     * Remove Recent Posts section
     */
    removeRecentPosts(element) {
      element.remove();
      debug(`Removed Recent Posts section`);
      return true;
    },

    /**
     * Process all Recent Posts sections on the page
     */
    processRecentPosts() {
      if (!Settings.removeRecentPosts) return 0;

      const sections = document.querySelectorAll(CONFIG.selectors.recentPosts);
      let count = 0;

      sections.forEach((section) => {
        if (this.removeRecentPosts(section)) {
          count++;
        }
      });

      if (count > 0) {
        debug(`Processed ${count} Recent Posts section(s)`);
      }

      return count;
    },

    /**
     * Remove Search Hero section
     */
    removeSearchHero(element) {
      element.remove();
      debug(`Removed Search Hero section`);
      return true;
    },

    /**
     * Process Search Hero section on the page
     */
    processSearchHero() {
      if (!Settings.removeSearchHero) return 0;

      const section = document.querySelector(CONFIG.selectors.searchHero);
      if (section && this.removeSearchHero(section)) {
        debug(`Processed Search Hero section`);
        return 1;
      }

      return 0;
    },

    /**
     * Close Resources section
     */
    closeResourcesSection(element) {
      // Remove open attribute from faceplate-expandable-section-helper
      element.removeAttribute("open");

      // Find and close the details element within
      const details = element.querySelector("details");
      if (details) {
        details.removeAttribute("open");
      }

      debug(`Closed Resources section`);
      return true;
    },

    /**
     * Process Resources section on the page
     */
    processResourcesSection() {
      const sections = document.querySelectorAll(
        CONFIG.selectors.resourcesSection,
      );
      let count = 0;

      sections.forEach((section) => {
        // Check if this section contains the RESOURCES control
        const summary = section.querySelector(
          'summary [aria-controls="RESOURCES"]',
        );
        if (
          summary &&
          section.hasAttribute("open") &&
          this.closeResourcesSection(section)
        ) {
          count++;
        }
      });

      if (count > 0) {
        debug(`Processed ${count} Resources section(s)`);
      }

      return count;
    },

    /**
     * Remove Advertise Button
     */
    removeAdvertiseButton(element) {
      element.remove();
      debug(`Removed Advertise Button`);
      return true;
    },

    /**
     * Process all Advertise Buttons on the page
     */
    processAdvertiseButtons() {
      const buttons = document.querySelectorAll(
        CONFIG.selectors.advertiseButton,
      );
      let count = 0;

      buttons.forEach((button) => {
        if (this.removeAdvertiseButton(button)) {
          count++;
        }
      });

      if (count > 0) {
        debug(`Processed ${count} Advertise Button(s)`);
      }

      return count;
    },

    /**
     * Process all declutter operations
     */
    processAll() {
      this.processSearchLinks();
      this.processRelatedAnswers();
      this.processTopPosts();
      this.processPromotions();
      this.processRelatedPosts();
      this.processCommentTreeAds();
      this.processAdPosts();
      this.processSidebarAds();
      this.processRecentPosts();
      this.processSearchHero();
      this.processResourcesSection();
      this.processAdvertiseButtons();
    },
  };

  // Mutation observer for dynamic content
  const ContentObserver = {
    observer: null,

    /**
     * Check if mutation contains elements we want to process
     */
    shouldProcessMutation(mutation) {
      if (mutation.addedNodes.length === 0) return false;

      for (const node of mutation.addedNodes) {
        if (node.nodeType !== Node.ELEMENT_NODE) continue;

        // Check for search trackers
        if (
          node.tagName === "SEARCH-TELEMETRY-TRACKER" ||
          node.querySelector?.(CONFIG.selectors.searchTracker)
        ) {
          return true;
        }

        // Check for related answers sections
        if (
          Settings.removeRelatedAnswers &&
          (node.matches?.(CONFIG.selectors.relatedAnswers) ||
            node.querySelector?.(CONFIG.selectors.relatedAnswers))
        ) {
          return true;
        }

        // Check for top posts sections (h2 with "Top Posts" text)
        if (Settings.removeTopPosts) {
          const h2Elements = node.querySelectorAll?.("h2") || [];
          for (const h2 of h2Elements) {
            if (h2.textContent.trim().toUpperCase().includes("TOP POSTS")) {
              return true;
            }
          }
          // Check if the node itself is an h2
          if (
            node.tagName === "H2" &&
            node.textContent.trim().toUpperCase().includes("TOP POSTS")
          ) {
            return true;
          }
        }

        // Check for promotion sections (always remove)
        if (
          node.matches?.(CONFIG.selectors.promotion) ||
          node.querySelector?.(CONFIG.selectors.promotion)
        ) {
          return true;
        }

        // Check for related posts sections
        if (
          Settings.removeRelatedPosts &&
          (node.matches?.(CONFIG.selectors.relatedPosts) ||
            node.querySelector?.(CONFIG.selectors.relatedPosts))
        ) {
          return true;
        }

        // Check for comment tree ads (always remove)
        if (
          node.tagName === "SHREDDIT-COMMENT-TREE-AD" ||
          node.querySelector?.(CONFIG.selectors.commentTreeAd)
        ) {
          return true;
        }

        // Check for ad posts (always remove)
        if (
          node.tagName === "SHREDDIT-AD-POST" ||
          node.querySelector?.(CONFIG.selectors.adPost)
        ) {
          return true;
        }

        // Check for sidebar ads (always remove)
        if (
          node.tagName === "SHREDDIT-ASYNC-LOADER" &&
          node.getAttribute?.("bundlename") === "sidebar_ad"
        ) {
          return true;
        }
        if (node.querySelector?.(CONFIG.selectors.sidebarAd)) {
          return true;
        }

        // Check for recent posts sections
        if (
          Settings.removeRecentPosts &&
          (node.tagName === "RECENT-POSTS" ||
            node.querySelector?.(CONFIG.selectors.recentPosts))
        ) {
          return true;
        }

        // Check for search hero section
        if (
          Settings.removeSearchHero &&
          (node.id === "search-hero" ||
            node.querySelector?.(CONFIG.selectors.searchHero))
        ) {
          return true;
        }

        // Check for Resources section (always close)
        if (
          node.tagName === "FACEPLATE-EXPANDABLE-SECTION-HELPER" &&
          node.querySelector?.('summary [aria-controls="RESOURCES"]')
        ) {
          return true;
        }

        // Check for advertise buttons (always remove)
        if (
          node.tagName === "ADVERTISE-BUTTON" ||
          node.querySelector?.(CONFIG.selectors.advertiseButton)
        ) {
          return true;
        }
      }

      return false;
    },

    /**
     * Handle mutations
     */
    handleMutations(mutations) {
      const shouldProcess = mutations.some((mutation) =>
        this.shouldProcessMutation(mutation),
      );

      if (shouldProcess) {
        Declutterer.processAll();
      }
    },

    /**
     * Start observing document
     */
    start() {
      this.observer = new MutationObserver((mutations) =>
        this.handleMutations(mutations),
      );

      this.observer.observe(document.body, {
        childList: true,
        subtree: true,
      });

      debug("Content observer started");
    },
  };

  // Menu commands
  function setupMenu() {
    GM_registerMenuCommand(
      `${Settings.removeRelatedAnswers ? "✓" : "✗"} Remove Related Answers`,
      () => {
        Settings.removeRelatedAnswers = !Settings.removeRelatedAnswers;
        const state = Settings.removeRelatedAnswers ? "enabled" : "disabled";
        alert(`Related Answers removal ${state}. Refresh the page to apply.`);
      },
    );

    GM_registerMenuCommand(
      `${Settings.removeTopPosts ? "✓" : "✗"} Remove Top Posts`,
      () => {
        Settings.removeTopPosts = !Settings.removeTopPosts;
        const state = Settings.removeTopPosts ? "enabled" : "disabled";
        alert(`Top Posts removal ${state}. Refresh the page to apply.`);
      },
    );

    GM_registerMenuCommand(
      `${Settings.removeRelatedPosts ? "✓" : "✗"} Remove Related Posts`,
      () => {
        Settings.removeRelatedPosts = !Settings.removeRelatedPosts;
        const state = Settings.removeRelatedPosts ? "enabled" : "disabled";
        alert(`Related Posts removal ${state}. Refresh the page to apply.`);
      },
    );

    GM_registerMenuCommand(
      `${Settings.removeRecentPosts ? "✓" : "✗"} Remove Recent Posts`,
      () => {
        Settings.removeRecentPosts = !Settings.removeRecentPosts;
        const state = Settings.removeRecentPosts ? "enabled" : "disabled";
        alert(`Recent Posts removal ${state}. Refresh the page to apply.`);
      },
    );

    GM_registerMenuCommand(
      `${Settings.removeSearchHero ? "✓" : "✗"} Remove Homepage Search`,
      () => {
        Settings.removeSearchHero = !Settings.removeSearchHero;
        const state = Settings.removeSearchHero ? "enabled" : "disabled";
        alert(`Homepage Search removal ${state}. Refresh the page to apply.`);
      },
    );
  }

  // Initialize
  function init() {
    debug("Initializing...");

    // Setup menu
    setupMenu();

    // Process existing content
    Declutterer.processAll();

    // Watch for new content
    ContentObserver.start();

    debug("Ready");
  }

  // Start when DOM is ready
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init);
  } else {
    init();
  }
})();