// `` discovery for the webfetch thin-extraction fallback. // // When the extractor + pandoc/w3m pipeline produces thin output (typically // because the page is a JS-rendered shell or a localized interstitial), a // useful clean alternative is often advertised right there in via the // W3C-standard alternate-link discovery mechanism (oEmbed et al.). Following // it costs us one HTTP round-trip and yields title/author/description for an // entire class of sites — without a single line of per-host code. // // Allowlist, not blocklist: unknown `type` values are skipped silently. New // media types are added in follow-up PRs with evidence. // // Explicitly excluded: // - application/rss+xml, application/atom+xml — feeds OF other content, // not alternate representations of the current page. // - — same shell page in another // viewport. // - android-app:// / ios-app:// hrefs — not http(s). // - / "amphtml" — out of scope (see issue #128 // non-goals). export const ALLOWED_ALTERNATE_TYPES: ReadonlySet = new Set([ // oEmbed standard JSON form. Highest signal: YouTube, Vimeo, Flickr, // WordPress.com, Substack, Spotify, DeviantArt, Slideshare. "application/json+oembed", // oEmbed XML form — same providers, less common. "application/xml+oembed", // Real-world variants verified against live HTML during PR #134 review: // - YouTube ships `text/xml+oembed` (NOT `application/xml+oembed`) // - SoundCloud ships both `text/xml+oembed` and `text/json+oembed` // The oEmbed spec itself only blesses the `application/*+oembed` forms, // but allowlisting the `text/*+oembed` siblings costs nothing (the // `+oembed` suffix is the discriminating signal, not the top-level type) // and unlocks the motivating providers. Anything without the literal // `+oembed` suffix — plain `application/json`, `text/xml` — stays out: // those are catch-all generic-data hints with no contract about content. "text/xml+oembed", "text/json+oembed", // Author-served markdown alternate. Rare, but unambiguous when present. "text/markdown", ]); export interface Alternate { /** href as written in the link tag — may be absolute or relative. */ url: string; /** type attribute, lowercased and trimmed. */ type: string; /** title attribute if present (some sites use it as a human-readable label). */ title?: string; } // Scope the scan to .... Fallbacks in priority order: // 1. closer (well-formed) // 2. closer at all and no either; the // no--at-all case is caught earlier by the outer // `if (!headMatch)` guard, which also rejects HTML where the entire // open tag is missing) // // Case (3) is intentional even though it can over-scan a truly malformed // page (e.g. a fragment response with `` opened but neither closed // nor followed by ``): the alternative — refusing to scan such // pages — silently regresses the YouTube/Substack/etc. happy path the // instant a server ships a non-conforming variant. Any `` we surface from the over-scanned tail still has to pass // the allowlist, same-origin, and SSRF gates at the call site, so the // blast radius of "we treated more bytes as than the spec allows" // is bounded. // // Without this scope a stray in (some sites // inject in-content video embeds with link tags) would be picked up too, // against the spec — alternate-link discovery is a -only mechanism. // Known limitation: HEAD_RE / LINK_RE both terminate the tag at the first // `>`, which is wrong for the (rare but legal) case of an attribute value // containing a literal `>` inside quotes — e.g. ``. // HTML5 doesn't actually require quoting `>` in attribute values, so this // is technically a parser bug. In practice it's a non-issue: the only // allowlisted attribute we read is `href` (URL-escaped) and `type` (a // media-type token); a stray `>` would only ever land in `title`, and the // truncated tag we'd then process still has well-formed `rel`/`type`/`href` // attributes if any followed the title. Documenting rather than fixing // because a real HTML tokenizer here would be wildly out of scope. // Same anti-substring-match rationale as the meta-charset sniffer in // webfetch.ts — keep these two regexes in sync if either grows attribute- // syntax support. const HEAD_RE = /]*>([\s\S]*?)(?:<\/head\s*>|]*)>/gi; // Same attribute tokenizer as the meta-charset sniffer in webfetch.ts. const ATTR_RE = /([A-Za-z_:][A-Za-z0-9_.:-]*)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>`]+)))?/g; /** * Scan the of `html` and return every with an * allowlistable shape (has `type`, has `href`, no `media`, http/https or * relative `href`). Type filtering against ALLOWED_ALTERNATE_TYPES happens * at the call site so callers can also see denied entries in tests/logs. * * Order matches document order. First-match-wins is a caller policy. * * Pure: no I/O, no side effects. Unit-testable without mocks. */ export function findAlternates(html: string): Alternate[] { const headMatch = HEAD_RE.exec(html); if (!headMatch) return []; const head = headMatch[1] ?? ""; // Strip HTML comments first so a commented-out can't leak through. // The global replace below removes only well-formed `` pairs, // so any `/g, ""); const unterm = scope.indexOf("