← Files DefuddleARCHIVED FILE
skills/defuddle-web-content/references/source/src/extractors/reddit.ts
15.6 KB · Oct 5, 2026 · 18:35 UTC
import { BaseExtractor, ExtractorOptions } from './_base';
import { ExtractorResult } from '../types/extractors';
import { parseHTML, serializeHTML } from '../utils/dom';
import { buildCommentTree, buildContentHtml, type CommentData } from '../utils/comments';
interface FeedEntry {
id: string;
author: string;
title: string;
date: string;
url: string;
content: string;
}
interface FetchedPage {
ok: boolean;
status: number;
url: string;
text: string;
}
interface RequestCacheEntry {
expires: number;
page: Promise<FetchedPage>;
}
const REQUEST_CACHE_TTL_MS = 60_000;
const REQUEST_ERROR_TTL_MS = 5_000;
const REQUEST_TIMEOUT_MS = 10_000;
// One parse touches two URLs, so this holds a handful of concurrent parses.
// Without a bound the map would grow with traffic, and each entry retains a
// full response body.
const MAX_CACHE_ENTRIES = 16;
const requestCache = new Map<string, RequestCacheEntry>();
/**
* Reddit rejects requests with an empty User-Agent outright, and asks scripts
* to identify themselves. Node's fetch always supplies one, but workerd sends
* none, so setting it here is what makes the Worker work rather than 403.
*/
const USER_AGENT = 'defuddle (+https://defuddle.md)';
const XML_ENTITIES: Record<string, string> = {
'<': '<',
'>': '>',
'"': '"',
''': "'",
'&': '&',
};
/**
* Anonymous requests to old.reddit.com are redirected to /login/. Fetch follows
* that redirect, so the login page arrives as a 200 and is only distinguishable
* by the final URL. That URL is caller-supplied via options.fetch, so it may be
* absent or unparseable.
*/
function isLoginWall(finalUrl: string): boolean {
try {
return /^\/login\/?$/.test(new URL(finalUrl).pathname);
} catch {
return false;
}
}
/**
* Decode the XML entities Atom uses to escape markup. Single-pass so that an
* escaped entity in the source (`&lt;`) decodes to `<` rather than `<`.
* Markup goes on to be parsed as HTML, but plain fields like titles are used
* as-is, so numeric references are resolved here too.
*/
function decodeXmlEntities(value: string): string {
return value.replace(/&(?:lt|gt|quot|apos|amp|#(\d+)|#x([0-9a-fA-F]+));/g, (entity, dec, hex) => {
if (dec || hex) return String.fromCodePoint(parseInt(dec ?? hex, dec ? 10 : 16));
return XML_ENTITIES[entity] ?? entity;
});
}
export class RedditExtractor extends BaseExtractor {
private shredditPost: Element | null;
private isOldReddit: boolean;
constructor(document: Document, url: string, schemaOrgData?: any, options?: ExtractorOptions) {
super(document, url, schemaOrgData, options);
this.shredditPost = document.querySelector('shreddit-post');
this.isOldReddit = !!document.querySelector('.thing.link');
}
canExtract(): boolean {
return !!this.shredditPost || this.isOldReddit;
}
canExtractAsync(): boolean {
return this.isCommentsPage() && !this.isOldReddit;
}
prefersAsync(): boolean {
// In server/worker contexts, fetch full content including comments from
// old.reddit.com, falling back to the Atom feed. In browser (real
// window), use the rendered DOM directly since both are CORS-blocked
// from www.reddit.com.
const isBrowser = typeof window !== 'undefined' && this.document.defaultView === window;
return this.isCommentsPage() && !this.isOldReddit && !isBrowser;
}
private isCommentsPage(): boolean {
return /\/r\/.+\/comments\//.test(this.url);
}
async extractAsync(): Promise<ExtractorResult> {
// old.reddit.com carries scores and reply nesting that the public feed
// does not, so it stays the preferred source. Anonymous clients now get
// a login wall there instead of content, but a caller injecting
// authenticated cookies through options.fetch still reaches the real
// page — so try it, and fall through to the feed when it fails.
const fromOldReddit = await this.tryOldReddit();
if (fromOldReddit) return fromOldReddit;
return this.extractFromFeed();
}
private async tryOldReddit(): Promise<ExtractorResult | null> {
const oldUrl = new URL(this.url);
oldUrl.hostname = 'old.reddit.com';
const response = await this.request(oldUrl.toString(), 'text/html,application/xhtml+xml')
.catch(() => null);
if (!response?.ok || isLoginWall(response.url)) return null;
const doc = this.parseDocument(response.text);
// A login page reached through a redirect still has no post.
return doc.querySelector('.thing.link') ? this.extractOldReddit(doc) : null;
}
/**
* Reddit's Atom feed is the only unauthenticated source still serving post
* content. It is flat and score-less, so the output is thinner than the
* old.reddit DOM, but it is the difference between a post and an error.
*/
private async extractFromFeed(): Promise<ExtractorResult> {
const feedUrl = new URL(this.url);
feedUrl.hostname = 'www.reddit.com';
feedUrl.pathname = `${feedUrl.pathname.replace(/\/+$/, '')}/.rss`;
const response = await this.request(feedUrl.toString(), 'application/atom+xml, application/rss+xml, application/xml');
if (!response.ok) {
// 429 is Reddit throttling the feed, which it does aggressively;
// 403 is the network-policy block datacenter IPs receive.
throw new Error(`Failed to fetch ${feedUrl.hostname}: ${response.status}`);
}
return this.extractFeed(response.text);
}
/**
* Fetch through a short-lived cache.
*
* A single user-facing parse reaches this extractor several times: the CLI
* and the Worker both reparse the page when the first pass yields nothing,
* and parseAsync() tries a preferred and a general finder. Reddit throttles
* the feed to roughly one request per half-minute, so without this the
* retries guarantee the 429 they are retrying against.
*
* The cache is keyed by URL alone, so it is bypassed when the caller
* supplies its own fetch — otherwise an authenticated response and an
* anonymous one could be served to each other.
*/
private async request(url: string, accept: string): Promise<FetchedPage> {
if (this.options.fetch) return this.send(url, accept);
const now = Date.now();
const cached = requestCache.get(url);
if (cached && cached.expires > now) return cached.page;
requestCache.delete(url);
const page = this.send(url, accept);
const entry: RequestCacheEntry = { expires: now + REQUEST_CACHE_TTL_MS, page };
requestCache.set(url, entry);
// A rejected fetch is cached only long enough to absorb the retry burst,
// so a transient error does not stick to a long-lived process.
page.catch(() => { entry.expires = Date.now() + REQUEST_ERROR_TTL_MS; });
while (requestCache.size > MAX_CACHE_ENTRIES) {
requestCache.delete(requestCache.keys().next().value!);
}
return page;
}
private async send(url: string, accept: string): Promise<FetchedPage> {
const response = await this.fetch(url, {
headers: { 'User-Agent': USER_AGENT, 'Accept': accept },
// Without a deadline a hung connection pins this extractor, and with
// it the whole source Document, for as long as the socket lives.
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
// The login wall is a full page that no caller reads, so skip buffering
// it — on the anonymous path that is every request to old.reddit.com.
const finalUrl = response.url || url;
const wanted = response.ok && !isLoginWall(finalUrl);
if (!wanted) response.body?.cancel().catch(() => {});
return {
ok: response.ok,
status: response.status,
url: finalUrl,
text: wanted ? await response.text() : '',
};
}
private parseDocument(html: string): Document {
const Parser = this.document.defaultView?.DOMParser ?? (typeof DOMParser !== 'undefined' ? DOMParser : null);
if (!Parser) {
throw new Error('DOMParser is not available in this environment');
}
return new Parser().parseFromString(html, 'text/html');
}
private extractFeed(xml: string): ExtractorResult {
const includeReplies = this.options.includeReplies !== false;
// Building an entry parses and reserializes its body, so discard the
// ones this parse does not want before paying for them.
const entries = xml.split('<entry>').slice(1)
.filter(entry => {
const id = entry.match(/<id>([\s\S]*?)<\/id>/)?.[1] ?? '';
return id.startsWith('t3_') || (includeReplies && id.startsWith('t1_'));
})
.map(entry => this.parseFeedEntry(entry));
const post = entries.find(entry => entry.id.startsWith('t3_'));
const postBody = post?.content || '';
const comments = buildCommentTree(entries
.filter(entry => entry.id.startsWith('t1_'))
.map(entry => ({
author: entry.author,
date: entry.date,
content: entry.content,
// The feed carries no parent links, so replies cannot be nested
// the way the old.reddit DOM allows.
depth: 0,
url: entry.url || undefined,
} satisfies CommentData)));
return this.buildResult(postBody, comments, {
title: post?.title || '',
author: post?.author || '',
subreddit: this.getSubreddit(),
});
}
private parseFeedEntry(entry: string): FeedEntry {
const field = (pattern: RegExp) => (entry.match(pattern)?.[1] ?? '').trim();
return {
id: field(/<id>([\s\S]*?)<\/id>/),
author: decodeXmlEntities(field(/<author>[\s\S]*?<name>([\s\S]*?)<\/name>/)).replace(/^\/u\//, ''),
title: decodeXmlEntities(field(/<title>([\s\S]*?)<\/title>/)),
date: field(/<updated>([\s\S]*?)<\/updated>/).split('T')[0],
url: decodeXmlEntities(field(/<link[^>]*href="([^"]*)"/)),
content: this.feedContentHtml(field(/<content[^>]*>([\s\S]*?)<\/content>/)),
};
}
/**
* Feed entry bodies are HTML escaped into the XML and wrap the real content
* in the same `div.md` the old.reddit DOM uses, so unwrap to match.
*/
private feedContentHtml(escaped: string): string {
if (!escaped) return '';
const container = this.document.createElement('div');
container.appendChild(parseHTML(this.document, decodeXmlEntities(escaped)));
const body = container.querySelector('.md');
return body ? serializeHTML(body) : serializeHTML(container);
}
extract(): ExtractorResult {
if (this.isOldReddit) {
return this.extractOldReddit(this.document);
}
const postTitle = this.document.querySelector('h1')?.textContent?.trim() || '';
const subreddit = this.getSubreddit();
const postAuthor = this.getPostAuthor();
const postContent = this.getPostContent();
const description = this.createDescription(postContent);
// Extract any comments already in the DOM (browser renders these via JS;
// SSR/Node HTML won't have them, so comments will be empty there).
const comments = this.options.includeReplies !== false ? this.extractComments() : '';
// If the content is empty (link/image post with no body and no DOM
// comments), parseAsync() falls through to extractAsync(), which tries
// old.reddit.com and then the Atom feed.
return this.buildResult(postContent, comments, { title: postTitle, author: postAuthor, subreddit }, description);
}
/**
* Every source — the rendered DOM, old.reddit.com and the feed — differs
* only in where the title, author and body come from, so they share one
* result shape.
*/
private buildResult(
postBody: string,
comments: string,
meta: { title: string; author: string; subreddit: string },
description = this.createDescription(postBody),
): ExtractorResult {
const contentHtml = this.createContentHtml(postBody, comments);
return {
content: contentHtml,
contentHtml: contentHtml,
extractedContent: {
postId: this.getPostId(),
subreddit: meta.subreddit,
postAuthor: meta.author,
},
variables: {
title: meta.title,
author: meta.author,
site: `r/${meta.subreddit}`,
description,
}
};
}
private extractOldReddit(root: Document | Element): ExtractorResult {
const thingLink = root.querySelector('.thing.link');
const postTitle = thingLink?.querySelector('a.title')?.textContent?.trim() || '';
const postAuthor = thingLink?.getAttribute('data-author') || '';
const subreddit = thingLink?.getAttribute('data-subreddit') || '';
const postBodyEl = thingLink?.querySelector('.usertext-body .md');
const postBody = postBodyEl ? serializeHTML(postBodyEl) : '';
let comments = '';
if (this.options.includeReplies !== false) {
const commentArea = root.querySelector('.commentarea .sitetable');
comments = buildCommentTree(commentArea ? this.collectOldRedditComments(commentArea) : []);
}
return this.buildResult(postBody, comments, { title: postTitle, author: postAuthor, subreddit });
}
private getPostContent(): string {
const textBodyEl = this.shredditPost?.querySelector('[slot="text-body"]');
const textBody = textBodyEl ? serializeHTML(textBodyEl) : '';
const mediaBody = this.shredditPost?.querySelector('#post-image')?.outerHTML || '';
return textBody + mediaBody;
}
private createContentHtml(postContent: string, comments: string): string {
return buildContentHtml('reddit', postContent, comments);
}
private extractComments(): string {
const comments = Array.from(this.document.querySelectorAll('shreddit-comment'));
return this.processComments(comments);
}
private getPostId(): string {
const match = this.url.match(/comments\/([a-zA-Z0-9]+)/);
return match?.[1] || '';
}
private getSubreddit(): string {
const match = this.url.match(/\/r\/([^/]+)/);
return match?.[1] || '';
}
private getPostAuthor(): string {
return this.shredditPost?.getAttribute('author') || '';
}
private createDescription(postContent: string): string {
if (!postContent) return '';
const tempDiv = this.document.createElement('div');
tempDiv.appendChild(parseHTML(this.document, postContent));
return tempDiv.textContent?.trim()
.slice(0, 140)
.replace(/\s+/g, ' ') || '';
}
private collectOldRedditComments(container: Element, depth: number = 0): CommentData[] {
const result: CommentData[] = [];
const comments = Array.from(container.querySelectorAll(':scope > .thing.comment'));
for (const comment of comments) {
const author = comment.getAttribute('data-author') || '';
const permalink = comment.getAttribute('data-permalink') || '';
const score = comment.querySelector('.entry .tagline .score.unvoted')?.textContent?.trim() || '';
const timeEl = comment.querySelector('.entry .tagline time[datetime]');
const datetime = timeEl?.getAttribute('datetime') || '';
const date = datetime ? new Date(datetime).toISOString().split('T')[0] : '';
const bodyEl = comment.querySelector('.entry .usertext-body .md');
const body = bodyEl ? serializeHTML(bodyEl) : '';
result.push({
author,
date,
content: body,
depth,
score: score || undefined,
url: permalink ? `https://reddit.com${permalink}` : undefined,
});
const childContainer = comment.querySelector('.child > .sitetable');
if (childContainer) {
result.push(...this.collectOldRedditComments(childContainer, depth + 1));
}
}
return result;
}
private processComments(comments: Element[]): string {
const commentData: CommentData[] = [];
for (const comment of comments) {
const depth = parseInt(comment.getAttribute('depth') || '0');
const author = comment.getAttribute('author') || '';
const score = comment.getAttribute('score') || '0';
const permalink = comment.getAttribute('permalink') || '';
const commentEl = comment.querySelector('[slot="comment"]');
const content = commentEl ? serializeHTML(commentEl) : '';
const timestamp = comment.getAttribute('created')
|| comment.querySelector('time')?.getAttribute('datetime')
|| '';
const date = timestamp ? new Date(timestamp).toISOString().split('T')[0] : '';
commentData.push({
author,
date,
content,
depth,
score: `${score} points`,
url: permalink ? `https://reddit.com${permalink}` : undefined,
});
}
return buildCommentTree(commentData);
}
} SHA-256: bfa5f49f762c891821a4666d027403c3c2b69510f3a3ee535bdf991b60ce490e