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 a userscript manager: Violentmonkey or Tampermonkey (Chrome/Edge/Firefox; on Android use Firefox + one of these, on iOS use the Userscripts app for Safari).
- Click Install — the manager will prompt you.
- Updates are automatic: your manager re-fetches from the URL it was installed from. No re-pasting.
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. |
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.
| 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.
- 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-basedGM.setValueinstead, 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 overytInitialPlayerResponsein the page scope. On iOS the script therefore falls back tolocalStorage, 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.
FocusandScheduleare both OFF by default — nothing in the Focus Pack is blocked until you turn one on. Two independent switches feed the same filter (Focusblocks always,Scheduleonly 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 — ifSchedulewas 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.phpon Facebook,/feedon 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/promosandLive chateach hide that part of the YouTube watch page.Relatedis ON by default; withWiden playerthe 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, notm.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.3or 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 —DI7in the player,naMon desktop, same names, same two-bit slots in aCATSTATregister that is XORed with a BotGuard value and sent on the attestation ping asr6b, besider6acarrying theDCLKSTATbait result. The player implements four; the desktop bundle implements the rest, including a JSON canary, aResponse.jsoncanary, a spoofed-URLfetchhoneypot, 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=3when it blocks thestatic.doubleclick.net/instream/ad_status.jsbait, 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.
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 |
- 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.
onAbnormalityDetectedsets thed.p_register to detected before any of this module's handling runs, and every signal also emits abiscottiBasedDetectionevent 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 adsships 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 CSSthen 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 CSSrewrites@mediarules in place, which needscssRulesaccess. 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:fixedoverlays, 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
JavaScriptsandbox on Firefox, and Violentmonkey's content mode after a CSP fallback, hand the script a wrapped page window whose objects can only be modified withexportFunction/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 defaultrawsandbox, 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.phpredirects.Strip trackingunwrapsl.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: 6l.phplinks present, 5 unwrapped, 1 opaque.utm_*parameters,pingattributes anddata-lynx-uriare stripped in every case. - Schedule day-spill. A schedule of
Mon22:00–06:00does 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.
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 carryingadPlacements,adSlotsandplayerAds. It is a two-element array ([{playerResponse}, {watchNextResponse}]) that the page consumes as a stream, so it never reachesResponse.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 afetchwrapper — the way uBlock Origin'sreplace-fetch-responsedoes it — keyed on the URL the network served (Response.url), so the synthetic-Responsecanary and thedata:-URL honeypot pass through untouched exactly as before, and the replacement carries the originalurl,ok,redirectedandtype. 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 noadSlotsorplayerAds,getPresentingPlayerType()reads 1 where it read 2 before, and the page'sfetchstill reports[native code], namefetch, length 1. The 8.22.0 suite had verified both transports by calling.json()itself, which is not what the page does. @grant unsafeWindowis declared. 8.20.0 moved the YouTube hooks ontounsafeWindowwithout 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 = globalin its API wrapper, for any@grantother thannone), but Tampermonkey's documentation says@grant"is used to whitelistGM_*andGM.*functions, theunsafeWindowobject and some powerfulwindowfunctions", so under its sandbox the hooks fell back to the sandbox's ownwindowand never reached the page. The iOS Userscripts app filters@grantvalues against a fixed list (validGrantsinFunctions.swift) that contains neitherunsafeWindownor the legacyGM_*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 dataswitch on, the page received its/api/graphql/feed responses carrying 0SponsoredDataobjects 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 itsjsonl-edit-xhr-response/json-editrules with the JSONPath semantics read from source: a feed edge whosecategoryisSPONSOREDloses itsnode, and anynodeornodes[]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 asth_dat_spo, notsponsored_data. The responses are consumed as a stream fromreadyState3, so theXMLHttpRequestreplacement is a subclass whoseresponse/responseTextgetters re-edit on every read; the subclass is registered as the native fortoString.JSON.parseis 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 thefetchand XHR hooks — uBlock counters this withtrusted-prevent-dom-bypass— so every frame reached through thecontentWindoworcontentDocumentgetter has the parent'sfetch,XMLHttpRequestandFunction.prototype.toStringcopied in, andtoStringon 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-onlyhrefwith__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),maskNativesandguardFramesserve both modules; YouTube also guards frames now, since uBlock's filters show YouTube using the same clean-frame trick forfetch. - 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 asCheck my storiesmatched the tokenstoriesand 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 hiddenSponsoredhoneypot 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 adisplay:nonelabel is kept, the visibly labelled tiles are hidden, the row is not. - LinkedIn matched the whole post, not its header.
promotedanywhere in a post's rendered text hid it ("happy to share that I've been promoted"), and the German markeranzeigeis a substring ofMehr 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, andAnzeige/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:innerHTMLthrows and so doesDOMParser.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.
runModulenow 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.pushStateis no longer patched. Navigation is detected by comparinglocation.hrefon each tick, which needs no page-visible modification and works in sandboxed worlds, where a patchedhistoryon 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 fixedinitVMfor 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 singleonRootdefers 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, sinceDOMContentLoadeddoes not fire afterwindow.stop(). - Mobile
Top barnow applies withHide feedon, as it already did on desktop. - Smaller things. The panel showed
v8.21.0on 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 tolocalStoragewhere nothing read it.Includes paid promotionwas 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"..prettierignorecarried 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 visibleAd, 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 carryAdd friendorJoinwhere Follow suggestions carryFollow, so both now count as recommendation markers underFollow suggestions(pinned by a fixture); andBlock ad datahas 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 scrambledSponsoredlabel, 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
hrefcarrying__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 theJSON.parsepass-through were additionally confirmed on the live logged-in feed.
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 routeonClickthroughlc5(event), which scoresisTrusted === falseas detected, raisesonAbnormalityDetected, and takes the branch that skips the skip:super.onClick()only runs in theelse. 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, becausetap()dispatched bothel.click()and a syntheticMouseEvent. 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 andopacity: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 anyseekingevent whilevideoData.isAd(), and its verdict is the literalxV(0, "a.s_", …), hard-coded to detected.isTrustedonly decorates the reported states — measured in a browser, a programmaticcurrentTimeassignment produces aseekingevent withisTrusted === trueand is reported all the same. There is nothing to evade: the seek is the signal.Seek past adsnow ships off, relabelledSeek past ads ⚠.n.k_/NKDSTAT(bit 20) — time compression. Every 5 s of ad media time,let r = M < G*.4 ? 0 : 1flags an ad whose media time outruns the wall clock by more than 2.5×, and callsonAbnormalityDetecteddirectly on pre-rolls. Seeking toduration - 0.1trips 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 kindAD_PLACEMENT_KIND_STARTexists or a slot triggers onSLOT_TRIGGER_EVENT_BEFORE_CONTENT; when a SABR context update of type 5 arrives andmsy()is false, the player reports and raisesonAbnormalityDetected. DeletingadPlacementsoutright is what announced the prune. A live fetch of a watch page with no ad to serve carries exactly one placement — aclientForecastingAdRendereratKIND_START— so the pre-roll slot is now left standing and only the renderers that would play something are dropped.j.s_(bit 2) — theJSON.parsecanary. 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()runsJSON.parseover the canary and deep-compares. The canary containsadPlacements,adSlotsandplayerAds, so anyJSON.parsehook that prunes ad keys fails it. TheJSON.parsehook is removed entirely — nothing here patches it any more._.QeL— the same canary throughnew Response(str).json(). A syntheticResponsehasurl === ""; a real one does not. TheResponse.prototype.jsonhook now acts only on a URL that came off the network, so the canary passes through untouched.NMP— a spoofed-URLfetchhoneypot. It builds adata:URLRequestand overrides itsurl,method,modeandbodyUsedgetters to claim it isPOST /youtubei/v1/player. Any blocker keyed on the request URL rewrites the canary and is caught. Measured:Request.urlreports the player endpoint whileResponse.urlreports the realdata: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.GDvis 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 scoresgetComputedStyle(innermost).display === "none"as detected. Measured against the old stylesheet: the chain ending in a class was safe, because the module matchesytd-ad-slot-rendereras a tag; the bare<div id="player-ads">matched the unscoped#player-adsrule and tripped it.#player-adsand#masthead-adare 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 aytd-enforcement-message-view-modelthat is absent or computes todisplay:noneas detected — and it runs only fromxUU(), immediately aftern0r()has opened the popup, so it never fires unless enforcement has already started. Better: both enforcement paths are gated onU2L(playerResponse), which readsauxiliaryUi.messageRenderers.bkaEnforcementMessageViewModel. TheonAbnormalityDetectedhandler only callsstopVideo()and rewritesplayabilityStatusto{status:"ERROR", errorScreen:{enforcementMessageViewModel}}if (S = U2L(f)), and the popup path only reachesn0r()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, ando49()is therefore never called.errorScreen.enforcementMessageViewModelis dropped too. And if a dialog is delivered by some other route anyway, the node is now left in the DOM and explicitly kept atdisplay:block, hidden instead byvisibility,opacityand off-screen positioning, with only the dialog shell and backdrop removed — soo49()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 matchestextContentagainst signatures from theab_det_sc_inj_valexperiment flag, defaulting tojson=JSON.parse(text);for(k of["playerAds","adPlacements","adSlots"]). The list is server-updatable. Dropping theJSON.parsehook removes the resemblance.o.p_,o.a_,g.o_,a.o_— integrity checks onObject.prototype(against a decoded watchlist ofglobalCallbacks,onAbnormalityDetected,onAdSkip,raw_player_response,adSlots), onArray.prototype.push/forEach/filter, onbtoa, and afor…invsObject.getOwnPropertyNamescomparison over the player response. This module trips none of them.DCLKSTATand the resource-timing probe —static.doubleclick.net/instream/ad_status.jsis loaded as bait (1= loaded andgoogle_ad_statusset,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.__wcAdPrunedwas a uniquely named global that identified the script to anything enumeratingwindow. - The three native patches are now Proxies, and match their natives completely. Measured in a browser: a
Proxyover a native function forwardsname,lengthand the absence of.prototype, reports the target's own-property list, and — the part that matters —Function.prototype.toStringon it returns"function () { [native code] }". So the whole passive shape matches without patchingFunction.prototype.toStringglobally, which is what uBlock'sproxyApplyFnhas to do only because it chains many proxies through one sharedtoString. The alternative of hanging an owntoStringon 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 atoStringintegrity check for ad-block detection. The six[native code]tests in the desktop bundle are Zone.js, Closure and gapi polyfill probes overNode,Element,ShadowRootandMutationObserver— andXMLHttpRequestandResponseappear 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
XMLHttpRequestas well asfetch. The URL is captured inside theopen()wrapper, before the page can touch the instance;responseURLis consulted only as a veto, because it is an accessor a page can redefine on its own object — the same trickNMPplays onRequest. A first cut gated on/^https?:/against the rawopen()argument and silently did nothing, because YouTube requests these paths relatively; the URL is resolved againstlocation.hreffirst. - The scrub no longer walks every parsed object, and is capped at depth 8. It previously ran over the result of every
JSON.parseon the page to depth 14 and could delete anadSlotskey out of an unrelated payload. - Verified by executing the module, not by reading it. The suite loads the real
webcleaner.user.jsin 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 coveringj.s_,_.QeL,NMP,e.h_,p.h_,o.p_,o.a_,a.o_,g.o_,fg6,s.i_,i.f_andm.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 thefetchand 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
\nescapes had folded onto one line and swallowed theban:property whole —SEL.banwasundefinedand the ad stylesheet was never built.node --checkpassed, prettier passed, and the olde.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 onyt-css stylesheet injectedandban 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, notGDv. They decode to the same element names but carry different random salts, andokIhas 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 onlyytd-,ytm-andytmusic-, 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 fore.h_covers it, andytd-merch-shelf-rendereris matched here as a tag, not a class.e.i_looks foriframe#yt-haven-embed-player, ands.b_reports detected whenwindow.sponsorblockexists, or whenwindow.versionCBis set andwindow.teardownCBis 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.
Hide feed— a switch that hides the feed column outright, for Facebook and LinkedIn, on desktop and mobile web. Off by default. It sits besideWiden feedin each module's panel section. The rest of the site stays usable: on Facebook it binds to/and/home.phponly, so profiles, groups, Watch, Marketplace and Messenger are untouched, and on LinkedIn it binds to/feedonly. 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:nonecontainer measures 0×0 with emptyinnerText, 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 sharedfeedHiderholds 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 widthhad been doing nothing since 8.19.0. That release dropped thefeedBox()call from the desktop sweep — correctly, since its container heuristic was the cause of the surviving ads — but leftfeedBox,tagWidenand three CSS rules keyed todata-fcf-feedanddata-fcf-wbehind. 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 absencehasDesktopShell()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'sfeedBoxuses, and refuses any candidate containingheader,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.
tagChromerejected 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 — leavingWiden feed, both rails and nowHide feedsilently dead on an iPhone SE or mini. Measured againstHEAD: 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
HEADthe same suite scores 18/43, which is what pins thefeedMaxWidthregression — 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,forceMostRecentredirecting to?sk=h_chr,widenFeedmeasured at exactly 1100px,hideFeed), LinkedIn 8 (rails,hidePromoted/hideSuggested, widen at 900px,hideFeedpolled to the sweep cadence at 1440/390/375px), YouTube 8 on the watch page (hideRelated4 present/0 visible against 3 visible with the module off,widenPlayerreleasingmax-widthfrom 1045px tonone, 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+Ldisables the module and restores what it had hidden). - The 2026-08-10 cleanup sweep took the formatting setup with it.
35d727edeleted.prettierrc.json,.prettierignoreand.githooks/pre-commit, but leftpackage.jsonrunningprettier --check .and still pointingcore.hooksPathat.githooks. With no config prettier fell back to its 80-column default against source written at 200 —HEADpasses--print-width 200and fails at 120, 160, 180, 220, 240, 260, 300 and 400 — sonpm run lintfailed 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.
- YouTube's ad pruning never ran under a sandboxing script manager. The module installs an accessor over
ytInitialPlayerResponseand wrapsJSON.parseandResponse.prototype.jsonso 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, wherewindowis 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 andadPlacements,playerAdsandadSlotswere all still present in the player response. - The hooks now target
unsafeWindowwhere the manager provides it and fall back towindowwhere it does not, so the same code reaches the page under both models. No new@grantwas added: the sandboxing managers already exposeunsafeWindowalongside 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.
- The desktop feed is one pass again. It had grown to four overlapping ones —
processDesktopwalkingfeedBox()'s children,hideLabelledAdswalking 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 exactAd/Sponsoredtoken, a marker inside a longer header, and the obfuscated variant read back throughpaintedLabel. feedBoxis 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 survivingChiltern RailwaysandFine Diningads, 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.
- Facebook's actual AI label is
AI content, which was never matched. The list added in 8.14.0 carriedAI generated,Imagined with AI,Meta AIand evenAI generated content, but not the two-word label Facebook actually renders — seen in the feed asADHD 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.
- 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
textContentresolve toJust 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 noware read correctly and kept.
- 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 asAdoand5ms. 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.
- Desktop ads survived because the sweep never looked at them.
processDesktoponly examines the children of the containerfeedBox()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: aChiltern Railways · Adpost fully visible with the module running. - Added
hideLabelledAds, which works the other way round: find the ad label first — an exactAd-style token or aSponsoredmarker — 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
feedBoxselects.
- 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.
- 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 containedadbecause 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 itstays, as doRead and download the guideandAhmed loaded the advice column.
- Desktop ad labels are scrambled, so reading
textContentwas useless. Facebook renders the desktopSponsoredlabel 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 saysSponsored; read withtextContentit sayssnopotSdre6th49…. The exact-label check added in 8.12.8 compared againsttextContent, so it could never have matched a real desktop ad. It now also compares the textvisTextreconstructs — 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.
- 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 … Adarrived as7upadand 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 strayAdfurther down a story — in a comment, say — still does not remove the post. Verified both directions on a synthetic desktop shell: headerAd,Sponsoredandإعلانremoved; anAdbelow the band, and a post reading "Adam said read the load", kept. junkIntakes an optional band rather than gaining a second copy for desktop, matching howvisTextalready works.
- Ads labelled "Ad" were never matched. The sponsored detector holds words like
sponsored,publicidadandممولand looks for them as substrings, which cannot include the two-letter label Facebook now uses on the mobile feed and Reels — searching foradinside text would hit read, load, download and Adam. Caught on a real iPhone on a 7UP reel rendering as7UP … 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 thatAdam,read,loadandadviceare still kept. - The exact-match list was previously gated on
hideReelsTrays, which is unrelated to advertising; ad labels are gated onhideSponsoredwhere they belong.
- 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:nonechild 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,scrollTopstayed at 0 every time and only 2 slots were visible; with it off,scrollTopadvanced 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.
- 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 adisplay:noneslot 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
sweepstill 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.
- One text reader instead of two.
readTextandvisTextwere 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 singlevisText(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 ownfits. 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__wcVpObsflag 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.
- 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 rawrequestAnimationFrame, and only one contained its errors. There is now a singledebouncedhelper, 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 setdisabledon 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 everyhtml:not(.…-off)prefix are gone, replaced by aMODULESdescriptor and onetoggleModule.
- Sponsored-Reel skipping never ran on iOS at all.
handleReelslocated the playing reel by scanning<video>elements taller than 200px, but the iOS Reels surface renders novideoelement — 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.skipReelsAdswas 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 thevideoscan on desktop. - Advancing past a sponsored reel now scrolls the snap container by one screen where one exists, instead of relying on a
Next Cardbutton 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
prettierto an exact version —latestmeant every install pulled whatever was newest, which is neither reproducible nor safe against a compromised release, the same concern that removed thenpx@latestfetch from the pre-commit hook.
- One pipeline for feed filtering, replacing four overlapping ones. The Facebook module had accumulated three ways to hide something (a
data-fcfattribute, a separatedata-fcf-eattribute, anddropPostcalling.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.slotOfwalks 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.
keepScrollAnchoredonly ever calledscrollBy, so it could not compensate the Reels inner scroller, which needed its ownscrollToparithmetic. 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/_lisigcopy of the same idea; both now use a singleWeakMapand onefresh()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.
- 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 thefillerspacer 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.
keepScrollAnchoredcompensates document scroll, but Reels scrolls an inner element, so collapsing a slot above the viewport shifted the current reel — measured asscrollTopjumping 10966 to 8385. The collapse now sums the height of slots hidden entirely above the scroller viewport and subtracts it fromscrollTop.
- 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.displayedwith 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 returnedbody, 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-eand 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.
- Settings use the modern
GM.*storage API when a manager provides it, instead of only the legacyGM_*one.gSetnow mirrors writes toGM.setValueas well asGM_setValueandlocalStorage, andhydrateSharedseedslocalStoragefromGM.getValueat 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 preferGM.registerMenuCommandand fall back to the legacy name. - Why the
@grantlines 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 implementsGM.getValue/GM.setValueand notGM_getValue/GM_setValue, so the old capability check was always false and every site fell back to its ownlocalStorage. 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 overytInitialPlayerResponse, patchesResponse.prototype.json, and prunesadPlacements/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 newGM.*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.
- Follow suggestions still showed in Reels on iOS.
hasFollowBtnonly 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 withpost.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 withhasVideoEl: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:
handleReelsselectsvideoelements 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.
- Turning the Facebook module off did not stop it deleting posts.
sweep()was the only module sweep with noenabledcheck — LinkedIn'ssweepLIand both YouTube sweeps have always had one. Everything the module hides is normally reversible, because hiding just setsdata-fcfand the stylesheet is gated onhtml:not(.fcf-off), so toggling the module off un-hides it again. But the mobile path does not hide:dropPostcallsel.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 reportedenabled:falseandfcf-offapplied, 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.
- Fixed a startup crash introduced in 8.10.9 that disabled the whole script for anyone with saved settings. Enforcing
BOUNDSinsidedeepMergereferencedclamp, butdeepMergeruns at module load to build the config — about fifty lines beforeconst clampinitialises — so the reference hit the temporal dead zone and threwReferenceError: 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 inBOUNDS, which is exactly what the panel writes the moment any setting is touched, since saving persists the whole module object.clampis 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.
- 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
safaridriveragainst Safari 26.5.2 (AppleWebKit/605.1.15, vendorApple Computer, Inc.), the script runs clean on live BBC News, Wikipedia, YouTube and Facebook with no thrown errors, andReflow CSSrewrites 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 onwindow(which is what the YouTube ad-data trap relies on), and writingCSSMediaRule.media.mediaText. ThematchMediaspoof returns a realMediaQueryListwith a workingaddEventListener, answers unspaced queries correctly, and preserves the original media text. All eleven media-query shapes were rewritten correctly, includingemunits, the(400px <= width <= 900px)range form, and anot 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
requestAnimationFrameas 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
safaridriverruns an isolated automation profile that does not inherit the normal browser session; the logged-in Facebook work is all done in Chrome.
- Fixed: Mobile view on a desktop browser showed a mostly dark-grey screen. 8.8.0 changed
frameOnDesktopfrom 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#202124across 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 CSSstill 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 suggestionsand 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 suggestionsis a single switch in the Facebook section if a fuller feed is preferred.
- 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 asubtree: trueMutationObserver 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.hideMobileChromehad 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.
- Fixed: LinkedIn's
Top barrule hid the wrong elements. It washeader{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 — aheaderat 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:
hideEndCardsandhideAutoplayonly match at a video's end screen,anti-adblockonly when YouTube shows the message, andhideShorts(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-renderermatches 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
hideCommentsmobile 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'sL.rail/R.railare 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).
- 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 onlybutton,link,presentation,tab,imgandtablist, and there is no<header>element either. - Fixed:
ComposerandTop barnever 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 labelledrole="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 sidebarandWiden feedare inert on mobile because mobile has no sidebars and no[role="main"]forfeedBoxto measure — there is nothing to fix, they simply do not apply there.CommentsandLike countsare off by default; their mobile aria-labels carry private-use icon glyphs (commentrather thanComment), so the prefix selectors cannot match, which is recorded here rather than patched blind.
- Fixed: the Stories tray was never hidden on mobile.
Reels/Storiesis 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, soprocessMobilenever examined it; it carries noaria-label, so the[aria-label="Stories"]rule matched nothing — a scan of every aria-label on the page returnedshare,More,comment,Video playerand similar, with noStoriesorReelsamong 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 aWeakSet. Runs only on the mobile shell, where desktop'saria-labelrule already works. - Audited the
Follow suggestionsheader 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.
- Video ads are now prevented on initial page load, not skipped after they start.
ytInitialPlayerResponseis assigned by an inlinevarin the served HTML, so it never reachesJSON.parse— which is why the 8.10.0 pruning could not touch it. Because the script runs atdocument-start, an accessor defined onwindowbeforehand intercepts that assignment instead: a globalvarwhose property already exists does not redefine it, it assigns through the setter. The incoming object is scrubbed there, andytInitialDatais 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-ABstill handles the message if one does.Block ad datacan be switched off in the panel, which falls back to the skip engine.
- 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 thereel_watch_sequenceAPI response carryingcommand.reelWatchEndpoint.adClientParams.isAd, which is why walking 45 live Shorts produced nogetPresentingPlayerType()of 2 or 3, noad-showing, and no.ytp-ad-*markup — the markers the old Shorts gate waited for do not exist for these ads.JSON.parseandResponse.prototype.jsonare now wrapped to drop flagged entries before the feed renders. Verified live: walking 40 Shorts across 5reel_watch_sequencefetches, 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 theisAdpath was traced from. - The same pass strips
adPlacements,playerAdsandadSlots, which is what uBlock prunes for video ads. ViaJSON.parsealone this covers player responses fetched during in-page navigation but not the initial page load, becauseytInitialPlayerResponseis an inline object literal in the served HTML and never passes throughJSON.parse. That remaining hole is closed in 8.10.1.
- 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 suggestionsis now user-repairable. Inspected live, the Follow control is adiv[role="button"][tabindex="0"]whose classes are hashed and rotating, with noaria-labeland nodata-*— 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: thereelAdfallback targetsytd-reel-video-renderer,ytd-shortsandytd-reel-player-renderer, and mobile Shorts uses none of them — its components areshorts-page,shorts-carousel,shorts-video,ytm-reel-player-overlay-rendererandyt-reel-*-view-model. The ad-UI branch is likewise desktop-only. That leavesgetPresentingPlayerType()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.
-
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 — sohasDesktopShell()is false andprocessDesktop,processCardsandhideLeftNavnever run on mobile. Everything rests onprocessMobile, 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 newFollow suggestionsdetection, 24 in total. Two structural consequences worth recording:feedBoxcannot run on mobile because it requires[role="main"], soWiden feedis desktop-only; andprocessCards' 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_Sincludedad-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 reportinggetPresentingPlayerType() === 1, carrying onlyad-created. Re-run as an A/B over the same feed, the old gate produced 2 false advances and the corrected gate 0.ad-createdis gone;ad-showingandad-interruptingremain, 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-playerdoes 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.
- 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 likeProton · Follow · 7handTurn 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.
processDesktopreads one concatenated header band, so with labels trailing the page name —Haven House Children's Hospice · Sponsored,The Queens · Sponsored—startsWithgenuinely could never fire there, andincludesis required.processMobileinstead 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 CSSgenuinely has almost nothing to rewrite there. Promoted/Suggested detection itself works: 8 of 23 scanned posts matched.
- Fixed: ad skipping never ran on mobile. Mobile YouTube loads a different player binary —
player-plasma-es6rather than desktop'splayer_ias. Of the eight.ytp-ad-*overlay classes the ad-UI test looked for, all eight are absent from the mobile player, soadUiPresentstayed 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 adsnever engaged, and unskippable ads were never seeked past. Detection now trustsgetPresentingPlayerType()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 claimedad-showing/ad-interruptingnever 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 showscls: true. The half that actually failed was the ad-UI test, not the class test.) - Guarded
Seek past adsagainst 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-progressandytp-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.
- Fixed: mobile YouTube loaded forever on search and feed pages.
Related(on by default) injectedytm-single-column-watch-next-results-rendererandytm-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 mobileytm-item-section-rendereris 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 wasdisplay:nonethe 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 tohtml[data-yt-watch], set fromlocation.pathnameand 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 usedincludes, 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 usesincludeslike 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 followandPages to followwere 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.
- 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)")returnedtruewhile the CSS@mediabehind it stayedfalseand 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 patchingwindow.matchMediadoes not affect CSS, which the layout engine evaluates itself. NewReflow CSSrewrites the site's own@mediabreakpoints in the CSSOM against the virtual phone width, coveringdocument,adoptedStyleSheets, shadow roots,@import, andmedia=""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:
initVMdied silently at document-start. WhendocumentElementwas not yet present,applyVP()threw onappendChildof the viewport meta and the top-levelrun()swallowed it. Everything after that line was lost — the MutationObserver, all four retry timers, the frame, and theAlt+Shift+Vhotkey registration — andvpLockedwas left stucktrue, 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.
vpLockedwas set and cleared synchronously, but MutationObserver callbacks arrive as microtasks after that, so the guard was alwaysfalsewhen checked — measured 18 fullapplyVPpasses on one Wikipedia load.applyVPis now idempotent (it returns early when the meta is already correct) and is skipped entirely on desktop, where viewport meta has no effect. - Fixed:
matchMediamissed unspaced queries. The width tests used\s*but the feature tests wereincludes("pointer: coarse")with a literal space, so(pointer:coarse)— the form minified CSS and JS emit — fell through to the native result and answeredfalsein 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 meansmatchMediareturns a realMediaQueryList, soaddEventListenerchange 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:fixeddescendants, so banners and sticky bars stay inside the phone instead of spanning the full window.frameOnDesktopnow defaults on.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- Fixed: turning off
PromotedandSuggestedalso 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.
- 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#workspaceon desktop, the document itself on mobile — which the capture-phase listener added in 8.5.2 handles either way.
- LinkedIn brought to parity with Facebook and YouTube. Added
Top bar,L.rail,R.railandWiden feed(with aFeed max widthunder 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.
- Fixed: promoted posts stayed visible while scrolling LinkedIn. LinkedIn scrolls an inner container (
main#workspace), not the window, so the module'swindowscroll 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 ondocument, 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.
- 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.
- 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-urnand 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.
- 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.
- 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.
- Fixed:
hideAutoplaystopped 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.
- 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.
- Fixed: sponsored Reels were never skipped on mobile.
handleReels()sat inside thehasDesktopShell()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:
hideCommentsdid not match on mobile. Mobile labels comment controlsComment 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.hideLikeCountsalso picks upreacted.
- Fixed: no filtering at all on mobile Facebook.
forceMostRecentredirected to/?sk=h_chrand 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.
Fixes
- Mobile YouTube ad-skipping never worked. The skip button is found and enabled, but
element.click()is ignored onm.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. frameOnDesktopcould 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. WithspoofUAon — 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 topointer-events:noneso the page stays usable. - Two stale Facebook selectors.
hideCommentsandhideLikeCountsmatched 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 playerexpanding 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
scheduleOnnow 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 inheritedonvisible.hideEndCards,hideInfoCardsandhideRelatednow default on.- README rewritten with a full feature reference, limits and this changelog.
- Ad-player detection corrected against a captured live ad: the ad player is
getPresentingPlayerType() === 2, not 3, andgetAdState()stays at-1throughout 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— andad-disclosure-banner-view-modeladded. Several dead player selectors removed, and.ytp-skip-ad-buttonrestored 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_getValueis wrapped so a throwing manager cannot kill the script. - Schedule parsing now fails closed — a malformed time produced
NaNand silently blocked the site all day. - Block screen under Trusted Types rebuilt from DOM nodes instead of
innerHTML, which threw afterwindow.stop()and failed open. - Duplicate
<style>elements on repeat calls; cluster drag triggering buttons; the View Mode button making the cluster undraggable;exportSettingsfailing in Firefox; an unthrottled full-document query per mutation. @versionhad been frozen across four releases, so auto-update would not have delivered any of the above.
- 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.comhostname — but a logged-in phone is servedwww.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.
- 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.
Behaviour is verified against live sites and pinned by a committed suite.
bun install,bunx playwright-core install chromium, thenbun test. The suite runs the realwebcleaner.user.jsin headless Chromium against the fixtures intest/fixtures/, behind aGM_*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-elementget_watcharray,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.parseconfirmed 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
@grantandunsafeWindow, read from their published source and documentation. - Verified live on a logged-in Facebook feed (12 October 2026): the GraphQL response edit stripped every
SponsoredDataobject 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.
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.
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: