Declutter LinkedIn

Remove news, ads, and other clutter from LinkedIn

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

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

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

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

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