Skip to content

About

One userscript, four modules — Facebook Clean Feed, YouTube Skip Ads, a Site Blocker with a work-hours focus schedule, and a desktop/mobile View Mode switcher — all configurable from one in-page panel. Tuned against the live DOM, auto-updating.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

Web Cleaner

A browser userscript for a cleaner, ad-free, more focused web — built and verified against the live 2026 Facebook and YouTube DOM.

Changelog · Limits — the version lives in the script's @version header, which is what your manager reads.

Install

  1. Install a userscript manager: Violentmonkey or Tampermonkey (Chrome/Edge/Firefox; on Android use Firefox + one of these, on iOS use the Userscripts app for Safari).
  2. Click Install — the manager will prompt you.
  3. Updates are automatic: your manager re-fetches from the URL it was installed from. No re-pasting.

One script, five modules

Each module runs inside its own error boundary, so one site's redesign breaking a module can't take down the others. Shared plumbing — the draggable button, storage, hotkeys, and the settings panel — is written once.

Module What it does
Facebook Clean Feed Removes sponsored units from Facebook's feed responses before they render, with the same GraphQL edits uBlock Origin ships, and hides what still reaches the page: Sponsored posts, Suggested, People-you-may-know, Follow suggestions, Stories and Reels trays, both sidebars, the composer and the top bar. Collapses Facebook's own abandoned empty cards, in the feed and in Reels, which otherwise render as grey boxes. Auto-skips sponsored Reels. Strips UTM/tracking parameters and unwraps l.php redirects. Widens the feed into the space freed by the hidden sidebars, or hides the feed column outright with Hide feed. Forces the chronological "Most Recent" feed. On the desktop shell everything is hidden by stylesheet and the module's off switch restores it; the mobile feed removes junk slots instead, because Facebook's virtualiser stops advancing past a hidden slot. Works on desktop and mobile web.
YouTube Skip Ads Strips ad payloads before the player sees them, mutes any ad that still plays, skips sponsored Shorts, hides feed/banner/overlay ads, dismisses the anti-adblock popup. Optional distraction-free watch page: hide recommendations, comments, chips, merch/promo shelves and live chat, with the player widened into the reclaimed space. Desktop, m.youtube.com and YouTube Music.
Site Blocker Adult filter (on by default) plus an opt-in "Focus Pack" of distracting sites, custom block/allow lists, a schedule window, and a snooze.
LinkedIn Hides Promoted posts and Suggested content, plus the top bar and both side rails, and widens the feed into the reclaimed space — or hides the feed column outright with Hide feed. Uses no CSS selectors — LinkedIn hashes its class names — so it matches on rendered text and identifies rails by geometry.
View Mode Switcher Force Desktop or Mobile rendering per site. On a phone, Desktop reflows the real page via the viewport meta. On a desktop browser, Mobile rewrites the site's own @media breakpoints to a phone width so pages genuinely reflow instead of only reporting a spoofed User-Agent — full-width by default, with an optional centred phone frame under Frame.

Everything is configurable from one in-page panel — open ⚙ Web Cleaner from your userscript-manager menu, or tap the 🧼 button.

Controls

Layer Where
⚙ Web Cleaner panel Every toggle, list, schedule, hotkey and tuning value — saved to your manager's storage where it exists, no file editing
Toggle hotkeys Per module (below), all rebindable in the panel
Draggable on-page button 🧼 panel · 🧹 Facebook · ⏭ YouTube · 💼 LinkedIn · 🖥/📱/🔄 View Mode. Remembers its position; dragging never triggers the button
Manager menu Panel + quick actions: toggle Facebook/YouTube/LinkedIn/blocking, block or allow this site, switch View mode
Shortcut Action
Alt+Shift+F Facebook clean feed — toggle
Alt+Shift+Y YouTube ad-skipping — toggle
Alt+Shift+B Site Blocker — toggle all blocking (works on the block screen too)
Alt+Shift+L LinkedIn — toggle
Alt+Shift+V View Mode — switch Desktop ⇄ Mobile (long-press the button to reset to Auto)

Shortcuts ignore typing in text fields and never use Cmd/Ctrl.

Site Blocker deliberately has no floating button — it matches every page on the web, so its controls live in the manager menu, the hotkey, the panel, and the block screen itself.

Behaviour notes

  • Settings & auto-updates. Every setting lives in the panel, so you never edit the script file and auto-updates keep working. Settings are stored under the wc7_ prefix and survive version upgrades. Import/Export/Reset are in the panel's ⚙ section; imported files are type-checked and sanitised.
  • Settings are per-site on iOS. Desktop managers expose GM_setValue, which is shared across every site, so a change made on one site applies everywhere. The iOS Userscripts extension implements the newer promise-based GM.setValue instead, and exposes it only to scripts injected in the content-script scope — which this script cannot use, because its YouTube ad blocking installs a getter over ytInitialPlayerResponse in the page scope. On iOS the script therefore falls back to localStorage, which browsers scope per origin. Practical effect: adding a host to the block list on one site does not block it from another, so configure the Blocker on the site you want blocked.
  • Focus Pack is opt-in. Focus and Schedule are both OFF by default — nothing in the Focus Pack is blocked until you turn one on. Two independent switches feed the same filter (Focus blocks always, Schedule only inside its window), so the Blocker section shows a live effective state line: ● Focus pack BLOCKING NOW (inside schedule 09:00–18:00) or ○ Focus pack not blocking (outside schedule …), and flags when the site you are on is one of them. Upgrading keeps your saved settings — if Schedule was on it stays on, and the state line now makes that visible.
  • Degradation is visible, not silent. If Facebook or YouTube rotate their markup and filtering stops matching, the panel shows a warning line and the affected module's button gets an amber ring. The check is precise: if hiding is working, no sponsored label is ever visible, so any visible one is a genuine miss. It never guesses at hiding things.
  • Widen feed. Hiding both sidebars reclaims roughly half a desktop window, so the feed widens to fill it (default 1100px; tune under Facebook → Advanced → Feed max width, or switch Widen feed off for Facebook's native 680px). Wider columns letterbox tall images — lower the value if you scroll a lot of portrait media.
  • Hide feed. Hide feed (Facebook and LinkedIn, OFF by default) removes the feed column itself rather than filtering what is in it, for when you want the rest of the site — Messenger, groups, profiles, notifications, search — without the scroll. It is scoped to the home feed only (/ and /home.php on Facebook, /feed on LinkedIn), so every other page is unaffected, and it stacks with the sidebar, composer and top-bar switches if you want the page empty. It hides the feed and nothing else — search, the tab strip and the global nav stay where they are, on desktop and on a phone. Nothing is deleted — like every other switch it hides by stylesheet, so turning it back off restores the feed. If feed detection fails the module falls back to normal filtering rather than blanking the page.
  • Distraction-free watch page. Related, Comments, Chips, Merch/promos and Live chat each hide that part of the YouTube watch page. Related is ON by default; with Widen player the video expands into the reclaimed space (measured 996×560 → 1385×779 at 1440px).
  • Facebook mobile. A logged-in phone is served the responsive www.facebook.com, not m.facebook.com, so the module picks its code path by inspecting the markup rather than the hostname. If something slips through, add markers under Extra junk phrases.
  • Site Blocker needs Tampermonkey or Violentmonkey (GM storage + menu). The snooze applies to every filter, adult included. For comprehensive adult blocking across all browsers and apps, pair it with a DNS family filter (Cloudflare 1.1.1.3 or NextDNS) — a userscript can't enumerate the whole category.
  • YouTube detection is measured, not guessed. Both of YouTube's bundles were pulled and read: the player (base.js) and the 10.8 MB desktop app bundle. They share a registry of 25 named detection signals — DI7 in the player, naM on desktop, same names, same two-bit slots in a CATSTAT register that is XORed with a BotGuard value and sent on the attestation ping as r6b, beside r6a carrying the DCLKSTAT bait result. The player implements four; the desktop bundle implements the rest, including a JSON canary, a Response.json canary, a spoofed-URL fetch honeypot, a honeypot skip button, bait elements built from an obfuscated string table, and a <script>-body scanner whose signature list is server-updatable. The module is built around what they actually check — see the 8.22.0 changelog for the signal-by-signal account. Note also that full uBlock Origin has been disabled on stable Chrome since v138 and its store listing was removed on 31 August 2026, so a Chrome + uBlock pairing has ended; desktop Firefox remains the capable option. For network-level blocking, pair with uBlock Origin — though note it trips two signals this script does not: DCLKSTAT=3 when it blocks the static.doubleclick.net/instream/ad_status.js bait, and the resource-timing probe that fetches a server-supplied test URL and compares it against a control. YouTube deliberately serves playback errors ("Something went wrong. Refresh or try again later.") to users it detects as ad-blocking. Toggling Anti-AB off will show you YouTube's real message.

Facebook surface coverage

Verified against a live logged-in session. "Feed model" means posts are a vertical list whose header carries the label; "tile grid" means a grid of cards, which the current detector does not model.

Surface Mobile Desktop Notes
Main feed ✅ ✅ Verified: sponsored, Suggested, People-you-may-know, Reels/Stories trays
Reels ✅ ✅ Skips sponsored reels and advances. Mobile was broken before 8.2.2
Watch ✅ ✅ Facebook now redirects /watch to /reel/, so Reels handling covers it
Groups feed & individual groups ✅ ✅ Verified: container found, post headers read correctly
Marketplace ✅ ✅ Mobile via the post matcher; desktop via tile-grid detection added in 8.2.3
Stories viewer ❌ ❌ Not covered. The Stories tray on the feed is hidden by Reels/Stories. The viewer is a full-screen media player, not a post list or card grid, so neither detection model applies — and it has not been possible to observe a sponsored story to build against
Search, Videos, Events, Gaming, Profile ✅ ✅ Covered by tile-grid detection wherever ads render as labelled cards

Limits — what it cannot do

  • Server-side ad stitching. YouTube bakes some ads into the video stream itself. No client-side script can remove those.
  • Being detected at all. Nothing here stops the reporting. onAbnormalityDetected sets the d.p_ register to detected before any of this module's handling runs, and every signal also emits a biscottiBasedDetection event independently of what the page does afterwards. What the module can do is avoid tripping signals in the first place, and disarm the enforcement that follows one. Those are different things, and only the first is prevention.
  • Skipping an ad that does play. Every route out of a playing ad is instrumented. A scripted click on the skip button is scored as detected and then refused, so it cannot work; a seek is reported unconditionally. If an ad reaches the player, the honest options are to wait it out or to click skip yourself. This is why removing the payload before the player sees it is the only mechanism the module relies on, and why Seek past ads ships off.
  • In-video sponsor segments. Creator read-outs ("this video is sponsored by…") are not ads in the DOM sense; use SponsorBlock for those.
  • User-Agent headers. No userscript can change a navigation's request headers. Sites that pick their interface server-side from the UA (and redirect before any page JS runs) still serve their desktop interface. Reflow CSS then restyles what was served, which is what makes Mobile mode work on desktop; it cannot fetch a different page.
  • Cross-origin stylesheets that block CSSOM reads. Reflow CSS rewrites @media rules in place, which needs cssRules access. Nearly all sites allow it (measured: Wikipedia 17/17 sheets readable, BBC 6/6, YouTube 15/18 — the 3 blocked are Google Fonts with no layout rules). LinkedIn is the known exception: its main sheet is CORS-blocked, so Mobile mode on desktop reflows little there.
  • Per-site overflow at phone width. The frame clamps the page and contains position:fixed overlays, but a site's own component can still overflow its container if that component has no mobile styling of its own (BBC's cookie banner buttons, for example). Cosmetic, and confined to the element.
  • Sandboxes that share page objects through Xray wrappers. Tampermonkey's JavaScript sandbox on Firefox, and Violentmonkey's content mode after a CSP fallback, hand the script a wrapped page window whose objects can only be modified with exportFunction/cloneInto, which this script does not use. There the YouTube payload hooks and the View Mode signal spoof do not reach the page; DOM hiding, the blocker and the panel are unaffected. Chrome, Tampermonkey's default raw sandbox, Violentmonkey's page mode and the iOS Userscripts app run the script in the page itself.
  • Substring matching. Facebook splits the "Sponsored" label across text nodes, so detection must use substring matching — which means text containing "sponsored" as a substring (e.g. "unsponsored") can be hidden. Exact matching was tested and caught 0 of 2 real ads, so this trade is deliberate.
  • Opaque l.php redirects. Strip tracking unwraps l.facebook.com/l.php?u=<encoded URL> back to its destination, and that still works where Facebook encodes one. Facebook now issues some of these with an opaque token instead (?u=AUC4PGjAr9mDl69…), which carries no recoverable URL, so those links are left as they are rather than mangled. Measured on a live feed: 6 l.php links present, 5 unwrapped, 1 opaque. utm_* parameters, ping attributes and data-lynx-uri are stripped in every case.
  • Schedule day-spill. A schedule of Mon 22:00–06:00 does not block Tuesday 02:00.
  • Markup rotation. Meta and Google change markup without notice. Three things happen: the panel warns you, the module button gets an amber ring, and a rendered-text fallback engages that recovers some ads without depending on markup. Recovery is partial by design — the fallback is bounded and hides only the tightest element containing a visible sponsored label, so it never risks the page. Full coverage returns when selectors are updated.

