Adding a feed adapter
September 19, 2026 · View on GitHub
A feed adapter tells Winnow how to find links on one kind of page so each can get a badge. Three exist: hn, youtube, and generic. Adding one touches four files and one test. Everything below is in src/extract/feed.ts unless noted.
1. Write a scanner
type Found = { item: FeedItem; element: HTMLElement };
type Scanner = (doc: Document) => Found[];
The scanner is synchronous, takes the document, and returns every candidate link it can see right now. For each one:
item.url: run the href throughnormalizeUrl(href, doc.baseURI). It resolves relative URLs, drops non-http(s) schemes, strips the hash, and removesutm_*params. Skip the item if it returnsnull.item.title: the link text, whitespace-collapsed (text(el)helper).item.snippet: anything cheap that helps a title-only judgment: domain, points, comment count, channel, view count, duration, a sentence from the surrounding block. Join parts with" · ". Keep it short; feed items are judged on title + snippet.item.videoId: only for YouTube-style items.element: the DOM node to observe for visibility and to hang the badge on. For HN it is the.athingrow; for YouTube the renderer element; for the generic scanner the anchor itself.
Scanners are called on every rescan, so they must be idempotent: makeAdapter skips elements already marked data-winnow="1".
Example, lobste.rs (not shipped; illustrative):
function scanLobsters(doc: Document): Found[] {
const out: Found[] = [];
for (const row of doc.querySelectorAll<HTMLElement>("li.story")) {
const a = row.querySelector<HTMLAnchorElement>(".link > a.u-url");
if (!a) continue;
const url = normalizeUrl(a.getAttribute("href") ?? "", doc.baseURI);
if (!url) continue;
const snippet = [text(row.querySelector(".domain")), text(row.querySelector(".score")), text(row.querySelector(".comments_label"))]
.filter(Boolean)
.join(" · ");
out.push({ item: { url, title: text(a), snippet }, element: row });
}
return out;
}
2. Add a detectFeed branch
detectFeed(doc, url, enabled) decides which adapter, if any, applies to the current page. Match on hostname (already stripped of www. / m.) and path, check the site toggle, and return makeAdapter(id, doc, scanner, isSpa):
if (host === "lobste.rs") {
return enabled.lobsters && (u.pathname === "/" || u.pathname === "/newest") ? makeAdapter("lobsters", doc, scanLobsters) : null;
}
Put the branch before the generic fallback. Return null when the page is on the host but not a list page (an HN comment thread, a YouTube watch page), otherwise the generic scanner may badge it instead.
makeAdapter(id, doc, scan, spa = false) gives you:
- two
IntersectionObservers (200 px margin to report visible, 600 px to report hidden), a 400 ms coalescing timer, and thedata-winnowde-duplication; - with
spa = true, a rescan 300 ms after anyyt-navigate-finishevent or DOM mutation underytd-page-manager(orbody). Set it for single-page apps that replace the list without a navigation. The event name is YouTube's; for another SPA you would add its own trigger insidemakeAdapter.
3. Extend the id union
In src/types.ts:
export type FeedAdapterId = "hn" | "youtube" | "generic" | "lobsters";
Settings.feedSites is Record<FeedAdapterId, boolean>, so the compiler will now point you at every place that needs the new key.
4. Add the settings default and toggle
src/background/storage.ts:
feedSites: { hn: true, youtube: true, generic: true, lobsters: true },
getSettings merges stored feedSites over these defaults, so existing users get the new toggle on without a migration.
The options page renders a checkbox per id from FEEDS in src/options/main.ts and matching <input id="feed-<id>"> markup in src/options/index.html; add both.
5. Check the badge anchor
src/content/feed.ts decides where the badge goes:
function anchorFor(el: HTMLElement): HTMLElement {
return el.querySelector<HTMLElement>(".titleline > a, a#video-title-link, a#video-title, #video-title") ?? el;
}
The badge is inserted immediately after the first element matching that selector inside your element, or after element itself. If your scanner returns a row and the title link is somewhere inside it, add its selector to this list so the badge lands next to the title rather than after the whole row.
6. Test it
Feed tests run under happy-dom with a hand-written HTML snippet and a fake IntersectionObserver, because happy-dom has none. Copy the pattern from src/extract/feed.test.ts:
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { detectFeed } from "./feed";
const ALL = { hn: true, youtube: true, generic: true, lobsters: true };
const parse = (html: string, base: string) =>
new DOMParser().parseFromString(`<base href="${base}">` + html, "text/html");
class FakeIO {
static instances: FakeIO[] = [];
observed: Element[] = [];
constructor(public cb: IntersectionObserverCallback, public opts: IntersectionObserverInit) { FakeIO.instances.push(this); }
observe(el: Element) { this.observed.push(el); }
disconnect() {}
fire(visible: boolean, els = this.observed) {
this.cb(els.map((target) => ({ target, isIntersecting: visible })) as IntersectionObserverEntry[], this as never);
}
}
beforeEach(() => { FakeIO.instances = []; vi.stubGlobal("IntersectionObserver", FakeIO); vi.useFakeTimers(); });
afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); });
const HTML = `<ol>
<li class="story"><span class="link"><a class="u-url" href="https://blog.example.com/post">A real post</a></span>
<span class="domain">blog.example.com</span> <span class="score">42</span> <a class="comments_label">12 comments</a></li>
</ol>`;
describe("lobsters adapter", () => {
it("detects the front page and yields items", () => {
const doc = parse(HTML, "https://lobste.rs/");
const ad = detectFeed(doc, "https://lobste.rs/", ALL);
expect(ad?.id).toBe("lobsters");
const onVisible = vi.fn();
ad!.observe(onVisible, vi.fn());
const [near] = FakeIO.instances as [FakeIO];
near.fire(true);
vi.advanceTimersByTime(250);
expect(onVisible).toHaveBeenCalledTimes(1);
const [{ item }] = onVisible.mock.calls[0]![0];
expect(item).toEqual({ url: "https://blog.example.com/post", title: "A real post", snippet: "blog.example.com · 42 · 12 comments" });
});
it("does not activate on a comment page", () => {
const doc = parse(HTML, "https://lobste.rs/s/abc/a_real_post");
expect(detectFeed(doc, "https://lobste.rs/s/abc/a_real_post", ALL)).toBeNull();
});
});
Existing tests that build an enabled map (ALL in feed.test.ts, and any feedSites literal in src/background/*.test.ts) will need the new key or the typecheck fails; that is the compiler doing its job.
Then:
pnpm typecheck && pnpm test
pnpm build
Load dist/ unpacked, open the site, and check that badges appear next to titles, that hovering shows the card, and that scrolling a long way past an item while it is still loading removes its badge (the cancel path).
What you do not need to touch
judgeFeedtakes the adapter id but does not branch on it; every adapter producesFeedItem[]and gets the same 7 feed questions.- The cache key is the item URL, so an item that appears on two feeds is judged once.
- The Jev request format, the verdict, and the badge rendering are adapter-agnostic.