fix: scope getRoot to the comment tree so the shadow walk doesn't collect reddit chrome (closes task/getroot-comment-tree-scope)
Source: task/getroot-comment-tree-scope (plan/steward-linker-design) — getRoot's union querySelector resolved to <main> on new reddit, so the shadow-piercing walk over-collected reddit chrome. - getRoot now tries shreddit-comment-tree, then .commentarea, then main/body sequentially (narrow-first) instead of one union querySelector, so the comment tree wins when present. - collectInto takes an allowShadow flag: a narrow comment root pierces every open shadow host; a broad fallback root descends only into comment-component hosts (tagName SHREDDIT-COMMENT*), so chrome shadow roots (nav/sidebar/composer) are never walked. - README + AGENTS shadow-DOM notes updated to describe the two-layer scoping. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -24,16 +24,21 @@ is tracked in the **towl** work-item store under project **`reddit-spotify-linke
|
|||||||
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
|
- **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
|
cross a shadow boundary, so it walks the chosen `root`'s light DOM AND recursively
|
||||||
recursively descends into the **open** shadow roots of elements within that subtree.
|
descends into the **open** shadow roots of elements within that subtree. New reddit's
|
||||||
New reddit's `shreddit-*` components may encapsulate comment bodies in a shadow root;
|
`shreddit-*` components may encapsulate comment bodies in a shadow root; this reaches
|
||||||
this reaches them whether reddit slots (light DOM) or encapsulates (shadow DOM), with
|
them whether reddit slots (light DOM) or encapsulates (shadow DOM). Scope is two-layer:
|
||||||
no need to know which. It is scoped to the comment-tree root (never the whole
|
`getRoot` tries the narrow comment containers first (`shreddit-comment-tree`, then old
|
||||||
document's shadow roots, which would pull in reddit chrome) and is a no-op on old
|
reddit's `.commentarea`) before the broad `main`/`body`, so on a new-reddit comment page
|
||||||
reddit (no shadow roots). Slotted light-DOM nodes are not double-collected (a shadow
|
the root is the comment tree and the walk stays within it; and when `getRoot` falls back
|
||||||
walker sees the shadow tree's own nodes, not a `<slot>`'s assigned light nodes; a
|
to a broad root (no comment tree present) the recursion descends only into comment-component
|
||||||
Set de-dupes belt-and-braces). **Closed** shadow roots are unreachable from a content
|
shadow hosts (`tagName` starting `SHREDDIT-COMMENT`), so reddit's chrome shadow roots (nav,
|
||||||
script — a documented limitation.
|
sidebar, composer) are never walked. (A single union `querySelector` could not do this — it
|
||||||
|
returns the first match in document order, and `<main>` is an ancestor of the comment tree,
|
||||||
|
so it would always win.) It 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,11 +180,14 @@ 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**
|
- **New reddit (shreddit) shadow DOM** — the scan roots at the comment tree
|
||||||
shadow roots of the `shreddit-*` web components within the comment tree, so it links
|
(`shreddit-comment-tree`, falling back to old reddit's `.commentarea`, then `main`/`body`)
|
||||||
comment bodies whether reddit slots them into light DOM or encapsulates them in a
|
and pierces the **open** shadow roots of the `shreddit-*` web components within it, so it
|
||||||
shadow root. **Closed** shadow roots are unreachable from a content script and stay
|
links comment bodies whether reddit slots them into light DOM or encapsulates them in a
|
||||||
unlinked (a browser limitation, not a setting).
|
shadow root. On a broad fallback root (no comment tree found) only comment-component shadow
|
||||||
|
roots are descended, so reddit's chrome (nav, sidebar, composer) is never collected.
|
||||||
|
**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.
|
||||||
|
|||||||
+36
-11
@@ -32,24 +32,28 @@
|
|||||||
|
|
||||||
// New reddit's shreddit-* web components may render comment bodies inside an OPEN shadow
|
// 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
|
// 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
|
// DOM AND, recursively, the open shadow roots of elements within that subtree — so it reaches
|
||||||
// the comment text whether reddit slots it (light DOM) or encapsulates it (shadow DOM). Old
|
// 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
|
// reddit has no shadow roots, so the recursion is a no-op there. The shadow walk is gated by
|
||||||
// `root` (getRoot()) — it never walks the whole document's shadow roots, which would pull in
|
// `allowShadow`: when the root is a narrow comment container the whole subtree is comment text,
|
||||||
// reddit's chrome/UI text and be unbounded. A slotted light-DOM node is reached only by the
|
// so every shadow host is descended; on a BROAD fallback root (main/body) only a shadow host
|
||||||
// light walk: a TreeWalker over a shadow root walks the shadow tree's own nodes, not a <slot>'s
|
// that is itself a comment component is descended, so reddit's chrome shadow roots (nav,
|
||||||
|
// sidebar, composer) are never walked. 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
|
// 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.
|
// 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.
|
const MAX_SHADOW_DEPTH = 50; // bound the shadow-host recursion; a real comment tree is far shallower.
|
||||||
|
|
||||||
function collectInto(root, nodes, seen, depth) {
|
function collectInto(root, nodes, seen, depth, allowShadow) {
|
||||||
if (depth > MAX_SHADOW_DEPTH) return;
|
if (depth > MAX_SHADOW_DEPTH) return;
|
||||||
// SHOW_TEXT to gather candidate text; SHOW_ELEMENT so we can spot shadow hosts to recurse into.
|
// 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, {
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, {
|
||||||
acceptNode(node) {
|
acceptNode(node) {
|
||||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
// Elements are visited only to discover shadow hosts; never collected as text.
|
// Elements are visited only to discover shadow hosts; never collected as text.
|
||||||
return node.shadowRoot ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
|
// On a broad root, only descend into comment-component hosts — never chrome.
|
||||||
|
if (!node.shadowRoot) return NodeFilter.FILTER_SKIP;
|
||||||
|
return (allowShadow || hostIsCommentComponent(node)) ? 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;
|
||||||
@@ -60,8 +64,9 @@
|
|||||||
let n;
|
let n;
|
||||||
while ((n = walker.nextNode())) {
|
while ((n = walker.nextNode())) {
|
||||||
if (n.nodeType === Node.ELEMENT_NODE) {
|
if (n.nodeType === Node.ELEMENT_NODE) {
|
||||||
// Open shadow host: descend into its shadow tree (and its descendants' shadow trees).
|
// Open shadow host (already gated above): descend. Inside a comment component the whole
|
||||||
collectInto(n.shadowRoot, nodes, seen, depth + 1);
|
// shadow subtree is comment content, so further descent is unconditionally allowed.
|
||||||
|
collectInto(n.shadowRoot, nodes, seen, depth + 1, true);
|
||||||
} else if (!seen.has(n)) {
|
} else if (!seen.has(n)) {
|
||||||
seen.add(n);
|
seen.add(n);
|
||||||
nodes.push(n);
|
nodes.push(n);
|
||||||
@@ -71,7 +76,9 @@
|
|||||||
|
|
||||||
function collectTextNodes(root) {
|
function collectTextNodes(root) {
|
||||||
const nodes = [];
|
const nodes = [];
|
||||||
collectInto(root, nodes, new Set(), 0);
|
// A narrow comment container scopes the whole walk to comment text → pierce every shadow
|
||||||
|
// host. A broad fallback root (main/body) pierces only comment-component hosts (see collectInto).
|
||||||
|
collectInto(root, nodes, new Set(), 0, isNarrowRoot(root));
|
||||||
return nodes;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,13 +160,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- scan orchestration ---------------------------------------------------
|
// ---- scan orchestration ---------------------------------------------------
|
||||||
|
// Try the NARROW comment containers first, one at a time, before the broad app
|
||||||
|
// shells. A single union querySelector returns the first element in DOCUMENT ORDER
|
||||||
|
// matching ANY selector, and on new reddit <main> wraps the whole app and is an
|
||||||
|
// ANCESTOR of shreddit-comment-tree, so <main> would always win — rooting the
|
||||||
|
// shadow-piercing walk at the whole app and over-collecting reddit's chrome. Picking
|
||||||
|
// the comment tree when present keeps the walk inside the comment subtree.
|
||||||
function getRoot() {
|
function getRoot() {
|
||||||
return (
|
return (
|
||||||
document.querySelector("main, .content[role='main'], shreddit-comment-tree, .commentarea") ||
|
document.querySelector("shreddit-comment-tree") || // new reddit
|
||||||
|
document.querySelector(".commentarea") || // old reddit
|
||||||
|
document.querySelector("main, .content[role='main']") ||
|
||||||
document.body
|
document.body
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A narrow comment container scopes the whole walk to comment text, so shadow-piercing
|
||||||
|
// is always safe there. On a BROAD fallback root (main/body — no comment tree found) the
|
||||||
|
// walk must NOT descend into the app's chrome shadow roots (nav, sidebar, composer): only
|
||||||
|
// descend into a shadow host that is itself a comment component.
|
||||||
|
const NARROW_ROOT_SELECTOR = "shreddit-comment-tree, .commentarea";
|
||||||
|
const isNarrowRoot = (root) =>
|
||||||
|
root.nodeType === Node.ELEMENT_NODE && typeof root.matches === "function" && root.matches(NARROW_ROOT_SELECTOR);
|
||||||
|
const hostIsCommentComponent = (el) =>
|
||||||
|
typeof el.tagName === "string" && el.tagName.startsWith("SHREDDIT-COMMENT");
|
||||||
|
|
||||||
async function scan() {
|
async function scan() {
|
||||||
const root = getRoot();
|
const root = getRoot();
|
||||||
const nodes = collectTextNodes(root);
|
const nodes = collectTextNodes(root);
|
||||||
|
|||||||
Reference in New Issue
Block a user