src / adapters / sites / redditExtractor.ts
src / adapters / sites / redditExtractor.ts
import { JSDOM } from "jsdom";
import TurndownService from "turndown";
import { ExtractedWebpage, ImageMarker } from "../../cache/WebpageCache";
import {
createExtractionVirtualConsole,
normalizeWhitespace,
} from "../../extractors/htmlExtractor";
/**
* Configures a TurndownService instance tailored for Reddit content.
* Preserves anchor hrefs and image URLs to maintain full context for LLM readers.
*/
function createRedditTurndownService(baseUrl: string): TurndownService {
const turndown = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
bulletListMarker: "-",
hr: "---",
});
// Strip script, style, iframe, svg, noscript
turndown.remove((node) => {
const tag = node.nodeName.toLowerCase();
return ["script", "style", "noscript", "iframe", "svg"].includes(tag);
});
// Preserve links as [text](href) with absolute URLs
turndown.addRule("preserveRedditLinks", {
filter: "a",
replacement: (content, node) => {
const element = node as Element;
const href = element.getAttribute("href")?.trim();
const text = content.trim();
if (!href || href.startsWith("javascript:") || href.startsWith("#")) {
return text;
}
try {
const absoluteUrl = new URL(href, baseUrl).href;
if (!text) {
return `<${absoluteUrl}>`;
}
return `[${text}](${absoluteUrl})`;
} catch {
return text || href;
}
},
});
// Format images as > **Image:** description (url)
turndown.addRule("preserveRedditImages", {
filter: "img",
replacement: (content, node) => {
const element = node as Element;
const src = element.getAttribute("src")?.trim();
const alt = element.getAttribute("alt")?.trim() || element.getAttribute("title")?.trim();
if (!src || src.startsWith("data:")) return "";
try {
const absoluteSrc = new URL(src, baseUrl).href;
const desc = alt && !["image", "icon", "photo"].includes(alt.toLowerCase()) ? alt : "Image";
return `\n\n> **Image:** ${desc} (${absoluteSrc})\n\n`;
} catch {
return "";
}
},
});
// Escape reduction for direct LLM consumption
turndown.escape = (str: string) =>
str.replace(/\\/g, "\\\\").replace(/^(\s*)([#\-+*])/gm, "$1\\$2");
return turndown;
}
/**
* Extracts structured Markdown, hierarchy, and metadata from server-rendered old.reddit.com HTML.
*/
export function extractRedditContent(html: string, originalUrl: string): ExtractedWebpage {
const rawSizeBytes = Buffer.byteLength(html || "", "utf-8");
const dom = new JSDOM(html, {
url: originalUrl,
virtualConsole: createExtractionVirtualConsole(),
});
const doc = dom.window.document;
const turndown = createRedditTurndownService(originalUrl);
const imageMarkers: ImageMarker[] = [];
let imagesFoundCount = 0;
// 1. Extract Post Header & Metadata
const postThing = doc.querySelector(".linklisting .thing.link, #siteTable .thing.link, .thing.link");
let postTitle = doc.title?.replace(/\s*:?\s*r\/\w+$/i, "").trim() || "Reddit Post";
let postAuthor: string | undefined;
let postSubreddit: string | undefined;
let postScore: string | undefined;
let postDate: string | undefined;
let postTargetUrl: string | undefined;
let isLinkPost = false;
let postBodyMarkdown = "";
if (postThing) {
const titleAnchor = postThing.querySelector("a.title");
if (titleAnchor?.textContent?.trim()) {
postTitle = titleAnchor.textContent.trim();
}
const rawHref = titleAnchor?.getAttribute("href")?.trim();
if (rawHref) {
try {
postTargetUrl = new URL(rawHref, originalUrl).href;
} catch {
postTargetUrl = rawHref;
}
}
const domainText = postThing.querySelector(".domain")?.textContent?.trim() || "";
if (domainText && !domainText.includes("self.") && postTargetUrl && postTargetUrl !== originalUrl) {
isLinkPost = true;
}
const authorEl = postThing.querySelector(".author");
if (authorEl?.textContent?.trim()) {
postAuthor = authorEl.textContent.trim();
}
const subEl = postThing.querySelector(".subreddit");
if (subEl?.textContent?.trim()) {
postSubreddit = subEl.textContent.trim();
}
const scoreEl =
postThing.querySelector(".score.unvoted") ||
postThing.querySelector(".score.likes") ||
postThing.querySelector(".score.dislikes");
postScore = scoreEl?.getAttribute("title")?.trim() || scoreEl?.textContent?.trim() || undefined;
const timeEl = postThing.querySelector("time");
postDate = timeEl?.getAttribute("datetime")?.trim() || timeEl?.textContent?.trim() || undefined;
// Self post body
const bodyEl = postThing.querySelector(".usertext-body .md");
if (bodyEl) {
postBodyMarkdown = normalizeWhitespace(turndown.turndown(bodyEl.innerHTML));
}
}
// 2. Extract Comments Tree & Detect "More Comments" Indicators
const commentArea = doc.querySelector(".commentarea");
const commentLines: string[] = [];
let extractedCommentCount = 0;
let hasMoreComments = false;
let moreCommentsText = "";
function parseCommentElement(element: Element, depth: number): void {
if (element.classList.contains("comment")) {
extractedCommentCount++;
const entry = element.querySelector(":scope > .entry");
if (!entry) return;
const authorEl = entry.querySelector(".author");
const authorText = authorEl?.textContent?.trim() || "";
const isDeleted = element.classList.contains("deleted") || authorText === "[deleted]";
const isRemoved = authorText === "[removed]";
const author = isDeleted ? "[deleted]" : isRemoved ? "[removed]" : authorText || "[anonymous]";
const scoreEl = entry.querySelector(".score.unvoted, .score.likes, .score.dislikes");
const score = scoreEl?.getAttribute("title")?.trim() || scoreEl?.textContent?.trim() || "";
const timeEl = entry.querySelector("time");
const time = timeEl?.getAttribute("datetime")?.trim() || timeEl?.textContent?.trim() || "";
const prefix = ">".repeat(depth + 1) + " ";
// Meta line
const metaParts: string[] = [`**${author}**`];
if (score) metaParts.push(score);
if (time) metaParts.push(time);
const permalinkEl = entry.querySelector("a.bfls, a.permalink");
const rawPermalink = permalinkEl?.getAttribute("href");
if (rawPermalink) {
try {
const absolutePermalink = new URL(rawPermalink, originalUrl).href;
metaParts.push(`[link](${absolutePermalink})`);
} catch {
// Ignore URL error
}
}
commentLines.push(`${prefix}${metaParts.join(" | ")}`);
// Body line
const bodyEl = entry.querySelector(".usertext-body .md");
if (isDeleted) {
commentLines.push(`${prefix}*[Comment deleted by user]*`);
} else if (isRemoved) {
commentLines.push(`${prefix}*[Comment removed by moderator]*`);
} else if (bodyEl) {
const bodyMd = normalizeWhitespace(turndown.turndown(bodyEl.innerHTML));
if (bodyMd) {
const bodyFormatted = bodyMd
.split("\n")
.map((line) => `${prefix}${line}`)
.join("\n");
commentLines.push(bodyFormatted);
}
}
commentLines.push(`${prefix.trim()}`); // Empty spacer line with prefix
// Process children
const childContainer = element.querySelector(":scope > .child > .sitetable");
if (childContainer) {
Array.from(childContainer.children).forEach((child) => {
parseCommentElement(child, depth + 1);
});
}
} else if (
element.classList.contains("morechildren") ||
element.classList.contains("morerow")
) {
hasMoreComments = true;
const moreLink = element.querySelector("a");
const label = moreLink?.textContent?.trim() || "load more comments";
const prefix = ">".repeat(depth + 1) + " ";
moreCommentsText = label;
commentLines.push(`${prefix}*... ${label}*`);
}
}
if (commentArea) {
const topSiteTable = commentArea.querySelector(":scope > .sitetable, .nestedlisting");
if (topSiteTable) {
Array.from(topSiteTable.children).forEach((child) => {
parseCommentElement(child, 0);
});
}
// Secondary check for morechildren at commentarea root
const rootMore = commentArea.querySelector(".morechildren, .morerow");
if (rootMore) {
hasMoreComments = true;
}
}
// 3. Assemble Markdown Document
const docLines: string[] = [];
docLines.push(`# ${postTitle}`);
const headerMeta: string[] = [];
if (postSubreddit) headerMeta.push(`**Subreddit:** ${postSubreddit}`);
if (postAuthor) headerMeta.push(`**Author:** ${postAuthor}`);
if (postScore) headerMeta.push(`**Score:** ${postScore}`);
if (postDate) headerMeta.push(`**Published:** ${postDate}`);
if (headerMeta.length > 0) {
docLines.push(headerMeta.join(" | "));
}
if (isLinkPost && postTargetUrl) {
docLines.push(`**Link Target:** [${postTargetUrl}](${postTargetUrl})`);
}
if (postBodyMarkdown) {
docLines.push("");
docLines.push(postBodyMarkdown);
}
docLines.push("");
docLines.push("---");
docLines.push("");
const commentHeader = `## Comments (${extractedCommentCount} extracted${
hasMoreComments ? `, more comments available on page` : ""
})`;
docLines.push(commentHeader);
docLines.push("");
if (commentLines.length > 0) {
docLines.push(commentLines.join("\n"));
} else {
docLines.push("*No comments found on this post.*");
}
const fullMarkdown = normalizeWhitespace(docLines.join("\n"));
// Track images
if (doc) {
const imgs = doc.querySelectorAll(".linklisting img, #siteTable img, .usertext-body img");
imagesFoundCount = imgs.length;
imgs.forEach((img, idx) => {
const src = img.getAttribute("src");
if (src && !src.startsWith("data:")) {
try {
const absoluteSrc = new URL(src, originalUrl).href;
imageMarkers.push({
src: absoluteSrc,
alt: img.getAttribute("alt") || undefined,
insertionIndex: idx,
});
} catch {
// Ignore
}
}
});
}
return {
url: originalUrl,
metadata: {
site: postSubreddit || "Reddit",
title: postTitle,
author: postAuthor,
publishedDate: postDate,
excerpt: postBodyMarkdown ? postBodyMarkdown.substring(0, 200) : undefined,
},
fullMarkdown,
totalChars: fullMarkdown.length,
rawHtmlLength: rawSizeBytes,
readabilityLength: postBodyMarkdown.length,
tablesDetected: fullMarkdown.includes("| --- |"),
imageMarkers,
imagesFoundCount,
imagesFilteredCount: 0,
};
}
import { JSDOM } from "jsdom";
import TurndownService from "turndown";
import { ExtractedWebpage, ImageMarker } from "../../cache/WebpageCache";
import {
createExtractionVirtualConsole,
normalizeWhitespace,
} from "../../extractors/htmlExtractor";
/**
* Configures a TurndownService instance tailored for Reddit content.
* Preserves anchor hrefs and image URLs to maintain full context for LLM readers.
*/
function createRedditTurndownService(baseUrl: string): TurndownService {
const turndown = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
bulletListMarker: "-",
hr: "---",
});
// Strip script, style, iframe, svg, noscript
turndown.remove((node) => {
const tag = node.nodeName.toLowerCase();
return ["script", "style", "noscript", "iframe", "svg"].includes(tag);
});
// Preserve links as [text](href) with absolute URLs
turndown.addRule("preserveRedditLinks", {
filter: "a",
replacement: (content, node) => {
const element = node as Element;
const href = element.getAttribute("href")?.trim();
const text = content.trim();
if (!href || href.startsWith("javascript:") || href.startsWith("#")) {
return text;
}
try {
const absoluteUrl = new URL(href, baseUrl).href;
if (!text) {
return `<${absoluteUrl}>`;
}
return `[${text}](${absoluteUrl})`;
} catch {
return text || href;
}
},
});
// Format images as > **Image:** description (url)
turndown.addRule("preserveRedditImages", {
filter: "img",
replacement: (content, node) => {
const element = node as Element;
const src = element.getAttribute("src")?.trim();
const alt = element.getAttribute("alt")?.trim() || element.getAttribute("title")?.trim();
if (!src || src.startsWith("data:")) return "";
try {
const absoluteSrc = new URL(src, baseUrl).href;
const desc = alt && !["image", "icon", "photo"].includes(alt.toLowerCase()) ? alt : "Image";
return `\n\n> **Image:** ${desc} (${absoluteSrc})\n\n`;
} catch {
return "";
}
},
});
// Escape reduction for direct LLM consumption
turndown.escape = (str: string) =>
str.replace(/\\/g, "\\\\").replace(/^(\s*)([#\-+*])/gm, "$1\\$2");
return turndown;
}
/**
* Extracts structured Markdown, hierarchy, and metadata from server-rendered old.reddit.com HTML.
*/
export function extractRedditContent(html: string, originalUrl: string): ExtractedWebpage {
const rawSizeBytes = Buffer.byteLength(html || "", "utf-8");
const dom = new JSDOM(html, {
url: originalUrl,
virtualConsole: createExtractionVirtualConsole(),
});
const doc = dom.window.document;
const turndown = createRedditTurndownService(originalUrl);
const imageMarkers: ImageMarker[] = [];
let imagesFoundCount = 0;
// 1. Extract Post Header & Metadata
const postThing = doc.querySelector(".linklisting .thing.link, #siteTable .thing.link, .thing.link");
let postTitle = doc.title?.replace(/\s*:?\s*r\/\w+$/i, "").trim() || "Reddit Post";
let postAuthor: string | undefined;
let postSubreddit: string | undefined;
let postScore: string | undefined;
let postDate: string | undefined;
let postTargetUrl: string | undefined;
let isLinkPost = false;
let postBodyMarkdown = "";
if (postThing) {
const titleAnchor = postThing.querySelector("a.title");
if (titleAnchor?.textContent?.trim()) {
postTitle = titleAnchor.textContent.trim();
}
const rawHref = titleAnchor?.getAttribute("href")?.trim();
if (rawHref) {
try {
postTargetUrl = new URL(rawHref, originalUrl).href;
} catch {
postTargetUrl = rawHref;
}
}
const domainText = postThing.querySelector(".domain")?.textContent?.trim() || "";
if (domainText && !domainText.includes("self.") && postTargetUrl && postTargetUrl !== originalUrl) {
isLinkPost = true;
}
const authorEl = postThing.querySelector(".author");
if (authorEl?.textContent?.trim()) {
postAuthor = authorEl.textContent.trim();
}
const subEl = postThing.querySelector(".subreddit");
if (subEl?.textContent?.trim()) {
postSubreddit = subEl.textContent.trim();
}
const scoreEl =
postThing.querySelector(".score.unvoted") ||
postThing.querySelector(".score.likes") ||
postThing.querySelector(".score.dislikes");
postScore = scoreEl?.getAttribute("title")?.trim() || scoreEl?.textContent?.trim() || undefined;
const timeEl = postThing.querySelector("time");
postDate = timeEl?.getAttribute("datetime")?.trim() || timeEl?.textContent?.trim() || undefined;
// Self post body
const bodyEl = postThing.querySelector(".usertext-body .md");
if (bodyEl) {
postBodyMarkdown = normalizeWhitespace(turndown.turndown(bodyEl.innerHTML));
}
}
// 2. Extract Comments Tree & Detect "More Comments" Indicators
const commentArea = doc.querySelector(".commentarea");
const commentLines: string[] = [];
let extractedCommentCount = 0;
let hasMoreComments = false;
let moreCommentsText = "";
function parseCommentElement(element: Element, depth: number): void {
if (element.classList.contains("comment")) {
extractedCommentCount++;
const entry = element.querySelector(":scope > .entry");
if (!entry) return;
const authorEl = entry.querySelector(".author");
const authorText = authorEl?.textContent?.trim() || "";
const isDeleted = element.classList.contains("deleted") || authorText === "[deleted]";
const isRemoved = authorText === "[removed]";
const author = isDeleted ? "[deleted]" : isRemoved ? "[removed]" : authorText || "[anonymous]";
const scoreEl = entry.querySelector(".score.unvoted, .score.likes, .score.dislikes");
const score = scoreEl?.getAttribute("title")?.trim() || scoreEl?.textContent?.trim() || "";
const timeEl = entry.querySelector("time");
const time = timeEl?.getAttribute("datetime")?.trim() || timeEl?.textContent?.trim() || "";
const prefix = ">".repeat(depth + 1) + " ";
// Meta line
const metaParts: string[] = [`**${author}**`];
if (score) metaParts.push(score);
if (time) metaParts.push(time);
const permalinkEl = entry.querySelector("a.bfls, a.permalink");
const rawPermalink = permalinkEl?.getAttribute("href");
if (rawPermalink) {
try {
const absolutePermalink = new URL(rawPermalink, originalUrl).href;
metaParts.push(`[link](${absolutePermalink})`);
} catch {
// Ignore URL error
}
}
commentLines.push(`${prefix}${metaParts.join(" | ")}`);
// Body line
const bodyEl = entry.querySelector(".usertext-body .md");
if (isDeleted) {
commentLines.push(`${prefix}*[Comment deleted by user]*`);
} else if (isRemoved) {
commentLines.push(`${prefix}*[Comment removed by moderator]*`);
} else if (bodyEl) {
const bodyMd = normalizeWhitespace(turndown.turndown(bodyEl.innerHTML));
if (bodyMd) {
const bodyFormatted = bodyMd
.split("\n")
.map((line) => `${prefix}${line}`)
.join("\n");
commentLines.push(bodyFormatted);
}
}
commentLines.push(`${prefix.trim()}`); // Empty spacer line with prefix
// Process children
const childContainer = element.querySelector(":scope > .child > .sitetable");
if (childContainer) {
Array.from(childContainer.children).forEach((child) => {
parseCommentElement(child, depth + 1);
});
}
} else if (
element.classList.contains("morechildren") ||
element.classList.contains("morerow")
) {
hasMoreComments = true;
const moreLink = element.querySelector("a");
const label = moreLink?.textContent?.trim() || "load more comments";
const prefix = ">".repeat(depth + 1) + " ";
moreCommentsText = label;
commentLines.push(`${prefix}*... ${label}*`);
}
}
if (commentArea) {
const topSiteTable = commentArea.querySelector(":scope > .sitetable, .nestedlisting");
if (topSiteTable) {
Array.from(topSiteTable.children).forEach((child) => {
parseCommentElement(child, 0);
});
}
// Secondary check for morechildren at commentarea root
const rootMore = commentArea.querySelector(".morechildren, .morerow");
if (rootMore) {
hasMoreComments = true;
}
}
// 3. Assemble Markdown Document
const docLines: string[] = [];
docLines.push(`# ${postTitle}`);
const headerMeta: string[] = [];
if (postSubreddit) headerMeta.push(`**Subreddit:** ${postSubreddit}`);
if (postAuthor) headerMeta.push(`**Author:** ${postAuthor}`);
if (postScore) headerMeta.push(`**Score:** ${postScore}`);
if (postDate) headerMeta.push(`**Published:** ${postDate}`);
if (headerMeta.length > 0) {
docLines.push(headerMeta.join(" | "));
}
if (isLinkPost && postTargetUrl) {
docLines.push(`**Link Target:** [${postTargetUrl}](${postTargetUrl})`);
}
if (postBodyMarkdown) {
docLines.push("");
docLines.push(postBodyMarkdown);
}
docLines.push("");
docLines.push("---");
docLines.push("");
const commentHeader = `## Comments (${extractedCommentCount} extracted${
hasMoreComments ? `, more comments available on page` : ""
})`;
docLines.push(commentHeader);
docLines.push("");
if (commentLines.length > 0) {
docLines.push(commentLines.join("\n"));
} else {
docLines.push("*No comments found on this post.*");
}
const fullMarkdown = normalizeWhitespace(docLines.join("\n"));
// Track images
if (doc) {
const imgs = doc.querySelectorAll(".linklisting img, #siteTable img, .usertext-body img");
imagesFoundCount = imgs.length;
imgs.forEach((img, idx) => {
const src = img.getAttribute("src");
if (src && !src.startsWith("data:")) {
try {
const absoluteSrc = new URL(src, originalUrl).href;
imageMarkers.push({
src: absoluteSrc,
alt: img.getAttribute("alt") || undefined,
insertionIndex: idx,
});
} catch {
// Ignore
}
}
});
}
return {
url: originalUrl,
metadata: {
site: postSubreddit || "Reddit",
title: postTitle,
author: postAuthor,
publishedDate: postDate,
excerpt: postBodyMarkdown ? postBodyMarkdown.substring(0, 200) : undefined,
},
fullMarkdown,
totalChars: fullMarkdown.length,
rawHtmlLength: rawSizeBytes,
readabilityLength: postBodyMarkdown.length,
tablesDetected: fullMarkdown.includes("| --- |"),
imageMarkers,
imagesFoundCount,
imagesFilteredCount: 0,
};
}