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>
This commit is contained in:
@@ -23,6 +23,17 @@ is tracked in the **towl** work-item store under project **`reddit-spotify-linke
|
|||||||
extractor (Title-Case runs + cue phrases for artists; quoted strings + album cue
|
extractor (Title-Case runs + cue phrases for artists; quoted strings + album cue
|
||||||
phrases for albums) — node-testable via `extract.test.cjs`. Album links render
|
phrases for albums) — node-testable via `extract.test.cjs`. Album links render
|
||||||
italicised (`.rsl-album`).
|
italicised (`.rsl-album`).
|
||||||
|
- **shreddit shadow DOM:** `collectTextNodes` is shadow-piercing. A TreeWalker can't
|
||||||
|
cross a shadow boundary, so it walks the comment-tree `root`'s light DOM AND
|
||||||
|
recursively descends into the **open** shadow roots of elements within that subtree.
|
||||||
|
New reddit's `shreddit-*` components may encapsulate comment bodies in a shadow root;
|
||||||
|
this reaches them whether reddit slots (light DOM) or encapsulates (shadow DOM), with
|
||||||
|
no need to know which. It is scoped to the comment-tree root (never the whole
|
||||||
|
document's shadow roots, which would pull in reddit chrome) and is 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 belt-and-braces). **Closed** shadow roots are unreachable from a content
|
||||||
|
script — a documented limitation.
|
||||||
- `scripts/check.sh` — the local check gate.
|
- `scripts/check.sh` — the local check gate.
|
||||||
|
|
||||||
## How work lands (IMPORTANT — this is NOT the towl repo)
|
## How work lands (IMPORTANT — this is NOT the towl repo)
|
||||||
|
|||||||
@@ -180,6 +180,11 @@ let the content script reach it).
|
|||||||
name links to a Google search for "<name> bandcamp" (which surfaces the Bandcamp page).
|
name links to a Google search for "<name> bandcamp" (which surfaces the Bandcamp page).
|
||||||
- **Without Spotify creds or the LLM, nothing links** — at least one gate must be
|
- **Without Spotify creds or the LLM, nothing links** — at least one gate must be
|
||||||
configured.
|
configured.
|
||||||
|
- **New reddit (shreddit) shadow DOM** — the comment-text scan pierces the **open**
|
||||||
|
shadow roots of the `shreddit-*` web components within the comment tree, so it links
|
||||||
|
comment bodies whether reddit slots them into light DOM or encapsulates them in a
|
||||||
|
shadow root. **Closed** shadow roots are unreachable from a content script and stay
|
||||||
|
unlinked (a browser limitation, not a setting).
|
||||||
- The optional LLM adds a little latency on the **first** call (ollama warms the
|
- The optional LLM adds a little latency on the **first** call (ollama warms the
|
||||||
model), then is fast.
|
model), then is fast.
|
||||||
- The resolver caches results in memory; it resets when you restart it.
|
- The resolver caches results in memory; it resets when you restart it.
|
||||||
|
|||||||
+34
-4
@@ -30,10 +30,27 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectTextNodes(root) {
|
// New reddit's shreddit-* web components may render comment bodies inside an OPEN shadow
|
||||||
const nodes = [];
|
// root. A TreeWalker can't cross a shadow boundary, so collectTextNodes walks `root`'s light
|
||||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
// 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) {
|
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;
|
const v = node.nodeValue;
|
||||||
if (!v || v.length > 5000 || !/[A-Za-z]/.test(v)) return NodeFilter.FILTER_REJECT;
|
if (!v || v.length > 5000 || !/[A-Za-z]/.test(v)) return NodeFilter.FILTER_REJECT;
|
||||||
if (inSkippable(node)) return NodeFilter.FILTER_REJECT;
|
if (inSkippable(node)) return NodeFilter.FILTER_REJECT;
|
||||||
@@ -41,7 +58,20 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
let n;
|
let n;
|
||||||
while ((n = walker.nextNode())) nodes.push(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;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user