Fix old Reddit comment discovery

This commit is contained in:
Jordan Wages 2026-08-25 23:37:08 -05:00
commit 803df7feee
5 changed files with 50 additions and 12 deletions

View file

@ -78,7 +78,7 @@ export class DefinitionEngine {
const matches = Array.from(post.querySelectorAll(selector)); const matches = Array.from(post.querySelectorAll(selector));
const contents = post.matches(selector) ? [post, ...matches] : matches; const contents = post.matches(selector) ? [post, ...matches] : matches;
for (const content of contents) { for (const content of contents) {
const text = visibleText(content, this.definition.post.excludedSelectors ?? []); const text = visibleText(content, this.definition.post.excludedSelectors ?? [], post);
if (text) return text; if (text) return text;
} }
} }
@ -140,13 +140,19 @@ function wildcardMatches(pattern: string, value: string): boolean {
function escapeRegex(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function escapeRegex(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
function visibleText(element: Element, excludedSelectors: string[]): string { function visibleText(element: Element, excludedSelectors: string[], postBoundary: Element): string {
const chunks: string[] = []; const chunks: string[] = [];
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let node: Node | null; let node: Node | null;
while ((node = walker.nextNode())) { while ((node = walker.nextNode())) {
const parent = node.parentElement; const parent = node.parentElement;
if (!parent || excludedSelectors.some((selector) => parent.closest(selector))) continue; // An excluded container only applies when it is inside this post's
// boundary. Nested Reddit comments are themselves inside an ancestor
// `.child` container, which must not exclude the comment's own text.
if (!parent || excludedSelectors.some((selector) => {
const excluded = parent.closest(selector);
return excluded !== null && postBoundary.contains(excluded);
})) continue;
if (isVisible(parent, element)) chunks.push(node.textContent ?? ""); if (isVisible(parent, element)) chunks.push(node.textContent ?? "");
} }
return normalizeText(chunks.join(" ")); return normalizeText(chunks.join(" "));

View file

@ -44,12 +44,12 @@
"https://old.reddit.com/*" "https://old.reddit.com/*"
], ],
"requiredSelectors": [ "requiredSelectors": [
"#siteTable .thing.link, #siteTable .thing.comment" "#siteTable .thing.link, .commentarea .thing.comment > .entry"
], ],
"post": { "post": {
"rootSelectors": [ "rootSelectors": [
"#siteTable > .thing.link", "#siteTable > .thing.link",
"#siteTable .thing.comment" ".commentarea .thing.comment > .entry"
], ],
"textSelectors": [ "textSelectors": [
".md", ".md",

View file

@ -2,9 +2,9 @@
"id": "reddit-old", "id": "reddit-old",
"name": "Reddit (old interface)", "name": "Reddit (old interface)",
"urlPatterns": ["https://old.reddit.com/*"], "urlPatterns": ["https://old.reddit.com/*"],
"requiredSelectors": ["#siteTable .thing.link, #siteTable .thing.comment"], "requiredSelectors": ["#siteTable .thing.link, .commentarea .thing.comment > .entry"],
"post": { "post": {
"rootSelectors": ["#siteTable > .thing.link", "#siteTable .thing.comment"], "rootSelectors": ["#siteTable > .thing.link", ".commentarea .thing.comment > .entry"],
"textSelectors": [".md", ".title"], "textSelectors": [".md", ".title"],
"excludedSelectors": [".expando", ".child"], "excludedSelectors": [".expando", ".child"],
"idAttributes": ["data-fullname"], "idAttributes": ["data-fullname"],

View file

@ -95,12 +95,35 @@ describe("DefinitionEngine", () => {
expect(posts.map((post) => post.text)).toEqual(["the main post on the threads detail page", "a reply on the threads detail page"]); expect(posts.map((post) => post.text)).toEqual(["the main post on the threads detail page", "a reply on the threads detail page"]);
}); });
it("discovers posts from the old Reddit interface using fullname IDs", () => { it("discovers old Reddit posts and comment entries from their separate page containers", () => {
document.body.innerHTML = readFileSync("tests/fixtures/reddit-old-feed.html", "utf8"); document.body.innerHTML = readFileSync("tests/fixtures/reddit-old-feed.html", "utf8");
const reddit = BUILTIN_DEFINITIONS.find((candidate) => candidate.id === "reddit-old")!; const reddit = BUILTIN_DEFINITIONS.find((candidate) => candidate.id === "reddit-old")!;
const posts = new DefinitionEngine(reddit).discover(document); const posts = new DefinitionEngine(reddit).discover(document);
expect(posts.map((post) => post.id)).toEqual(["reddit-old:t3_abc123", "reddit-old:t1_def456"]); expect(posts.map((post) => post.id)).toEqual([
expect(posts.map((post) => post.text)).toEqual(["the post body with visible text.", "a useful comment with visible text."]); "reddit-old:t3_abc123",
"reddit-old:https://old.reddit.com/r/example/comments/abc123/example_post/def456/",
"reddit-old:https://old.reddit.com/r/example/comments/abc123/example_post/ghi789/"
]);
expect(posts.map((post) => post.text)).toEqual([
"the post body with visible text.",
"a useful comment with visible text.",
"a nested reply with its own visible text."
]);
});
it("discovers old Reddit comment entries appended after observation starts", async () => {
document.body.innerHTML = readFileSync("tests/fixtures/reddit-old-feed.html", "utf8");
const reddit = BUILTIN_DEFINITIONS.find((candidate) => candidate.id === "reddit-old")!;
const engine = new DefinitionEngine(reddit);
const observed: string[] = [];
const stop = engine.observe((posts) => observed.push(...posts.map((post) => post.text)));
const comment = document.createElement("div");
comment.className = "thing comment";
comment.innerHTML = '<div class="entry"><div class="md"><p>A newly loaded reply.</p></div><a class="bylink" href="/r/example/comments/abc123/example_post/jkl012/">permalink</a></div>';
document.querySelector(".commentarea .sitetable")?.append(comment);
await new Promise((resolve) => setTimeout(resolve, 0));
stop();
expect(observed).toContain("a newly loaded reply.");
}); });
it("discovers X posts using semantic tweet markers and permalink IDs", () => { it("discovers X posts using semantic tweet markers and permalink IDs", () => {

View file

@ -3,7 +3,16 @@
<p class="title"><a class="title" href="https://old.reddit.com/r/example/comments/abc123/example_post/">A useful Reddit post</a></p> <p class="title"><a class="title" href="https://old.reddit.com/r/example/comments/abc123/example_post/">A useful Reddit post</a></p>
<div class="entry"><div class="expando"><div class="md">Hidden expanded media controls</div></div><div class="md"><p>The post body with visible text.</p></div></div> <div class="entry"><div class="expando"><div class="md">Hidden expanded media controls</div></div><div class="md"><p>The post body with visible text.</p></div></div>
</div> </div>
<div class="thing comment" data-fullname="t1_def456"> </div>
<div class="entry"><div class="md"><p>A useful comment with visible text.</p></div><a class="bylink" href="https://old.reddit.com/r/example/comments/abc123/example_post/def456/">permalink</a></div> <div class="commentarea">
<div class="sitetable nestedlisting">
<div class="thing comment" data-fullname="t1_def456">
<div class="entry"><div class="md"><p>A useful comment with visible text.</p></div><a class="bylink" href="https://old.reddit.com/r/example/comments/abc123/example_post/def456/">permalink</a></div>
<div class="child">
<div class="thing comment" data-fullname="t1_ghi789">
<div class="entry"><div class="md"><p>A nested reply with its own visible text.</p></div><a class="bylink" href="https://old.reddit.com/r/example/comments/abc123/example_post/ghi789/">permalink</a></div>
</div>
</div>
</div>
</div> </div>
</div> </div>