// The release-please block markers below wrap the whole metadata block: any
// semver-looking string inside it gets bumped on release. Keep @version as
// the ONLY version-like value in the header (no versioned @require URLs).
// x-release-please-start-version
// ==UserScript==
// @name Litter Invalidator Purity (LI Purity)
// @namespace https://github.com/RoFz/lipurity
// @version 0.1.0
// @description Cleans up your professional network feed: hide sponsored posts, suggested content, people/course/job recommendations and other injected modules via independent toggles, with an optional full-width feed when the right column is hidden.
// @author Rodrigo Ferraz
// @homepage https://github.com/RoFz/lipurity
// @homepageURL https://github.com/RoFz/lipurity
// @supportURL https://github.com/RoFz/lipurity/issues
// @downloadURL https://raw.githubusercontent.com/RoFz/lipurity/main/lipurity.user.js
// @updateURL https://raw.githubusercontent.com/RoFz/lipurity/main/lipurity.user.js
// @match https://www.linkedin.com/*
// @run-at document-idle
// @grant none
// @noframes
// @license MIT
// ==/UserScript==
// x-release-please-end
(function () {
'use strict';
/* -------------------------------------------------------------------------
* Why this works (verified against the live feed, June 2026):
* - Every feed post is wrapped in div[role="listitem"] inside [role="list"].
* - The ad/recommendation marker is plain readable text inside a childless
* element: Promoted / Suggested.
* - The site's CSS class names are hashed and change between builds
* (e.g. "_6a6de872"), so we NEVER match on classes. We match on the ARIA
* role (stable accessibility semantics) + the label text.
* ---------------------------------------------------------------------- */
// ---- Configuration -------------------------------------------------------
// Labels to detect, per language. The KEY is the internal category; the
// VALUE is the exact visible text the site renders in that locale.
// English is verified live. Add your locale's exact strings here if your
// UI is not in English (the value must match character-for-character).
const LABELS = {
Promoted: ['Promoted'],
Suggested: ['Suggested'],
PeopleYouMayKnow: ['People you may know'],
RecommendedForYou: ['Recommended for you'],
// Not yet observed live in the DOM (modules appear sporadically); assumed
// to follow the same header-label pattern as Suggested/Recommended.
// The string below is the literal on-screen text of that module's header.
PopularCourse: ['Popular course on LinkedIn Learning'],
// Both apostrophe variants: the site typically renders U+2019 (’), but an
// exact match would silently miss if a straight quote ever ships.
TopApplicantJobs: [
'You’re a top applicant for these jobs',
"You're a top applicant for these jobs",
],
};
// " follows this Page" / " and follow this Page" header line.
// The names are profile links, so the text is split across child elements;
// this category is matched with a regex on the smallest containing element
// rather than an exact-text label.
const FOLLOWS_PAGE_CATEGORY = 'FollowsPage';
const FOLLOWS_PAGE_RX = /follows? this Page\s*$/;
// Only trust the phrase when it sits in the header strip at the top of the
// card; an organic post whose BODY happens to say "... follows this Page"
// must not be hidden.
const FOLLOWS_PAGE_MAX_OFFSET_PX = 100;
// The whole right rail (puzzles, follow recommendations, the ad box).
// Verified live: it is aside[aria-label="Aside"] while the left profile
// column is a separate aside[aria-label="Sidebar"] . A geometry fallback
// covers builds/locales where the aria-label differs.
const RIGHT_RAIL_CATEGORY = 'RightRail';
const RIGHT_RAIL_SELECTOR = 'aside[aria-label="Aside"]';
// Optionally stretch the feed into the freed space when the rail is hidden.
// Verified live: the page is a 24px-column CSS grid; the left sidebar spans
// cols 1-5, the feed section 6-17, the rail 18 to the end. Re-spanning the
// section's grid-column-end to -1 and unfixing the inner widths makes the
// posts (and their media) fill the full remaining width.
// This is a separate toggle (StretchFeed) because the site's own layout has
// viewport-driven feed width tiers; with the stretch off, hiding the rail
// leaves the native widths untouched.
const STRETCH_OPTION = 'StretchFeed';
const STRETCH_CLASS = 'lfp-stretch';
const STRETCH_CSS = `
html.${STRETCH_CLASS} section[aria-label="Primary content"]{
grid-column-end:-1 !important;width:auto !important}
html.${STRETCH_CLASS} section[aria-label="Primary content"] [role="list"]{
width:100% !important}`;
const CATEGORIES = [...Object.keys(LABELS), FOLLOWS_PAGE_CATEGORY, RIGHT_RAIL_CATEGORY];
const DISPLAY_NAMES = {
Promoted: 'Promoted',
Suggested: 'Suggested',
PeopleYouMayKnow: 'People you may know',
RecommendedForYou: 'Recommended for you',
PopularCourse: 'Popular course (Learning)',
TopApplicantJobs: 'Top applicant jobs',
FollowsPage: '"Follows this Page"',
RightRail: 'Right column',
StretchFeed: 'Stretch feed into freed space',
};
const STORAGE_KEY = 'lfp_settings_v1';
const SWEEP_DEBOUNCE_MS = 150;
// Flatten label -> category lookup, e.g. { "Promoted": "Promoted", ... }
const LABEL_TO_CATEGORY = {};
for (const [category, words] of Object.entries(LABELS)) {
for (const w of words) LABEL_TO_CATEGORY[w] = category;
}
const ALL_LABELS = new Set(Object.keys(LABEL_TO_CATEGORY));
// ---- Settings persistence (portable: no GM_* API required) ---------------
const defaults = { panelCollapsed: false };
for (const c of CATEGORIES) defaults[c] = true;
defaults[STRETCH_OPTION] = true;
function loadSettings() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? Object.assign({}, defaults, JSON.parse(raw)) : Object.assign({}, defaults);
} catch (e) {
return Object.assign({}, defaults);
}
}
function saveSettings() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); } catch (e) {}
}
const settings = loadSettings();
let hiddenCount = {};
// ---- Detection -----------------------------------------------------------
// Determine which categories a post card belongs to. Returns an array of
// category strings (empty for organic posts). A card can match more than one
// category at once (e.g. a sponsored post shown because a connection follows
// the advertiser's Page).
// Exact labels: we look for a CHILDLESS element whose trimmed text is EXACTLY
// a known label, which avoids false positives from body text like
// "I was promoted to ...".
function detectCategories(card) {
const found = [];
const candidates = card.querySelectorAll('span, p, a');
for (const el of candidates) {
if (el.children.length !== 0) continue;
const t = el.textContent.trim();
const cat = LABEL_TO_CATEGORY[t];
if (cat && !found.includes(cat)) found.push(cat);
}
// "follows this Page": regex on the smallest element containing the
// phrase, restricted to the card's header strip.
const cardTop = card.getBoundingClientRect().top;
for (const el of candidates) {
if (!FOLLOWS_PAGE_RX.test(el.textContent || '')) continue;
// skip wrappers: only the innermost element containing the phrase counts
let smallest = true;
for (const child of el.children) {
if (FOLLOWS_PAGE_RX.test(child.textContent || '')) { smallest = false; break; }
}
if (!smallest) continue;
const offset = el.getBoundingClientRect().top - cardTop;
if (offset >= 0 && offset <= FOLLOWS_PAGE_MAX_OFFSET_PX) {
found.push(FOLLOWS_PAGE_CATEGORY);
break;
}
}
return found;
}
// A post card sits under wrapper divs; the element that actually
// participates in the feed's flex layout is the topmost box-generating
// ancestor between the card and the [role="list"] container (intermediate
// wrappers are display:contents). The list has an 8px flex gap, and a
// zero-height wrapper still counts as a flex item, so hiding only the inner
// card would leak an 8px blank gap per hidden post (verified live: 7 hidden
// posts left a 64px blank band at the top of the feed).
function feedFlexItem(card) {
const list = card.closest('[role="list"]');
if (!list) return card;
let n = card, item = card;
while (n.parentElement && n.parentElement !== list) {
n = n.parentElement;
if (getComputedStyle(n).display !== 'contents') item = n;
}
return item;
}
// Locate the right rail. Once hidden its geometry collapses to 0, so a
// node we already marked is found via the marker attribute first.
function findRightRail() {
let rail = document.querySelector('[data-lfp-rail]');
if (rail) return rail;
rail = document.querySelector(RIGHT_RAIL_SELECTOR);
if (rail) return rail;
// fallback: a visible aside sitting in the right third of the viewport
for (const a of document.querySelectorAll('aside')) {
const r = a.getBoundingClientRect();
if (r.width > 0 && r.left > window.innerWidth * 0.6) return a;
}
return null;
}
// ---- Sweep ---------------------------------------------------------------
function sweep() {
const counts = {};
for (const c of CATEGORIES) counts[c] = 0;
const cards = document.querySelectorAll('div[role="listitem"]');
for (const card of cards) {
// Skip nested listitems: each person inside a "People you may know" /
// "Recommended for you" module is itself a role="listitem". They match
// no label, resolve to the same flex item as the module, and would
// restore the wrapper the module hide just collapsed.
if (card.parentElement && card.parentElement.closest('div[role="listitem"]')) continue;
const matched = detectCategories(card);
// hide if ANY matched category is enabled; attribute the hide to the
// first enabled one for the panel counters
const hideAs = matched.find(c => settings[c]);
const target = feedFlexItem(card);
if (hideAs) {
if (target.style.display !== 'none') target.style.display = 'none';
target.dataset.lfpHidden = hideAs; // remember WE hid it, and why
counts[hideAs]++;
} else if (target.dataset.lfpHidden) {
// We previously hid this node (toggle turned off, or node reused by the
// virtualized feed for a different post). Restore only our own hides.
target.style.display = '';
delete target.dataset.lfpHidden;
}
}
// Right rail: a single fixed element, not a feed card. Hiding it also
// stretches the feed into the freed space via the root stretch class.
const rail = findRightRail();
if (rail) {
if (settings[RIGHT_RAIL_CATEGORY]) {
rail.setAttribute('data-lfp-rail', '');
if (rail.style.display !== 'none') rail.style.display = 'none';
document.documentElement.classList.toggle(STRETCH_CLASS, !!settings[STRETCH_OPTION]);
counts[RIGHT_RAIL_CATEGORY] = 1;
} else if (rail.hasAttribute('data-lfp-rail')) {
rail.style.display = '';
rail.removeAttribute('data-lfp-rail');
document.documentElement.classList.remove(STRETCH_CLASS);
}
}
hiddenCount = counts;
updatePanelCounts();
}
// Debounced sweep driven by feed mutations (infinite scroll / virtualization).
let sweepTimer = null;
function scheduleSweep() {
if (sweepTimer) return;
sweepTimer = setTimeout(() => { sweepTimer = null; sweep(); }, SWEEP_DEBOUNCE_MS);
}
function startObserver() {
const observer = new MutationObserver(scheduleSweep);
observer.observe(document.body, { childList: true, subtree: true });
}
// ---- Control panel UI ----------------------------------------------------
let panelEls = {};
// Built with DOM methods (no innerHTML) so it works under the site's
// Trusted Types CSP, both in-page (@grant none) and in an isolated world.
const PANEL_CSS = `
#lfp-panel{position:fixed;bottom:16px;left:16px;z-index:99999;
font:13px/1.4 -apple-system,system-ui,sans-serif;color:#1d2226;
background:#fff;border:1px solid #d0d5dd;border-radius:10px;
box-shadow:0 6px 24px rgba(0,0,0,.16);width:248px;overflow:hidden}
#lfp-panel .lfp-head{display:flex;align-items:center;justify-content:space-between;
padding:8px 10px;background:#0a66c2;color:#fff;cursor:pointer;user-select:none}
#lfp-panel .lfp-head b{font-weight:600;font-size:12.5px}
#lfp-panel .lfp-body{padding:8px 10px}
#lfp-panel label{display:flex;align-items:center;justify-content:space-between;
gap:8px;padding:5px 0;cursor:pointer}
#lfp-panel .lfp-cnt{color:#5f6b7a;font-variant-numeric:tabular-nums;font-size:12px}
#lfp-panel input{width:16px;height:16px;accent-color:#0a66c2;cursor:pointer}
#lfp-panel .lfp-name{display:flex;align-items:center;gap:8px;flex:1}
#lfp-panel .lfp-foot{border-top:1px solid #eef0f2;padding-top:6px;margin-top:4px;
color:#8a94a0;font-size:11px;text-align:center}
#lfp-panel.lfp-collapsed .lfp-body{display:none}
#lfp-panel .lfp-chev{transition:transform .15s}
#lfp-panel.lfp-collapsed .lfp-chev{transform:rotate(180deg)}`;
function el(tag, props, children) {
const node = document.createElement(tag);
if (props) Object.assign(node, props);
if (children) for (const c of children) node.appendChild(c);
return node;
}
function buildRow(cat, opts) {
const cb = el('input', { type: 'checkbox' });
cb.checked = !!settings[cat];
cb.addEventListener('change', () => {
settings[cat] = cb.checked;
saveSettings();
sweep();
});
const prefix = (opts && opts.noPrefix) ? ' ' : ' Hide ';
const name = el('span', { className: 'lfp-name' }, [cb, document.createTextNode(prefix + (DISPLAY_NAMES[cat] || cat))]);
const kids = [name];
if (!(opts && opts.noCount)) {
const cnt = el('span', { className: 'lfp-cnt', textContent: '0' });
panelEls.cnt[cat] = cnt;
kids.push(cnt);
}
return el('label', null, kids);
}
function buildPanel() {
const style = el('style', { textContent: PANEL_CSS + STRETCH_CSS });
document.head.appendChild(style);
panelEls.cnt = {};
const chev = el('span', { className: 'lfp-chev', textContent: '▾' });
const head = el('div', { className: 'lfp-head' }, [el('b', { textContent: 'LI Purity' }), chev]);
const body = el('div', { className: 'lfp-body' }, [
...CATEGORIES.map(c => buildRow(c)),
buildRow(STRETCH_OPTION, { noPrefix: true, noCount: true }),
el('div', { className: 'lfp-foot', textContent: 'hidden this view' }),
]);
const panel = el('div', { id: 'lfp-panel' }, [head, body]);
if (settings.panelCollapsed) panel.classList.add('lfp-collapsed');
head.addEventListener('click', () => {
panel.classList.toggle('lfp-collapsed');
settings.panelCollapsed = panel.classList.contains('lfp-collapsed');
saveSettings();
});
document.body.appendChild(panel);
panelEls.panel = panel;
}
function updatePanelCounts() {
if (!panelEls.cnt) return;
for (const c of CATEGORIES) {
if (panelEls.cnt[c]) panelEls.cnt[c].textContent = hiddenCount[c] || 0;
}
}
// ---- Init ----------------------------------------------------------------
function init() {
if (document.getElementById('lfp-panel')) return; // guard against double-inject
buildPanel();
startObserver();
sweep();
}
if (document.body) init();
else window.addEventListener('DOMContentLoaded', init);
})();