Changelog

8.23.0

A full audit: the script, the three managers' published source, the live YouTube endpoints and the neighbouring scripts, with a committed test suite. Every claim below is measured in a browser or quoted from a manager's code.

  • YouTube ads played on every video after the first. Measured on a live watch page (12 October 2026): clicking a related video fetches /youtubei/v1/get_watch, a 1.1 MB response carrying adPlacements, adSlots and playerAds. It is a two-element array ([{playerResponse}, {watchNextResponse}]) that the page consumes as a stream, so it never reaches Response.json(), where 8.22.0 did its pruning. Three independent misses: the endpoint was not in the gate, the scrub skipped arrays, and the hook was on a method the page does not call. The response body is now rewritten inside a fetch wrapper — the way uBlock Origin's replace-fetch-response does it — keyed on the URL the network served (Response.url), so the synthetic-Response canary and the data:-URL honeypot pass through untouched exactly as before, and the replacement carries the original url, ok, redirected and type. Shorts had the same gap: the live endpoint is /youtubei/v1/reel/reel_watch_sequence, not /youtubei/v1/reel_watch_sequence. Every /youtubei/ response is now in scope on both transports. Measured after the change on the same navigation: the second video's player response carries no adSlots or playerAds, getPresentingPlayerType() reads 1 where it read 2 before, and the page's fetch still reports [native code], name fetch, length 1. The 8.22.0 suite had verified both transports by calling .json() itself, which is not what the page does.
  • @grant unsafeWindow is declared. 8.20.0 moved the YouTube hooks onto unsafeWindow without granting it, on the belief that sandboxing managers expose it regardless and that granting it would push the iOS host into the content-script scope. Both halves were checked against source. Violentmonkey does expose it unconditionally (gm.unsafeWindow = global in its API wrapper, for any @grant other than none), but Tampermonkey's documentation says @grant "is used to whitelist GM_* and GM.* functions, the unsafeWindow object and some powerful window functions", so under its sandbox the hooks fell back to the sandbox's own window and never reached the page. The iOS Userscripts app filters @grant values against a fixed list (validGrants in Functions.swift) that contains neither unsafeWindow nor the legacy GM_* names already in the header, so the grant changes nothing there. The View Mode spoof now targets the same page window, which it never did.
  • Facebook ads are removed from the feed responses, not only from the DOM — verified on the live logged-in feed (12 October 2026). With the new Block ad data switch on, the page received its /api/graphql/ feed responses carrying 0 SponsoredData objects where the raw network response held 6, the feed's scroll height collapsed from roughly 18,000px to 2,000px, and the two visible sponsored-info links dropped to zero. uBlock Origin stopped relying on markup for Facebook; its maintained filters edit the /api/graphql/ responses, which arrive as JSON lines. The edits here are translated from its jsonl-edit-xhr-response/json-edit rules with the JSONPath semantics read from source: a feed edge whose category is SPONSORED loses its node, and any node or nodes[] entry carrying a descendant with __typename: "SponsoredData" is dropped. Matching is on the typename, not the key, because the live sponsored node carries that child as th_dat_spo, not sponsored_data. The responses are consumed as a stream from readyState 3, so the XMLHttpRequest replacement is a subclass whose response/responseText getters re-edit on every read; the subclass is registered as the native for toString. JSON.parse is deliberately left unhooked: Facebook parses a canary of this exact shape ({"data":{"node":{"s":{"__typename":"SponsoredData"}}}…}) through it inside a throwaway frame, and when the canary comes back edited it falls back to its own JSON5 parser, so a parse hook is both reported and bypassed. Facebook also fetches clean natives from that frame to get around the fetch and XHR hooks — uBlock counters this with trusted-prevent-dom-bypass — so every frame reached through the contentWindow or contentDocument getter has the parent's fetch, XMLHttpRequest and Function.prototype.toString copied in, and toString on the patched natives reports the native string. A desktop DOM rule covers the ads that survive the response edit on some sessions: the label there is drawn as an SVG with no text, identifiable by its sponsored-info link (a query-only href with __cft__ and no __tn__), so that story is hidden too. On by default. The response edit and frame guard were confirmed live; the DOM rules remain fixture-verified, since the live markup rotates.
  • The YouTube and Facebook network layers are one. hookResponses (fetch and XHR, gated on the served URL), textEditor (JSON or JSON-lines), maskNatives and guardFrames serve both modules; YouTube also guards frames now, since uBlock's filters show YouTube using the same clean-frame trick for fetch.
  • Facebook hid any post whose header mentioned stories or reels. The exact-label set used for whole-token matching had been built from the ad labels plus the tray titles (Reels, Stories), so a header such as Check my stories matched the token stories and the post went, on desktop and mobile. The token set is now the ad labels only; tray titles are still matched as a whole element's text. Pinned on both shells.
  • Facebook's tile-grid pass read hidden text. It matched on textContent, so a hidden Sponsored honeypot inside a listing hid the listing — the trick 8.4.0 made the rescue path immune to. The pass now uses the same rendered-text tightest-match as the rescue and LinkedIn, and remembers a clean verdict after six passes like every other pass. Pinned: a tile with a display:none label is kept, the visibly labelled tiles are hidden, the row is not.
  • LinkedIn matched the whole post, not its header. promoted anywhere in a post's rendered text hid it ("happy to share that I've been promoted"), and the German marker anzeige is a substring of Mehr anzeigen, the see-more control on every long German post. Matching is now restricted to the top 130px of the candidate, the header band Facebook already uses, and Anzeige/Ad-style labels are matched as whole tokens there. Fixture-verified only; the live LinkedIn feed was not re-checked in this pass.
  • The panel is built from DOM nodes. Measured in Chromium under require-trusted-types-for 'script' with the script's policy name disallowed: innerHTML throws and so does DOMParser.parseFromString, so the 8.2.0 fallback never had a path to work and the panel always degraded to a notice there. Elements are now created directly, which needs no Trusted Types policy at all, so the panel renders on every site; the policy, the HTML escaper and the degraded notice are gone. Pinned under an enforced policy.
  • One module runner. Facebook, YouTube and LinkedIn each wired their own observer, intervals, scroll listener, hotkey, health ring and enabled gate, and disagreed in the details — YouTube's feed tagging and nudges ignored the off switch, Facebook's video muting kept running with the module off, LinkedIn had no rescue path. runModule now owns all of it; a module supplies its stylesheet, its sweep, and optionally a per-frame hook and a rescue vocabulary. The three sponsored-label lists are one, and the two zero-width-character patterns are one.
  • history.pushState is no longer patched. Navigation is detected by comparing location.href on each tick, which needs no page-visible modification and works in sandboxed worlds, where a patched history on the sandbox's window never saw the page's calls. The watch-page marker, the autoplay nudge and Facebook's per-route state all run from it; pinned.
  • Document-start before <html> exists is handled once. 8.8.0 fixed initVM for a manager that runs document-start before the root element exists, but every other module appended its stylesheet unguarded and died the same way, and the blocker's screen waited for its 5-second retry. A single onRoot defers DOM work until the root exists; the test harness injects at that earlier moment deliberately, and the block screen now attaches the button cluster itself, since DOMContentLoaded does not fire after window.stop().
  • Mobile Top bar now applies with Hide feed on, as it already did on desktop.
  • Smaller things. The panel showed v8.21.0 on 8.22.0; the constant is now pinned to the header by a test. Settings were written twice on managers that provide both GM APIs, and mirrored to localStorage where nothing read it. Includes paid promotion was in the rescue vocabulary, which is a creator disclosure, not an ad. The long YouTube comments duplicated the 8.22.0 changelog; the code keeps six one-line notes at the places that must not be "fixed". .prettierignore carried entries for files the repository does not have.
  • Facebook mobile verified on the live logged-in feed (12 October 2026, 390×844 iPhone emulation). Across ten loads of the feed, the module removed every slot labelled Ad, the Reels tray, "Suggested for you" groups, Follow-recommendation posts and Facebook's empty skeleton cards, with no visible Ad, Follow or Add friend label left and no page errors; friends', groups' and followed pages' posts were kept, the tab strip and composer were hidden. Removing slots does not stall the feed: over twelve scrolls to the bottom it loaded 122 distinct posts against 126 without the script, and made more load requests, not fewer. Two findings from that run: recommended posts from strangers carry Add friend or Join where Follow suggestions carry Follow, so both now count as recommendation markers under Follow suggestions (pinned by a fixture); and Block ad data has nothing to act on there — the phone shell renders server-driven components whose network payloads carry no readable sponsored marker, so on mobile the DOM path is the whole mechanism.
  • Every Facebook surface surveyed live, desktop and phone (12 October 2026), which found and fixed three defects. Recommendation markers — People you may know, Follow, Add friend, Join, the Reels and Stories trays, empty skeleton cards — had been treated as junk on every route, so the mobile Friends page was nearly wiped (2,209 characters of text down to 78) and mobile search lost Follow results. They now apply only on feed routes (home, groups, Watch, Reels); ad labels, AI labels and your own junk phrases apply everywhere. Desktop Marketplace ad tiles end in a bare Ad, which the tile pass never matched; the tile is now found from its label (18 visible before, 0 on screen after across six scrolls). Desktop search results use the scrambled Sponsored label, but the label pass only ran on feed routes; it now runs everywhere (0 sponsored-info links on screen after). The rescue sweep now matches ad labels only. Results after the fix: home feed, groups, Marketplace and search clean on both shells, Friends and notifications intact, and Reels skips sponsored reels on both shells — without the script 2 of 16 desktop reels and 4 of 16 phone reels were sponsored, with it none of 32 desktop and 16 phone reels were, and navigation advanced on every step.
  • A desktop sponsored-info-link rule. Some sessions serve the sponsored story with its label drawn as a textless SVG, so no text match reaches it; it is identified by its sponsored-info link — a query-only href carrying __cft__ without the __tn__ an organic timestamp link has — and the story is hidden. Pinned by a fixture whose timestamp link is kept.
  • Tests. test/ holds the Playwright suite described under Verification. Against 8.22.0 it fails on exactly the defects above. The Facebook response edit, the clean-frame guard and the JSON.parse pass-through were additionally confirmed on the live logged-in feed.

8.22.0

Reverse-engineered YouTube's live anti-adblock and rebuilt the module around it. Both bundles were pulled and read — the player (base.js e937390a, 2.6 MB) and the desktop app (ytmainappweb, 10.8 MB). Every claim below is quoted from that code or measured in a browser against it.

What YouTube runs. A registry of 25 signals — DI7 in the player, naM on desktop, identical names and slots. Fourteen own two bits each in a CATSTAT register; CATSTAT is XORed with a BotGuard value and reported on the attestation ping as r6b=broadSpectrumDetectionResult, next to r6a=reportingStatus carrying DCLKSTAT. Every signal also emits a biscottiBasedDetection client event (id 439), and CATSTAT rides along on user-feedback reports too. The result codes are 0 = DETECTED, 1 not detected, 2 unspecified, 3/4 failure; only 0 sets the enforcement flag.

