Fix: disguised-ad-flag replaces entire Reddit feed as one placeholder (#228)#230
Conversation
…#228) When one card inside a feed momentarily lacks its own heading (different ad creative, mid-hydration burst), the walk-up from that card's label skipped past it and reached the surrounding feed. The feed passed isArticleShaped because *sibling* cards carry headings/images/links — the rule then replaced the whole feed with one placeholder. Add a sibling-card guard in isArticleShaped: a candidate fails if it encloses a second image+link subtree outside the label's ancestor chain. Also stop the walk at role="feed" (the ARIA infinite-scroll pattern). Each individual card now matches on its own walk-up; the feed wrapper stays unmatched. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
1 issue found.
About Unblocked
Unblocked has been set up to automatically review your team's pull requests to identify genuine bugs and issues.
📖 Documentation — Learn more in our docs.
💬 Ask questions — Mention @unblocked to request a review or summary, or ask follow-up questions.
👍 Give feedback — React to comments with 👍 or 👎 to help us improve.
⚙️ Customize — Adjust settings in your preferences.
| function hasOtherCardSubtree(element: Element, labelElement: Element): boolean { | ||
| for (const candidate of element.querySelectorAll("*")) { | ||
| if (candidate === labelElement) { | ||
| continue; | ||
| } | ||
| if (labelElement.contains(candidate)) { | ||
| continue; | ||
| } | ||
| if (candidate.contains(labelElement)) { | ||
| continue; | ||
| } | ||
| if (candidate.querySelector("img, picture") === null) { | ||
| continue; | ||
| } | ||
| if (candidate.querySelector("a[href]") === null) { | ||
| continue; | ||
| } | ||
| return true; | ||
| } | ||
| return false; | ||
| } |
There was a problem hiding this comment.
hasOtherCardSubtree returns true as soon as it finds any descendant outside the label's ancestor chain that contains both an <img>/<picture> and an <a href>. This correctly rejects a feed with multiple cards, but it also rejects a single ad card whenever a wrapper <div> sits between the label and the card's content.
Example — common in the wild but absent from the test suite:
<article>
<span class="label">Sponsored</span>
<div class="card-body">
<h2><a href="/ad">Title</a></h2>
<img src="ad.jpg" />
<p>Long body text that exceeds the prose minimum …</p>
</div>
</article>Trace: div.card-body is not the label, is not contained by the label, and does not contain the label. div.card-body.querySelector("img, picture") → <img> (not null). div.card-body.querySelector("a[href]") → <a> (not null). → returns true → isArticleShaped returns false → the legitimate ad card escapes detection.
The comment on line R218-R219 says "a single advertorial card has at most one such subtree (its own)", but that assumption breaks whenever a card's label is a sibling to (rather than inside) a content wrapper.
Consider requiring two non-overlapping matches before rejecting, e.g. count the number of qualifying subtrees and return count >= 2. That way a single card's content wrapper counts as one (tolerated), but a feed with multiple cards still trips the guard.
Summary
Closes #228.
When one card inside a feed momentarily lacks its own heading — different ad creative, mid-hydration burst, or any condition where the per-card heading element isn't visible to
querySelector— the walk-up from that card's "Promoted" / "Sponsored" label skipped past the card and reached the surrounding feed. The feed passedisArticleShapedbecause sibling cards carry headings, images, links, and prose. The rule then replaced the whole feed with one placeholder.Repro
https://www.reddit.com/: I confirmed the path with Playwright. Reddit's<shreddit-feed>contains many<shreddit-ad-post>cards. Each ad post's title is a<shreddit-dynamic-ad-link class="headline">. Normal<shreddit-post>cards carry no headings at all. With one ad-post'sheadlineclass temporarily removed (simulating a different ad variant or hydration race), the walk landed on<shreddit-feed>at hop 5 — the feed had image+link+prose plus other ad posts' headlines, so it passed every check.Fix
hasOtherCardSubtree(element, labelElement): rejects any candidate that encloses a second image+link subtree outside the label's ancestor chain. The image+link pair is the cheapest stable signal for "card-ness" and is rare in non-card chrome.isPageBoundarynow also stops atrole="feed"(the ARIA infinite-scroll pattern), as defense-in-depth.Each individual card still matches on its own walk-up. A wrapper holding multiple cards no longer does. Cards that lack their own heading aren't redacted (degraded, but acceptable — the previous behavior was to hide the entire feed).
Test plan
bun run check— cleanbun run typecheck— cleanbun run knip— clean./node_modules/.bin/jest— 1916 tests passing, including:disguised-ad-flag.test.tsfor the multi-card wrapper, therole="feed"boundary, and a section landingdisguised-ad-flag.property.test.tswith fast-check properties asserting that wrappers holding multiple labeled card-shaped children are never replaced (per the memory note about property tests for rule matchers)reddit.comwith the unpacked extension once this lands🤖 Generated with Claude Code