Files
linker/extension/content.js
T
paulandClaude Opus 4.8 071cc3f85a fix: pierce new-reddit (shreddit) shadow DOM when collecting comment text (closes task/handle-shreddit-shadow-dom)
Source: task/handle-shreddit-shadow-dom (plan/steward-linker-design) — the TreeWalker only walked light DOM, so new-reddit comment bodies inside shadow roots were missed.

- collectTextNodes now walks the comment-tree root's light DOM AND recursively descends into the open shadow roots of elements within that subtree, so comment text is reached whether reddit slots it (light DOM) or encapsulates it (shadow DOM).
- Scoped to getRoot()'s comment tree (never the whole document's shadow roots); bounded by a depth cap; a no-op on old reddit (no shadow roots).
- Slotted light-DOM nodes are not double-collected (a shadow walker sees the shadow tree's own nodes, not a <slot>'s assigned light nodes); a Set de-dupes by node identity belt-and-braces.
- All existing guards preserved (inSkippable / rsl-link skip). README + AGENTS document the support.
- Limitation: CLOSED shadow roots are unreachable from a content script and stay unlinked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:28:31 +00:00

216 lines
9.7 KiB
JavaScript

// Reddit -> Spotify/Bandcamp linker (content script).
//
// On a reddit thread page it: pulls candidate artist names out of comment text,
// asks the local resolver service which ones are real artists, and rewrites those
// occurrences in place into links. The resolver does the hard "is this an artist"
// part; this script only handles extraction + DOM rewriting.
(() => {
"use strict";
const api = globalThis.browser ?? globalThis.chrome;
const log = (...a) => console.log("[rsl]", ...a);
// Candidate extraction is pure + lives in extract.js (loaded first per manifest order;
// defines globalThis.RSLExtract) so it can be unit-tested in node. allMatchesIn(text)
// returns {name,index,length} for Title-Case runs AND cue-phrase ("check out <x>") names.
const Extract = globalThis.RSLExtract;
// ---- DOM walking ----------------------------------------------------------
const SKIP_TAGS = new Set([
"A", "SCRIPT", "STYLE", "CODE", "PRE", "TEXTAREA", "INPUT", "BUTTON", "TIME", "NOSCRIPT", "SVG", "SELECT",
]);
function inSkippable(node) {
for (let el = node.parentElement; el; el = el.parentElement) {
if (SKIP_TAGS.has(el.tagName)) return true;
if (el.classList && el.classList.contains("rsl-link")) return true;
if (el.isContentEditable) return true;
}
return false;
}
// New reddit's shreddit-* web components may render comment bodies inside an OPEN shadow
// root. A TreeWalker can't cross a shadow boundary, so collectTextNodes walks `root`'s light
// DOM AND, recursively, every open shadow root of elements within that subtree — so it reaches
// the comment text whether reddit slots it (light DOM) or encapsulates it (shadow DOM). Old
// reddit has no shadow roots, so the recursion is a no-op there. Scoped to the comment-tree
// `root` (getRoot()) — it never walks the whole document's shadow roots, which would pull in
// reddit's chrome/UI text and be unbounded. A slotted light-DOM node is reached only by the
// light walk: a TreeWalker over a shadow root walks the shadow tree's own nodes, not a <slot>'s
// assigned (light) nodes, so it isn't double-collected — a Set keyed on node identity is a
// belt-and-braces guard. Closed shadow roots (`.shadowRoot === null`) stay unreachable.
const MAX_SHADOW_DEPTH = 50; // bound the shadow-host recursion; a real comment tree is far shallower.
function collectInto(root, nodes, seen, depth) {
if (depth > MAX_SHADOW_DEPTH) return;
// SHOW_TEXT to gather candidate text; SHOW_ELEMENT so we can spot shadow hosts to recurse into.
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, {
acceptNode(node) {
if (node.nodeType === Node.ELEMENT_NODE) {
// Elements are visited only to discover shadow hosts; never collected as text.
return node.shadowRoot ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
}
const v = node.nodeValue;
if (!v || v.length > 5000 || !/[A-Za-z]/.test(v)) return NodeFilter.FILTER_REJECT;
if (inSkippable(node)) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
},
});
let n;
while ((n = walker.nextNode())) {
if (n.nodeType === Node.ELEMENT_NODE) {
// Open shadow host: descend into its shadow tree (and its descendants' shadow trees).
collectInto(n.shadowRoot, nodes, seen, depth + 1);
} else if (!seen.has(n)) {
seen.add(n);
nodes.push(n);
}
}
}
function collectTextNodes(root) {
const nodes = [];
collectInto(root, nodes, new Set(), 0);
return nodes;
}
// ---- link building / rewriting --------------------------------------------
// Per the user's "if on spotify, link that, else a Bandcamp Google-search fallback"
// rule: one link to the primary platform, colour-coded so you can tell which at a glance.
// `kind` ("artist" | "album", absent => "artist") adds an rsl-album class + an album word
// in the tooltip so album links read as albums; the platform colour is unchanged.
function makeLink(label, links) {
const primary = links.primary || { platform: "spotify", url: links.spotify };
const name = links.name || label;
const platform = primary.platform;
const kind = links.kind === "album" ? "album" : "artist";
const noun = kind === "album" ? "album" : "artist";
// Re-validate the href scheme client-side: only http(s) becomes a clickable link.
// A swapped/compromised/buggy resolver could send a javascript:/data: url; never
// inject that into reddit's DOM. Returning null leaves the original text in place.
if (!Extract.isSafeHttpUrl(primary.url)) return null;
const a = document.createElement("a");
a.className = `rsl-link rsl-${platform} rsl-${kind}`;
a.textContent = label;
a.href = primary.url;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.title = platform === "spotify"
? `Open ${name} (${noun}) on Spotify`
: `Find ${name} (${noun}) on Bandcamp (Google search)`;
return a;
}
function wrapNode(node) {
const text = node.nodeValue;
const ms = Extract.allMatchesIn(text).filter((m) => {
const links = resolved.get(m.name);
return links && ((links.primary && links.primary.url) || links.spotify);
});
if (!ms.length) return;
const frag = document.createDocumentFragment();
let last = 0;
for (const m of ms) {
if (m.index < last) continue; // skip overlaps
if (m.index > last) frag.appendChild(document.createTextNode(text.slice(last, m.index)));
const matchText = text.substr(m.index, m.length);
const link = makeLink(matchText, resolved.get(m.name));
// makeLink returns null for an unsafe (non-http(s)) href: keep the plain text.
frag.appendChild(link || document.createTextNode(matchText));
last = m.index + m.length;
}
if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last)));
node.parentNode && node.parentNode.replaceChild(frag, node);
}
// ---- resolver client ------------------------------------------------------
const resolved = new Map(); // candidate string -> { name, spotify?, google?, primary } | null
// The resolver caps each /resolve call at MAX_CANDIDATES (service/resolve-core.js); names past the
// cap are silently dropped from the response. So split a large ask into batches of at most that cap
// and resolve each — every candidate is actually answered, none lost to the server-side slice.
// MUST stay <= the server's MAX_CANDIDATES.
const BATCH = 100;
// The network call goes through the background script — a page-context fetch to
// http://localhost is blocked by reddit's CSP / mixed-content rules.
async function resolve(names) {
log("resolving", names.length, "candidate(s) via background:", names.slice(0, 8));
try {
const resp = await api.runtime.sendMessage({ type: "resolve", candidates: names });
if (!resp) throw new Error("no response from background script");
if (!resp.ok) throw new Error(resp.error || "resolve failed");
const hits = Object.values(resp.data).filter(Boolean).length;
log("resolver returned", hits, "artist(s) of", names.length);
return resp.data;
} catch (e) {
console.warn("[rsl] resolve failed (is the resolver running on :8787?):", e.message);
return null;
}
}
// ---- scan orchestration ---------------------------------------------------
function getRoot() {
return (
document.querySelector("main, .content[role='main'], shreddit-comment-tree, .commentarea") ||
document.body
);
}
async function scan() {
const root = getRoot();
const nodes = collectTextNodes(root);
log("scan: root", root.tagName || root.nodeName, "| text nodes", nodes.length);
if (!nodes.length) return;
const seen = new Set();
for (const node of nodes) for (const m of Extract.allMatchesIn(node.nodeValue)) seen.add(m.name);
log("candidates found:", seen.size);
const ask = [...seen].filter((c) => !resolved.has(c));
// Resolve in server-cap-sized batches so no candidate is dropped by the resolver's slice.
// Only cache keys the response actually carries: a name the server didn't answer (cap overflow,
// partial error, or a resolver-down null response) is left OUT of `resolved` so it is retried on
// the next scan, instead of being stuck at null and never retried again.
for (const batch of Extract.chunk(ask, BATCH)) {
const res = await resolve(batch);
if (!res) continue; // whole-batch failure (resolver down): leave every name un-cached for retry
for (const c of batch) {
if (Object.prototype.hasOwnProperty.call(res, c)) resolved.set(c, res[c] || null);
}
}
for (const node of nodes) if (node.isConnected) wrapNode(node);
const total = document.querySelectorAll("a.rsl-link").length;
api.runtime.sendMessage({ type: "badge", count: total }).catch(() => {});
log("scan done; links on page:", total);
}
// serialize scans; coalesce overlapping triggers
let running = false;
let pending = false;
function schedule() {
if (running) { pending = true; return; }
running = true;
scan()
.catch((e) => console.warn("[rsl]", e))
.finally(() => {
running = false;
if (pending) { pending = false; setTimeout(schedule, 300); }
});
}
function debounce(fn, ms) {
let t;
return () => { clearTimeout(t); t = setTimeout(fn, ms); };
}
const onThread = () => /\/comments\//.test(location.pathname);
log("content script loaded:", location.href, "| thread page:", onThread());
if (onThread()) schedule();
new MutationObserver(debounce(() => { if (onThread()) schedule(); }, 600))
.observe(document.documentElement, { childList: true, subtree: true });
})();