src / adapters / sites / RedditAdapter.ts
src / adapters / sites / RedditAdapter.ts
import { ExtractedWebpage } from "../../cache/WebpageCache";
import { HTTPClientError } from "../../models/errors";
import { Logger } from "../../utils/Logger";
import { AdapterContext, AdapterResult, SiteAdapter } from "../SiteAdapter";
import { extractRedditContent } from "./redditExtractor";
export class RedditAdapter implements SiteAdapter {
public readonly name = "RedditAdapter";
/**
* Synchronously evaluates if the target URL belongs to Reddit.
* Matches reddit.com, *.reddit.com, and redd.it (excluding media domains like i.redd.it, v.redd.it).
*/
public matches(parsedUrl: URL): boolean {
const host = parsedUrl.hostname.toLowerCase();
if (host === "i.redd.it" || host === "v.redd.it" || host === "preview.redd.it") {
return false;
}
return host === "reddit.com" || host.endsWith(".reddit.com") || host === "redd.it";
}
/**
* Rewrites Reddit URLs to old.reddit.com server-rendered variants and extracts content.
*/
public async extract(context: AdapterContext): Promise<AdapterResult> {
const transformedUrl = this.transformUrl(context.parsedUrl);
Logger.debug(
"[RedditAdapter]",
`Rewriting Reddit URL '${context.originalUrl}' -> '${transformedUrl}'`
);
const res = await context.httpClient.get<string>(transformedUrl, {
headers: {
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
},
});
if (res.statusCode >= 400) {
throw new HTTPClientError(
`Reddit server responded with status code ${res.statusCode} for ${transformedUrl}`
);
}
const pageHtml = res.text || "";
if (!pageHtml.trim()) {
throw new Error(`Reddit returned an empty HTML payload for ${transformedUrl}`);
}
// Use Reddit-specific content extractor preserving post body, comments, hierarchy, links, and images
const extracted = extractRedditContent(pageHtml, context.originalUrl);
return {
extracted,
actualFetchUrl: transformedUrl,
adapterName: this.name,
};
}
/**
* Transforms any Reddit URL to an old.reddit.com server-rendered equivalent.
*/
public transformUrl(parsedUrl: URL): string {
const targetUrl = new URL(parsedUrl.toString());
targetUrl.protocol = "https:";
targetUrl.hostname = "old.reddit.com";
// Handle redd.it shortlinks (e.g. redd.it/18x9999 -> old.reddit.com/comments/18x9999)
if (parsedUrl.hostname.toLowerCase() === "redd.it") {
const cleanPath = parsedUrl.pathname.replace(/^\/+/, "");
if (cleanPath && !cleanPath.startsWith("r/") && !cleanPath.startsWith("comments/")) {
targetUrl.pathname = `/comments/${cleanPath}`;
}
}
return targetUrl.toString();
}
}
export const redditAdapter = new RedditAdapter();
import { ExtractedWebpage } from "../../cache/WebpageCache";
import { HTTPClientError } from "../../models/errors";
import { Logger } from "../../utils/Logger";
import { AdapterContext, AdapterResult, SiteAdapter } from "../SiteAdapter";
import { extractRedditContent } from "./redditExtractor";
export class RedditAdapter implements SiteAdapter {
public readonly name = "RedditAdapter";
/**
* Synchronously evaluates if the target URL belongs to Reddit.
* Matches reddit.com, *.reddit.com, and redd.it (excluding media domains like i.redd.it, v.redd.it).
*/
public matches(parsedUrl: URL): boolean {
const host = parsedUrl.hostname.toLowerCase();
if (host === "i.redd.it" || host === "v.redd.it" || host === "preview.redd.it") {
return false;
}
return host === "reddit.com" || host.endsWith(".reddit.com") || host === "redd.it";
}
/**
* Rewrites Reddit URLs to old.reddit.com server-rendered variants and extracts content.
*/
public async extract(context: AdapterContext): Promise<AdapterResult> {
const transformedUrl = this.transformUrl(context.parsedUrl);
Logger.debug(
"[RedditAdapter]",
`Rewriting Reddit URL '${context.originalUrl}' -> '${transformedUrl}'`
);
const res = await context.httpClient.get<string>(transformedUrl, {
headers: {
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
},
});
if (res.statusCode >= 400) {
throw new HTTPClientError(
`Reddit server responded with status code ${res.statusCode} for ${transformedUrl}`
);
}
const pageHtml = res.text || "";
if (!pageHtml.trim()) {
throw new Error(`Reddit returned an empty HTML payload for ${transformedUrl}`);
}
// Use Reddit-specific content extractor preserving post body, comments, hierarchy, links, and images
const extracted = extractRedditContent(pageHtml, context.originalUrl);
return {
extracted,
actualFetchUrl: transformedUrl,
adapterName: this.name,
};
}
/**
* Transforms any Reddit URL to an old.reddit.com server-rendered equivalent.
*/
public transformUrl(parsedUrl: URL): string {
const targetUrl = new URL(parsedUrl.toString());
targetUrl.protocol = "https:";
targetUrl.hostname = "old.reddit.com";
// Handle redd.it shortlinks (e.g. redd.it/18x9999 -> old.reddit.com/comments/18x9999)
if (parsedUrl.hostname.toLowerCase() === "redd.it") {
const cleanPath = parsedUrl.pathname.replace(/^\/+/, "");
if (cleanPath && !cleanPath.startsWith("r/") && !cleanPath.startsWith("comments/")) {
targetUrl.pathname = `/comments/${cleanPath}`;
}
}
return targetUrl.toString();
}
}
export const redditAdapter = new RedditAdapter();