The signals this module was tripping, and what changed:

  • i.s_ / ISDSTAT (bit 8) — the skip button. Both skip-button classes route onClick through lc5(event), which scores isTrusted === false as detected, raises onAbnormalityDetected, and takes the branch that skips the skip: super.onClick() only runs in the else. A scripted click was reported and refused — it never skipped an ad. It fired every 400 ms for the length of every ad, twice per attempt, because tap() dispatched both el.click() and a synthetic MouseEvent. Clicking is gone; tap() is deleted.
  • i.f_ — a honeypot skip button. XBe() appends an invisible <button class="ytp-ad-skip-button"> with zero size and opacity:0, waits 50 ms, and reports detected if anything clicks it. Removing the auto-click closes this too.
  • a.s_ / ASDSTAT — seeking inside an ad. sHY() runs on any seeking event while videoData.isAd(), and its verdict is the literal xV(0, "a.s_", …), hard-coded to detected. isTrusted only decorates the reported states — measured in a browser, a programmatic currentTime assignment produces a seeking event with isTrusted === true and is reported all the same. There is nothing to evade: the seek is the signal. Seek past ads now ships off, relabelled Seek past ads ⚠.
  • n.k_ / NKDSTAT (bit 20) — time compression. Every 5 s of ad media time, let r = M < G*.4 ? 0 : 1 flags an ad whose media time outruns the wall clock by more than 2.5×, and calls onAbnormalityDetected directly on pre-rolls. Seeking to duration - 0.1 trips it; so does any playback-rate trick.
  • m.p_ / MBSTAT (bit 28) — the server/client cross-check. msy(playerResponse) is true when a placement of kind AD_PLACEMENT_KIND_START exists or a slot triggers on SLOT_TRIGGER_EVENT_BEFORE_CONTENT; when a SABR context update of type 5 arrives and msy() is false, the player reports and raises onAbnormalityDetected. Deleting adPlacements outright is what announced the prune. A live fetch of a watch page with no ad to serve carries exactly one placement — a clientForecastingAdRenderer at KIND_START — so the pre-roll slot is now left standing and only the renderers that would play something are dropped.
  • j.s_ (bit 2) — the JSON.parse canary. This is the one that mattered most. A canary JSON string and the object it must parse to are baked into the desktop bundle; _.RhU() runs JSON.parse over the canary and deep-compares. The canary contains adPlacements, adSlots and playerAds, so any JSON.parse hook that prunes ad keys fails it. The JSON.parse hook is removed entirely — nothing here patches it any more.
  • _.QeL — the same canary through new Response(str).json(). A synthetic Response has url === ""; a real one does not. The Response.prototype.json hook now acts only on a URL that came off the network, so the canary passes through untouched.
  • NMP — a spoofed-URL fetch honeypot. It builds a data: URL Request and overrides its url, method, mode and bodyUsed getters to claim it is POST /youtubei/v1/player. Any blocker keyed on the request URL rewrites the canary and is caught. Measured: Request.url reports the player endpoint while Response.url reports the real data: URL — so every interception here keys off the URL the network actually served, never one the page hands us.
  • e.h_ (bit 6) — bait elements. GDv is a table of base64 strings decoded by _.Vs() — base64, rotate by -68 mod len, truncate at #. Decoded, it builds a nested chain of decoy divs (id="primary" → class="ytd-two-column-browse-results-renderer style-scope" → id="masthead-ad" → … → class="ytd-ad-slot-renderer") plus a bare <div id="player-ads">, appends them to <body>, and scores getComputedStyle(innermost).display === "none" as detected. Measured against the old stylesheet: the chain ending in a class was safe, because the module matches ytd-ad-slot-renderer as a tag; the bare <div id="player-ads"> matched the unscoped #player-ads rule and tripped it. #player-ads and #masthead-ad are now scoped to the app root, which the bait sits outside of.
  • p.h_ (bit 12) — the enforcement popup, now clean rather than merely tidier. o49() scores a ytd-enforcement-message-view-model that is absent or computes to display:none as detected — and it runs only from xUU(), immediately after n0r() has opened the popup, so it never fires unless enforcement has already started. Better: both enforcement paths are gated on U2L(playerResponse), which reads auxiliaryUi.messageRenderers.bkaEnforcementMessageViewModel. The onAbnormalityDetected handler only calls stopVideo() and rewrites playabilityStatus to {status:"ERROR", errorScreen:{enforcementMessageViewModel}} if (S = U2L(f)), and the popup path only reaches n0r() when that renderer exists. So dropping it from the payload disarms the enforcement itself — the video is not stopped, the playability status is not rewritten, the popup never opens, and o49() is therefore never called. errorScreen.enforcementMessageViewModel is dropped too. And if a dialog is delivered by some other route anyway, the node is now left in the DOM and explicitly kept at display:block, hidden instead by visibility, opacity and off-screen positioning, with only the dialog shell and backdrop removed — so o49() reads clean. The previous build deleted the node, which is precisely what the signal scores.
  • s.i_ (bit 14) — a <script> body scanner. _.gaY() walks every <script> element and matches textContent against signatures from the ab_det_sc_inj_val experiment flag, defaulting to json=JSON.parse(text);for(k of["playerAds","adPlacements","adSlots"]). The list is server-updatable. Dropping the JSON.parse hook removes the resemblance.
  • o.p_, o.a_, g.o_, a.o_ — integrity checks on Object.prototype (against a decoded watchlist of globalCallbacks, onAbnormalityDetected, onAdSkip, raw_player_response, adSlots), on Array.prototype.push/forEach/filter, on btoa, and a for…in vs Object.getOwnPropertyNames comparison over the player response. This module trips none of them.
  • DCLKSTAT and the resource-timing probe — static.doubleclick.net/instream/ad_status.js is loaded as bait (1 = loaded and google_ad_status set, 2 = loaded without it, 3 = never loaded in 5 s), and a second probe fetches a server-supplied test URL against a control and compares Resource Timing entries. Both catch network-level blockers. This module blocks no requests and reads clean on both; pairing it with uBlock Origin does not.

Other changes:

  • The page marker is gone. window.__wcAdPruned was a uniquely named global that identified the script to anything enumerating window.
  • The three native patches are now Proxies, and match their natives completely. Measured in a browser: a Proxy over a native function forwards name, length and the absence of .prototype, reports the target's own-property list, and — the part that matters — Function.prototype.toString on it returns "function () { [native code] }". So the whole passive shape matches without patching Function.prototype.toString globally, which is what uBlock's proxyApplyFn has to do only because it chains many proxies through one shared toString. The alternative of hanging an own toString on a shim was rejected by measurement: it leaves own properties [length, name, prototype, toString] where a native has [length, name]. Worth stating plainly, though: neither bundle runs a toString integrity check for ad-block detection. The six [native code] tests in the desktop bundle are Zone.js, Closure and gapi polyfill probes over Node, Element, ShadowRoot and MutationObserver — and XMLHttpRequest and Response appear in neither of those lists, so the patches here are outside that path entirely. This is shape hygiene against a check that does not currently exist, bought at no cost.
  • A real XHR path. The player response is rewritten on XMLHttpRequest as well as fetch. The URL is captured inside the open() wrapper, before the page can touch the instance; responseURL is consulted only as a veto, because it is an accessor a page can redefine on its own object — the same trick NMP plays on Request. A first cut gated on /^https?:/ against the raw open() argument and silently did nothing, because YouTube requests these paths relatively; the URL is resolved against location.href first.
  • The scrub no longer walks every parsed object, and is capped at depth 8. It previously ran over the result of every JSON.parse on the page to depth 14 and could delete an adSlots key out of an unrelated payload.
  • Verified by executing the module, not by reading it. The suite loads the real webcleaner.user.js in a page with only the host constant swapped, runs it, and then puts YouTube's own predicates — transcribed from the two bundles — against the resulting page: 33 checks covering j.s_, _.QeL, NMP, e.h_, p.h_, o.p_, o.a_, a.o_, g.o_, fg6, s.i_, i.f_ and m.p_, the passive shape of all three patched natives, and — the part that matters — that ads are still stripped from a genuine response on both the fetch and XHR transports. All 33 read as intended.
  • That suite exists because the previous one gave a false pass. An earlier version read the selector strings out of the file textually instead of running the module, and so did not notice that a comment inserted with literal \n escapes had folded onto one line and swallowed the ban: property whole — SEL.ban was undefined and the ad stylesheet was never built. node --check passed, prettier passed, and the old e.h_ check passed too, because with no stylesheet at all nothing gets hidden and the bait looks clean. Re-running the current suite against a deliberately re-broken copy fails it on yt-css stylesheet injected and ban selectors present, which is the negative control the old one lacked. The XHR relative-URL bug was caught the same way.

The full detector set, exercised. The master sweep was recovered — Q1f() in the desktop bundle runs sixteen checks in a fixed order, and the player runs four more, so nineteen of the twenty-five registry slots are actually implemented; the rest are declared with no body. Reading Q1f turned up three checks the earlier pass had missed, and one correction:

  • The bait table used at runtime is okI, not GDv. They decode to the same element names but carry different random salts, and okI has a third chain the other lacks: a bare <ytlr-banner-cta-renderer>, created by tag name. That matters because the earlier conclusion — "tag selectors are safe, id selectors are not" — only held for the table being read. ytlr- is the living-room prefix and this module matches only ytd-, ytm- and ytmusic-, so it stays clean, but the general rule was wrong.
  • n.h_ is a second element bait: <div id="player-ads" class="ytd-merch-shelf-renderer ytp-featured-product">. The app-root scoping added for e.h_ covers it, and ytd-merch-shelf-renderer is matched here as a tag, not a class.
  • e.i_ looks for iframe#yt-haven-embed-player, and s.b_ reports detected when window.sponsorblock exists, or when window.versionCB is set and window.teardownCB is a function. SponsorBlock is detected by name — worth knowing, since this README recommends it for creator read-outs.
  • b.f_ fetches the Biscotti (Google ad-cookie) id and scores a failure as detected, which means blocked third-party cookies can trip it on their own.

Running all sixteen against the module: twelve read clean, p.i_ reads unspecified because it probes a window.cqZ this module does not define, and three (b.f_, a.b_, p.m_) need network or media fixtures that a local harness cannot supply. Packing the results into the CATSTAT register the way k56()/vaY() do yields no detected slot.

How the neighbours fare — measured, not read. Nine other scripts were loaded into the same harness and put through the same executed checks: the six most-installed YouTube ad scripts on Greasyfork plus three GitHub projects. Behavioural signals (i.s_, a.s_, n.k_, m.p_) need a real ad and are excluded; these are the passive and bait checks only.

Script Detected by
webcleaner 8.22.0 none
EasyTube V5 e.h_, n.h_
Auto Skip YouTube Ads e.h_, n.h_
Youtube Ads BYPASSER e.h_, n.h_
Universal Ad Blocker Pro j.s_, i.f_
SysAdminDoc/YoutubeAdblock j.s_, f.i_
YouTube Ads-Bypass, Youtube-Adblock v5.0, Yaw-Dev none passively; trip behavioural signals in source
NullDev/YT-Anti-Anti-Adblock did not load without GM_info

Two results are worth pulling out. Universal Ad Blocker Pro clicked the honeypot skip button — i.f_ caught it live, with no ad present. And SysAdminDoc/YoutubeAdblock, which advertises itself as "undetectable", rewrote the NMP canary: it matches on the request URL, so YouTube's data:-URL request wearing a /youtubei/v1/player costume walked straight through it. It also trips j.s_ by patching JSON.parse, and sets playbackRate = 16 during ads, which is well inside n.k_'s threshold. The others mostly trip the element baits with an unscoped #player-ads or #masthead-ad.

None of this vocabulary is public. Searching for broadSpectrumDetectionResult, CATSTAT or biscottiBasedDetection returns nothing; the public write-ups describe the behaviour ("ad slots that loaded but never played, timing anomalies") without the mechanism. Note also that the two bait tables carry different salts for the same strings, which is direct evidence of per-build randomisation — so any of these specific names can rotate.

What is still not fixed. Three checks need fixtures a local harness cannot supply — b.f_ (a Biscotti id fetch), a.b_ (fetching a real streamingData media URL) and p.m_ (a <ytd-player> and webm blob bait) — and the four behavioural signals need an ad actually playing, so those are argued from source rather than measured. d.p_ is set directly by the onAbnormalityDetected handler, which is reached only when one of the other signals fires; nothing here can suppress the reporting once that happens, only avoid causing it. The ab_det_sc_inj_val signature list is server-updatable and the bait tables are per-build randomised, so today's clean read is a snapshot, not a property. And none of it is confirmed against a live enforcement session, which needs an account YouTube is actively enforcing against — everything here is verified against YouTube's own predicates, not against YouTube's servers.

