diff --git a/AI_STATUS.md b/AI_STATUS.md index 2cfd1a2..1b55bdf 100644 --- a/AI_STATUS.md +++ b/AI_STATUS.md @@ -1,7 +1,7 @@ # Project Status > Auto-updated. Read this before starting any work. -> Last updated: 2026-04-30 09:15 +> Last updated: 2026-06-12 (v1.3.5 prepared locally) ## Goal @@ -10,8 +10,21 @@ Success = 1000+ active users and positive reviews before reintroducing any paid ## In Progress -- LinkClean v1.3.3 layout-safety hotfix submitted to Chrome Web Store and Microsoft Edge Add-ons on 2026-04-30; Firefox AMO submission is blocked by API throttle until roughly 2026-04-30 21:50 UTC / 2026-05-01 02:50 Asia/Qyzylorda. -- LinkClean v1.3.2 submitted to Chrome Web Store, Firefox AMO, and Microsoft Edge Add-ons on 2026-04-28; waiting for store review/propagation. +- **v1.3.5 promoted-post hotfix prepared locally (2026-06-12)** after a second FormSubmit uninstall feedback ("It didn't hide promoted posts correctly", 2026-06-12 00:37 UTC). Root cause: LinkedIn's 2026 feed DOM rewrite (`data-testid="mainFeed"`, `article[data-id="main-feed-card"]`, actor line in `p[componentkey]`) was invisible to the v1.3.4 detector — the loose label collector explicitly skipped all `

` content as body copy, and only ~15 of 28 locales had promoted keywords. Evidence base: open-source LinkedinSponsorBlock (Hogwai, updated 2026-05-31) modern profile selectors. Fix: (1) new-DOM containers in discovery/identity; (2) structural promoted markers `data-sponsored-tracking-url`, `data-view-tracking-scope*="sponsored"`, `data-promoted-tracking-control-name`; (3) strict-equality matching of `

