← Files DefuddleARCHIVED FILE

skills/defuddle-web-content/references/source/src/extractors/mastodon.ts

8.47 KB · Oct 3, 2026 · 06:36 UTC

↓ Download file

import { BaseExtractor, ExtractorOptions } from './_base';
import { ExtractorResult } from '../types/extractors';
import { escapeHtml } from '../utils/dom';
import { buildCommentTree, buildContentHtml, type CommentData } from '../utils/comments';

export class MastodonExtractor extends BaseExtractor {
	private mainPost: Element | null = null;
	private replyStatuses: Element[] = [];

	constructor(document: Document, url: string, schemaOrgData?: any, options?: ExtractorOptions) {
		super(document, url, schemaOrgData, options);

		this.mainPost = document.querySelector('.detailed-status__wrapper');

		const wrappers = Array.from(document.querySelectorAll('.status__wrapper'));
		this.replyStatuses = wrappers.filter(w => {
			return !!w.querySelector('.status[data-id]');
		});
	}

	canExtract(): boolean {
		if (!this.mainPost) return false;

		if (this.document.getElementById('mastodon')) return true;

		const initialState = this.document.querySelector('script#initial-state');
		if (initialState) {
			const text = initialState.textContent || '';
			if (text.includes('mastodon/mastodon') || text.includes('"mastodon"')) return true;
		}

		const links = Array.from(this.document.querySelectorAll('link[rel="stylesheet"]'));
		return links.some(link => (link.getAttribute('href') || '').includes('mastodon'));
	}

	extract(): ExtractorResult {
		const mainFullHandle = this.getFullHandle(this.mainPost!);
		const mainHandle = mainFullHandle.split('@')[0];
		const displayName = this.getDisplayName(this.mainPost!);

		// Classify replies: consecutive self-replies at the start form the thread
		const threadItems: Element[] = [];
		const replyItems: Element[] = [];
		let threadEnded = false;

		for (const status of this.replyStatuses) {
			const handle = this.getFullHandle(status).split('@')[0];
			if (!threadEnded && handle === mainHandle) {
				threadItems.push(status);
			} else {
				threadEnded = true;
				replyItems.push(status);
			}
		}

		const mainContent = this.extractPostContent(this.mainPost!);
		const threadParts = threadItems.map(item => this.extractPostContent(item));
		const allParts = [mainContent, ...threadParts].filter(Boolean);
		const postContent = allParts.join('\n<hr>\n');

		const comments = this.options.includeReplies !== false
			? this.extractComments(replyItems)
			: '';

		const contentHtml = buildContentHtml('mastodon', postContent, comments);
		const author = displayName || `@${mainFullHandle}`;
		const description = this.getDescription();
		const published = this.getPublishedDate();
		const siteName = this.document.querySelector('meta[property="og:site_name"]')?.getAttribute('content') || '';
		const title = this.postTitle(author, siteName || 'Mastodon');

		return {
			content: contentHtml,
			contentHtml,
			extractedContent: {
				postAuthor: mainFullHandle,
			},
			variables: {
				title,
				author,
				site: siteName || 'Mastodon',
				description,
				...(published && { published }),
			}
		};
	}

	private getFullHandle(container: Element): string {
		const account = container.querySelector('.display-name__account');
		const text = account?.textContent?.trim() || '';
		return text.replace(/^@/, '');
	}

	private getDisplayName(container: Element): string {
		const name = container.querySelector('.display-name__html');
		if (!name) return '';
		const clone = name.cloneNode(true) as Element;
		this.replaceEmojiImages(clone);
		return clone.textContent?.trim() || '';
	}

	private getReplyDate(wrapper: Element): string {
		const time = wrapper.querySelector('time[datetime]');
		if (!time) return '';
		const datetime = time.getAttribute('datetime') || '';
		try {
			return new Date(datetime).toISOString().split('T')[0];
		} catch { return ''; }
	}

	private getReplyPermalink(wrapper: Element): string {
		const link = wrapper.querySelector('a.status__relative-time[href]');
		if (!link) return '';
		const href = link.getAttribute('href') || '';
		if (!href) return '';

		try {
			const base = new URL(this.url);
			return href.startsWith('http') ? href : `${base.origin}${href}`;
		} catch {
			return href;
		}
	}

	private getPublishedDate(): string {
		const meta = this.document.querySelector('meta[property="og:published_time"]');
		if (meta) {
			const content = meta.getAttribute('content') || '';
			try {
				return new Date(content).toISOString().split('T')[0];
			} catch { /* fall through */ }
		}

		if (this.mainPost) {
			const time = this.mainPost.querySelector('time[datetime]');
			if (time) {
				try {
					return new Date(time.getAttribute('datetime') || '').toISOString().split('T')[0];
				} catch { /* fall through */ }
			}
		}

		return '';
	}