8.21.0

  • Hide feed — a switch that hides the feed column outright, for Facebook and LinkedIn, on desktop and mobile web. Off by default. It sits beside Widen feed in each module's panel section. The rest of the site stays usable: on Facebook it binds to / and /home.php only, so profiles, groups, Watch, Marketplace and Messenger are untouched, and on LinkedIn it binds to /feed only. Once the column is hidden the module stops sweeping it — nothing inside a hidden feed needs filtering — so the toggle costs less work than leaving it off, not more.
  • Hiding a feed had to be made stateful, because both detectors find the feed by geometry. A display:none container measures 0×0 with empty innerText, so the next pass would skip the node it had just hidden, pick whatever was still visible, and hide that instead — cascading down the page until it ran out of candidates. A shared feedHider holds the node it hid, re-adopts it from the DOM after an SPA re-render, and releases it when navigation leaves the feed route. Its attribute is deliberately separate from the widen anchor, so releasing it on a route change cannot tear the widening off too.
  • Feed max width had been doing nothing since 8.19.0. That release dropped the feedBox() call from the desktop sweep — correctly, since its container heuristic was the cause of the surviving ads — but left feedBox, tagWiden and three CSS rules keyed to data-fcf-feed and data-fcf-w behind. Nothing set those attributes any more, so the only widen rule still biting was [role="main"]{max-width:none;width:100%}, which stretches the column full-bleed and ignores the configured width entirely. feedBox() is called again for the widen anchor only; it stays out of the ad path, which remains the single label-anchored pass 8.19.0 established.
  • Facebook mobile hides the feed slots, not a container. feedBox() reads [role="main"], which mobile web does not render — the same absence hasDesktopShell() already tests — so the desktop finder returns nothing there. Deriving the column from the slots' common parent was tried first and is wrong: on the real shell the search bar, composer and tab strip are siblings of the posts inside that parent, so hiding it blanked the navigation along with the feed. Verified in Firefox on a shell built that way — the wrapper was hidden and the tab strip went with it. Hiding each slot instead cannot over-select, and new slots are tagged as the virtualiser produces them.
  • LinkedIn's feed detector no longer selects by width. It kept the narrowest candidate, which isolates the column on desktop only because the rails make it genuinely narrower. On a phone every ancestor is full-width, so the comparison carries no signal: driven against the live feed at 390px it selected div#root, the page root, whose removal would take the entire application with it — and a repeat run selected nothing at all, so the result was not merely wrong but unstable between renders. It now scores candidates by child count, the same signal Facebook's feedBox uses, and refuses any candidate containing header, nav, [role=banner] or [role=navigation]. Measured on the live feed the column carries 8–18 children against 1–6 for its ancestors and 2–7 for individual posts, so the margin is wide. Six consecutive live runs at 1750px, 390px and 375px now select the feed column every time, never the root and never swallowing the chrome; when it cannot identify the column confidently it selects nothing and the next sweep retries, so the failure mode is that the switch does nothing rather than that the page goes blank.
  • LinkedIn was undetectable below 380px. tagChrome rejected any candidate narrower than a hard-coded 380px while its upper bound floors at 760px, so on a 320px or 375px viewport the window was empty and nothing was ever found — leaving Widen feed, both rails and now Hide feed silently dead on an iPhone SE or mini. Measured against HEAD: detection failed at 320px and 375px and succeeded at 390px and 412px; it now succeeds at all four. The lower bound scales with the viewport, unchanged on desktop.
  • Verified in Firefox 153 across 43 checks covering both sites, both surfaces and viewports from 320px to 1440px: baseline visibility, hiding, the route gates, rail and chrome survival, and single-node selection with no cascade. Run against HEAD the same suite scores 18/43, which is what pins the feedMaxWidth regression — the feed measures 680px there against the configured 1100px.
  • Also driven against live logged-in Facebook and LinkedIn, which is where the two detectors were actually checked against real markup rather than a fixture. Facebook selects its 680×2035 feed column, LinkedIn its 900×4536 column, neither containing any site chrome. Both mobile defects above were found this way and would not have surfaced against fixtures alone — the fixtures were extended afterwards to cover them.
  • Every module was then exercised against its live site, not just the new switch: Facebook 25 checks (chrome hiding and its inverse controls, junk tagging, stripTracking, forceMostRecent redirecting to ?sk=h_chr, widenFeed measured at exactly 1100px, hideFeed), LinkedIn 8 (rails, hidePromoted/hideSuggested, widen at 900px, hideFeed polled to the sweep cadence at 1440/390/375px), YouTube 8 on the watch page (hideRelated 4 present/0 visible against 3 visible with the module off, widenPlayer releasing max-width from 1045px to none, banners 2→0, live chat, comments, ad payload), Site Blocker 6 (custom list, allow-list override, focus pack, module off), View Mode 6 (UA, touch points, coarse pointer, desktop), and the panel 12 (opens from the button, correct sections per site, switches persist, Alt+Shift+L disables the module and restores what it had hidden).
  • The 2026-08-10 cleanup sweep took the formatting setup with it. 35d727e deleted .prettierrc.json, .prettierignore and .githooks/pre-commit, but left package.json running prettier --check . and still pointing core.hooksPath at .githooks. With no config prettier fell back to its 80-column default against source written at 200 — HEAD passes --print-width 200 and fails at 120, 160, 180, 220, 240, 260, 300 and 400 — so npm run lint failed on a clean checkout, and the pre-commit formatter it would have relied on was silently a no-op against an empty hooks directory. All three files are restored as they were.

8.20.0

  • YouTube's ad pruning never ran under a sandboxing script manager. The module installs an accessor over ytInitialPlayerResponse and wraps JSON.parse and Response.prototype.json so ad payloads are stripped before the player sees them. Those hooks only work in the page's own scope. On iOS the script runs there, so they were live — verified earlier — but Tampermonkey and Violentmonkey run scripts in a sandbox, where window is not the page's window, so on desktop the hooks were installed somewhere the page never looked. Confirmed on a live watch page: the script's page marker was absent and adPlacements, playerAds and adSlots were all still present in the player response.
  • The hooks now target unsafeWindow where the manager provides it and fall back to window where it does not, so the same code reaches the page under both models. No new @grant was added: the sandboxing managers already expose unsafeWindow alongside the existing grants, and adding it to the header would have pushed the iOS host into the content-script scope that breaks these hooks entirely. (Corrected in 8.23.0: only Violentmonkey exposes it without the grant; Tampermonkey requires it, and the iOS host ignores it.)
  • DOM-level ad hiding was unaffected throughout — home, search and watch each showed ad elements present and none visible, with search results intact.

8.19.0

  • The desktop feed is one pass again. It had grown to four overlapping ones — processDesktop walking feedBox()'s children, hideLabelledAds walking labels, processCards, and the health rescue — with five different ways of deciding something was an ad. They disagreed, and the gaps between them are where ads lived.
  • There is now a single path: find the label, climb to its story, retire it. One classifier, labelHit, covers every form the label takes — an exact Ad/Sponsored token, a marker inside a longer header, and the obfuscated variant read back through paintedLabel.
  • feedBox is out of the ad path entirely. Its heuristic picks one container and scores it by child count; on the live DOM it selected a container with four children while the real stories sat elsewhere, so those stories were never examined no matter how good the matching was. That single dependency caused the surviving Chiltern Railways and Fine Dining ads, and it made the test fixture unstable — adding a case changed which container was chosen and unrelated assertions flipped.
  • The desktop suite now covers every label form, an ad deliberately placed outside feedBox's container, and the false-positive controls, at 14/14. Three cases that had been failing were never code defects: they sat outside the viewport guard in a fixture that never scrolls.

8.18.0

  • Facebook's actual AI label is AI content, which was never matched. The list added in 8.14.0 carried AI generated, Imagined with AI, Meta AI and even AI generated content, but not the two-word label Facebook actually renders — seen in the feed as ADHD Treatment · AI content · 2m. Added, along with localised equivalents. Since AI markers share the junk-marker path, this covers the feed and Reels on both desktop and mobile at once.
  • Re-checked the false-positive guards: Dubai, chair, email, captain, Air India and advice are all still kept, as is a post that merely discusses AI in its body, because only short label-like elements are examined and the markers are multi-word.

8.17.0

  • Reads the desktop label as painted, instead of guessing from structure. 8.16.0 treated "many spans plus a U+034F joiner" as an ad signal, which was wrong in the most dangerous direction: reverse-engineering the live DOM showed every post header is built that way — organic posts carry the identical 60-joiner obfuscation around their timestamp. That heuristic would have hidden genuine posts.
  • The obfuscation works by painting the real characters on the topmost line at increasing x, while roughly sixty decoy characters are stacked at a single x on a lower line, everything interleaved with U+034F. Reading the client rects — keep one-character spans that actually render, take the top line, sort by x, drop the stacked duplicates — recovers the real word. Verified live: the same labels that read as noise in textContent resolve to Just now, Ad, Learn more, Just now.
  • So an obfuscated label is only treated as an ad when its reconstructed text matches a sponsor or ad marker. Organic timestamps such as Just now are read correctly and kept.

8.16.0

  • Desktop "Sponsored" labels are scrambled beyond any text matching. Facebook renders them as roughly sixty one-character spans in deliberately wrong DOM order, padded with decoy characters and separated by U+034F grapheme joiners, then paints the correct subset in the right order with CSS. Read from the DOM the label is 59 characters of noise; even visText, which reconstructs painted text, recovers only fragments such as Ado and 5ms. Reported from a live feed as a YuMOVE UK post that survived every text-based rule.
  • Since the text is unrecoverable, the structure is now the signal: a header element holding 15 or more spans whose combined text carries a U+034F joiner is an obfuscated label, and its story is hidden. No genuine post header is built that way — verified against a control post whose caption is split across many spans without joiners, which is kept.

8.15.0

  • Desktop ads survived because the sweep never looked at them. processDesktop only examines the children of the container feedBox() picks, and on the current Facebook DOM that heuristic can select a container holding a handful of elements while the real stories sit elsewhere — measured at four children on a live feed. Any ad outside it was never tested, no matter how good the label matching was. Reported from a real desktop feed: a Chiltern Railways · Ad post fully visible with the module running.
  • Added hideLabelledAds, which works the other way round: find the ad label first — an exact Ad-style token or a Sponsored marker — then climb to its story container and hide that. It is anchored to [role="main"], skips the right rail (handled separately), requires the label to sit within 160px of the container's top so a stray mention deep in a post cannot remove it, and is capped at eight per sweep.
  • Pinned by a test whose ad deliberately sits outside the container feedBox selects.

8.14.0

  • New: AI content filtering, on by default, under Facebook → AI content. Hides posts and cards carrying Facebook's AI markers — AI generated, Imagined with AI, Made with AI, Created with AI, Generated by AI, AI info — plus Meta AI entry points (Meta AI, Ask Meta AI, Try Meta AI, AI Studio) and a few localised equivalents.
  • It reuses the junk-marker mechanism rather than adding a parallel one, which also supplies the false-positive guard for free: only short, label-like elements are examined, never body text. So a post that merely discusses AI is kept, and words that happen to contain the letters — Dubai, chair, portrait, email, captain, Air India, sail, maintain — are never matched, because the markers are multi-word phrases rather than the bare token.

8.13.0

  • Ads whose label is not its own element were missed. The exact-label match added in 8.12.7 requires an element whose own text is 40 characters or fewer and normalises to exactly ad. On desktop the header often renders as one string — Sam Despo Marketing and ElevenLabs Ad · 🌐 — so the smallest matching element is longer than the cap and was skipped entirely, while the substring list never contained ad because it would match read, load and download. Both real ads reported from a live desktop feed, from Sam Despo Marketing and from Hilton, were labelled this way. The header band's reconstructed text is now split on non-letter boundaries and each whole token is compared against the ad labels, which catches the label wherever it sits in the header without ever matching a word that merely contains those letters.
  • Applied to the mobile path as well, so a label that stops being its own element there is still caught.
  • Verified the rejections that matter: Adam Shaheen and friends read the advice and loaded it stays, as do Read and download the guide and Ahmed loaded the advice column.

8.12.9

  • Desktop ad labels are scrambled, so reading textContent was useless. Facebook renders the desktop Sponsored label as individual characters in the wrong DOM order, interleaved with hidden decoy letters and U+034F joiners, and paints them in the correct order with CSS. Read from the accessibility tree it says Sponsored; read with textContent it says snopotSdre6th49…. The exact-label check added in 8.12.8 compared against textContent, so it could never have matched a real desktop ad. It now also compares the text visText reconstructs — which drops invisible nodes and sorts by painted position — so the label is read as it appears on screen. Pinned by a test whose label is scrambled the same way.
  • The scan also covers a[href], because desktop wraps that label in a link rather than a bare span.

8.12.8

  • The "Ad" label fix now works on desktop too. 8.12.7 matched short ad labels against each element's own text, which the mobile sweep does, but the desktop sweep tests the header band as one concatenated string — so an ad rendering as 7UP … Ad arrived as 7upad and the exact match could never fire. Desktop now runs the same element-wise scan, restricted to the same 130px header band it already used, so a stray Ad further down a story — in a comment, say — still does not remove the post. Verified both directions on a synthetic desktop shell: header Ad, Sponsored and إعلان removed; an Ad below the band, and a post reading "Adam said read the load", kept.
  • junkIn takes an optional band rather than gaining a second copy for desktop, matching how visText already works.

8.12.7

  • Ads labelled "Ad" were never matched. The sponsored detector holds words like sponsored, publicidad and ممول and looks for them as substrings, which cannot include the two-letter label Facebook now uses on the mobile feed and Reels — searching for ad inside text would hit read, load, download and Adam. Caught on a real iPhone on a 7UP reel rendering as 7UP … Learn more … Ad. Short ad labels are now matched as whole words against the element's own text, the same exact-match mechanism the Reels and Stories tray headers already used, in 26 languages. Verified that Adam, read, load and advice are still kept.
  • The exact-match list was previously gated on hideReelsTrays, which is unrelated to advertising; ad labels are gated on hideSponsored where they belong.

8.12.6

  • Reels could not be scrolled at all. 8.12.5 removed junk from the mobile feed but still hid it in Reels, and a display:none child of a scroll-snap container loses its snap point. With eight or nine slots hidden the scroller had almost nothing left to snap to, so every swipe snapped straight back. Measured on a real iPhone across five swipes: with the module on, scrollTop stayed at 0 every time and only 2 slots were visible; with it off, scrollTop advanced 1290 → 2580 → 3870 → 7740 with 10–14 visible. Reels now retires nodes the same way the feed does.
  • Both mobile surfaces therefore remove, and only the desktop shell hides — a single branch in retire, which is why this was a one-line correction rather than another patch.

