← Files DefuddleARCHIVED FILE

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

11.8 KB · Oct 4, 2026 · 12:35 UTC

↓ Download file

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

export class TwitterExtractor extends BaseExtractor {
	private mainTweet: Element | null = null;
	private threadTweets: Element[] = [];
	private replyTweets: Element[] = [];
	private replyDepths: number[] = [];

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

		this.classifyCells(this.conversationCells());

		// A markup change must never yield nothing: a bare post beats falling
		// through to the generic extractor, which produces unusable output.
		if (!this.mainTweet) {
			this.mainTweet = document.querySelector('article[data-testid="tweet"]');
		}
	}

	/**
	 * Conversation cells, in document order, stopping at X's "Discover more" and
	 * similar trailing sections.
	 *
	 * Cells and boundary candidates are collected in one combined-selector query
	 * so that document order comes from the query itself. Comparing positions
	 * any other way would mean `compareDocumentPosition`, which cannot be used
	 * here: the `Node` global is absent under linkedom, and linkedom inverts the
	 * result for nodes at different depths.
	 *
	 * Anything before the first cell is a header, not a boundary — X renders a
	 * "Post" heading above the timeline and wraps the conversation in a
	 * `<section>`, and treating either as the boundary drops every tweet.
	 */
	private conversationCells(): Element[] {
		const cells: Element[] = [];

		for (const el of Array.from(this.document.querySelectorAll('[data-testid="cellInnerDiv"], section, h2'))) {
			if (el.getAttribute('data-testid') === 'cellInnerDiv') {
				cells.push(el);
			} else if (cells.length && !el.closest('article[data-testid="tweet"]')) {
				// A heading inside a tweet belongs to that tweet, not to a boundary.
				break;
			}
		}

		return cells;
	}

	/**
	 * Walk cells to classify tweets:
	 * 1. Main tweet (first article)
	 * 2. Thread tweets: consecutive self-replies by the main author at the top of
	 *    the timeline, before any reply by a different person
	 * 3. Reply tweets: everything after the thread ends
	 */
	private classifyCells(cells: Element[]): void {
		let mainHandle = '';
		let threadEnded = false;
		let lastWasTweet = false;
		let currentDepth = 0;

		for (const cell of cells) {
			const article = cell.querySelector('article[data-testid="tweet"]');
			if (!article) {
				lastWasTweet = false;
				continue;
			}

			if (!this.mainTweet) {
				this.mainTweet = article;
				mainHandle = this.getHandle(article);
				lastWasTweet = true;
				continue;
			}

			const handle = this.getHandle(article);

			// Before the thread has ended, self-replies by the main author are
			// part of the thread (post content)
			if (!threadEnded && handle && handle === mainHandle) {
				this.threadTweets.push(article);
				lastWasTweet = true;
				continue;
			}

			// First tweet by a different person ends the thread
			threadEnded = true;

			// Determine reply depth
			currentDepth = lastWasTweet ? currentDepth + 1 : 0;

			this.replyTweets.push(article);
			this.replyDepths.push(currentDepth);
			lastWasTweet = true;
		}
	}

	canExtract(): boolean {
		return !!this.mainTweet;
	}

	extract(): ExtractorResult {
		// Build post content from main tweet + thread (self-replies)
		const parts = [this.extractTweetContent(this.mainTweet)];
		for (const tweet of this.threadTweets) {
			parts.push(this.extractTweetContent(tweet));
		}
		const postContent = parts.join('\n<hr>\n');

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

		const contentHtml = buildContentHtml('twitter', postContent, comments);

		const tweetId = this.getTweetId();
		const tweetAuthor = this.getTweetAuthor();
		const description = this.createDescription(this.mainTweet);
		const title = this.postTitle(tweetAuthor, 'X');

		return {
			content: contentHtml,
			contentHtml: contentHtml,
			extractedContent: {
				tweetId,
				tweetAuthor,
			},
			variables: {
				title,
				author: tweetAuthor,
				site: 'X (Twitter)',
				description,
			}
		};
	}

	private extractComments(): string {
		if (this.replyTweets.length === 0) return '';

		const commentData: CommentData[] = this.replyTweets.map((tweet, i) => {
			const userInfo = this.extractUserInfo(tweet);
			const content = this.extractTweetContent(tweet);

			return {
				author: userInfo.fullName ? `${userInfo.fullName} ${userInfo.handle}` : userInfo.handle,
				date: userInfo.date,
				content,
				depth: this.replyDepths[i],
				url: userInfo.permalink,
			};
		});

		return buildCommentTree(commentData);
	}

	// X routes that are not profiles, so /<name> in these cases is not a handle.
	private static readonly RESERVED_PATHS = new Set([
		'i', 'home', 'explore', 'search', 'notifications', 'messages',
		'settings', 'compose', 'hashtag', 'intent',
	]);

	/**
	 * The @handle for a tweet.
	 *
	 * Tried in order: link text starting with "@", the profile path in a link
	 * href, then any "@name" in the name block's text (quoted tweets render the
	 * handle as plain text rather than a link). Positional lookups like
	 * `links[1]` break whenever X reorders the name block.
	 */
	private getHandle(tweet: Element): string {
		const nameElement = tweet.querySelector('[data-testid="User-Name"]');
		if (!nameElement) return '';

		const links = Array.from(nameElement.querySelectorAll('a'));

		for (const link of links) {
			const text = link.textContent?.trim() || '';
			if (/^@\w{1,15}$/.test(text)) return text;
		}

		for (const link of links) {
			const href = link.getAttribute('href') || '';
			const match = href.match(/^(?:https?:\/\/[^/]+)?\/(\w{1,15})(?:\/|$)/);
			if (match && !TwitterExtractor.RESERVED_PATHS.has(match[1].toLowerCase())) {
				return `@${match[1]}`;
			}
		}

		const match = (nameElement.textContent || '').match(/@(\w{1,15})/);
		return match ? `@${match[1]}` : '';
	}

	private formatTweetText(text: string): string {
		if (!text) return '';

		// Create a temporary div to parse and clean the HTML
		const tempDiv = this.document.createElement('div');
		tempDiv.appendChild(parseHTML(this.document, text));

		// Convert links to plain text with @ handles
		tempDiv.querySelectorAll('a').forEach(link => {
			const handle = link.textContent?.trim() || '';
			link.replaceWith(handle);
		});

		// Remove unnecessary spans and divs but keep their content
		tempDiv.querySelectorAll('span, div').forEach(element => {
			element.replaceWith(...Array.from(element.childNodes));
		});

		// Get cleaned text and split into paragraphs
		const cleanText = serializeHTML(tempDiv);
		const paragraphs = cleanText.split('\n')
			.map(line => line.trim())
			.filter(line => line);

		// Wrap each paragraph in <p> tags
		return paragraphs.map(p => `<p>${p}</p>`).join('\n');
	}

	private replaceEmojiImages(container: Element): void {
		container.querySelectorAll('img[src*="/emoji/"]').forEach(img => {
			const altText = img.getAttribute('alt');
			if (altText) {
				img.replaceWith(altText);
			}
		});
	}

	private findQuotedTweet(tweet: Element): Element | null {
		return tweet.querySelector('[aria-labelledby*="id__"]')
			?.querySelector('[data-testid="User-Name"]')
			?.closest('[aria-labelledby*="id__"]') || null;
	}

	private extractTweetContent(tweet: Element | null): string {
		if (!tweet) return '';

		const tweetClone = tweet.cloneNode(true) as Element;
		this.replaceEmojiImages(tweetClone);

		const tweetTextEl = tweetClone.querySelector('[data-testid="tweetText"]');
		const tweetText = tweetTextEl ? serializeHTML(tweetTextEl) : '';
		const formattedText = this.formatTweetText(tweetText);

		const quotedTweet = this.findQuotedTweet(tweet);
		const images = this.extractImages(tweet, quotedTweet);
		const quotedHtml = quotedTweet ? this.extractQuotedTweet(quotedTweet) : '';
		const cardLink = this.extractCard(tweet);

		let html = '';
		if (formattedText) html += formattedText;
		if (images.length) html += `\n${images.join('\n')}`;
		if (cardLink) html += `\n${cardLink}`;
		if (quotedHtml) html += `\n${quotedHtml}`;

		return html;
	}

	private extractQuotedTweet(quotedTweet: Element): string {
		const tweetClone = quotedTweet.cloneNode(true) as Element;
		this.replaceEmojiImages(tweetClone);

		const tweetTextEl = tweetClone.querySelector('[data-testid="tweetText"]');
		const tweetText = tweetTextEl ? serializeHTML(tweetTextEl) : '';
		const formattedText = this.formatTweetText(tweetText);
		const userInfo = this.extractUserInfo(quotedTweet);
		const images = this.extractImages(quotedTweet, null);

		let content = '';
		if (formattedText) content += formattedText;
		if (images.length) content += `\n${images.join('\n')}`;

		const author = userInfo.fullName
			? `${userInfo.fullName} ${userInfo.handle}`
			: userInfo.handle;

		return buildQuotedPost({
			author: author || undefined,
			date: userInfo.date || undefined,
			content,
		});
	}

	private extractUserInfo(tweet: Element) {
		const nameElement = tweet.querySelector('[data-testid="User-Name"]');
		if (!nameElement) return { fullName: '', handle: '', date: '', permalink: '' };

		const handle = this.getHandle(tweet);

		// Display name: the first link that is neither the handle nor the
		// timestamp. Quoted tweets render no links, so fall back to the first
		// child ([0] = display name, [1] = "@handle·date").
		let fullName = Array.from(nameElement.querySelectorAll('a'))
			.map(link => (link.querySelector('time') ? '' : link.textContent?.trim() || ''))
			.find(text => text && text !== handle && !text.startsWith('@')) || '';

		if (!fullName) {
			const first = nameElement.children[0]?.textContent?.trim() || '';
			if (first && !first.startsWith('@')) fullName = first;
		}

		const timestamp = tweet.querySelector('time');
		const datetime = timestamp?.getAttribute('datetime') || '';
		const date = datetime ? new Date(datetime).toISOString().split('T')[0] : '';
		const permalink = timestamp?.closest('a')?.href || '';

		return { fullName, handle, date, permalink };
	}

	private extractImages(tweet: Element, quotedTweet: Element | null): string[] {
		const imageContainers = [
			'[data-testid="tweetPhoto"]',
			'[data-testid="tweet-image"]',
			'img[src*="media"]'
		];

		const images: string[] = [];

		for (const selector of imageContainers) {
			const elements = tweet.querySelectorAll(selector);

			elements.forEach(img => {
				if (quotedTweet?.contains(img)) {
					return;
				}

				if (img.tagName.toLowerCase() === 'img' && img.getAttribute('alt')) {
					const highQualitySrc = img.getAttribute('src')?.replace(/&name=\w+$/, '&name=large') || '';
					const cleanAlt = img.getAttribute('alt')?.replace(/\s+/g, ' ').trim() || '';
					images.push(`<img src="${escapeHtml(highQualitySrc)}" alt="${escapeHtml(cleanAlt)}" />`);
				}
			});
		}

		return images;
	}

	private extractCard(tweet: Element): string {
		const card = tweet.querySelector('[data-testid="card.wrapper"]');
		if (!card) return '';

		const cardLink = card.querySelector('a[href]') as HTMLAnchorElement | null;
		if (!cardLink) return '';

		const href = cardLink.getAttribute('href') || '';
		const label = cardLink.getAttribute('aria-label') || '';
		const title = label.split(/\n/)[0]?.trim() || href;

		return `<p><a href="${escapeHtml(href)}">${escapeHtml(title)}</a></p>`;
	}

	private getTweetId(): string {
		const match = this.url.match(/status\/(\d+)/);
		return match?.[1] || '';
	}

	private getTweetAuthor(): string {
		const handle = this.getHandle(this.mainTweet!);
		return handle.startsWith('@') ? handle : `@${handle}`;
	}

	private createDescription(tweet: Element | null): string {
		if (!tweet) return '';

		const tweetText = tweet.querySelector('[data-testid="tweetText"]')?.textContent || '';
		return tweetText.trim().slice(0, 140).replace(/\s+/g, ' ');
	}
}

SHA-256: 31c4939dfb0c145c6a0970a653893491a99da63e6dcb2d0160eb0f78207faf74