← Files DefuddleARCHIVED FILE
skills/defuddle-web-content/references/source/src/extractors/substack.ts
7.02 KB · Oct 5, 2026 · 18:35 UTC
import { BaseExtractor } from './_base';
import { ExtractorResult } from '../types/extractors';
import { parseHTML, escapeHtml } from '../utils/dom';
const INJECTED_ATTR = 'data-defuddle-substack-post';
interface SubstackPostData {
title?: string;
subtitle?: string;
body_html?: string;
post_date?: string;
canonical_url?: string;
publishedBylines?: Array<{ name?: string; handle?: string }>;
}
export class SubstackExtractor extends BaseExtractor {
private noteText: Element | null = null;
private noteImage: Element | null = null;
private postData: SubstackPostData | null = null;
private postContentSelector: string | null = null;
constructor(document: Document, url: string, schemaOrgData?: any, options?: any) {
super(document, url, schemaOrgData, options);
// Check for rendered post body first (browser/extension context, after React hydration)
if (document.querySelector('div.body.markup')) {
this.postData = this.extractPreloadData(); // metadata only
this.postContentSelector = 'div.body.markup';
return;
}
// Fall back to window._preloads script (SSR/curl/worker context)
this.postData = this.extractPreloadData();
if (this.postData?.body_html) {
// Inject body_html into the document so the pipeline can process it
const existing = document.querySelector(`[${INJECTED_ATTR}]`);
if (!existing) {
const wrapper = document.createElement('div');
wrapper.setAttribute(INJECTED_ATTR, '');
wrapper.appendChild(parseHTML(document, this.postData.body_html));
document.body.appendChild(wrapper);
}
this.postContentSelector = `[${INJECTED_ATTR}]`;
return;
}
// Fall back to Notes extraction (ProseMirror editor div)
// On permalink note pages, the main note is inside a feedPermalinkUnit container.
// Other ProseMirror elements on the page belong to feed recommendations.
const permalinkUnit = document.querySelector('[class*="feedPermalinkUnit"]');
this.noteText = (permalinkUnit || document).querySelector('div.ProseMirror.FeedProseMirror');
if (this.noteText) {
const feedCommentBody = this.noteText.closest('[class*="feedCommentBody"]:not([class*="feedCommentBodyInner"])');
if (feedCommentBody) {
// imageGrid can be a sibling of feedCommentBody or of its parent wrapper
const candidates = [feedCommentBody.nextElementSibling, feedCommentBody.parentElement?.nextElementSibling];
for (const el of candidates) {
if (el && (el.getAttribute('class') || '').includes('imageGrid')) {
this.noteImage = el;
break;
}
}
}
}
}
canExtract(): boolean {
return this.postContentSelector !== null || this.noteText !== null;
}
extract(): ExtractorResult {
if (this.postContentSelector) {
return this.extractPost();
}
return this.extractNote();
}
private extractPost(): ExtractorResult {
const title = this.postData?.title || this.document.querySelector('meta[property="og:title"]')?.getAttribute('content') || '';
const description = this.postData?.subtitle || this.document.querySelector('meta[property="og:description"]')?.getAttribute('content') || '';
const author = this.postData?.publishedBylines?.[0]?.name
|| this.document.querySelector('a[href*="substack.com/@"]')?.textContent?.trim()
|| '';
const published = this.postData?.post_date
|| this.parseDateFromByline()
|| '';
return {
content: '',
contentHtml: '',
contentSelector: this.postContentSelector!,
variables: {
title,
author,
site: 'Substack',
description,
published,
},
};
}
private extractNote(): ExtractorResult {
const textHtml = this.noteText!.outerHTML;
const imageHtml = this.buildImageHtml();
const content = imageHtml ? `${textHtml}\n${imageHtml}` : textHtml;
const title = this.document.querySelector('meta[property="og:title"]')?.getAttribute('content') || '';
const description = this.document.querySelector('meta[property="og:description"]')?.getAttribute('content') || '';
const author = title.replace(/\s*\(@[^)]+\)\s*$/, '').trim();
return {
content,
contentHtml: content,
variables: {
title,
author,
site: 'Substack',
description,
},
};
}
private parseDateFromByline(): string {
const byline = this.document.querySelector('[class*="byline-wrapper"]');
if (!byline) return '';
// textContent runs adjacent words together (e.g. "ZhutovFeb") — insert space at case boundaries
const text = (byline.textContent || '').trim().replace(/([a-z])([A-Z])/g, '$1 $2');
// Match "Feb 24, 2026" style (Substack uses abbreviated month names in the UI)
const ABBREV_MONTHS = 'Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec';
const MONTH_MAP: Record<string, string> = {
Jan: '01', Feb: '02', Mar: '03', Apr: '04', May: '05', Jun: '06',
Jul: '07', Aug: '08', Sep: '09', Oct: '10', Nov: '11', Dec: '12',
};
const match = text.match(new RegExp(`\\b(${ABBREV_MONTHS})\\s+(\\d{1,2}),?\\s+(\\d{4})\\b`));
if (match) {
const month = MONTH_MAP[match[1]];
const day = match[2].padStart(2, '0');
return `${match[3]}-${month}-${day}T00:00:00+00:00`;
}
return '';
}
private extractPreloadData(): SubstackPostData | null {
const scripts = Array.from(this.document.querySelectorAll('script'));
for (const script of scripts) {
const text = script.textContent || '';
if (!text.includes('window._preloads') || !text.includes('body_html')) continue;
const jsonParseIdx = text.indexOf('JSON.parse("');
if (jsonParseIdx === -1) continue;
const startIdx = jsonParseIdx + 'JSON.parse("'.length;
let i = startIdx;
while (i < text.length) {
if (text[i] === '\\') {
i += 2;
} else if (text[i] === '"') {
break;
} else {
i++;
}
}
try {
const innerStr = text.slice(startIdx, i);
const jsonString = JSON.parse('"' + innerStr + '"') as string;
const data = JSON.parse(jsonString);
const post: SubstackPostData = data?.feedData?.initialPost?.post;
if (post?.body_html) return post;
} catch {
// ignore parse errors
}
}
return null;
}
private buildImageHtml(): string {
if (!this.noteImage) return '';
const ogImage = this.document.querySelector('meta[property="og:image"]')?.getAttribute('content');
if (ogImage) return `<img src="${escapeHtml(ogImage)}" alt="" />`;
const img = this.noteImage.querySelector('img');
if (!img) return '';
const src = this.getLargestSrc(img);
return src ? `<img src="${escapeHtml(src)}" alt="" />` : '';
}
private getLargestSrc(img: Element): string {
const srcset = img.getAttribute('srcset') || '';
if (srcset) {
const entryPattern = /(.+?)\s+(\d+(?:\.\d+)?)w/g;
let bestUrl = '';
let bestWidth = 0;
let match;
let lastIndex = 0;
while ((match = entryPattern.exec(srcset)) !== null) {
let url = match[1].trim();
if (lastIndex > 0) url = url.replace(/^,\s*/, '');
lastIndex = entryPattern.lastIndex;
const width = parseFloat(match[2]);
if (url && width > bestWidth) {
bestWidth = width;
bestUrl = url;
}
}
if (bestUrl) return bestUrl.replace(/,w_\d+/g, '').replace(/,c_\w+/g, '');
}
return img.getAttribute('src') || '';
}
}
SHA-256: 4f573316a427103a1cce126e10d8cbe755c8b666ee55b4a4352c48f53f13621e