Declutter LinkedIn

Remove news, ads, and other clutter from LinkedIn

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Declutter LinkedIn
// @namespace    August4067
// @version      2.0.0
// @description  Remove news, ads, and other clutter from LinkedIn
// @author       August4067
// @license      MIT
// @match        https://www.linkedin.com/*
// @noframes
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @run-at       document-start
// @icon         https://www.linkedin.com/favicon.ico
// ==/UserScript==

/* jshint esversion: 8 */
/* eslint-env es2017 */

(function () {
  "use strict";

  // ============================================
  // CONFIGURATION
  // ============================================

  const CONFIG = {
    pollInterval: 2000,
    throttleDelay: 100,
    debug: false,
  };

  const SETTINGS_CONFIG = {
    removeNews: {
      displayName: "Remove LinkedIn News",
      default: true,
    },
    removePremiumUpsells: {
      displayName: "Remove Premium upsells",
      default: true,
    },
    removePromotedPosts: {
      displayName: "Remove promoted posts",
      default: true,
    },
    removeGames: {
      displayName: "Remove games/puzzles",
      default: true,
    },
  };

  // ============================================
  // SETTINGS
  // ============================================

  class Setting {
    constructor(name, config) {
      this.name = name;
      this.displayName = config.displayName;
      this.default = config.default;
    }

    get value() {
      return GM_getValue(this.name, this.default);
    }

    set value(val) {
      GM_setValue(this.name, val);
    }

    toggle() {
      this.value = !this.value;
    }
  }

  const Settings = Object.fromEntries(
    Object.entries(SETTINGS_CONFIG).map(([name, config]) => [
      name,
      new Setting(name, config),
    ])
  );

  // ============================================
  // UTILITIES
  // ============================================

  function debug(message, ...args) {
    if (CONFIG.debug) {
      console.log(`[Declutter LinkedIn] ${message}`, ...args);
    }
  }

  // ============================================
  // CSS INJECTION
  // ============================================

  function injectStyles() {
    if (document.getElementById("declutter-linkedin-styles")) return;
    const style = document.createElement("style");
    style.id = "declutter-linkedin-styles";
    style.textContent = `
      /* Hide premium upsell links in menus instantly via CSS */
      a[href*="/premium/products/"],
      li:has(> a[href*="/premium/products/"]),
      [data-view-name="seeker-next-best-action-card"],
      [data-view-name="premium-upsell-link"] {
        display: none !important;
      }
      /* Hide games/puzzles links instantly via CSS */
      ${GM_getValue("removeGames", true) ? `a[href*="/games/"] { display: none !important; }` : ""}
    `;
    (document.head || document.documentElement).appendChild(style);
  }

  // Inject as early as possible
  injectStyles();

  // ============================================
  // MAIN LOGIC
  // ============================================

  const Declutterer = {
    // Walk up from an element to find the outermost card container.
    // LinkedIn wraps upsells in nested divs — we need to hide the outermost
    // one to avoid blank boxes.
    _findCard(el) {
      // Walk up through componentkey ancestors, then check if there's a
      // non-componentkey wrapper above that (the visual card div)
      let card = el.closest('[componentkey]');
      if (!card) return null;

      // Keep walking up through componentkey parents
      let parent = card.parentElement?.closest('[componentkey]');
      while (parent) {
        card = parent;
        parent = card.parentElement?.closest('[componentkey]');
      }

      // Check if the card's grandparent is the visual card wrapper
      // (div without componentkey that acts as the card boundary)
      let wrapper = card.parentElement;
      while (wrapper && wrapper !== document.body) {
        // Stop at elements that contain other sibling content (not just wrappers)
        if (wrapper.children.length > 1) break;
        wrapper = wrapper.parentElement;
      }
      // Use the last single-child wrapper, or fall back to the componentkey card
      let target = card;
      let cur = card.parentElement;
      while (cur && cur !== document.body && cur.children.length === 1) {
        target = cur;
        cur = cur.parentElement;
      }
      return target;
    },

    removeNews() {
      if (!Settings.removeNews.value) return;

      // Find "LinkedIn News" heading and hide its componentkey container
      // (not the whole card, since games/puzzles are siblings)
      document.querySelectorAll("p").forEach((p) => {
        if (p.dataset.dlHidden) return;
        if (p.textContent.trim() === "LinkedIn News") {
          const container = p.closest('[componentkey]');
          if (container && !container.dataset.dlHidden) {
            container.style.setProperty("display", "none", "important");
            container.dataset.dlHidden = "1";
            debug("Hidden LinkedIn News section");
          }
        }
      });

      // Also hide news links in case they're outside the componentkey
      document.querySelectorAll('a[href*="/news/story/"]').forEach((a) => {
        if (!a.dataset.dlHidden) {
          a.style.setProperty("display", "none", "important");
          a.dataset.dlHidden = "1";
        }
      });

      // Hide "Top stories" label
      document.querySelectorAll("p").forEach((p) => {
        if (p.dataset.dlHidden) return;
        if (p.textContent.trim() === "Top stories") {
          p.style.setProperty("display", "none", "important");
          p.dataset.dlHidden = "1";
        }
      });

      // Hide "Show more" button in news section
      document.querySelectorAll("button").forEach((btn) => {
        if (btn.dataset.dlHidden) return;
        if (btn.textContent.trim() === "Show more" &&
            btn.closest('[componentkey]')?.querySelector('a[href*="/news/story/"]')) {
          btn.style.setProperty("display", "none", "important");
          btn.dataset.dlHidden = "1";
        }
      });
    },

    removeGames() {
      if (!Settings.removeGames.value) return;

      // Hide "Today's puzzles" heading and its sibling game links
      document.querySelectorAll("p").forEach((p) => {
        if (p.dataset.dlHidden) return;
        if (p.textContent.trim() === "Today\u2019s puzzles" ||
            p.textContent.trim() === "Today's puzzles") {
          // Hide the parent container that holds the heading + game link
          const container = p.parentElement;
          if (container && !container.dataset.dlHidden) {
            container.style.setProperty("display", "none", "important");
            container.dataset.dlHidden = "1";
            debug("Hidden games/puzzles section");
          }
        }
      });

      // Hide any remaining game links the CSS might miss
      document.querySelectorAll('a[href*="/games/"]').forEach((a) => {
        if (!a.dataset.dlHidden) {
          a.style.setProperty("display", "none", "important");
          a.dataset.dlHidden = "1";
        }
      });
    },

    removePromotedPosts() {
      if (!Settings.removePromotedPosts.value) return;

      // Find <p> elements with "Promoted" text inside feed posts
      document.querySelectorAll("p").forEach((p) => {
        if (p.dataset.dlHidden) return;
        if (p.textContent.trim() === "Promoted") {
          // Walk up to the feed post container (role="listitem" only)
          // Avoid the componentkey fallback — "Promoted" on /jobs is a label, not an ad
          const post = p.closest('[role="listitem"]');
          if (post && !post.dataset.dlHidden) {
            post.style.setProperty("display", "none", "important");
            post.dataset.dlHidden = "1";
            debug("Hidden promoted post");
          }
        }
      });
    },

    // Find the narrowest meaningful container for a premium upsell element.
    // Prefers carousel <li>, role="listitem", or immediate componentkey div
    // over _findCard which walks too far up and hides entire sections.
    _findPremiumContainer(el) {
      // In carousels, the upsell is an <li> alongside real profile cards
      const carouselItem = el.closest('li[data-testid="carousel-child-container"]');
      if (carouselItem) return carouselItem;

      // In list layouts, the upsell may be a listitem
      const listItem = el.closest('[role="listitem"]');
      if (listItem) return listItem;

      // Fall back to the immediate componentkey container
      const component = el.closest('[componentkey]');
      if (component) return component;

      // Last resort: use _findCard (home page standalone cards)
      return this._findCard(el);
    },

    removePremiumUpsells() {
      if (!Settings.removePremiumUpsells.value) return;

      // Hide sections containing the Premium wordmark badge (upsell cards)
      document.querySelectorAll('svg#premium-badge-v2-xsmall').forEach((svg) => {
        const container = svg.closest('section') || svg.closest('[componentkey]');
        if (container && !container.dataset.dlHidden) {
          container.style.setProperty("display", "none", "important");
          container.dataset.dlHidden = "1";
          debug("Hidden Premium upsell (badge section)");
        }
      });

      // Hide links to /premium/products/ and their containers
      document.querySelectorAll('a[href*="/premium/products/"]').forEach((a) => {
        const card = this._findPremiumContainer(a);
        if (card && !card.dataset.dlHidden) {
          card.style.setProperty("display", "none", "important");
          card.dataset.dlHidden = "1";
          debug("Hidden Premium upsell (link)");
        }
      });


      // Hide elements with "Try Premium" / "Try Recruiter" text as a fallback
      document.querySelectorAll("p").forEach((p) => {
        if (p.dataset.dlHidden) return;
        const text = p.textContent.trim();
        if (/^try (premium|recruiter)/i.test(text) || /^unlock all with premium/i.test(text) || /^claim premium/i.test(text)) {
          const card = this._findPremiumContainer(p);
          if (card && !card.dataset.dlHidden) {
            card.style.setProperty("display", "none", "important");
            card.dataset.dlHidden = "1";
            debug("Hidden Premium upsell (text)");
          }
        }
      });
    },
  };

  // ============================================
  // PROCESSING
  // ============================================

  function processPage() {
    try {
      debug("Processing page");
      Declutterer.removeNews();
      Declutterer.removeGames();
      Declutterer.removePromotedPosts();
      Declutterer.removePremiumUpsells();
    } catch (error) {
      debug("Error during processing:", error);
    }
  }

  // ============================================
  // MENU
  // ============================================

  function setupMenu() {
    for (const [key, setting] of Object.entries(Settings)) {
      GM_registerMenuCommand(
        `${setting.value ? "\u2713" : "\u2717"} ${setting.displayName}`,
        () => {
          setting.toggle();
          const state = setting.value ? "enabled" : "disabled";
          alert(`${setting.displayName} ${state}. Refresh the page to apply.`);
        }
      );
    }
  }

  // ============================================
  // INITIALIZATION
  // ============================================

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

    setupMenu();
    setupMutationObserver();

    if (document.readyState === "loading") {
      document.addEventListener("DOMContentLoaded", processPage);
    } else {
      processPage();
    }

    debug("Ready");
  }

  function setupMutationObserver() {
    let timeoutId = null;
    const observer = new MutationObserver((mutations) => {
      let shouldProcess = false;
      for (const m of mutations) {
        if (m.addedNodes.length > 0) {
          shouldProcess = true;
          break;
        }
      }

      if (shouldProcess) {
        if (!timeoutId) {
          timeoutId = setTimeout(() => {
            processPage();
            timeoutId = null;
          }, CONFIG.throttleDelay);
        }
      }
    });

    const target = document.documentElement || document.body;
    if (target) {
      observer.observe(target, { childList: true, subtree: true });
      debug("MutationObserver setup");
    }
  }

  function safeInit() {
    try {
      init();
    } catch (error) {
      console.error("[Declutter LinkedIn] Initialization failed:", error);
    }
  }

  safeInit();

  // Continuous polling for dynamic content (SPA)
  let lastUrl = location.href;
  let pollTimer = null;

  function startPolling(interval) {
    if (pollTimer) clearInterval(pollTimer);
    pollTimer = setInterval(() => {
      processPage();

      if (location.href !== lastUrl) {
        debug(`Navigation detected: ${lastUrl} -> ${location.href}`);
        lastUrl = location.href;
      }
    }, interval);
  }

  document.addEventListener("visibilitychange", () => {
    startPolling(document.hidden ? CONFIG.pollInterval * 4 : CONFIG.pollInterval);
  });

  startPolling(CONFIG.pollInterval);
})();