8.12.5

  • Restores the 8.12.x consolidation work with the one thing it got wrong corrected. 8.12.0 replaced dropPost's .remove() with reversible stylesheet hiding, on the reasoning that the leftover blank space belonged to the post's fixed-height wrapper and hiding that wrapper collapses it. That is true in isolation and every synthetic test agreed, but it is not true on the live mobile feed: Facebook's virtualiser tracks each slot's height, and a display:none slot stays in its bookkeeping as a zero-height row, so the feed stops advancing. On the mobile feed the node has to leave the tree.
  • Retirement is now a single policy in one function rather than a decision spread across call sites: Reels and the desktop shell mark the node and let the stylesheet hide it, the mobile feed removes it. The destructive-when-disabled bug that 8.11.3 fixed stays fixed, because sweep still returns immediately when the module is off, so nothing is ever removed while it is disabled.
  • Everything else from 8.12.0–8.12.3 is retained: one classifier, one node-state store shared by every module, one debounce, one text reader, one tightest-match search, one scroll anchor covering both the document and the Reels inner scroller, and no copy-paste duplication.

8.12.3

  • One text reader instead of two. readText and visText were the same tree walk, the same visibility filtering, the same de-duplication by rendered position and the same sort, differing only in that one accepted a vertical band and the two returned differently-normalised strings. They are now a single visText(scope, cap, bandTop, bandBottom) where the band is optional, and every caller normalises, which is what most of them already did.
  • One tightest-match search instead of two. The health rescue sweep and the LinkedIn sweep both implemented "hide the smallest element whose visible text matches a marker, unless a child matches too" — the same knowledge, written twice with different geometry limits. Extracted as tightestMatch(el, marks, fits), with each caller passing its own fits. Copy-paste detection now reports 0 clones at a 5-line threshold.
  • The view-mode switcher had a fourth private debounce, using requestAnimationFrame || setTimeout, plus an ad-hoc __wcVpObs flag stashed on a DOM node. Both are gone; it uses the shared helper and a local variable.
  • README corrected where it had drifted: the Facebook row did not mention Follow suggestions or empty-card collapsing, and both it and the settings note claimed everything is deleted and stored in the manager's storage, neither of which is true after 8.12.0 or on iOS.

8.12.2

  • One scheduler, one toggle, one way to switch a module off. The three modules each carried their own idle-debounce wrapper, and they disagreed — two used requestIdleCallback, LinkedIn used a raw requestAnimationFrame, and only one contained its errors. There is now a single debounced helper, and its idle callback is bounded by a 250ms timeout so a busy page cannot defer a sweep indefinitely. Every module already has an interval backstop, so this is latency, not correctness.
  • The three toggle functions were the same four steps written three times, and they disabled their module two different ways: Facebook and LinkedIn gated every CSS rule behind a root class (.fcf-off, .li-off) while YouTube set disabled on its stylesheet. Since each module's rules live in a single <style> element, the stylesheet flag does everything the root class did with one fewer concept. Both classes and every html:not(.…-off) prefix are gone, replaced by a MODULES descriptor and one toggleModule.

8.12.1

  • Sponsored-Reel skipping never ran on iOS at all. handleReels located the playing reel by scanning <video> elements taller than 200px, but the iOS Reels surface renders no video element — the reel is a snap-scroll container with the media painted elsewhere — so the search returned nothing and the function returned before doing any work. skipReelsAds was dead on the platform the setting matters most on. Reel identification now uses the same slot enumeration as the rest of the module: the child of the Reels scroller nearest the centre of the scroller's own viewport, falling back to the video scan on desktop.
  • Advancing past a sponsored reel now scrolls the snap container by one screen where one exists, instead of relying on a Next Card button and synthetic ArrowDown keys that the mobile surface does not have. Pinned by a test asserting a sponsored reel advances exactly one screen and a genuine one does not move.
  • Pinned prettier to an exact version — latest meant every install pulled whatever was newest, which is neither reproducible nor safe against a compromised release, the same concern that removed the npx@latest fetch from the pre-commit hook.

8.12.0

  • One pipeline for feed filtering, replacing four overlapping ones. The Facebook module had accumulated three ways to hide something (a data-fcf attribute, a separate data-fcf-e attribute, and dropPost calling .remove()), four per-node state fields (_wc, _wcN, _wcE, _wcSig), two "is this element empty" predicates that disagreed, and two post enumerations. Each had been correct in isolation and wrong together. There is now a single path: enumerate slots, classify once, hide reversibly, anchor the scroll.
  • Nothing is removed from the DOM any more. .remove() existed because hiding a post left its blank space behind — but the space belonged to the post's fixed-height wrapper, not the post, so hiding the wrapper collapses it completely. slotOf walks up through single-child parents to that wrapper. This is why the destructive behaviour was never necessary, and it removes a whole bug class: hiding is undone by the module's off switch, so turning Facebook off now genuinely restores every post. Pinned by tests asserting the junk post is invisible, still present in the DOM, and leaves 0px of blank.
  • One scroll anchor for both scroll hosts. keepScrollAnchored only ever called scrollBy, so it could not compensate the Reels inner scroller, which needed its own scrollTop arithmetic. It now resolves the scroll host — the Reels scroller or the document — and anchors either.
  • One node-state store shared by every module. LinkedIn had its own _li/_lisig copy of the same idea; both now use a single WeakMap and one fresh() helper that resets a node's verdict when its content changes.
  • processMobile's slot scan is now gated on the mobile shell, so it cannot touch a desktop page. Verified: 0 spurious changes across eight unrelated sites, and the full harness set is unchanged.

8.11.7

  • Removed the full-screen blank slots in Reels. On the iOS Reels surface the snap scroller holds one child per reel; some are [data-tracking-duration-id] nodes with no children, no text and no media, sized to a full screen. Tracked eight of them on a real iPhone across four swipes and sixteen seconds: none ever rendered, and two were on screen while empty, which is the blank you actually see. Facebook eventually zeroes them itself and recycles one out of the DOM, so they are abandoned slots rather than pending ones. They are now hidden after three consecutive empty sweeps, so a slot that is merely mid-render is never touched, and the filler spacer is always skipped because it is the virtualisation element that reserves scroll height.
  • Hiding inside the Reels scroller no longer moves the reel you are on. keepScrollAnchored compensates document scroll, but Reels scrolls an inner element, so collapsing a slot above the viewport shifted the current reel — measured as scrollTop jumping 10966 to 8385. The collapse now sums the height of slots hidden entirely above the scroller viewport and subtracts it from scrollTop.

8.11.6

  • Removed the grey boxes in the mobile feed. They are Facebook's own empty skeleton cards, not spacing and not anything this script left behind: div.m.displayed with an inline pixel height, no text, no media and no post inside. Measured on a real iPhone, they were the entire blank area — two of them accounted for 100% of the blank bands in view, and a hit test at their centre returned body, meaning nothing was painted there at all. Tracked three of them for twelve seconds and across a scroll: none ever gained text, media or a post, so they are dead space rather than placeholders waiting on content.
  • They are now marked data-fcf-e and hidden by stylesheet, never removed. That matters for two reasons: the module's off switch un-hides them like everything else, and if Facebook does later inject content into one, the next sweep clears the mark and it reappears. Cards within 1200px of the document bottom are always left alone — that is the load-more sentinel region, and hiding it in 8.10.16 is what stopped the feed loading.
  • Added an Empty cards toggle under the Facebook section.

8.11.5

  • Settings use the modern GM.* storage API when a manager provides it, instead of only the legacy GM_* one. gSet now mirrors writes to GM.setValue as well as GM_setValue and localStorage, and hydrateShared seeds localStorage from GM.getValue at startup so the next page load starts in sync. Startup reads stay synchronous, so nothing about boot order changes — the modern API is promise-based and cannot be read during config construction. Menu commands prefer GM.registerMenuCommand and fall back to the legacy name.
  • Why the @grant lines still name the legacy APIs, deliberately. On iOS, settings are stored per-origin: adding a host to the block list on one site has no effect on another, because the Userscripts extension implements GM.getValue/GM.setValue and not GM_getValue/GM_setValue, so the old capability check was always false and every site fell back to its own localStorage. Granting the modern names would fix that, but Userscripts only exposes GM APIs to scripts injected in the content script scope — and this script needs the page scope, where it installs a getter/setter over ytInitialPlayerResponse, patches Response.prototype.json, and prunes adPlacements/playerAds/adSlots. Verified on a real iPhone that all three are currently live in page scope. Granting the modern names would silently kill YouTube ad pruning to gain shared settings, so the grants stay as they are; the new GM.* support activates by runtime detection on desktop managers, which have no such restriction.
  • The Site Blocker itself is correct on iOS — verified on-device that adding a host blocks it immediately and after reload, with the block screen covering the viewport — it is only the storage scope that is per-site.

8.11.4

  • Follow suggestions still showed in Reels on iOS. hasFollowBtn only accepts a Follow button inside a 130px band below the post's top, because in the feed that button belongs to the post header — anything lower is unrelated chrome. 8.11.2 added an exemption for full-bleed media, where the button is overlaid low on the card instead, but it detected that case with post.querySelector("video"). On the iOS Reels surface Facebook renders no <video> element at all — the reel is a snap-scroll container (vscroller-snap) with the media painted elsewhere — so the exemption could never fire. Measured on a real iPhone: every Follow button sat 544–552px below its own post top with hasVideoEl:false, putting all of them outside the band with no exemption available. The exemption now also applies on any /reel/ or /reels/ route. Verified that this does not make the feed over-eager: a Follow button 544px deep in an ordinary feed post is still ignored, and only the header-band case is removed there.
  • Reused the new route test in handleReels, which duplicated the same expression.
  • Also noted while testing, not yet addressed: handleReels selects video elements over 200px tall to find the active reel, so on this same iOS Reels UI it finds nothing and the sponsored-reel skip never engages there.

8.11.3

  • Turning the Facebook module off did not stop it deleting posts. sweep() was the only module sweep with no enabled check — LinkedIn's sweepLI and both YouTube sweeps have always had one. Everything the module hides is normally reversible, because hiding just sets data-fcf and the stylesheet is gated on html:not(.fcf-off), so toggling the module off un-hides it again. But the mobile path does not hide: dropPost calls el.remove(), and no CSS class can bring a removed node back. So with the module switched off, the sweep kept running on every mutation, scroll and 1.2s interval, and permanently destroyed any post it judged to be a suggestion — including false positives the user could no longer inspect. Confirmed on a real iPhone, where the page reported enabled:false and fcf-off applied, correctly un-hiding all marked nodes, while the in-sweep marker showed the sweep still running. sweep() now returns immediately when the module is off.
  • Removed collapseEmptyWrapper, dead since the 8.10.18 rollback — declared, never called.

8.10.12

  • Fixed a startup crash introduced in 8.10.9 that disabled the whole script for anyone with saved settings. Enforcing BOUNDS inside deepMerge referenced clamp, but deepMerge runs at module load to build the config — about fifty lines before const clamp initialises — so the reference hit the temporal dead zone and threw ReferenceError: Cannot access 'clamp' before initialization. That line sits at the top level of the script rather than inside an error boundary, so nothing after it ran: no filtering, no view mode, no blocker, no panel. It only triggered when merging a stored value whose key is in BOUNDS, which is exactly what the panel writes the moment any setting is touched, since saving persists the whole module object. clamp is now a hoisted function declaration.
  • Why the regression suite missed it, and what changed. Every test seeded only wc7_vm, a plain string, so no run ever merged a stored numeric setting — the suite exercised a first-install profile and never a returning user's. Startup with a realistic stored config is now part of the standard run, alongside a check that the script leaves non-target sites completely alone: across eight unrelated sites it hides nothing, never shows the block screen, never applies the frame or its viewport meta, and raises no page errors, while still rendering its button.
  • Also verified at both ends of BOUNDS.mobileWidth — 240 and 1080 both frame correctly with no horizontal overflow and content intact — and with every module disabled, where the script stays inert and hides nothing.