` metadata texts (full text, direct text nodes, direct child spans) against keyword sets — catches new-DOM labels without body-copy false positives; (4) promoted/suggested keywords expanded to ~40 languages incl. CJK/Thai (substring match only for no-space scripts, length ≥ 3); (5) hiding moved from inline `style.display` to injected CSS rule on `data-linkclean-hidden` with `!important` so LinkedIn re-renders can't un-hide posts; (6) mutation handling switched from resetting debounce to 250 ms throttle (no starvation during continuous feed mutations); (7) poll filter no longer scans `innerHTML` for "poll" (organic posts mentioning polls survive). Consilium council review (run 20260612T092359, R4 audit skipped: cursor quota) flagged and we fixed: tracking-scope matching anchored to quoted `"sponsored"` token + post root only (organic `sponsored-content-follow` interactions survive); CJK matching via scope-not-length (strict equality + token match inside LinkedIn-generated `sub-description` lines only, no substring rule); NFKC + zero-width stripping in `normalizeText`; leading+trailing throttle; static CSS pre-hide rules on LinkedIn's own sponsored attrs (no ad flash). Bonus fix found by new tests: container-div label collection false positive (short organic post mentioning "sponsored" in a `div[data-urn]` container was hidden even in v1.3.4) — label elements containing body copy are now excluded. Verified: `npm test` 33/33, `npm run typecheck`, `npm run build`, `npm run zip` (manifest `version=1.3.5`), Prettier clean. Committed locally as `87bf7be`. +- **v1.3.5 submitted to all stores (2026-06-12, owner-approved)** via MiranaApps `publish.sh --all`: Chrome Web Store upload `SUCCEEDED` + publish HTTP 200, official v2 `fetchStatus` confirms `published=PUBLISHED submitted=PENDING_REVIEW version=1.3.5`; Firefox AMO validation 0 errors / 9 warnings (https://addons.mozilla.org/en-US/developers/addon/2994972/file/4847482/validation), published successfully; Edge Add-ons package uploaded and submitted for review (product `91c40340-8f47-426f-adb0-2ba51223fc92`). Git push to `origin/master` was blocked by the local permission classifier (direct default-branch push) — commit `87bf7be` is local-only until Tugelbay pushes or allows it. +- **Repo git object store was corrupted and repaired (2026-06-12)**: `git fsck` showed invalid sha1 pointers/reflog entries and `git log`/`git diff` failed. `git fetch origin --refetch` restored all objects from GitHub; local master fast-forwarded to origin/master (`97f0413 chore: make CodeRabbit advisory`); fsck now clean. +- LinkClean v1.3.4 promoted-post reliability hotfix is published on Chrome Web Store after uninstall feedback that promoted posts were not hidden correctly. Fresh CWS official `fetchStatus` reports `published=PUBLISHED`, `submitted=null`, `latest_crx_version=1.3.4`. +- Firefox AMO v1.3.4 is public/current in authenticated status (`id=6258981`, `reviewed=2026-05-12T21:46:08Z`), with `0` average daily users / `0` weekly downloads. +- Microsoft Edge Add-ons v1.3.4 was submitted after fixing MiranaApps `scripts/publish.sh` for Edge API v1.1 ApiKey auth. Upload operation `3396b617-e3fa-4fb3-bda7-fe7eaa630ce8` completed and the submit call was accepted; wait for Edge review/public listing propagation. +- Firefox AMO v1.3.3 was manually retried after the throttle window on 2026-05-01. The scheduled retry log showed AMO API `read ECONNRESET`, not a remaining throttle. The manual safe-wrapper retry completed upload + listed-version submit with validation 0 errors / 9 warnings (`https://addons.mozilla.org/en-US/developers/addon/2994972/file/4787089/validation`), but the public AMO API still reports current public version `1.3.2`, so v1.3.3 is submitted and awaiting AMO review/publication. +- Firefox AMO v1.3.3 was retried again on 2026-05-05 08:38 Asia/Qyzylorda via `powershell -ExecutionPolicy Bypass -File .\scripts\submit-firefox.ps1`. AMO returned `409 Conflict` on version creation with `_data.version: "Version 1.3.3 already exists."` This is a duplicate-version blocker, not throttle/auth failure; current action is to wait for AMO review/publication of the existing 1.3.3 submission. + +- Firefox AMO v1.3.3 retry rerun on 2026-05-17 11:10 Asia/Qyzylorda. C:\Projects\LinkClean is empty, so active repo path \\wsl.localhost\Ubuntu-24.04\home\tugelbay\Projects\00-Workspace\LinkClean was used. Preflight confirmed git status; 1.3.3 ZIP artifacts were missing, npm run zip:firefox produced only 1.3.4 ZIPs. Running powershell -ExecutionPolicy Bypass -File .\scripts\submit-firefox.ps1 from repo context failed before AMO API with sanitized blocker Missing FIREFOX_JWT_ISSUER, FIREFOX_JWT_SECRET, or FIREFOX_EXTENSION_ID (no submit attempted). +- Firefox AMO v1.3.3 retry rerun on 2026-05-18 10:20 Asia/Qyzylorda in active repo \\wsl.localhost\Ubuntu-24.04\home\tugelbay\Projects\00-Workspace\LinkClean (C:\Projects\LinkClean remains empty). Preflight confirmed git status; 1.3.3 ZIP artifacts were missing and `npm run zip:firefox` again produced only 1.3.4 ZIPs. Running `powershell -ExecutionPolicy Bypass -File .\scripts\submit-firefox.ps1` failed before AMO API with sanitized blocker `Missing FIREFOX_JWT_ISSUER, FIREFOX_JWT_SECRET, or FIREFOX_EXTENSION_ID`, so AMO did not accept a new retry submission. +- Firefox AMO v1.3.3 retry rerun on 2026-05-20 03:06 Asia/Qyzylorda in active repo `\\wsl.localhost\Ubuntu-24.04\home\tugelbay\Projects\00-Workspace\LinkClean` (`C:\Projects\LinkClean` is still not a git repo). Preflight in active repo confirmed git status; `1.3.3` ZIP artifacts were missing and `npm run zip:firefox` produced only `1.3.4` ZIPs. Running `powershell -ExecutionPolicy Bypass -File .\scripts\submit-firefox.ps1` failed before AMO API with sanitized blocker `Missing FIREFOX_JWT_ISSUER, FIREFOX_JWT_SECRET, or FIREFOX_EXTENSION_ID`, so AMO did not accept a new retry submission. +- Firefox AMO v1.3.3 retry rerun on 2026-05-22 03:22 Asia/Qyzylorda in active repo `\\wsl.localhost\Ubuntu-24.04\home\tugelbay\Projects\00-Workspace\LinkClean` (`C:\Projects\LinkClean` is still empty/not a git repo). Preflight in active repo confirmed git status; `1.3.3` ZIP artifacts were missing and `npm run zip:firefox` produced only `1.3.4` ZIPs. Running `powershell -ExecutionPolicy Bypass -File .\scripts\submit-firefox.ps1` failed before AMO API with sanitized blocker `Missing FIREFOX_JWT_ISSUER, FIREFOX_JWT_SECRET, or FIREFOX_EXTENSION_ID`, so AMO did not accept a new retry submission. +- Firefox AMO v1.3.3 retry rerun on 2026-05-23 03:32 Asia/Qyzylorda in active repo `\\wsl.localhost\Ubuntu-24.04\home\tugelbay\Projects\00-Workspace\LinkClean` (`C:\Projects\LinkClean` is still empty/not a git repo). Preflight in active repo confirmed git status; `1.3.3` ZIP artifacts were missing and `npm run zip:firefox` produced only `1.3.4` ZIPs. Running `powershell -ExecutionPolicy Bypass -File .\scripts\submit-firefox.ps1` via `powershell.exe` failed before AMO API with sanitized blocker `Missing FIREFOX_JWT_ISSUER, FIREFOX_JWT_SECRET, or FIREFOX_EXTENSION_ID`, so AMO did not accept a new retry submission. +- Firefox AMO v1.3.3 retry rerun on 2026-05-25 06:53 Asia/Qyzylorda in active repo \\wsl.localhost\Ubuntu-24.04\home\tugelbay\Projects\00-Workspace\LinkClean (C:\Projects\LinkClean is not a git repo). Preflight in active repo confirmed git status and both required artifacts exist: .output/linkclean-1.3.3-firefox.zip and .output/linkclean-1.3.3-sources.zip. Running powershell -ExecutionPolicy Bypass -File .\scripts\submit-firefox.ps1 failed before AMO API with sanitized blocker Missing FIREFOX_JWT_ISSUER, FIREFOX_JWT_SECRET, or FIREFOX_EXTENSION_ID, so AMO did not accept the submission. - Investigated version bump: repo version **1.3.0** was introduced by commit `2aa51d3` on 2026-03-31 by Tugelbay Konabayev; there is no `v1.3.0` git tag, so the standard GitHub tag-based release workflow did not publish it. ## Done @@ -36,6 +49,10 @@ Success = 1000+ active users and positive reviews before reintroducing any paid - **v1.3.2 submitted to all stores (2026-04-28)**: Chrome Web Store upload/publish API returned OK for extension `ipdckibncofmlnoaajkdhnbclbpgppdg`; Firefox AMO accepted the listed submission with validation result 0 errors / 9 warnings (`https://addons.mozilla.org/en-US/developers/addon/2994972/file/4783244/validation`); Microsoft Edge Add-ons API v1.1 accepted package upload operation `958c355a-77de-4ed8-ae81-9acda7a867d0` and publish operation `6d559182-96ca-4fb1-8b01-ac96413ea391`. - **v1.3.3 layout-safety hotfix prepared locally (2026-04-30)**: content script now only mutates LinkedIn `/feed`, restores only elements LinkClean hid, preserves previous inline `display`, re-checks after SPA path changes, avoids generic profile/about/sidebar articles as feed posts, and no longer treats normal jobs/premium links as sidebar ads without promo labels. Regression tests were added for profile articles, sidebar content, non-feed paths, organic `suggested` text, and normal jobs links. - **v1.3.3 submitted to Chrome and Edge (2026-04-30)**: Chrome Web Store `wxt submit` completed upload + submit-for-review successfully; Microsoft Edge Add-ons API v1.1 accepted package upload operation `b33ef930-8365-47af-8b64-d4fd976960e6` and publish operation `69bb5560-6623-4c0a-99d2-a2b72a9010ad`. Firefox AMO returned 429 throttle during version creation (`Expected available in 63167 seconds`), so Firefox retry is pending. +- **v1.3.3 Chrome re-submit verified (2026-04-30 15:17 Asia/Qyzylorda)**: MiranaApps `publish.sh --chrome` uploaded `.output/linkclean-1.3.3-chrome.zip` and Chrome Web Store publish API returned `Status: OK`. A fresh CWS official `fetchStatus` then confirmed `LinkClean: published=PUBLISHED submitted=PENDING_REVIEW version=1.3.3`. +- **v1.3.3 Edge retry attempted (2026-04-30 15:17 Asia/Qyzylorda)**: `scripts/submit-edge-v11.ps1` uploaded package operation `dda87669-2ae6-47d8-ad38-8d560341d300` successfully, then publish operation `53b63987-6b1c-4488-8fbd-1cc4e12d59bd` failed with `Can't publish extension as your extension submission is in progress. Please try again later.` +- **v1.3.3 Firefox retry attempted and automated (2026-04-30 15:18 Asia/Qyzylorda)**: `scripts/submit-firefox.ps1` reached AMO version creation but returned 429 throttle. One-off Windows Scheduled Task `MiranaApps-LinkClean-Firefox-1.3.3-Retry` is ready for 2026-05-01 03:55 Asia/Qyzylorda. +- **v1.3.4 promoted-post reliability hotfix prepared and submitted to all stores (2026-05-13)**: audited the LinkedIn feed detector with Claude Opus/max second opinion after FormSubmit uninstall feedback. The fix expands feed post discovery to newer LinkedIn `urn:li:*`, `data-finite-scroll-hotkey-item`, and `data-view-name="feed-full-update"` containers; detects promoted labels inside actor metadata lines, compact `div` labels, `aria-describedby`, `alt`/`title`/`aria-label`, and `data-promoted-tracking-control-name`; re-processes existing posts when lazy-loaded ad label text/attributes change; keeps sidebar label matching stricter to avoid body-copy false positives. Verified with `npm test` (`23/23`), `npm run typecheck`, targeted Prettier check, `npm run build`, `npm run zip`, zip manifest `version=1.3.4`, and `npm audit --audit-level=high` (`0` vulnerabilities). MiranaApps `publish.sh --chrome` uploaded `.output/linkclean-1.3.4-chrome.zip`; CWS publish API returned `Status: OK`; official CWS status now shows `published=PUBLISHED`, `submitted=null`, `latest_crx_version=1.3.4`. Firefox AMO status now shows current/public `1.3.4`, reviewed `2026-05-12T21:46:08Z`. Microsoft Edge Add-ons accepted the v1.3.4 submission after the publisher switched to Edge API v1.1 ApiKey auth. - Firefox AMO uploaded via web-ext sign (waiting review) - Dev.to article published: https://dev.to/konabayev/i-built-a-chrome-extension-to-clean-my-linkedin-feed-heres-how-541c - 2 GitHub Awesome list PRs: best-chrome-extensions#22, awesome-productivity#228 @@ -47,8 +64,8 @@ Success = 1000+ active users and positive reviews before reintroducing any paid ## Next Up -1. Retry Firefox AMO submission for LinkClean **v1.3.3** after 2026-05-01 02:50 Asia/Qyzylorda, then monitor all stores for review/propagation. -1. Monitor Chrome Web Store, Firefox AMO, and Microsoft Edge Add-ons review/propagation for LinkClean **v1.3.2**; confirm public store pages show the new version after approval. +1. Monitor Edge Add-ons review/public listing propagation for LinkClean **v1.3.4**. +1. Watch uninstall feedback and CWS private WAU after the v1.3.4 fix; latest private CWS funnel is `552` impressions -> `48` page views -> `63` installs -> `7` WAU. 1. Handle Firefox `data_collection_permissions` before the next AMO policy deadline; current build warning says future submissions may require it. 1. Confirm whether the live CWS `1.3.0` publish used local `publish.sh` / manual CWS upload path and document the exact release path. 1. Sync scripts to VPS + set up daily cron for auto-scan. diff --git a/CLAUDE.md b/CLAUDE.md index 11df2aa..1c28fdd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,7 @@ # Local Claude Instructions + @/home/tugelbay/Projects/CLAUDE.md Root Claude rules apply first. These local notes only add project-specific context. @@ -10,7 +11,7 @@ Root Claude rules apply first. These local notes only add project-specific conte # LinkClean — LinkedIn Feed Cleaner > Part of Mirana Apps portfolio. Parent: /home/tugelbay/Projects/MiranaApps/CLAUDE.md -> CWS knowledge: /mnt/c/Brain/Knowledge/cws-unified.md +> CWS knowledge: /home/tugelbay/Projects/Brain/Knowledge/cws-unified.md ## This Extension diff --git a/package-lock.json b/package-lock.json index ee5f361..e933df1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkclean", - "version": "1.3.3", + "version": "1.3.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkclean", - "version": "1.3.3", + "version": "1.3.5", "hasInstallScript": true, "dependencies": { "lucide-react": "^0.468.0", diff --git a/package.json b/package.json index 7809d0f..8d588f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkclean", - "version": "1.3.3", + "version": "1.3.5", "description": "LinkClean — LinkedIn Feed Cleaner, Filter Ads & Promoted Posts", "type": "module", "scripts": { diff --git a/src/entrypoints/linkedin-feed.content.ts b/src/entrypoints/linkedin-feed.content.ts index 4c8b0c2..8c3277d 100644 --- a/src/entrypoints/linkedin-feed.content.ts +++ b/src/entrypoints/linkedin-feed.content.ts @@ -24,6 +24,62 @@ import { const POST_HIDDEN_ATTR = "data-linkclean-hidden"; const SIDEBAR_HIDDEN_ATTR = "data-linkclean-sidebar"; const PREVIOUS_DISPLAY_ATTR = "data-linkclean-previous-display"; +const HIDE_STYLE_ID = "linkclean-hide-style"; + +const OBSERVED_ATTRIBUTES = [ + "aria-label", + "aria-describedby", + "title", + "alt", + "data-test-id", + "data-promoted-tracking-control-name", + "data-sponsored-tracking-url", + "data-view-tracking-scope", + "data-finite-scroll-hotkey-item", + "data-urn", + "data-id", + "componentkey", +]; + +/** + * Hide via stylesheet keyed on our marker attribute, not inline styles. + * LinkedIn re-renders overwrite element.style and would un-hide posts while + * the marker attribute survives — the CSS rule keeps them hidden. + * + * When the promoted filter is active on the feed, also pre-hide elements + * carrying LinkedIn's own sponsored markers so ads never paint at all + * (no visible-then-hidden flash while the JS pass catches up). + */ +function buildHideStyleContent( + filters: FilterSettings, + onFeedSurface: boolean, +): string { + const rules = [ + `[${POST_HIDDEN_ATTR}="true"], [${SIDEBAR_HIDDEN_ATTR}="true"] { display: none !important; }`, + ]; + + if (onFeedSurface && filters.enabled && filters.hidePromoted) { + rules.push( + "main article[data-sponsored-tracking-url], " + + "main [data-urn*='promoted'], " + + "main [data-id*='promoted'] { display: none !important; }", + ); + } + + return rules.join("\n"); +} + +function ensureHideStyle(content: string): void { + let style = document.getElementById(HIDE_STYLE_ID) as HTMLStyleElement | null; + if (!style || !style.isConnected) { + style = document.createElement("style"); + style.id = HIDE_STYLE_ID; + (document.head ?? document.documentElement).appendChild(style); + } + if (style.textContent !== content) { + style.textContent = content; + } +} export default defineContentScript({ matches: ["*://*.linkedin.com/*"], @@ -34,11 +90,12 @@ export default defineContentScript({ let pendingCount = 0; let flushTimer: ReturnType | null = null; let processTimer: ReturnType | null = null; + let lastProcessedAt = 0; let currentPathname = window.location.pathname; chrome.storage.onChanged.addListener((changes, area) => { if (area === "local" && changes.linkclean_filters) { - filters = changes.linkclean_filters.newValue; + filters = changes.linkclean_filters.newValue ?? filters; scheduleProcessAll(); } }); @@ -58,8 +115,6 @@ export default defineContentScript({ function hideManagedElement(element: HTMLElement, attr: string): boolean { if (element.getAttribute(attr) === "true") return false; - element.setAttribute(PREVIOUS_DISPLAY_ATTR, element.style.display); - element.style.display = "none"; element.setAttribute(attr, "true"); return true; } @@ -67,14 +122,19 @@ export default defineContentScript({ function restoreManagedElement(element: HTMLElement, attr: string): void { if (element.getAttribute(attr) !== "true") return; - const previousDisplay = element.getAttribute(PREVIOUS_DISPLAY_ATTR) ?? ""; - if (previousDisplay) { - element.style.display = previousDisplay; - } else { - element.style.removeProperty("display"); - } element.removeAttribute(attr); - element.removeAttribute(PREVIOUS_DISPLAY_ATTR); + + // Cleanup for elements hidden by older LinkClean versions that used + // inline display styles. + const previousDisplay = element.getAttribute(PREVIOUS_DISPLAY_ATTR); + if (previousDisplay !== null) { + if (previousDisplay) { + element.style.display = previousDisplay; + } else { + element.style.removeProperty("display"); + } + element.removeAttribute(PREVIOUS_DISPLAY_ATTR); + } } function restoreAllManagedElements(): void { @@ -108,13 +168,23 @@ export default defineContentScript({ } } + function runProcessPass(): void { + lastProcessedAt = Date.now(); + ensureHideStyle(buildHideStyleContent(filters, isActiveFeedSurface())); + processAllPosts(); + hideSidebarAds(); + } + function scheduleProcessAll(): void { - if (processTimer) clearTimeout(processTimer); + // Leading+trailing throttle, not debounce: LinkedIn's feed mutates + // continuously while scrolling, and a resetting debounce would starve + // processing. After a quiet period the first batch runs immediately. + if (processTimer) return; + const delay = Date.now() - lastProcessedAt > 600 ? 0 : 250; processTimer = setTimeout(() => { - processAllPosts(); - hideSidebarAds(); processTimer = null; - }, 250); + runProcessPass(); + }, delay); } function scheduleFlush(): void { @@ -240,7 +310,23 @@ export default defineContentScript({ } } - // MutationObserver — watch for new posts added to the feed + function shouldProcessMutation(mutation: MutationRecord): boolean { + if (mutation.type === "childList") { + return mutation.addedNodes.length > 0; + } + + if (mutation.type === "characterData") { + return true; + } + + if (mutation.type === "attributes") { + return OBSERVED_ATTRIBUTES.includes(mutation.attributeName ?? ""); + } + + return false; + } + + // MutationObserver — watch for new posts and lazy-loaded ad labels const observer = new MutationObserver((mutations) => { let hasNewNodes = false; const pathnameChanged = currentPathname !== window.location.pathname; @@ -249,7 +335,7 @@ export default defineContentScript({ } for (const mutation of mutations) { - if (mutation.addedNodes.length > 0) { + if (shouldProcessMutation(mutation)) { hasNewNodes = true; break; } @@ -260,10 +346,15 @@ export default defineContentScript({ }); // Observe the entire body to catch both feed posts and lazy-loaded sidebar ads - observer.observe(document.body, { childList: true, subtree: true }); + observer.observe(document.body, { + childList: true, + subtree: true, + characterData: true, + attributes: true, + attributeFilter: OBSERVED_ATTRIBUTES, + }); // Process existing posts + sidebar - processAllPosts(); - hideSidebarAds(); + runProcessPass(); }, }); diff --git a/src/lib/linkedin-detector.test.ts b/src/lib/linkedin-detector.test.ts index 87c9ee8..6fa290f 100644 --- a/src/lib/linkedin-detector.test.ts +++ b/src/lib/linkedin-detector.test.ts @@ -53,6 +53,54 @@ describe("LinkedIn feed detector", () => { expect(posts[0].getAttribute("data-urn")).toBe("urn:li:activity:123"); }); + it("finds newer LinkedIn feed update containers", () => { + const document = render(` +

