← Files DefuddleARCHIVED FILE

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

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

↓ Download file

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

export class LwnExtractor extends BaseExtractor {
	canExtract(): boolean {
		return !!this.document.querySelector('.PageHeadline') &&
			!!this.document.querySelector('.ArticleText');
	}

	extract(): ExtractorResult {
		const main = this.document.querySelector('.ArticleText main');
		const articleContent = main ? this.getArticleContent(main) : '';
		const comments = this.options.includeReplies !== false && main ? this.extractComments(main) : '';
		const contentHtml = buildContentHtml('lwn', articleContent, comments);

		const byline = this.document.querySelector('.Byline')?.textContent?.trim() || '';

		return {
			content: contentHtml,
			contentHtml: contentHtml,
			extractedContent: {},
			variables: {
				title: this.document.querySelector('.PageHeadline h1')?.textContent?.trim() || '',
				author: byline.match(/by\s+(\w+)/i)?.[1] || '',
				site: 'LWN.net',
				published: this.parseDate(byline),
				description: this.document.querySelector('meta[property="og:description"]')?.getAttribute('content') || '',
			}
		};
	}

	private parseDate(text: string): string {
		const match = text.match(/Posted\s+(\w+\s+\d+,\s+\d{4})/);
		if (!match) return '';
		const date = new Date(match[1]);
		return isNaN(date.getTime()) ? '' : date.toISOString().split('T')[0];
	}

	private getArticleContent(main: Element): string {
		const clone = main.cloneNode(true) as Element;
		for (const el of Array.from(clone.querySelectorAll('details.CommentBox, form, a[name^="Comm"]'))) {
			el.remove();
		}

		// Remove trailing <hr> and <br clear="all"> separating article from comments
		let lastEl = clone.lastElementChild;
		while (lastEl && (lastEl.tagName === 'HR' || (lastEl.tagName === 'BR' && lastEl.getAttribute('clear')))) {
			const prev = lastEl.previousElementSibling;
			lastEl.remove();
			lastEl = prev;
		}

		return serializeHTML(clone);
	}

	private extractComments(main: Element): string {
		const allBoxes = Array.from(main.querySelectorAll('details.CommentBox'));
		const commentData: CommentData[] = [];

		for (const box of allBoxes) {
			const depth = this.getCommentDepth(box, main);
			const data = this.extractCommentData(box, depth);
			if (data) commentData.push(data);
		}

		return commentData.length > 0 ? buildCommentTree(commentData) : '';
	}

	private getCommentDepth(el: Element, root: Element): number {
		let depth = 0;
		let parent = el.parentElement;
		while (parent && parent !== root) {
			if (parent.tagName === 'DETAILS' && parent.classList.contains('CommentBox')) {
				depth++;
			}
			parent = parent.parentElement;
		}
		return depth;
	}

	private extractCommentData(box: Element, depth: number): CommentData | null {
		const poster = box.querySelector(':scope > summary .CommentPoster');
		if (!poster) return null;

		const author = poster.querySelector('b')?.textContent?.trim() || '';
		const linkEl = poster.querySelector('a[href^="/Articles/"]');
		const articlePath = linkEl?.getAttribute('href') || '';
		const url = articlePath ? `https://lwn.net${articlePath}` : '';
		const date = this.parseDate(poster.textContent || '');

		const title = box.querySelector(':scope > summary h3.CommentTitle')?.textContent?.trim() || '';

		// Only include title if it differs from the parent comment's title
		const parentBox = box.parentElement?.closest('details.CommentBox');
		const parentTitle = parentBox?.querySelector(':scope > summary h3.CommentTitle')?.textContent?.trim() || '';
		const uniqueTitle = title && title !== parentTitle ? title : '';

		const content = this.getCommentContent(box, uniqueTitle);

		return { author, date, content, depth, url };
	}

	private getCommentContent(box: Element, title: string): string {
		let content = '';

		if (title) {
			content += `<p><strong>${escapeHtml(title)}</strong></p>`;
		}

		const formatted = box.querySelector(':scope > .FormattedComment');
		if (formatted) {
			content += serializeHTML(formatted);
		} else {
			// Collect direct content nodes, skipping structural elements
			const tempContainer = this.document.createElement('div');
			for (const child of Array.from(box.childNodes)) {
				if (child.nodeType === 1) {
					const el = child as Element;
					const tag = el.tagName;
					if (tag === 'SUMMARY' || tag === 'DETAILS' || el.classList.contains('CommentReplyButton')) continue;
					if (tag === 'FORM') continue;
					if (tag === 'A' && el.getAttribute('name')?.startsWith('CommAnchor')) continue;
					if (tag === 'P' && !el.textContent?.trim()) continue;
				}
				tempContainer.appendChild(child.cloneNode(true));
			}
			const text = serializeHTML(tempContainer).trim();
			if (text) content += text;
		}

		return content;
	}
}

SHA-256: 1e7395299c05aea5927af4b310ee0f34c427a1ffcbf1b4b66514c763d7177190