8.10.7

  • Hardened the mobile scans against weak devices. The two full-document scanners added in 8.10.2/8.10.3 ran once per sweep, and the sweep is driven by a mutation observer over a feed that mutates constantly. Even after the 8.10.5 memoization fix that meant a querySelectorAll("div") walk on every frame, which a desktop absorbs and a phone may not — and on a phone a saturated main thread stops the site's own infinite scroll from ever running, so the feed appears to stall after the first couple of posts. Both scanners are now behind a 400ms throttle. Measured under a simulated mutation storm on a live logged-in feed at iPhone dimensions: 318 sweeps in 6 seconds, of which 15 ran the scan and 303 skipped it — 2.5 scans per second, 14.9ms of scanning total, 0.25% of wall-clock time — with the Stories tray still correctly hidden.
  • Now verified on real Safari, not just an emulated user agent. Driven through safaridriver against Safari 26.5.2 (AppleWebKit/605.1.15, vendor Apple Computer, Inc.), the script runs clean on live BBC News, Wikipedia, YouTube and Facebook with no thrown errors, and Reflow CSS rewrites real site CSS through WebKit's CSSOM exactly as it does in Chrome — 502 of 565 media rules on BBC News, 0 stylesheets blocked. Every engine API the script depends on is supported in Safari: WeakSet, closest, toggleAttribute, adoptedStyleSheets, :has(), \p{L} unicode property escapes, defining an accessor on window (which is what the YouTube ad-data trap relies on), and writing CSSMediaRule.media.mediaText. The matchMedia spoof returns a real MediaQueryList with a working addEventListener, answers unspaced queries correctly, and preserves the original media text. All eleven media-query shapes were rewritten correctly, including em units, the (400px <= width <= 900px) range form, and a not all and (…) query correctly left untouched.
  • The scroll fix measured on the real engine. Running the scanner variants against a 3130-div mutating feed in Safari, paced to requestAnimationFrame as the real sweep is: the pre-8.10.5 code measured 550,492 elements and held 19.9% of the main thread; 8.10.5's memoization cut that to 3,351 elements and 2.5%; the 8.10.7 throttle takes it to 12ms and effectively 0%. A phone is several times slower than the machine this was measured on, so a fifth of the main thread held continuously is more than enough to starve a site's own infinite scroll — which is what a feed stopping after two posts looks like.
  • Still not covered: iOS Safari specifically. macOS Safari shares the engine but not the mobile viewport, memory limits or the iOS userscript host, and no iOS simulator is installed on this machine. Facebook could only be checked logged-out in Safari, because safaridriver runs an isolated automation profile that does not inherit the normal browser session; the logged-in Facebook work is all done in Chrome.

8.10.6

  • Fixed: Mobile view on a desktop browser showed a mostly dark-grey screen. 8.8.0 changed frameOnDesktop from off to on, reasoning that the phone frame made Mobile mode "coherent out of the box". In practice the frame clamps the page to 412px and paints #202124 across everything else, so on a 1512px window roughly 73% of the screen becomes dark grey — which reads as a broken page, not a feature. The default is back to off. Nothing else changes: Reflow CSS still rewrites the site's breakpoints, so Mobile mode still genuinely reflows the page, now full-width and without the surround. Verified side by side on a live logged-in Facebook at 1512px — with the frame the content sits in a narrow column inside a large grey field, without it the page renders as a clean full-width mobile layout. The frame remains available as Frame in the View Mode section for anyone who wants the phone look.
  • Why the feed looks like it stops loading. It does not — measured on a live logged-in mobile feed, 61 post units continued to arrive across 22 scroll steps, so infinite scroll is working. What changed is how much of it is shown: 22 units hidden by the phrase matcher, 18 hidden by Follow suggestions and 21 left visible, i.e. 66% of the feed hidden, with the Follow filter alone accounting for 30%. That is the filter doing exactly what it was asked to do on a feed that is mostly recommendations, but the effect is a sparse feed that can read as "posts stopped coming". Follow suggestions is a single switch in the Facebook section if a fuller feed is preferred.

8.10.5

  • Fixed a scrolling regression introduced in 8.10.2. The mobile tray detector memoized elements after its viewport-proximity test, not before, so every div outside the viewport band was skipped without ever being recorded — and re-measured with getBoundingClientRect() on every sweep, forever. Because the sweep is driven by a subtree: true MutationObserver over a feed that mutates constantly while scrolling, this meant a full-document forced synchronous layout on essentially every frame, with a cost that grew as the feed grew. Measured on a live logged-in mobile feed scrolled to 3153 divs: 2846 elements re-measured per sweep at 2.5–3.4ms, versus 84 at 0.4–0.7ms after the fix — 34× fewer measurements, and constant rather than growing. The viewport test was never needed for correctness in the first place, since the detector matches on width and height, which do not depend on scroll position; removing it makes the detector both cheaper and able to catch a tray before it scrolls into view. hideMobileChrome had the same unbounded shape with no memoization at all, and would rescan every div on every sweep whenever a page had no composer to find, so it now memoizes too. Verified after the fix: still exactly one node tagged and the Stories tray still hidden.

8.10.4

  • Fixed: LinkedIn's Top bar rule hid the wrong elements. It was header{display:none}, unqualified. On a live logged-in feed there are 31 <header> elements and none of them is the global navigation — they are modal, section and post headers. Of the four visible ones, two were the headers of promoted posts (Get SOC 2 compliant without…, Experience wealth management…), so enabling the setting hid ad headers while leaving the actual chrome in place. It is now a geometric definition of a top bar — a header at least 90% of the viewport wide, 40px or taller, within 120px of the document top — tagged in JS like the other LinkedIn chrome. Verified live: the old rule would have hidden 4 visible headers, the new rule tags 0, which is the correct answer for a layout that has no global nav. The setting is off by default. Honest limit: the desktop global-nav match could not be re-verified because the logged-in session did not survive the user-agent switch, so the rule is proven to stop hiding the wrong things but not yet proven to hide the right one. It fails safe — under-matching leaves the bar visible rather than removing content.
  • Audited every YouTube selector group across four page/viewport combinations. No dead rules were found once state is accounted for: hideEndCards and hideAutoplay only match at a video's end screen, anti-adblock only when YouTube shows the message, and hideShorts (off by default) needs a signed-in home for its shelves to render. One group looked alarming and turned out to be fine — #secondary,#related,ytd-watch-next-secondary-results-renderer matches 5 elements on the desktop home and search pages even though it is a watch-page rule, but every one of them is collapsed to 0×0 and already invisible there, while on the watch page they are 432×2805 and correctly hidden. The desktop rules really are self-scoping, unlike the mobile ones fixed in 8.8.1.
  • Recorded, not patched: YouTube's hideComments mobile selectors (ytm-comments-entry-point-header-renderer, ytm-comments-entry-point-teaser-renderer) matched zero on a live mobile watch page. The setting is off by default, and rather than guess at replacement markup the finding is written down until a real mobile comments entry point can be captured. LinkedIn's L.rail/R.rail are inert on mobile because there are no <aside> elements there and no rails to hide, which is expected rather than broken; feed detection and Promoted/Suggested matching both work on mobile (2 hides on a mobile feed, 8 of 23 posts on desktop).

8.10.3

  • Audited every Facebook CSS selector against a live logged-in feed on both shells, and 11 of 14 were dead on mobile. Desktop matched all fourteen. Mobile matched only [aria-label*="reaction"] and [aria-label*="reacted"] (8 each). Everything else — [role="banner"], [role="main"], [role="complementary"], [role="region"][aria-label="Create a post"], [aria-label="Shortcuts"], [aria-label="Facebook"], [aria-label="Account Controls and Settings"], [aria-label="Stories"], [aria-label="Reels"], [aria-label="Leave a comment"], [aria-label^="Comment"], [aria-label^="Like:"] — matched zero. Mobile Facebook barely uses landmark roles at all: a role census returned only button, link, presentation, tab, img and tablist, and there is no <header> element either.
  • Fixed: Composer and Top bar never worked on mobile, despite both being on by default. The composer is now found structurally — a full-width row 50–95px tall, positioned above the first real post, containing a labelled role="button" and an avatar image — and the top bar via [role="tablist"], which is exactly the six-icon navigation row and is locale-independent. Verified live: 2 nodes tagged, both hidden, first post untouched, 8ms. The Facebook wordmark and search row above the tabs is deliberately left alone, so search remains reachable.
  • Not changed, and why: Right sidebar, Left sidebar and Widen feed are inert on mobile because mobile has no sidebars and no [role="main"] for feedBox to measure — there is nothing to fix, they simply do not apply there. Comments and Like counts are off by default; their mobile aria-labels carry private-use icon glyphs (󰍹comment rather than Comment), so the prefix selectors cannot match, which is recorded here rather than patched blind.

8.10.2

  • Fixed: the Stories tray was never hidden on mobile. Reels/Stories is on by default and the README has always claimed it hides both trays, but on a live logged-in mobile feed all three detection paths were dead for Stories. It has no [data-tracking-duration-id] anywhere in its ancestry, so processMobile never examined it; it carries no aria-label, so the [aria-label="Stories"] rule matched nothing — a scan of every aria-label on the page returned share, More, comment, Video player and similar, with no Stories or Reels among them; and the word "Stories" does not appear on mobile at all (0 occurrences), so the exact-text path could not fire either. Detection is now geometric and locale-independent: a full-width container 150–340px tall holding three or more portrait tiles (80–170 wide, 130–300 tall) that share at most two distinct tops — the signature of a horizontal card carousel. Verified live: exactly one node tagged (the outermost container, since nested candidates are skipped once an ancestor is tagged), the tray hidden, and no false positives anywhere in the feed. Cost is 13.5ms on the first pass over 920 divs and 0.5–1.8ms afterwards, since examined nodes are memoized in a WeakSet. Runs only on the mobile shell, where desktop's aria-label rule already works.
  • Audited the Follow suggestions header band for misses, and found none. Across 117 live post nodes, all 22 Follow posts placed the button at an offset of exactly 15px from the post top — none outside the 130px band, so nothing is missed by that limit. End-to-end on the live feed, applying the real tagging and hide rule left 0 visible Follow buttons across 61 hidden units.

8.10.1

  • Video ads are now prevented on initial page load, not skipped after they start. ytInitialPlayerResponse is assigned by an inline var in the served HTML, so it never reaches JSON.parse — which is why the 8.10.0 pruning could not touch it. Because the script runs at document-start, an accessor defined on window beforehand intercepts that assignment instead: a global var whose property already exists does not redefine it, it assigns through the setter. The incoming object is scrubbed there, and ytInitialData is handled the same way. Verified as a controlled A/B on one video, same browser, minutes apart — control: ad keys ["adPlacements","playerAds","adSlots"] all present, 72 ad frames observed, ad played. With the trap: those keys present on arrival and empty after, 0 ad frames, no ad at all, video playing normally at 143s of 282s with no visible error and no anti-adblock message. The control matters: it proves ads were genuinely being served at that moment, so the zero is prevention rather than luck.
  • Worth knowing: this is a stronger intervention than skipping. Ads are never scheduled, so there is no seek-past-ad behaviour for YouTube to notice — but suppressing the ad payload is also exactly what YouTube's anti-adblock checks look for. None appeared in testing, and Anti-AB still handles the message if one does. Block ad data can be switched off in the panel, which falls back to the skip engine.

8.10.0

  • New: Block ad data — and it closes the Shorts gap that DOM detection never could. Shorts ads are not player ads. They arrive as ordinary entries in the reel_watch_sequence API response carrying command.reelWatchEndpoint.adClientParams.isAd, which is why walking 45 live Shorts produced no getPresentingPlayerType() of 2 or 3, no ad-showing, and no .ytp-ad-* markup — the markers the old Shorts gate waited for do not exist for these ads. JSON.parse and Response.prototype.json are now wrapped to drop flagged entries before the feed renders. Verified live: walking 40 Shorts across 5 reel_watch_sequence fetches, one real ad was removed (oGoeeR08D8o) — an id that had played during an earlier walk of the same feed, when detection did nothing. The approach follows uBlock Origin's maintained YouTube filters, which is where the isAd path was traced from.
  • The same pass strips adPlacements, playerAds and adSlots, which is what uBlock prunes for video ads. Via JSON.parse alone this covers player responses fetched during in-page navigation but not the initial page load, because ytInitialPlayerResponse is an inline object literal in the served HTML and never passes through JSON.parse. That remaining hole is closed in 8.10.1.

8.9.2

  • Confirmed on a live mobile ad break — the gap that was open since 8.8.2. Two real ads captured on m.youtube.com. The decisive one is a 15-second ad where the ad UI never rendered: {api:true, cls:true, ui:false, oldGate:false, newGate:true, dur:15}. The old gate evaluated false while an ad was actively playing; the shipped gate caught it and muting engaged. A second 6s ad was muted and its skip button tapped. This is the mobile counterpart of the desktop break verified in 8.9.1, and it closes the last unverified claim in the ad-skip work.
  • Follow suggestions is now user-repairable. Inspected live, the Follow control is a div[role="button"][tabindex="0"] whose classes are hashed and rotating, with no aria-label and no data-* — the visible text is the only stable anchor available, so a wording change would break detection silently. Entries in FB → Junk phrases now also feed the Follow word list, so a new or localized wording can be added from the panel without waiting for a release. Existing phrases are unaffected: they are matched as whole button text, and no realistic phrase is ever a button's entire label.
  • Known gap, now measured rather than assumed: mobile Shorts ad detection. Walking 45 live Shorts, getPresentingPlayerType() never returned 2 or 3 and no genuine Shorts ad appeared, so the path is still unproven end to end. Worse, two of its three branches are structurally dead on mobile: the reelAd fallback targets ytd-reel-video-renderer, ytd-shorts and ytd-reel-player-renderer, and mobile Shorts uses none of them — its components are shorts-page, shorts-carousel, shorts-video, ytm-reel-player-overlay-renderer and yt-reel-*-view-model. The ad-UI branch is likewise desktop-only. That leaves getPresentingPlayerType() as the only live branch on mobile Shorts. No selectors have been invented for it, because no real mobile Shorts ad has been observed to build against.