+
+ Alex Morgan +

This is a normal feed post with enough text to look realistic.

+
+
+ `); + + const posts = findLinkedInFeedPosts(document); + + expect(posts).toHaveLength(1); + expect(posts[0].getAttribute("data-view-name")).toBe("feed-full-update"); + }); + + it("finds LinkedIn posts with newer URN families", () => { + const document = render(` +
+
+ Acme Inc · Promoted · 2d + + +
+
+ `); + + const posts = findLinkedInFeedPosts(document); + + expect(posts).toHaveLength(1); + expect(posts[0].getAttribute("data-urn")).toBe("urn:li:ugcPost:123"); + }); + + it("finds short image-style ads when they expose a promoted signal", () => { + const document = render(` +
+
+ Promoted + Learn more +
+
+ `); + + const posts = findLinkedInFeedPosts(document); + + expect(posts).toHaveLength(1); + }); + it("does not treat generic profile articles as feed posts", () => { const document = render(`
@@ -110,6 +158,71 @@ describe("LinkedIn feed detector", () => { expect(shouldHideLinkedInPost(post, baseFilters)).toBe(true); }); + it("hides promoted labels rendered in compact actor description divs", () => { + const document = render(` +
+
+

Feed post

+
+ Promoted · 2nd +
+

Try this hiring tool today.

+
+
+ `); + const [post] = findLinkedInFeedPosts(document); + + expect(shouldHideLinkedInPost(post, baseFilters)).toBe(true); + }); + + it("hides promoted labels embedded in actor metadata lines", () => { + const document = render(` +
+
+

Feed post

+
+ Acme Inc · 11,234 followers · Promoted · 2d +
+

Try this hiring tool today.

+
+
+ `); + const [post] = findLinkedInFeedPosts(document); + + expect(shouldHideLinkedInPost(post, baseFilters)).toBe(true); + }); + + it("hides promoted labels exposed through LinkedIn ad tracking attributes", () => { + const document = render(` +
+
+

Feed post

+

Try this hiring tool today.

+
+
+ `); + const [post] = findLinkedInFeedPosts(document); + + expect(shouldHideLinkedInPost(post, baseFilters)).toBe(true); + }); + + it("hides promoted labels referenced by aria-describedby", () => { + const document = render(` +
+
+

Feed post

+ + This is a promoted post + +

Ad creative where the label is only exposed to accessibility APIs.

+
+
+ `); + const [post] = findLinkedInFeedPosts(document); + + expect(shouldHideLinkedInPost(post, baseFilters)).toBe(true); + }); + it("does not hide an organic post that only mentions sponsored in body copy", () => { const document = render(`
@@ -125,6 +238,23 @@ describe("LinkedIn feed detector", () => { expect(shouldHideLinkedInPost(post, baseFilters)).toBe(false); }); + it("does not hide organic commentary spans that mention sponsored work", () => { + const document = render(` +
+
+

Feed post

+ Priya Shah +
+ We sponsored the local developer meetup last weekend. +
+
+
+ `); + const [post] = findLinkedInFeedPosts(document); + + expect(shouldHideLinkedInPost(post, baseFilters)).toBe(false); + }); + it("does not hide organic posts that mention suggested in body copy", () => { const document = render(`
@@ -180,6 +310,166 @@ describe("LinkedIn feed detector", () => { expect(isPromotionalSidebarWidget(widget)).toBe(false); }); + it("hides promoted posts in the 2026 main-feed-card DOM with p[componentkey] labels", () => { + const document = render(` +
+
+

Promoted

+

Try this hiring tool today and grow your pipeline faster.

+
+
+ `); + const posts = findLinkedInFeedPosts(document); + + expect(posts).toHaveLength(1); + expect(shouldHideLinkedInPost(posts[0], baseFilters)).toBe(true); + }); + + it("hides posts that carry LinkedIn's sponsored tracking attributes", () => { + const document = render(` +
+
+

Short ad creative without any visible label text at all.

+
+
+ `); + const posts = findLinkedInFeedPosts(document); + + expect(posts).toHaveLength(1); + expect(shouldHideLinkedInPost(posts[0], baseFilters)).toBe(true); + }); + + it("hides posts whose view tracking scope marks the sponsored transporter", () => { + const document = render(` +
+
+ Acme Inc +

This ad creative has enough text to register as a feed post.

+
+
+ `); + const posts = findLinkedInFeedPosts(document); + + expect(posts).toHaveLength(1); + expect(shouldHideLinkedInPost(posts[0], baseFilters)).toBe(true); + }); + + it("hides promoted labels in languages without spaces (ja/zh/ko)", () => { + for (const label of ["プロモーション", "广告", "광고"]) { + const document = render(` +
+
+

Feed post

+ ${label} +

Localized ad creative with enough text to look like a post.

+
+
+ `); + const [post] = findLinkedInFeedPosts(document); + + expect(shouldHideLinkedInPost(post, baseFilters)).toBe(true); + } + }); + + it("hides promoted tokens inside LinkedIn metadata lines (CJK combined line)", () => { + const document = render(` +
+
+

Feed post

+
+ Acme株式会社・プロモーション・2日 +
+

Localized ad creative with enough text to look like a post.

+
+
+ `); + const [post] = findLinkedInFeedPosts(document); + + expect(shouldHideLinkedInPost(post, baseFilters)).toBe(true); + }); + + it("does not hide organic posts when sponsored only appears in interaction tracking scopes", () => { + const document = render(` +
+
+ Priya Shah +

Organic post where a user interacted with shared sponsored content.

+
+
+ `); + const posts = findLinkedInFeedPosts(document); + + expect(posts).toHaveLength(1); + expect(shouldHideLinkedInPost(posts[0], baseFilters)).toBe(false); + }); + + it("does not hide organic posts whose author headline contains promoted-like words", () => { + const document = render(` +
+
+

Feed post

+
+ Open to suggestions | Promu directeur général chez Acme +
+

Organic post written by a marketer with a tricky headline.

+
+
+ `); + const [post] = findLinkedInFeedPosts(document); + + expect(shouldHideLinkedInPost(post, baseFilters)).toBe(false); + }); + + it("hides poll posts via structural markers when the poll filter is on", () => { + const document = render(` +
+
+

Feed post

+ Priya Shah +
Which framework do you prefer?
+

Vote below and share your reasoning in the comments.

+
+
+ `); + const [post] = findLinkedInFeedPosts(document); + + expect( + shouldHideLinkedInPost(post, { ...baseFilters, hidePolls: true }), + ).toBe(true); + }); + + it("does not hide organic main-feed-card posts that mention promoted in body copy", () => { + const document = render(` +
+
+

Priya Shah

+

We promoted our community meetup last weekend and it sold out.

+
+
+ `); + const posts = findLinkedInFeedPosts(document); + + expect(posts).toHaveLength(1); + expect(shouldHideLinkedInPost(posts[0], baseFilters)).toBe(false); + }); + + it("does not hide organic posts that merely mention poll in body copy", () => { + const document = render(` +
+
+

Feed post

+ Priya Shah +

The poll results from last week's survey are finally in.

+
+
+ `); + const [post] = findLinkedInFeedPosts(document); + + expect( + shouldHideLinkedInPost(post, { ...baseFilters, hidePolls: true }), + ).toBe(false); + }); + it("does not hide normal jobs links without a promo label", () => { const document = render(`