	private getDescription(): string {
		if (!this.mainPost) return '';
		const textEl = this.mainPost.querySelector('.status__content__text');
		if (!textEl) return '';
		return (textEl.textContent || '')
			.trim()
			.slice(0, 140)
			.replace(/\s+/g, ' ');
	}

	private extractPostContent(container: Element): string {
		const parts: string[] = [];

		const text = this.extractTextContent(container.querySelector('.status__content'));
		if (text) parts.push(text);

		const images = this.extractImages(container);
		if (images) parts.push(images);

		const card = this.extractLinkCard(container);
		if (card) parts.push(card);

		return parts.join('\n');
	}

	private extractTextContent(contentEl: Element | null): string {
		if (!contentEl) return '';

		const textEl = contentEl.querySelector('.status__content__text');
		if (!textEl) return '';

		const clone = textEl.cloneNode(true) as Element;

		this.replaceEmojiImages(clone);

		// Mastodon uses invisible spans to hide parts of displayed URLs
		clone.querySelectorAll('span.invisible').forEach(el => el.remove());

		// Unwrap remaining spans (ellipsis, mention wrappers, etc.)
		clone.querySelectorAll('span').forEach(el => {
			el.replaceWith(...Array.from(el.childNodes));
		});

		return (clone.innerHTML || clone.textContent || '').trim();
	}

	/** Replace emoji `<img>` tags with their alt text (Unicode emoji or :shortcode:). */
	private replaceEmojiImages(container: Element): void {
		container.querySelectorAll('img.emojione').forEach(img => {
			const alt = img.getAttribute('alt') || '';
			if (alt) {
				img.replaceWith(img.ownerDocument.createTextNode(alt));
			} else {
				img.remove();
			}
		});
	}

	private extractImages(container: Element): string {
		const gallery = container.querySelector('.media-gallery');
		if (!gallery) return '';

		const images: string[] = [];
		gallery.querySelectorAll('.media-gallery__item-thumbnail').forEach(link => {
			const href = link.getAttribute('href') || '';
			const img = link.querySelector('img');
			const alt = img?.getAttribute('alt') || '';

			if (href) {
				images.push(`<img src="${escapeHtml(href)}" alt="${escapeHtml(alt)}" />`);
			}
		});

		return images.join('\n');
	}

	private extractLinkCard(container: Element): string {
		const card = container.querySelector('a.status-card[href]');
		if (!card) return '';

		const href = card.getAttribute('href') || '';
		const title = card.querySelector('.status-card__title')?.textContent?.trim() || '';
		const description = card.querySelector('.status-card__description')?.textContent?.trim() || '';
		const img = card.querySelector('.status-card__image-image');

		if (!title && !href) return '';

		let html = '';
		if (img) {
			const src = img.getAttribute('src') || '';
			if (src) {
				html += `<a href="${escapeHtml(href)}"><img src="${escapeHtml(src)}" alt="${escapeHtml(title)}" /></a>\n`;
			}
		}
		html += `<p><a href="${escapeHtml(href)}">${escapeHtml(title || href)}</a></p>`;
		if (description) {
			html += `\n<p>${escapeHtml(description)}</p>`;
		}

		return html;
	}

	private extractComments(replyItems: Element[]): string {
		if (replyItems.length === 0) return '';

		let currentDepth = 0;

		const commentData: CommentData[] = replyItems.map((wrapper, index) => {
			const handle = this.getFullHandle(wrapper);
			const displayName = this.getDisplayName(wrapper);
			const content = this.extractPostContent(wrapper);
			const date = this.getReplyDate(wrapper);
			const permalink = this.getReplyPermalink(wrapper);

			// status--first-in-thread = new top-level reply (depth 0)
			// otherwise = sub-reply (increment depth)
			const statusEl = wrapper.querySelector('.status--first-in-thread');
			if (statusEl || index === 0) {
				currentDepth = 0;
			} else {
				currentDepth++;
			}

			return {
				author: displayName ? `${displayName} @${handle}` : `@${handle}`,
				date,
				content,
				depth: currentDepth,
				url: permalink || undefined,
			};
		});

		return buildCommentTree(commentData);
	}
}

SHA-256: 6f6a430dbd5ce5f3e435558b1eccd8e4e84363afa8657a5e5745e5dbd5df0b9b