8.9.1

  • Verified the Facebook mobile path on a live logged-in phone-emulated feed. A logged-in phone gets responsive www.facebook.com, and there is no [role="main"] there — so hasDesktopShell() is false and processDesktop, processCards and hideLeftNav never run on mobile. Everything rests on processMobile, which still resolves posts through [data-tracking-duration-id]. Measured across 46 real posts: 17 hidden by the phrase matcher and 7 more by the new Follow suggestions detection, 24 in total. Two structural consequences worth recording: feedBox cannot run on mobile because it requires [role="main"], so Widen feed is desktop-only; and processCards' width limit computes to 226px at a 412px viewport while mobile posts are ~412px wide, so it would match nothing there even if it did run.

  • Fixed: Shorts skipped genuine videos at random. AD_S included ad-created, but that class means "an ad player was constructed", not "an ad is playing" — it stays on the player afterwards. Paired with a preloaded, hidden ad slot satisfying the ad-UI half of the test, the gate fired on ordinary content. Caught live while walking a real Shorts feed: two advances triggered on Shorts reporting getPresentingPlayerType() === 1, carrying only ad-created. Re-run as an A/B over the same feed, the old gate produced 2 false advances and the corrected gate 0. ad-created is gone; ad-showing and ad-interrupting remain, and the authoritative player API is unaffected.

  • Verified a live desktop ad break. Two ads, both caught during the window before YouTube renders its ad UI — the old gate evaluated false there while the fixed one was true from the first frame, and muting engaged where it previously could not. Once the break ended the gate went false and the 282s video played unmuted and unseeked, so the 180s seek guard held.

  • Verified mobile Shorts structurally. #shorts-player does not exist on mobile — the player is #movie_player, so the existing fallback is load-bearing — and no false skip occurred on a normal Short after the fix.

8.9.0

  • New: Follow suggestions. Verified against a live logged-in feed, Facebook no longer labels its recommended posts "Suggested for you" — across a full scroll of that feed the phrase appeared zero times. What it does instead is put a bare Follow button after the author, giving headers like Proton · Follow · 7h and Turn key narrowboats · Follow · 7h. No phrase list could catch that, and substring-matching "follow" would be reckless because it also matches followers, following and any post that merely says "follow me". Detection is therefore element-level: an innermost clickable element inside the post's 130px header band whose visible text is exactly "Follow". Measured live: 8 distinct posts flagged against 57 distinct posts kept, with no false positives — the composer, friends' posts, group posts and sponsored posts were all left alone, and no post whose header merely contained the word "follow" was flagged. On by default, switchable in the panel, and localized for the common variants.
  • On the 8.8.1 matcher change, measured properly. An earlier note here claimed the old mobile matcher caught "0 of 76" posts. That figure came from feeding desktop concatenated headers into both matchers and does not describe the mobile path, which is corrected now. processDesktop reads one concatenated header band, so with labels trailing the page name — Haven House Children's Hospice · Sponsored, The Queens · Sponsored — startsWith genuinely could never fire there, and includes is required. processMobile instead tests individual elements under 40 characters, where a span reading exactly "Sponsored" already matched. Re-measured on a live logged-in mobile feed: across 46 posts the old and new matchers both hid 17. The change is still correct — it aligns the two paths and catches elements combining author and label — but it is a robustness fix on mobile, not a repair of a total failure.
  • Confirmed the LinkedIn limitation. On a live logged-in feed, 1 of 5 stylesheets is CORS-blocked and the readable ones carry zero width-based media queries, so Reflow CSS genuinely has almost nothing to rewrite there. Promoted/Suggested detection itself works: 8 of 23 scanned posts matched.

8.8.2

  • Fixed: ad skipping never ran on mobile. Mobile YouTube loads a different player binary — player-plasma-es6 rather than desktop's player_ias. Of the eight .ytp-ad-* overlay classes the ad-UI test looked for, all eight are absent from the mobile player, so adUiPresent stayed false there. The gate was (adClass || adApi) && adUiPresent, so on mobile it could only fire once a skip button had already rendered — the unskippable opening seconds were never detected, Mute ads never engaged, and unskippable ads were never seeked past. Detection now trusts getPresentingPlayerType() on its own, present 47 times in the mobile player and returning 1 for ordinary playback on both platforms; the older class path keeps its UI confirmation. The same one-sided gate is fixed for Shorts. (An earlier draft of this note also claimed ad-showing/ad-interrupting never appear on mobile. Those strings are absent from the mobile binary, but the classes are applied to the player at runtime — a captured live mobile ad shows cls: true. The half that actually failed was the ad-UI test, not the class test.)
  • Guarded Seek past ads against real videos. Now that an API reading alone can trigger a skip, the seek additionally requires a duration of 180s or less, so a misread can never fast-forward a genuine video. Verified on live playback: getPresentingPlayerType() reports 1, the video is left unmuted and unseeked on both desktop and mobile.
  • Throttled the skip tap. tap() fired on every DOM mutation during an ad — 126 synthetic tap sequences in 2.6 seconds on desktop. Now capped at one per 400ms, which cut it to 10 while still skipping and muting.
  • Mobile ad-UI selectors added for the two that do exist in the mobile player, ytp-ad-progress and ytp-ad-skip-button-container.

Validated on live pages: normal playback untouched and a simulated ad muted and skipped, on desktop and mobile, over repeated runs. Mobile Shorts could not be confirmed end to end — signed-out Shorts redirects to a consent page — so that path is fixed structurally from the player source but is unverified against a live Short.

8.8.1

  • Fixed: mobile YouTube loaded forever on search and feed pages. Related (on by default) injected ytm-single-column-watch-next-results-renderer and ytm-item-section-renderer:has(ytm-video-with-context-renderer) as global CSS. On desktop the equivalent selectors exist only on the watch page, so it was self-scoping; on mobile ytm-item-section-renderer is the generic feed section, so it also hid search results, and any feed built from it. Measured on a live mobile search: one matched element wrapped all 18 results (3.5M px², 3099 of the page's 5267 text characters). Because that container was display:none the document never grew past 915px, so YouTube's infinite scroll kept requesting continuations to fill a viewport that could never fill — 117 result nodes loaded instead of 19, every one of them invisible. That runaway is what "keeps loading" was. The mobile watch-next rules are now scoped to html[data-yt-watch], set from location.pathname and refreshed on SPA navigation. Verified: search shows 19 of 19 results with no continuation loop, watch-page related videos still hidden, and the marker toggles correctly across pushState navigation both ways.
  • Fixed: Facebook suggested posts leaked on mobile but not desktop. The mobile pass matched with t===m || t.startsWith(m) while the desktop pass used includes, so any label that did not begin with the marker was hidden on desktop and shown on mobile — and Facebook usually puts the author name first, as in "Julia Smith · Suggested for you". Mobile now uses includes like desktop, still bounded by the existing 40-character node cap that keeps it off post body text. Desktop and mobile now agree on every label tested.
  • Added the follow-suggestion phrases. Suggested accounts, People to follow, Accounts to follow and Pages to follow were in no list, so they were shown on desktop as well. Checked against false positives: "Great suggestion from a friend", "I followed the recipe" and a bare "Follow" are all left alone.

8.8.0

  • Mobile view on desktop now actually reflows the page. Previously it changed only what page JavaScript reads, so on a desktop browser it was close to a no-op on every site: measured on a live 1512px window, matchMedia("(max-width:600px)") returned true while the CSS @media behind it stayed false and the layout never moved. The three levers it pulled cannot work there — desktop browsers ignore <meta viewport> entirely, a userscript cannot change a navigation's UA header (every document request went out with the real desktop UA), and patching window.matchMedia does not affect CSS, which the layout engine evaluates itself. New Reflow CSS rewrites the site's own @media breakpoints in the CSSOM against the virtual phone width, covering document, adoptedStyleSheets, shadow roots, @import, and media="" attributes. Verified live: BBC 463 media rules rewritten and it renders its real mobile masthead and nav, Wikipedia 37, Hacker News 12, 0 blocked.
  • Fixed: initVM died silently at document-start. When documentElement was not yet present, applyVP() threw on appendChild of the viewport meta and the top-level run() swallowed it. Everything after that line was lost — the MutationObserver, all four retry timers, the frame, and the Alt+Shift+V hotkey registration — and vpLocked was left stuck true, permanently blocking the retries meant to cover exactly this case. Hotkey registration now happens first, the DOM writes are guarded, and the retry schedule re-arms if the document root appears late.
  • Fixed: the viewport re-entrancy guard never worked. vpLocked was set and cleared synchronously, but MutationObserver callbacks arrive as microtasks after that, so the guard was always false when checked — measured 18 full applyVP passes on one Wikipedia load. applyVP is now idempotent (it returns early when the meta is already correct) and is skipped entirely on desktop, where viewport meta has no effect.
  • Fixed: matchMedia missed unspaced queries. The width tests used \s* but the feature tests were includes("pointer: coarse") with a literal space, so (pointer:coarse) — the form minified CSS and JS emit — fell through to the native result and answered false in Mobile mode. Queries are now parsed, not substring-matched.
  • Fixed: compound queries silently dropped their other terms. (min-width:768px) and (orientation:landscape) was answered from the width alone. The spoof now rewrites only the terms it owns and hands the rest to the native engine, so unknown features stay correct — which also means matchMedia returns a real MediaQueryList, so addEventListener change events work. This removes the previous "returns a stub" limitation.
  • Frame contains fixed overlays. The phone frame moved to the root element with a transform, making it the containing block for position:fixed descendants, so banners and sticky bars stay inside the phone instead of spanning the full window. frameOnDesktop now defaults on.

8.7.1

  • Fixed rails reappearing, compressed, after the feed is widened. Rails were identified by comparing their position against the feed's current rect — but widening the feed moves its edges, so on later passes the rails overlapped it and no longer qualified. They stayed visible and were then squeezed by the wider feed. The module was measuring against a value it had itself changed. Rails are now identified independently: a narrow <aside> that is neither inside the feed nor contains it, assigned left or right by which half of the viewport its midpoint falls in. Verified on a live feed across five consecutive passes with the feed widened to 900px throughout: both rails (216px and 312px) tagged on the first pass and still tagged on the fifth, none visible in any round, none left compressed.

8.7.0

  • Fixed the "works sometimes" behaviour on LinkedIn. LinkedIn virtualises its feed, recycling DOM nodes for different posts as you scroll. The module marked each element it had examined with a permanent flag, so once a node had been checked it was skipped forever — and when LinkedIn reused that node for a different post containing an ad, the ad was never examined. That is why filtering appeared to work on some posts and not others, and why results differed between sessions. Elements are now re-examined whenever their content changes, and an element that was hidden but no longer matches is released. Facebook already worked this way; LinkedIn did not.

8.6.7

  • Fixed the actual cause of "only the feed is missing" on LinkedIn. The promoted-post sweep could hide the feed column itself. The column contains promoted posts, so it matches the markers, and whenever no child qualified as a tighter match the whole feed was tagged and hidden — leaving every surrounding element in place. The feed container and any ancestor of it are now excluded from the sweep outright. Verified live: feed visible at 900×1280 with its content, never tagged, both rails hidden, promoted posts hidden, none left in view.

8.6.6

  • LinkedIn feed detection rewritten against the live DOM, and tested there. 8.6.3 through 8.6.5 each guessed at how to identify the feed column and each was wrong: by width (matched a page wrapper and squeezed the feed), then by counting post-shaped children (matched an empty skeleton placeholder with three children and zero text, while the real feed has only one such child). Measuring the live page showed the reliable signal is simply text: the feed is the narrowest column at least 380px wide that contains real text. Verified live end to end — the 552px feed is selected over both the 1128px wrapper and the empty skeleton, both rails are hidden, the feed stays visible with all its content, and widens 550 → 900px.

8.6.4

  • Fixed a regression in 8.6.3 that could hide or squeeze the LinkedIn feed itself. Widening the search range to support wider layouts made it possible to select a page wrapper instead of the feed column, and the widening CSS then applied to the wrong element. The feed is now identified by structure rather than size — a column is only accepted if at least two of its direct children are post-shaped (tall, and nearly as wide as the column). If no such column is found, nothing is tagged and the module leaves the layout completely alone, which is the correct failure mode. Verified against wrapper, 552px column, 900px column and side-rail shapes.

8.6.3

  • Fixed: nothing hidden on wider LinkedIn layouts. The feed column was located by a hard-coded 480–700px range, taken from the 552px column measured on one window size. On a wider window — or any layout where LinkedIn gives the feed more room — no column matched, so nothing was tagged and the rails, top bar and widening all silently did nothing. Detection is now relative to viewport width (380px up to 80% of the window, capped at 1400px) and picks the widest matching column. Verified across six layouts from 1024px to 2560px: the old range missed three of them, the new one finds all six.

8.6.2

  • Fixed: turning off Promoted and Suggested also disabled rail hiding and feed widening. Those are independent features, but an early return sat between the stylesheet being installed and the sweep being wired — and the sweep is what tags the rails and feed column. So with both post filters off, the chrome settings silently did nothing. The chrome tagging now runs regardless; only the promoted-post scan is gated on having markers.

8.6.1

  • LinkedIn mobile verified. Previously listed as untested. Confirmed on a live logged-in mobile session at 412×915: the feed column is 412px so it clears the module's 280px minimum, promoted posts are hidden as you scroll, none remain in view, and all 70 genuine posts are intact. Worth noting the scroll container differs by layout — main#workspace on desktop, the document itself on mobile — which the capture-phase listener added in 8.5.2 handles either way.

8.6.0

  • LinkedIn brought to parity with Facebook and YouTube. Added Top bar, L.rail, R.rail and Widen feed (with a Feed max width under Advanced), alongside the existing Promoted and Suggested toggles. LinkedIn hashes its class names, so the rails are identified by geometry relative to the feed column rather than by selector — the same approach Facebook uses for its left nav. Verified on a live feed with a clean baseline: both rails tagged (216px left, 312px right), both hidden, top bar hidden, feed widened 552 → 900px, and feed content intact. All three site modules now have identical wiring — panel section, GM menu entry, hotkey, cluster button, affects() handling and enabled-toggle branch.

8.5.2

  • Fixed: promoted posts stayed visible while scrolling LinkedIn. LinkedIn scrolls an inner container (main#workspace), not the window, so the module's window scroll listener never fired and sweeps fell back to the 1.5s timer — which you outscroll. Measured on the live feed: scrolling that container produced 0 window scroll events and 7 capture-phase events. Both Facebook and LinkedIn now also listen in the capture phase on document, which catches scrolling from any container. The detection itself was never at fault: once a promoted post was in view, the existing sweep hid it correctly.

8.5.1

  • Fixed a hole in all three tightest-match scanners (LinkedIn, the Facebook tile-grid pass, and the rotation fallback). Each skipped a matching element if one of its children also matched — correct, so a row is never hidden for one ad tile. But the child test only checked width and minimum height, while the main loop also enforces a maximum height and a viewport band. So an oversized or off-screen matching child would suppress its parent and then be rejected itself, and neither was hidden. Proven on a fixture: a 718px card containing a 2000px matching child was missed by the old logic and is caught by the new. A child now only suppresses its parent if it would itself be eligible.

8.5.0

  • Added LinkedIn. Hides Promoted posts and, optionally, Suggested/recommended content in the feed. Toggle with Alt+Shift+L, the 💼 button, or the panel. LinkedIn has moved to fully hashed class names — feed-shared-update-v2, data-urn and the other long-standing selectors all match nothing now — so this module uses no selectors at all. It reads rendered text and hides the tightest post-sized element containing a Promoted label, the same approach that makes the rotation fallback honeypot-immune. Verified on a live feed: 5 promoted posts hidden with 69 genuine posts untouched. The two promoted units that survived 8.5.0 were traced to scroll detection, fixed in 8.5.2: LinkedIn scrolls an inner container so the window scroll listener never fired. The 8.5.1 eligibility fix was a separate real bug found along the way.

8.4.0

  • Added a markup-rotation safety net. When the health check reports that sponsored labels are visible — meaning the normal selectors have stopped matching — a fallback engages that ignores markup entirely. It reads only rendered text (position-aware, filtered by computed style and geometry) and hides the tightest bounded element containing a sponsored label. This matters because Facebook is documented to plant hidden "Sponsored" honeypots inside ordinary posts specifically to make naive text-matching blockers hide real content; reading rendered text rather than the DOM is immune to that by construction. It is a safety net, not a replacement: tested with every known selector renamed, it recovered 4 of 10 ad elements with zero of 20 real videos affected, in 20ms. Partial recovery with no collateral damage is the intended behaviour — it only runs when detection is already failing, so normal operation is unchanged.

8.3.0

  • Added Seek past ads, and a note on why you might turn it off. When an ad has no working skip button the script seeks the player to the end. That is the most effective way to clear an unskippable ad, but it is also the exact signature YouTube's detection is described as looking for — an ad slot that loaded but never played, with an impossible timing profile. If you get repeated "Something went wrong" playback errors on videos with unskippable ads, switch Seek past ads off: skipping, muting and all feed/banner hiding keep working, and unskippable ads simply play muted instead of being jumped. On by default, because skipping ads is the point.

8.2.4

  • Fixed: hideAutoplay stopped working after the first video. Disabling YouTube's autoplay toggle was wired to page load only, but YouTube navigates client-side, so it never ran again once you moved to another video. Every other per-navigation concern in the module was already hooked to navigation; this one was the exception. Now re-applied on each navigation.

8.2.3

  • Added tile-grid ad detection, covering desktop Marketplace and other card-based surfaces. The existing detector reads each feed row's top header band, which works for posts but finds nothing on Marketplace, where ads are tiles inside horizontal rows with the label at the bottom. A second pass now scans bounded cards by text content and hides the tightest element containing a marker, so a row is never hidden when only one of its tiles is an ad. Verified on desktop Marketplace: 19 ads hidden, no sponsored label left visible, and all 48 real listings untouched. It is a clean no-op on the main feed, where labels are obfuscated and posts exceed the card size cap, so the two models do not overlap.

8.2.2

  • Fixed: sponsored Reels were never skipped on mobile. handleReels() sat inside the hasDesktopShell() branch of the sweep, and mobile Facebook has no [role="main"], so it never ran. The reel machinery itself worked fine on mobile — video, container and skip target all resolve — it was simply gated out. Reels handling is now ungated (it already self-guards on the URL path).
  • Fixed: hideComments did not match on mobile. Mobile labels comment controls Comment on reel by X, N comments, which none of the previous four selectors matched. Broadened to [aria-label^="Comment"], which covers every variant in one rule. hideLikeCounts also picks up reacted.

8.2.1

  • Fixed: no filtering at all on mobile Facebook. forceMostRecent redirected to /?sk=h_chr and returned early, but mobile Facebook silently strips that parameter and serves / again — so the redirect fired on every load and the module never got past it. Filtering never initialised, and every sponsored post stayed visible. The redirect is now attempted at most once per tab, after which setup proceeds normally. Desktop still lands on the chronological feed.

8.2.0

Fixes

  • Mobile YouTube ad-skipping never worked. The skip button is found and enabled, but element.click() is ignored on m.youtube.com — a 72-second ad ran to completion, merely muted. Because the button was found, the existing seek fallback never fired either. Added a full pointer/touch tap sequence plus a seek fallback after 3 ticks, so an unresponsive button can no longer keep an ad on screen.
  • frameOnDesktop could never work. The UA spoof ran first, so the "are we on a real phone?" check read the spoofed mobile UA and disabled the desktop phone-frame. With spoofUA on — the default — the whole feature was dead code. Real-device detection is now captured once before any spoofing.
  • Trusted Types could leave a dead overlay. Where a site's CSP blocks the script's policy, the panel rendered empty inside a full-screen host with pointer-events:all — an invisible click trap with no way to dismiss it. Now degrades to a small dismissible notice, with the host set to pointer-events:none so the page stays usable.
  • Two stale Facebook selectors. hideComments and hideLikeCounts matched nothing — wrong [role="article"] scoping (real posts are plain divs) plus renamed labels. Now [aria-label="Leave a comment"] and [aria-label^="Like:"].
  • Site modules ran on the block page. After the blocker replaced the document, the Facebook module still fired its "Most Recent" redirect, so the block screen flashed and navigated away; both modules also installed observers and intervals on a page that is only a block notice.

Added

  • Distraction-free YouTube watch page: hide Related, Comments, Chips, Merch/promos and Live chat, with Widen player expanding the video into the reclaimed space.
  • Visible degradation warning. If markup rotation stops filtering from matching, the panel says so and the module's button gets an amber ring — instead of failing silently. It only fires when the relevant module and its hiding option are actually on.
  • Focus Pack effective-state line in the Blocker panel.

Changed

  • scheduleOn now defaults to off, so a fresh install no longer blocks Facebook and YouTube during work hours while the Focus toggle reads OFF. Existing installs keep their saved value; the new state line makes an inherited on visible.
  • hideEndCards, hideInfoCards and hideRelated now default on.
  • README rewritten with a full feature reference, limits and this changelog.

8.1.0

  • Ad-player detection corrected against a captured live ad: the ad player is getPresentingPlayerType() === 2, not 3, and getAdState() stays at -1 throughout an ad, so it is useless as a signal. Shorts ads are in-feed slots and detect through a different branch again.
  • Mobile YouTube ad selectors corrected by reverse-engineering the shipped bundle — ytm-companion-slot, not …-renderer — and ad-disclosure-banner-view-model added. Several dead player selectors removed, and .ytp-skip-ad-button restored after it turned out to be present in both player bundles.
  • Storage hardening. Import is key-filtered, type-checked and array-sanitised: a hostile file previously replaced the config object's prototype, and a type mismatch crashed the blocker inside an unguarded interval. GM_getValue is wrapped so a throwing manager cannot kill the script.
  • Schedule parsing now fails closed — a malformed time produced NaN and silently blocked the site all day.
  • Block screen under Trusted Types rebuilt from DOM nodes instead of innerHTML, which threw after window.stop() and failed open.
  • Duplicate <style> elements on repeat calls; cluster drag triggering buttons; the View Mode button making the cluster undraggable; exportSettings failing in Firefox; an unthrottled full-document query per mutation.
  • @version had been frozen across four releases, so auto-update would not have delivered any of the above.

8.0.0

  • Fixed the two root causes of sponsored posts still appearing. On desktop, the feed-container scan only looked 1–2 levels deep while the real container sits at depth 5–8, so nothing was ever filtered. On mobile, the code path was gated on the m.facebook.com hostname — but a logged-in phone is served www.facebook.com, so the mobile code was dead on every real device. Layout is now detected from the markup.
  • Posts are re-checked when their content changes, instead of being marked permanently clean after 4 sweeps (Facebook injects labels lazily).
  • Feed widening to reclaim the space freed by hidden sidebars; panel widened with an auto-fitting toggle grid.
  • Removed the Instagram, X/Twitter and Reddit modules. Their selectors were largely stale and they are no longer maintained. Those domains remain in the Site Blocker's Focus Pack, which is a separate feature.
  • Various YouTube selector corrections and dead-selector removal.

7.1.0

  • Anti-adblock dialog dismissal tracked per node, so it stops re-triggering play() on a video you paused deliberately.
  • Ad detection requires actual ad UI to be present, preventing ordinary videos being seeked to the end.
  • Ranked candidate selectors for the mobile Facebook feed.

Verification

Behaviour is verified against live sites and pinned by a committed suite.

  • bun install, bunx playwright-core install chromium, then bun test. The suite runs the real webcleaner.user.js in headless Chromium against the fixtures in test/fixtures/, behind a GM_* shim, injected before the document's root element exists — the strictest document-start a manager offers. Each fixture carries labelled and control posts at real sizes, so geometry-based detection is exercised rather than stubbed, and the YouTube fixture serves the live endpoint shapes (player, the streamed two-element get_watch array, reel/reel_watch_sequence) on both transports.
  • 46 checks: Facebook desktop, tile grid and mobile, its GraphQL response editing on fetch, XHR and through a fresh frame's natives (with JSON.parse confirmed left intact); YouTube's stylesheet and bait elements, the enforcement node, the inline-payload accessor, fetch and XHR scrubbing, the canary and honeypot pass-through, a fresh frame's fetch, in-page navigation and the rescue path; LinkedIn; the Site Blocker; View Mode; the panel, including under an enforced Trusted Types policy. Run against 8.22.0 the suite fails on exactly the defects listed under 8.23.0, which is its negative control. CI runs it on every push.
  • Live in this release: YouTube desktop watch, in-page navigation and Shorts (12 October 2026, logged out), and the three managers' handling of @grant and unsafeWindow, read from their published source and documentation.
  • Verified live on a logged-in Facebook feed (12 October 2026): the GraphQL response edit stripped every SponsoredData object before the page rendered, the feed shed its sponsored stories, the sponsored-info links went to zero, the clean-frame guard held, and no page errors were raised. The Facebook phone shell, and every Facebook surface on both shells (home, groups, Reels, Marketplace, search, Friends, notifications, profile), were verified live the same day in the same session. Not re-verified live: LinkedIn (needs a logged-in session), Firefox, Safari and a real iOS device. The Facebook DOM rules remain fixture-verified, since the live markup rotates.

Contributing

Issues, ideas, and PRs are welcome — keep PRs focused on a single concern and follow the existing conventions. Comments are reserved for the few places the code must not be "fixed" without knowing why; put the rest in commit messages, and add a fixture test for any behaviour you change.

Support & sponsors

Web Cleaner is free and has no tracking or ads. If it's useful to you, you can support continued development — pay what you like, once or monthly:

Donate once via Stripe   Sponsor monthly via Stripe

About

One userscript, four modules — Facebook Clean Feed, YouTube Skip Ads, a Site Blocker with a work-hours focus schedule, and a desktop/mobile View Mode switcher — all configurable from one in-page panel. Tuned against the live DOM, auto-updating.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages