← Files DefuddleARCHIVED FILE

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

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

↓ Download file

import { MetadataExtractor } from './metadata';
import { DefuddleOptions, DefuddleResponse, MetaTagItem, DebugRemoval } from './types';
import { ExtractorRegistry } from './extractor-registry';
import type { ExtractorOptions } from './extractors/_base';
import { BaseExtractor } from './extractors/_base';
import {
	MOBILE_WIDTH,
	BLOCK_ELEMENTS_SELECTOR,
	HIDDEN_EXACT_SKIP_SELECTOR,
	ENTRY_POINT_ELEMENTS
} from './constants';
import { standardizeContent, standardizeExtractorOutput } from './standardize';
import { standardizeFootnotes, FOOTNOTE_SECTION_RE } from './elements/footnotes';
import { standardizeCallouts } from './elements/callouts';
import { ContentScorer, ContentScore } from './removals/scoring';
import { findSmallImages, removeSmallImages } from './removals/small-images';
import { removeHiddenElements } from './removals/hidden';
import { removeBySelector } from './removals/selectors';
import { removeByContentPattern, removeEyebrowLabel } from './removals/content-patterns';
import { removeMetadataBlock } from './removals/metadata-block';
import { getComputedStyle, textPreview, countWords } from './utils';
import { parseHTML, serializeHTML, decodeHTMLEntities, isDangerousUrl, getClassName } from './utils/dom';

interface StyleChange {
	selector: string;
	styles: string;
}

/** Keys from extractor variables that map to top-level DefuddleResponse fields */
const STANDARD_VARIABLE_KEYS = new Set(['title', 'author', 'published', 'site', 'description', 'image', 'language']);

// CSS-special characters that make class names invalid in selectors (Tailwind utilities like sm:pt-[131px])
const UNSAFE_CSS_CLASS_RE = /[:\[\]()#>~+,]/;

// Mirrors the descendant removal list for unsafe-root checks.
const UNSAFE_ELEMENT_TAGS = new Set([
	'SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'FRAME', 'FRAMESET', 'OBJECT', 'EMBED', 'APPLET', 'BASE',
	'ANIMATE', 'SET', 'ANIMATEMOTION', 'ANIMATETRANSFORM', 'ANIMATECOLOR', 'DISCARD'
]);

export class Defuddle {
	// Reassigned briefly during the schema.org fallback so re-extraction runs
	// against a sanitized clone instead of the caller's live document.
	private doc: Document;
	private options: DefuddleOptions;
	private debug: boolean;
	private _schemaOrgData: any = undefined;
	private _schemaOrgExtracted = false;
	private _metaTags: MetaTagItem[] | undefined;
	private _metadata: any | undefined;
	private _mobileStyles: StyleChange[] | undefined;
	private _smallImages: Set<string> | undefined;
	private _inExtractorPipelineRun = false;

	/**
	 * Create a new Defuddle instance
	 * @param doc - The document to parse
	 * @param options - Options for parsing
	 */
	constructor(doc: Document, options: DefuddleOptions = {}) {
		this.doc = doc;
		this.options = options;
		this.debug = options.debug || false;
	}

	/**
	 * Lazily extract and cache schema.org data. Must be called before
	 * parse() strips script tags from the document.
	 */
	private getSchemaOrgData(): any {
		if (!this._schemaOrgExtracted) {
			this._schemaOrgData = this._extractSchemaOrgData(this.doc);
			this._schemaOrgExtracted = true;
		}
		return this._schemaOrgData;
	}

	/**
	 * Parse the document and extract its main content
	 */
	parse(): DefuddleResponse {
		// Normalize non-standard attribute casing (e.g. React SSR outputs
		// "srcSet" instead of "srcset") before any image processing.
		if (this.doc.body) {
			this._normalizeAttributes(this.doc.body);
			this._resolveNoscriptImages(this.doc.body);
		}

		// Try first with default settings
		let result = this.parseInternal();

		// If result has very little content, try again without clutter removal
		if (result.wordCount < 200) {
			this._log('Initial parse returned very little content, trying again');
			const retryResult = this.parseInternal({
				removePartialSelectors: false
			});

			// Only use the retry if it produces significantly more content.
			// A small increase likely means partial selectors correctly removed
			// clutter (author blocks, related articles, etc.) from a short article.
			// A large increase (2x+) suggests partial selectors were too aggressive.
			if (retryResult.wordCount > result.wordCount * 2) {
				this._log('Retry produced more content');
				result = retryResult;
			}
		}

		// If still very little content, the page may be an index/listing page
		// or a page that reveals content at runtime from a hidden wrapper.
		// Retry once with hidden-element removal disabled.
		if (result.wordCount < 50) {
			this._log('Still very little content, retrying without hidden-element removal');
			const hiddenRetry = this.parseInternal({
				removeHiddenElements: false
			});
			if (hiddenRetry.wordCount > result.wordCount * 2) {
				this._log('Hidden-element retry produced more content');
				result = hiddenRetry;
			}

			// Try targeting the largest hidden subtree directly to avoid body-level
			// leftovers (e.g. FPS counters) when hidden content is the real article.
			const hiddenSelector = this.findLargestHiddenContentSelector();
			if (hiddenSelector) {
				this._log('Retrying with hidden content selector:', hiddenSelector);
				const hiddenSelectorRetry = this.parseInternal({
					removeHiddenElements: false,
					removePartialSelectors: false,
					contentSelector: hiddenSelector
				});
				if (
					hiddenSelectorRetry.wordCount > result.wordCount ||
					(
						hiddenSelectorRetry.wordCount > Math.max(20, result.wordCount * 0.7) &&
						hiddenSelectorRetry.content.length < result.content.length
					)
				) {
					this._log('Hidden-selector retry produced better focused content');
					result = hiddenSelectorRetry;
				}
			}
		}

		// If still very little content, the page may be an index/listing page
		// where card elements were scored as non-content or removed by partial
		// selectors (e.g. "post-preview"). Retry with both disabled.
		if (result.wordCount < 50) {
			this._log('Still very little content, retrying without scoring/partial selectors (possible index page)');
			const indexRetry = this.parseInternal({
				removeLowScoring: false,
				removePartialSelectors: false,
				removeContentPatterns: false
			});
			if (indexRetry.wordCount > result.wordCount) {
				this._log('Index page retry produced more content');
				result = indexRetry;
			}
		}

		// If schema.org has text content that is significantly longer than what we
		// extracted, the scorer likely picked the wrong element from a feed page.
		// Use a 1.5x threshold to avoid triggering when the difference is small
		// (e.g. just related-content link text removed).
		const schemaText = this._getSchemaText(result.schemaOrgData);
		if (schemaText && this.countHtmlWords(schemaText) > result.wordCount * 1.5) {
			// Re-extract from a sanitized clone so dangerous elements and URI
			// attributes (e.g. data:text/html in an img src) in the matched
			// element are stripped, without mutating the caller's live document.
			// Mobile styles / small images were cached during the first parse, so
			// the clone (which has no window) doesn't need to be re-measured.
			const liveDoc = this.doc;
			const safeDoc = liveDoc.cloneNode(true) as Document;
			this._stripUnsafeElements(safeDoc.body);
			this.doc = safeDoc;
			try {
				const bestMatch = this._findElementBySchemaText(this.doc.body, schemaText);
				if (bestMatch) {
					// Re-run the full pipeline with the schema-identified element as the
					// content root so it benefits from the same cleanup as normal extraction.
					const selector = this.getElementSelector(bestMatch);
					this._log('Schema.org suggests a better content element, retrying with selector:', selector);
					const schemaRetry = this.parseInternal({ contentSelector: selector });
					result = schemaRetry;
				} else {
					this._log('Using schema.org text as content (DOM element not found)');
					const safeSchemaHtml = this._sanitizeExtractorHtml(schemaText);
					result.content = safeSchemaHtml;
					result.wordCount = this.countHtmlWords(safeSchemaHtml);
				}
			} finally {
				this.doc = liveDoc;
			}
		}

		return result;
	}

	/**
	 * Extract text content from schema.org data (e.g. SocialMediaPosting, Article)
	 */
	private _getSchemaText(schemaOrgData: any, depth: number = 0): string {
		if (!schemaOrgData || depth > 10) return '';

		const items = Array.isArray(schemaOrgData) ? schemaOrgData : [schemaOrgData];
		for (const item of items) {
			// Recurse into nested arrays
			if (Array.isArray(item)) {
				const found = this._getSchemaText(item, depth + 1);
				if (found) return found;
				continue;
			}
			if (item?.text && typeof item.text === 'string') {
				return item.text;
			}
			if (item?.articleBody && typeof item.articleBody === 'string') {
				return item.articleBody;
			}
			// Traverse @graph arrays (common in JSON-LD with multiple entities)
			if (item?.['@graph'] && Array.isArray(item['@graph'])) {
				const found = this._getSchemaText(item['@graph'], depth + 1);
				if (found) return found;
			}
		}
		return '';
	}

	/**
	 * Serialize the body for the fallback/error paths, where extraction found
	 * no content and we return the whole body. Sanitizes a CLONE so the
	 * caller's live document is never mutated — stripping elements from the
	 * live page (e.g. its <style> blocks) would destroy its layout. The normal
	 * extraction pipeline already removes script/style/etc. via EXACT_SELECTORS,
	 * so only these raw-body paths need to sanitize here.
	 */
	private _serializeFallbackBody(): string {
		if (!this.doc.body) return '';
		const safeBody = this.doc.body.cloneNode(true) as HTMLElement;
		this._stripUnsafeElements(safeBody);
		return this.resolveContentUrls(serializeHTML(safeBody));
	}

	/**
	 * Remove dangerous elements and attributes from the given body element.
	 * Returns true when the supplied root is itself unsafe and must not be
	 * serialized by the caller.
	 */
	private _stripUnsafeElements(body: HTMLElement | null): boolean {
		if (!body) return false;

		// querySelectorAll omits the root; neutralize it and let the caller omit it.
		const unsafeRoot = this._isUnsafeElement(body);
		if (unsafeRoot) {
			this._neutralizeUnsafeElement(body);
		}

		// Remove dangerous elements. Iframes are kept — same-origin policy
		// isolates them, and they're widely used for legitimate media embeds.
		// Dangerous iframe attributes (srcdoc, javascript: src) are stripped
		// in the attribute pass below. Math scripts are preserved for LaTeX
		// content (matching the EXACT_SELECTORS approach). SVG <style> is
		// removed too: CSS @import / url() inside it can fetch external
		// resources from the reader's IP. applySvgFallbackStyles in
		// standardize.ts reconstructs basic fill/stroke from class names.
		// Remove SVG SMIL elements that can mutate sanitized URL attributes.
		// Template fragments evade descendant traversal and can cause mutation XSS.
		const dangerousElements = body.querySelectorAll(
			'script:not([type^="math/"]), style, noscript, template, frame, frameset, object, embed, applet, base, ' +
			'animate, set, animatemotion, animatetransform, animatecolor, discard, ' +
			'animateMotion, animateTransform, animateColor'
		);
		for (const el of dangerousElements) {
			el.remove();
		}

		// Remove event handler attributes, dangerous URIs, and srcdoc.
		// Includes the root itself: on the main content path `body` is the
		// content element and is serialized via outerHTML, so its own
		// attributes end up in the output.
		const allElements = [body, ...Array.from(body.querySelectorAll('*'))];
		for (const el of allElements) {
			for (const attr of Array.from(el.attributes)) {
				const name = attr.name.toLowerCase();
				if (name.startsWith('on')) {
					el.removeAttribute(attr.name);
				} else if (name === 'srcdoc') {
					el.removeAttribute(attr.name);
				} else if (['href', 'src', 'action', 'formaction', 'xlink:href'].includes(name)) {
					// data:image/* is allowed for inline images, but an SVG document
					// in an iframe can run script — so no data: URI is allowed as an
					// iframe src.
					const allowInlineImage = !(name === 'src' && el.tagName === 'IFRAME');
					if (isDangerousUrl(attr.value, allowInlineImage)) {
						el.removeAttribute(attr.name);
					}
				}
			}
		}

		return unsafeRoot;
	}

	private _isUnsafeElement(el: Element): boolean {
		const tag = el.tagName.toUpperCase();
		// Math scripts are preserved for LaTeX content, matching the sweep.
		if (tag === 'SCRIPT') return !(el.getAttribute('type') || '').toLowerCase().startsWith('math/');
		return UNSAFE_ELEMENT_TAGS.has(tag);
	}

	/** Remove attributes, children, and inert template content from an unsafe root. */
	private _neutralizeUnsafeElement(el: Element): void {
		for (const attr of Array.from(el.attributes)) el.removeAttribute(attr.name);
		while (el.firstChild) el.removeChild(el.firstChild);
		const content = (el as HTMLTemplateElement).content;
		if (content) {
			while (content.firstChild) content.removeChild(content.firstChild);
		}
	}

	/**
	 * Replace base64 placeholder images with real URLs from <noscript> fallbacks.
	 * Next.js (data-nimg) renders a tiny base64 gif as src with the real image
	 * only inside a <noscript> sibling. This promotes the real URL before
	 * noscript elements are stripped.
	 */

	/**
	 * Remove duplicate images within figures and adjacent elements, keeping
	 * the highest resolution version. Handles Reader mode and lazy-loading
	 * hydration creating a second copy alongside the original.
	 */
	private _deduplicateImages(body: Element): void {
		for (const figure of body.querySelectorAll('figure')) {
			const figImgs = Array.from(figure.querySelectorAll('img'))
				.filter(img => !img.closest('noscript') && img.parentElement);
			if (figImgs.length < 2) continue;

			// Group by alt text; empty-alt images share one group
			const groups = new Map<string | null, Element[]>();
			for (const img of figImgs) {
				const src = img.getAttribute('src') || '';
				if (!src || src.startsWith('data:')) continue;
				const key = (img.getAttribute('alt') || '').trim() || null;
				const group = groups.get(key) || [];
				group.push(img);
				groups.set(key, group);
			}

			for (const [key, group] of groups) {
				if (group.length < 2) continue;
				// Same-src images with alt text are legitimate repeats, not dupes
				if (key !== null && group.every(img => img.getAttribute('src') === group[0].getAttribute('src'))) continue;
				this._keepBestImage(group);
			}
		}

		// Also deduplicate images outside figures that are the same image rendered
		// twice by lazy-load hydration: a placeholder/low-res copy *alongside* the
		// real one. Only collapse when the two <img> are genuinely adjacent in the
		// DOM — distinct article images separated by text are real content even when
		// they share generic or boilerplate alt text (e.g. alt="image", or a CMS that
		// reuses the article title as alt), so they must be kept (#286).
		const imgs = Array.from(body.querySelectorAll('img'));
		for (let i = 0; i < imgs.length - 1; i++) {
			const img = imgs[i];
			if (!img.parentElement) continue; // already removed as a loser
			if (img.closest('noscript') || img.closest('figure')) continue;

			const alt = (img.getAttribute('alt') || '').trim();
			if (!alt) continue;
			const src = img.getAttribute('src') || '';
			if (!src || src.startsWith('data:')) continue;

			const other = imgs[i + 1];
			if (!other.parentElement) continue;
			if (other.closest('noscript') || other.closest('figure')) continue;

			if ((other.getAttribute('alt') || '').trim() !== alt) continue;
			const otherSrc = other.getAttribute('src') || '';
			if (!otherSrc || otherSrc.startsWith('data:')) continue;
			if (otherSrc === src) continue; // legitimate repeat (same image twice)

			// Require true adjacency: only whitespace/wrapper markup between them.
			if (!this._noVisibleContentBetween(img, other)) continue;

			this._keepBestImage([img, other]);
		}

		// Remove lightbox duplicate images: a standalone <img> whose src matches
		// the href of a sibling <a> that already contains its own <img>.
		// Pattern: <a href="full.jpg"><img src="thumb.jpg"></a><img src="full.jpg">
		for (const img of Array.from(body.querySelectorAll('img'))) {
			if (!img.parentElement) continue;
			if (img.closest('a, figure, noscript')) continue;

			const src = img.getAttribute('src') || '';
			if (!src || src.startsWith('data:')) continue;

			const parent = img.parentElement;
			const normalizedSrc = this._normalizeSrc(src);
			for (const link of parent.querySelectorAll(':scope > a[href]')) {
				if (!link.querySelector('img')) continue;
				const href = link.getAttribute('href') || '';
				if (normalizedSrc === this._normalizeSrc(href)) {
					img.remove();
					break;
				}
			}
		}
	}

	private _keepBestImage(group: Element[]): void {
		let best = group[0];
		for (let i = 1; i < group.length; i++) {
			const winner = this._pickBestImage(best, group[i]);
			(winner === best ? group[i] : best).remove();
			best = winner;
		}
	}

	/**
	 * True when nothing but whitespace and wrapper markup sits between `a` and `b`
	 * in document order (`a` must precede `b`). Used to confirm two <img> are a
	 * genuine lazy-load duplicate pair rather than distinct images separated by text.
	 */
	private _noVisibleContentBetween(a: Node, b: Node): boolean {
		const next = (node: Node | null): Node | null => {
			if (!node) return null;
			if (node.firstChild) return node.firstChild;
			let n: Node | null = node;
			while (n) {
				if (n.nextSibling) return n.nextSibling;
				n = n.parentNode;
			}
			return null;
		};
		const TEXT_NODE = 3;
		for (let node = next(a); node && node !== b; node = next(node)) {
			if (node.nodeType === TEXT_NODE) {
				if ((node.textContent || '').trim()) return false;
			}
		}
		return true;
	}

	/** Strip protocol and query string for loose URL comparison. */
	private _normalizeSrc(url: string): string {
		return url.replace(/^https?:\/\//, '').split('?')[0];
	}

	/**
	 * Remove the cover/hero image from content when it matches the page's
	 * metadata image (og:image). The image is already captured as result.image;
	 * keeping it inline duplicates information.
	 * Only removes when the image is not inside a figure with a caption
	 * (captioned figures are intentional content references).
	 * Returns the highest-resolution URL from the image's srcset (if available)
	 * so callers can upgrade the metadata image.
	 */
	private _removeCoverImage(body: Element, metadataImage: string): string | undefined {
		if (!metadataImage) return;

		const metaNorm = this._normalizeSrc(metadataImage);

		for (const img of body.querySelectorAll('img')) {
			const src = img.getAttribute('src') || '';
			if (!src || src.startsWith('data:')) continue;
			if (this._normalizeSrc(src) !== metaNorm) continue;

			const bestUrl = this._getLargestImageSrc(img);

			// Don't remove if inside a figure with a caption (intentional content use)
			const figure = img.closest('figure');
			if (figure && figure.querySelector('figcaption')) return bestUrl;

			img.remove();
			return bestUrl;
		}
	}

	// Matches width hints in CDN image URLs:
	//   imgproxy:    /width:1300/  or /w:1300/
	//   Cloudinary:  /w_1300/ or ,w_1300,
	//   Query param: ?w=1300 or &width=1300 or ?width=1300
	//   Next.js:     ?w=1300
	private static _urlWidthPattern = /(?:width[=:/]|[/,?&]w[_:=])(\d+)/;

	private _pickBestImage(a: Element, b: Element): Element {
		// Tier 1: prefer images with srcset or inside <picture>
		const tierA = a.getAttribute('srcset') ? 2 : a.closest('picture') ? 1 : 0;
		const tierB = b.getAttribute('srcset') ? 2 : b.closest('picture') ? 1 : 0;
		if (tierA !== tierB) return tierA > tierB ? a : b;

		// Tier 2: compare URL width hints within the same tier
		const widthA = Defuddle._urlWidth(a);
		const widthB = Defuddle._urlWidth(b);
		if (widthA !== widthB) return widthA > widthB ? a : b;

		return a;
	}

	private static _urlWidth(img: Element): number {
		const src = img.getAttribute('src') || '';
		const m = src.match(Defuddle._urlWidthPattern);
		return m ? parseInt(m[1], 10) : 0;
	}

	/**
	 * Rename non-standard HTML attributes to their canonical lowercase forms.
	 * React SSR outputs camelCase attributes like "srcSet" that some DOM
	 * parsers (e.g. linkedom) preserve verbatim instead of lowercasing.
	 */
	private _normalizeAttributes(body: Element): void {
		const renames: [string, string][] = [['srcSet', 'srcset']];
		const elements = body.querySelectorAll('img, source');
		for (const el of elements) {
			for (const [from, to] of renames) {
				const value = el.getAttribute(from);
				if (value !== null) {
					el.removeAttribute(from);
					el.setAttribute(to, value);
				}
			}
		}
	}

	private _resolveNoscriptImages(body: Element): void {
		const noscripts = body.querySelectorAll('noscript');
		for (const noscript of noscripts) {
			// Try direct child query first (linkedom parses noscript children).
			// In browsers with JS enabled, noscript content is raw text:
			// innerHTML is entity-encoded, textContent has the raw HTML.
			let noscriptImg = noscript.querySelector('img');
			if (!noscriptImg) {
				let html = noscript.innerHTML || '';
				if (!html.includes('<img')) {
					html = noscript.textContent || '';
				}
				if (!html.includes('<img')) continue;
				const fragment = parseHTML(this.doc, html);
				noscriptImg = fragment.querySelector('img');
			}
			if (!noscriptImg) continue;

			const realSrc = noscriptImg.getAttribute('src') || '';
			if (!realSrc || realSrc.startsWith('data:')) continue;

			const alt = noscriptImg.getAttribute('alt');
			const parent = noscript.parentElement;
			if (!parent) continue;

			let matched = false;
			const siblingImgs = parent.querySelectorAll(':scope > img');
			for (const img of siblingImgs) {
				const src = img.getAttribute('src') || '';
				if (!src.startsWith('data:')) continue;
				// Match by alt text; require a non-empty alt to avoid false matches
				if (!alt || img.getAttribute('alt') !== alt) continue;

				img.setAttribute('src', realSrc);
				const srcset = noscriptImg.getAttribute('srcset') || '';
				if (srcset) {
					img.setAttribute('srcset', srcset);
				}
				matched = true;
				break;
			}

			// No matching sibling img — promote the noscript img directly.
			// Only promote inside lazy-loading contexts (not tracking pixels)
			// and only when no JS-hydrated image already exists.
			// Exclude noscript children from the check — linkedom parses them
			// as real elements.
			if (!matched && this._isLazyImageContext(noscript)) {
				const container = noscript.closest('figure') || parent;
				const existingImgs = container.querySelectorAll('img');
				let hasRealImage = false;
				for (const img of existingImgs) {
					if (img.closest('noscript')) continue;
					const src = img.getAttribute('src') || '';
					if (src && !src.startsWith('data:')) {
						hasRealImage = true;
						break;
					}
				}
				if (!hasRealImage) {
					const promotedImg = noscriptImg.cloneNode(true) as Element;
					parent.insertBefore(promotedImg, noscript);
				}
			}
		}
	}

	/**
	 * Detect whether a <noscript> is inside a lazy-loading image wrapper
	 * (vs. a standalone tracking pixel that should not be promoted).
	 */
	private _isLazyImageContext(noscript: Element): boolean {
		if (noscript.closest('figure')) return true;

		const parent = noscript.parentElement;
		if (parent) {
			for (const sibling of parent.children) {
				if (sibling === noscript) continue;
				if (getClassName(sibling).toLowerCase().includes('lazy')) return true;
			}
			const parentCls = getClassName(parent).toLowerCase();
			if (parentCls.includes('image') || parentCls.includes('img') ||
				parentCls.includes('picture') || parentCls.includes('photo') ||
				parentCls.includes('media')) return true;
		}

		return false;
	}

	/**
	 * Find the smallest DOM element whose text contains the search phrase
	 * and whose word count is at least 80% of the expected count.
	 * Shared by _findSchemaContentElement and _findContentBySchemaText.
	 */
	private _findElementBySchemaText(root: Element, schemaText: string): Element | null {
		const firstPara = schemaText.split(/\n\s*\n/)[0]?.trim() || '';
		const searchPhrase = firstPara.substring(0, 100).trim();
		if (!searchPhrase) return null;

		const schemaWordCount = countWords(schemaText);
		let bestMatch: Element | null = null;
		let bestSize = Infinity;

		const allElements = root.querySelectorAll('*');
		for (const el of allElements) {
			if (el === root) continue;

			const elText = el.textContent || '';
			if (!elText.includes(searchPhrase)) continue;

			const elWords = countWords(elText);
			if (elWords >= schemaWordCount * 0.8 && elWords < bestSize) {
				bestSize = elWords;
				bestMatch = el;
			}
		}

		return bestMatch;
	}


	private findLargestHiddenContentSelector(): string | undefined {
		const body = this.doc.body;
		if (!body) return undefined;

		const candidates = Array.from(
			body.querySelectorAll(HIDDEN_EXACT_SKIP_SELECTOR)
		).filter(el => {
			const className = el.getAttribute('class') || '';
			return !className.includes('math');
		});

		let best: Element | null = null;
		let bestWords = 0;
		for (const el of candidates) {
			const words = countWords(el.textContent || '');
			if (words > bestWords) {
				best = el;
				bestWords = words;
			}
		}

		if (!best || bestWords < 30) return undefined;
		return this.getElementSelector(best);
	}

	/**
	 * Get the largest available src from an img element,
	 * checking srcset for higher-resolution versions.
	 */
	private _getLargestImageSrc(img: Element): string {
		const srcset = img.getAttribute('srcset') || '';
		if (!srcset) return img.getAttribute('src') || '';

		// Parse srcset entries: each ends with a width descriptor (e.g. "424w")
		// URLs may contain commas (e.g. Substack CDN), so split on width descriptors
		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;
			}
		}

		let url = bestUrl || img.getAttribute('src') || '';

		// Strip CDN width/crop constraints to get the full resolution image
		// (e.g. Cloudinary-style params: ,w_852,c_limit → removed)
		url = url.replace(/,w_\d+/g, '').replace(/,c_\w+/g, '');

		return url;
	}

	/**
	 * Parse the document asynchronously. Checks for extractors that prefer
	 * async (e.g. YouTube transcripts) before sync, then falls back to async
	 * extractors if sync parse yields no content.
	 */
	async parseAsync(): Promise<DefuddleResponse> {
		if (this.options.useAsync !== false) {
			const asyncResult = await this.tryAsyncExtractor(
				ExtractorRegistry.findPreferredAsyncExtractor.bind(ExtractorRegistry)
			);
			if (asyncResult) return asyncResult;
		}

		const result = this.parse();

		if (result.wordCount > 0 || this.options.useAsync === false) {
			return result;
		}

		return (await this.tryAsyncExtractor(
			ExtractorRegistry.findAsyncExtractor.bind(ExtractorRegistry)
		)) ?? result;
	}

	/**
	 * Fetch only async variables (e.g. transcript) without re-parsing.
	 * Safe to call after parse() — uses cached schema.org data since
	 * parse() strips script tags from the document.
	 */
	async fetchAsyncVariables(): Promise<{ [key: string]: string } | null> {
		if (this.options.useAsync === false) return null;

		try {
			const url = this.options.url || this.doc.URL;
			const schemaOrgData = this.getSchemaOrgData();
			const extractorOpts: ExtractorOptions = { includeReplies: this.options.includeReplies ?? 'extractors', language: this.options.language, fetch: this.options.fetch };
			const extractor = ExtractorRegistry.findPreferredAsyncExtractor(this.doc, url, schemaOrgData, extractorOpts);

			if (extractor) {
				const extracted = await extractor.extractAsync();
				return this.getExtractorVariables(extracted.variables) || null;
			}
		} catch (error) {
			console.error('Defuddle', 'Error fetching async variables:', error);
		}

		return null;
	}

	private async tryAsyncExtractor(
		finder: (document: Document, url: string, schemaOrgData?: any, options?: ExtractorOptions) => BaseExtractor | null
	): Promise<DefuddleResponse | null> {
		try {
			const url = this.options.url || this.doc.URL;
			const schemaOrgData = this.getSchemaOrgData();
			const extractorOpts: ExtractorOptions = { includeReplies: this.options.includeReplies ?? 'extractors', language: this.options.language, fetch: this.options.fetch };
			const extractor = finder(this.doc, url, schemaOrgData, extractorOpts);

			if (extractor) {
				const startTime = Date.now();
				const extracted = await extractor.extractAsync();
				const pageMetaTags = this._collectMetaTags();
				const metadata = MetadataExtractor.extract(this.doc, schemaOrgData, pageMetaTags, url);
				return this.buildExtractorResponse(extracted, metadata, startTime, extractor, pageMetaTags);
			}
		} catch (error) {
			console.error('Defuddle', 'Error in async extraction:', error);
		}

		return null;
	}

	/**
	 * Internal parse method that does the actual work
	 */
	private parseInternal(overrideOptions: Partial<DefuddleOptions> = {}): DefuddleResponse {
		const startTime = Date.now();
		const profile: Record<string, number> = {};
		const doProfile = this.options.profile ?? false;
		const profileStep = <T>(name: string, fn: () => T): T => {
			if (!doProfile) return fn();
			const t = performance.now();
			const result = fn();
			profile[name] = Math.round(performance.now() - t);
			return result;
		};

		// Guard against empty/broken documents (e.g. empty HTML, bot-blocked pages)
		if (!this.doc.documentElement) {
			const url = this.options.url || '';
			return {
				content: '',
				title: '',
				description: '',
				domain: url ? new URL(url).hostname : '',
				favicon: '',
				image: '',
				language: '',
				parseTime: Date.now() - startTime,
				published: '',
				author: '',
				site: '',
				schemaOrgData: null,
				wordCount: 0,
			};
		}

		const options = {
			removeExactSelectors: true,
			removePartialSelectors: true,
			removeHiddenElements: true,
			removeLowScoring: true,
			removeSmallImages: true,
			removeContentPatterns: true,
			standardize: true,
			includeReplies: 'extractors',
			...this.options,
			...overrideOptions
		};
		const debugRemovals: DebugRemoval[] = [];

		// Extract schema.org data (cached — must happen before _stripUnsafeElements removes scripts)
		const schemaOrgData = this.getSchemaOrgData();

		// Cache meta tags and metadata across retries
		if (!this._metaTags) {
			this._metaTags = this._collectMetaTags();
		}
		const pageMetaTags = this._metaTags;

		if (!this._metadata) {
			this._metadata = MetadataExtractor.extract(this.doc, schemaOrgData, pageMetaTags, options.url);
		}
		const metadata = this._metadata;

		if (options.removeImages) {
			this.removeImages(this.doc);
		}

		try {
			// Use site-specific extractor first, if there is one
			const url = options.url || this.doc.URL;
			const extractorOpts: ExtractorOptions = {
				includeReplies: options.includeReplies as ExtractorOptions['includeReplies'],
				language: options.language,
				fetch: options.fetch,
			};
			if (!this._inExtractorPipelineRun) {
				const extractor = ExtractorRegistry.findExtractor(this.doc, url, schemaOrgData, extractorOpts);
				if (extractor && extractor.canExtract()) {
					const extracted = extractor.extract();
					if (extracted.contentSelector) {
						this._inExtractorPipelineRun = true;
						try {
							const pipelineResult = this.parseInternal({
								contentSelector: extracted.contentSelector,
								removeLowScoring: false,
								removeHiddenElements: false,
							});
							const variables = this.getExtractorVariables(extracted.variables);
							return {
								...pipelineResult,
								title: extracted.variables?.title || pipelineResult.title,
								description: extracted.variables?.description || pipelineResult.description,
								author: extracted.variables?.author || pipelineResult.author,
								published: extracted.variables?.published || pipelineResult.published,
								site: extracted.variables?.site || pipelineResult.site,
								language: extracted.variables?.language || pipelineResult.language,
								extractorType: extractor.constructor.name.replace('Extractor', '').toLowerCase(),
								...(variables ? { variables } : {}),
							};
						} finally {
							this._inExtractorPipelineRun = false;
						}
					}
					return this.buildExtractorResponse(extracted, metadata, startTime, extractor, pageMetaTags);
				}
			}

			// Continue if there is no extractor...

			// Evaluate mobile styles and sizes on original document (cached across retries)
			if (!this._mobileStyles) {
				this._mobileStyles = this._evaluateMediaQueries(this.doc);
			}
			const mobileStyles = this._mobileStyles;

			// Find small images in original document (cached across retries)
			if (!this._smallImages) {
				this._smallImages = findSmallImages(this.doc, this.debug);
			}
			const smallImages = this._smallImages;

			// Clone document
			let clone!: Document;
			profileStep('cloneDocument', () => {
				clone = this.doc.cloneNode(true) as Document;
				// Merge adjacent text nodes that some DOM implementations (e.g. linkedom)
				// create when parsing HTML entities like &#39;
				clone.body?.normalize();
			});

			// Flatten shadow DOM content into the clone
			profileStep('flattenShadowRoots', () => this.flattenShadowRoots(this.doc, clone));

			// Resolve React streaming SSR suspense boundaries
			profileStep('resolveStreamedContent', () => this.resolveStreamedContent(clone));

			// Apply mobile styles to clone
			profileStep('applyMobileStyles', () => this.applyMobileStyles(clone, mobileStyles));

			// Find main content
			const mainContent = profileStep('findMainContent', (): Element | null => {
				let found: Element | null = null;
				if (options.contentSelector) {
					found = clone.querySelector(options.contentSelector);
					this._log('Using contentSelector:', options.contentSelector, found ? 'found' : 'not found');
				}
				if (!found) {
					found = this.findMainContent(clone);
				}

				// If the selected element is inside defuddle extractor output,
				// expand to the whole container so post + comments stay together.
				if (found) {
					const defuddleAncestor = found.closest('[data-defuddle]');
					if (defuddleAncestor) {
						found = defuddleAncestor;
					}
				}

				// If we fell back to <body>, try using schema.org articleBody/text
				// to find a more specific content element within the DOM.
				if (found && found.tagName.toLowerCase() === 'body') {
					const schemaText = this._getSchemaText(schemaOrgData);
					if (schemaText) {
						const schemaContent = this._findElementBySchemaText(clone.body, schemaText);
						if (schemaContent) {
							this._log('Found content element via schema.org text');
							found = schemaContent;
						}
					}
				}
				return found;
			});

			if (!mainContent) {
				const fallbackContent = this._serializeFallbackBody();
				const endTime = Date.now();
				return {
					content: fallbackContent,
					...metadata,
					wordCount: this.countHtmlWords(fallbackContent),
					parseTime: Math.round(endTime - startTime),
					metaTags: pageMetaTags
				};
			}

			// Remove h1-adjacent date/author metadata blocks from the content.
			// These are extracted as frontmatter but also appear in the body when a
			// wide container (e.g. <main>) is selected as the content element.
			profileStep('removeMetadataBlock', () => {
				if (metadata.published || metadata.author) {
					removeMetadataBlock(mainContent!);
				}
				// Remove <wbr> elements — word break opportunity hints that carry no
				// content but cause unwanted whitespace during standardization.
				mainContent!.querySelectorAll('wbr').forEach(el => el.remove());
			});

			// Pull in footnote sections that live outside the main content element
			profileStep('adoptExternalFootnotes', () => {
				if (options.standardize) {
					this.adoptExternalFootnotes(mainContent!, clone);
				}
			});

			// Standardize footnotes before cleanup (CSS sidenotes use display:none)
			profileStep('standardizeFootnotesCallouts', () => {
				if (options.standardize) {
					standardizeFootnotes(mainContent!);
					standardizeCallouts(mainContent!);
				}
			});

			// Remove small images
			profileStep('removeSmallImages', () => {
				if (options.removeSmallImages) {
					removeSmallImages(clone, smallImages, this.debug);
				}
			});

			// Remove hidden elements using computed styles
			profileStep('removeHiddenElements', () => {
				if (options.removeHiddenElements) {
					removeHiddenElements(clone, this.debug, debugRemovals);
				}
			});

			// Remove "eyebrow" category labels before selector removal — these
			// are anchored on the first <h1>, which some sites strip via class
			// (e.g. Substack's .post-title) in the selector phase.
			profileStep('removeEyebrowLabel', () => {
				if (options.removeContentPatterns && mainContent) {
					removeEyebrowLabel(mainContent!, this.debug, debugRemovals);
				}
			});

			// Remove clutter using selectors — deterministic removal of known
			// non-content elements (nav, footer, .sidebar, etc.) by class/id.
			// Runs before scoring so the heuristic scorer sees a cleaner DOM.
			profileStep('removeBySelector', () => {
				if (options.removeExactSelectors || options.removePartialSelectors) {
					removeBySelector(
						clone,
						this.debug,
						options.removeExactSelectors,
						options.removePartialSelectors,
						mainContent!,
						debugRemovals,
						options.removeHiddenElements === false
					);
				}
			});

			// Remove non-content blocks by scoring — heuristic removal based
			// on link density, text ratios, and navigation indicators.
			profileStep('removeLowScoring', () => {
				if (options.removeLowScoring) {
					ContentScorer.scoreAndRemove(clone, this.debug, debugRemovals, mainContent!);
				}
			});

			// Remove elements by content patterns (read time, boilerplate, article cards)
			profileStep('removeByContentPattern', () => {
				if (options.removeContentPatterns && mainContent) {
					const url = this.options.url || this.doc.URL || '';
					removeByContentPattern(mainContent!, this.debug, url, metadata.title || '', metadata.description || '', debugRemovals);
				}
			});

			// Normalize the main content
			profileStep('standardizeContent', () => {
				if (options.standardize) {
					standardizeContent(mainContent!, metadata, this.doc, this.debug, doProfile ? profile : undefined);
				}
			});

			// Resolve relative URLs to absolute
			profileStep('resolveRelativeUrls', () => this.resolveRelativeUrls(mainContent!));

			// Remove duplicate images (same alt, different resolution)
			// after all image processing and URL resolution is complete
			this._deduplicateImages(mainContent!);

			// Remove cover/hero image that duplicates the metadata image.
			// If the content image has a higher-resolution srcset URL, upgrade metadata.
			const bestCoverUrl = this._removeCoverImage(mainContent!, metadata.image || '');
			if (bestCoverUrl) {
				metadata.image = bestCoverUrl;
			}

			// Neutralizing an unsafe root strips the selector's id/class.
			const debugSelector = this.debug ? this.getElementSelector(mainContent) : '';

			// Strip dangerous elements and URI attributes from the final output.
			// Runs unconditionally — the pipeline steps above are all optional, so
			// this is the only guaranteed sanitization boundary on this path.
			// Safe to mutate: mainContent belongs to the clone, not the live document.
			const unsafeContentRoot = this._stripUnsafeElements(mainContent as HTMLElement);

			// Never serialize an unsafe root, even after neutralization.
			const content = unsafeContentRoot ? '' : mainContent.outerHTML;
			const endTime = Date.now();

			const result: DefuddleResponse = {
				content,
				...metadata,
				wordCount: this.countHtmlWords(content),
				parseTime: Math.round(endTime - startTime),
				metaTags: pageMetaTags
			};

			if (this.debug) {
				result.debug = {
					contentSelector: debugSelector,
					removals: debugRemovals
				};
			}

			if (this.options.profile) {
				result.profile = profile;
			}

			return result;
		} catch (error) {
			console.error('Defuddle', 'Error processing document:', error);
			const errorContent = this._serializeFallbackBody();
			const endTime = Date.now();
			return {
				content: errorContent,
				...metadata,
				wordCount: this.countHtmlWords(errorContent),
				parseTime: Math.round(endTime - startTime),
				metaTags: pageMetaTags
			};
		}
	}

	private countHtmlWords(content: string): number {
		// Strip HTML tags and decode common entities without DOM parsing
		const text = content
			.replace(/<[^>]*>/g, ' ')
			.replace(/&nbsp;/gi, ' ')
			.replace(/&amp;/gi, '&')
			.replace(/&lt;/gi, '<')
			.replace(/&gt;/gi, '>')
			.replace(/&quot;/gi, '"')
			.replace(/&#\d+;/g, ' ')
			.replace(/&\w+;/g, ' ');

		return countWords(text);
	}

	private _log(...args: any[]): void {
		if (this.debug) {
			console.log('Defuddle:', ...args);
		}
	}

	private _evaluateMediaQueries(doc: Document): StyleChange[] {
		const mobileStyles: StyleChange[] = [];
		const maxWidthRegex = /max-width[^:]*:\s*(\d+)/;

		try {
			if (!doc.styleSheets) return mobileStyles;

			// Get all styles, including inline styles
			const sheets = Array.from(doc.styleSheets).filter(sheet => {
				try {
					// Access rules once to check validity
					sheet.cssRules;
					return true;
				} catch (e) {
					// Expected error for cross-origin stylesheets or Node.js environment
					if (e instanceof DOMException && e.name === 'SecurityError') {
						return false;
					}
					return false;
				}
			});
			
			// Process all sheets in a single pass
			const mediaRules = sheets.flatMap(sheet => {
				try {
					// Check if we're in a browser environment where CSSMediaRule is available
					if (typeof CSSMediaRule === 'undefined') {
						return [];
					}

					return Array.from(sheet.cssRules)
						.filter((rule): rule is CSSMediaRule => 
							rule instanceof CSSMediaRule &&
							rule.conditionText.includes('max-width')
						);
				} catch (e) {
					if (this.debug) {
						console.warn('Defuddle: Failed to process stylesheet:', e);
					}
					return [];
				}
			});

			// Process all media rules in a single pass
			mediaRules.forEach(rule => {
				const match = rule.conditionText.match(maxWidthRegex);
				if (match) {
					const maxWidth = parseInt(match[1]);
					
					if (MOBILE_WIDTH <= maxWidth) {
						// Batch process all style rules
						const styleRules = Array.from(rule.cssRules)
							.filter((r): r is CSSStyleRule => r instanceof CSSStyleRule);

						styleRules.forEach(cssRule => {
							try {
								mobileStyles.push({
									selector: cssRule.selectorText,
									styles: cssRule.style.cssText
								});
							} catch (e) {
								if (this.debug) {
									console.warn('Defuddle: Failed to process CSS rule:', e);
								}
							}
						});
					}
				}
			});
		} catch (e) {
			console.error('Defuddle: Error evaluating media queries:', e);
		}

		return mobileStyles;
	}

	private applyMobileStyles(doc: Document, mobileStyles: StyleChange[]) {
		let appliedCount = 0;

		mobileStyles.forEach(({selector, styles}) => {
			try {
				const elements = doc.querySelectorAll(selector);
				elements.forEach(element => {
					element.setAttribute('style', 
						(element.getAttribute('style') || '') + styles
					);
					appliedCount++;
				});
			} catch (e) {
				console.error('Defuddle', 'Error applying styles for selector:', selector, e);
			}
		});

	}

	private removeImages(doc: Document) {
		const images = doc.getElementsByTagName('img');
		Array.from(images).forEach(image => {
			image.remove();
		});
	}

	private findMainContent(doc: Document): Element | null {
		// Find all potential content containers
		const candidates: { element: Element; score: number; selectorIndex: number }[] = [];

		ENTRY_POINT_ELEMENTS.forEach((selector, index) => {
			const elements = doc.querySelectorAll(selector);
			elements.forEach(element => {
				// Base score from selector priority (earlier = higher)
				let score = (ENTRY_POINT_ELEMENTS.length - index) * 40;

				// Add score based on content analysis
				score += ContentScorer.scoreElement(element);

				candidates.push({ element, score, selectorIndex: index });
			});
		});

		if (candidates.length === 0) {
			// Fall back to scoring block elements
			return this.findContentByScoring(doc);
		}

		// Sort by score descending
		candidates.sort((a, b) => b.score - a.score);

		if (this.debug) {
			this._log('Content candidates:', candidates.map(c => ({
				element: c.element.tagName,
				selector: this.getElementSelector(c.element),
				score: c.score
			})));
		}

		// If we only matched body, try table-based detection
		if (candidates.length === 1 && candidates[0].element.tagName.toLowerCase() === 'body') {
			const tableContent = this.findTableBasedContent(doc);
			if (tableContent) {
				return tableContent;
			}
		}

		// If the top candidate contains a child candidate that matched a
		// higher-priority selector, prefer the most specific (deepest) child.
		// This prevents e.g. <main> from winning over a contained <article>
		// just because sibling noise inflates the parent's content score.
		// Only prefer the child if it has meaningful content (>50 words),
		// otherwise it may be an empty card element (e.g. related article cards).
		// Skip this when the parent contains multiple children matching the
		// same selector — that indicates a listing/portfolio page where the
		// parent is the real content container.
		const top = candidates[0];
		let best = top;
		for (let i = 1; i < candidates.length; i++) {
			const child = candidates[i];
			const childWords = countWords(child.element.textContent || '');
			if (child.selectorIndex < best.selectorIndex && best.element.contains(child.element) && childWords > 50) {
				// Count how many candidates share this selector index inside
				// the top element. Use top (not best) as the stable reference
				// so the check isn't affected by earlier iterations.
				let siblingsAtIndex = 0;
				for (const c of candidates) {
					if (c.selectorIndex === child.selectorIndex && top.element.contains(c.element)) {
						if (++siblingsAtIndex > 1) break;
					}
				}
				if (siblingsAtIndex > 1) {
					// Multiple articles/cards inside the parent — it's a listing page
					continue;
				}
				best = child;
			}
		}
		if (best !== top) {
			return best.element;
		}

		return top.element;
	}

	private findTableBasedContent(doc: Document): Element | null {
		// First check if this looks like an old-style table-based layout
		const tables = Array.from(doc.getElementsByTagName('table'));
		const hasTableLayout = tables.some(table => {
			const width = parseInt(table.getAttribute('width') || '0');
			const style = this.getComputedStyle(table);
			const tableClass = getClassName(table).toLowerCase();
			return width > 400 ||
				(style?.width?.includes('px') && parseInt(style.width) > 400) ||
				table.getAttribute('align') === 'center' ||
				tableClass.includes('content') ||
				tableClass.includes('article') ||
				// Multi-column layout: a row with 2+ cells where at least one has an explicit width
				Array.from(table.getElementsByTagName('tr')).some(row => {
					const cells = Array.from(row.children).filter(c => c.tagName === 'TD');
					return cells.length >= 2 && cells.some(c => c.getAttribute('width'));
				});
		});

		if (!hasTableLayout) {
			return null; // Don't try table-based extraction for modern layouts
		}

		const cells = Array.from(doc.getElementsByTagName('td'));
		const bestCell = ContentScorer.findBestElement(cells);
		if (!bestCell) return null;

		// If there's more text outside the best cell than inside it,
		// tables are peripheral (TOC, intro boxes, data tables) — not the
		// main content container. Fall back to body.
		const bestCellWords = countWords(bestCell.textContent || '');
		const bodyWords = countWords((doc.body || doc.documentElement).textContent || '');
		if (bestCellWords * 2 < bodyWords) {
			return null;
		}

		return bestCell;
	}

	private findContentByScoring(doc: Document): Element | null {
		const candidates: ContentScore[] = [];

		doc.querySelectorAll(BLOCK_ELEMENTS_SELECTOR).forEach((element: Element) => {
			const score = ContentScorer.scoreElement(element);
			if (score > 0) {
				candidates.push({ score, element });
			}
		});

		return candidates.length > 0 ? candidates.sort((a, b) => b.score - a.score)[0].element : null;
	}

	private getElementSelector(element: Element): string {
		const parts: string[] = [];
		let current: Element | null = element;

		while (current && current !== this.doc.documentElement) {
			let selector = current.tagName.toLowerCase();
			if (current.id) {
				selector += '#' + current.id;
			} else if (getClassName(current)) {
				const safe = getClassName(current).trim().split(/\s+/)
					.filter(cls => !UNSAFE_CSS_CLASS_RE.test(cls));
				if (safe.length) {
					selector += '.' + safe.join('.');
				}
			}
			parts.unshift(selector);
			current = current.parentElement;
		}

		return parts.join(' > ');
	}

	private getComputedStyle(element: Element): CSSStyleDeclaration | null {
		return getComputedStyle(element);
	}

	// Move footnote sections that live outside the main content element into it.
	private adoptExternalFootnotes(mainContent: Element, root: Document | Element): void {
		const body = (root as any).body || root;
		if (!body || mainContent === body) return;

		body.querySelectorAll('div, section, aside').forEach((el: Element) => {
			const className = getClassName(el);
			const id = (el as any).id || '';
			if (!/footnote/i.test(className) && !/footnote/i.test(id)) return;

			if (mainContent.contains(el) || el.contains(mainContent)) return;

			const heading = el.querySelector('h1, h2, h3, h4, h5, h6');
			if (!heading || !FOOTNOTE_SECTION_RE.test(heading.textContent?.trim() || '')) return;

			mainContent.appendChild(el);
		});
	}

	/**
	 * Resolve relative URLs to absolute within a DOM element
	 */
	private resolveRelativeUrls(element: Element): void {
		const docUrl = this.options.url || this.doc.URL;
		if (!docUrl) return;

		// Respect <base href> for relative URL resolution, matching browser behavior
		let baseUrl = docUrl;
		const baseEl = this.doc.querySelector('base[href]');
		if (baseEl) {
			const baseHref = baseEl.getAttribute('href');
			if (baseHref) {
				try {
					baseUrl = new URL(baseHref, docUrl).href;
				} catch {
					// Invalid base href, fall back to document URL
				}
			}
		}

		const resolve = (url: string): string => {
			// Some pages ship escaped quoted hrefs like \"mailto:...\" in server templates.
			// Normalize these before URL resolution.
			const normalized = url
				.trim()
				.replace(/^\\?["']+/, '')
				.replace(/\\?["']+$/, '');
			// Fragment-only hrefs reference anchors within the same document — keep them relative.
			if (normalized.startsWith('#')) return normalized;
			try {
				return new URL(normalized, baseUrl).href;
			} catch {
				return normalized || url;
			}
		};

		element.querySelectorAll('[href]').forEach(el => {
			const href = el.getAttribute('href');
			if (href) el.setAttribute('href', resolve(href));
		});

		element.querySelectorAll('[src]').forEach(el => {
			const src = el.getAttribute('src');
			if (src) el.setAttribute('src', resolve(src));
		});

		element.querySelectorAll('[srcset]').forEach(el => {
			const srcset = el.getAttribute('srcset');
			if (srcset) {
				// Parse srcset using width/density descriptors as delimiters,
				// not commas — URLs may contain commas (e.g. CDN transform params)
				const entryPattern = /(.+?)\s+(\d+(?:\.\d+)?[wx])/g;
				const entries: string[] = [];
				let match;
				let lastIdx = 0;

				while ((match = entryPattern.exec(srcset)) !== null) {
					let url = match[1].trim();
					if (lastIdx > 0) {
						url = url.replace(/^,\s*/, '');
					}
					lastIdx = entryPattern.lastIndex;
					entries.push(`${resolve(url)} ${match[2]}`);
				}

				if (entries.length > 0) {
					el.setAttribute('srcset', entries.join(', '));
				} else {
					// Fallback: simple comma split for srcsets without descriptors
					const resolved = srcset.split(',').map(entry => {
						const parts = entry.trim().split(/\s+/);
						if (parts[0]) parts[0] = resolve(parts[0]);
						return parts.join(' ');
					}).join(', ');
					el.setAttribute('srcset', resolved);
				}
			}
		});

		element.querySelectorAll('[poster]').forEach(el => {
			const poster = el.getAttribute('poster');
			if (poster) el.setAttribute('poster', resolve(poster));
		});
	}

	/** Hoist server-parsed declarative shadow roots into the light DOM. */
	private flattenDeclarativeShadowRoots(clone: Document): void {
		if (!clone.body) return;

		// Hoisting can expose nested templates; bound the traversal depth.
		for (let depth = 0; depth < 10; depth++) {
			const templates = Array.from(
				clone.body.querySelectorAll('template[shadowrootmode], template[shadowroot]')
			).filter(template => {
				// Prefer the standard attribute when both forms are present.
				const mode = (
					template.getAttribute('shadowrootmode')
					?? template.getAttribute('shadowroot')
					?? ''
				).toLowerCase();
				return mode === 'open' || mode === 'closed';
			});
			if (templates.length === 0) return;

			for (const template of templates) {
				const host = template.parentNode;
				if (!host) continue;
				// linkedom does not always expose .content; fall back to childNodes.
				const content = (template as HTMLTemplateElement).content;
				const source: Node = content && content.firstChild ? content : template;
				while (source.firstChild) {
					host.insertBefore(source.firstChild, template);
				}
				template.remove();
			}
		}
	}

	/**
	 * Flatten shadow DOM content into a cloned document.
	 * Walks both trees in parallel so positional correspondence is exact.
	 */
	private flattenShadowRoots(original: Document, clone: Document): void {
		if (!original.body || !clone.body) return;

		// Capture references before hoisting shifts positional indices.
		const origElements = Array.from(original.body.querySelectorAll('*'));
		const cloneElements = Array.from(clone.body.querySelectorAll('*'));

		// Server DOMs leave declarative roots as inert templates; browsers do not.
		this.flattenDeclarativeShadowRoots(clone);

		// Find the first element with a shadow root (also serves as the hasShadowRoots check)
		const firstShadow = origElements.find(el => el.shadowRoot);
		if (!firstShadow) return;

		// Check if we can directly read shadow DOM content (main world / Node.js).
		// In content script isolated worlds, shadowRoot exists but content is empty.
		const canReadShadow = (firstShadow.shadowRoot?.childNodes?.length ?? 0) > 0;

		if (canReadShadow) {
			// Direct traversal works (main world / Node.js)
			for (let i = origElements.length - 1; i >= 0; i--) {
				const origEl = origElements[i];
				if (!origEl.shadowRoot) continue;

				const cloneEl = cloneElements[i];
				if (!cloneEl) continue;

				const shadowHtml = origEl.shadowRoot.innerHTML;
				if (shadowHtml.length > 0) {
					this.replaceShadowHost(cloneEl, shadowHtml, clone);
				}
			}
		} else {
			// Content script isolated world — read data-defuddle-shadow attributes
			// stamped by an external main-world script.
			const shadowData: {cloneEl: Element, html: string}[] = [];
			for (let i = 0; i < origElements.length; i++) {
				const origEl = origElements[i];
				const shadowHtml = origEl.getAttribute('data-defuddle-shadow');
				if (!shadowHtml) continue;

				const cloneEl = cloneElements[i];
				if (!cloneEl) continue;

				shadowData.push({cloneEl, html: shadowHtml});
				// Clean up temporary attributes from both original and clone
				origEl.removeAttribute('data-defuddle-shadow');
				cloneEl.removeAttribute('data-defuddle-shadow');
			}
			for (const {cloneEl, html} of shadowData) {
				this.replaceShadowHost(cloneEl, html, clone);
			}
		}
	}

	/**
	 * Resolve React streaming SSR suspense boundaries.
	 * React's streaming SSR places content in hidden divs (id="S:0") and
	 * template placeholders (id="B:0") with $RC scripts to swap them.
	 * Since we don't execute scripts, we perform the swap manually.
	 */
	private resolveStreamedContent(doc: Document): void {
		// Find $RC("B:X","S:X") calls in inline scripts
		const scripts = doc.querySelectorAll('script');
		const swaps: { templateId: string; contentId: string }[] = [];
		const rcPattern = /\$RC\("(B:\d+)","(S:\d+)"\)/g;

		for (const script of scripts) {
			const text = script.textContent || '';
			if (!text.includes('$RC(')) continue;
			rcPattern.lastIndex = 0;
			let match;
			while ((match = rcPattern.exec(text)) !== null) {
				swaps.push({ templateId: match[1], contentId: match[2] });
			}
		}

		if (swaps.length === 0) return;

		let swapCount = 0;
		for (const { templateId, contentId } of swaps) {
			const template = doc.getElementById(templateId);
			const content = doc.getElementById(contentId);
			if (!template || !content) continue;

			const parent = template.parentNode;
			if (!parent) continue;

			// Remove the fallback/skeleton content after the template
			// until the <!--/$--> comment marker
			let next = template.nextSibling;
			let foundMarker = false;
			while (next) {
				const following = next.nextSibling;
				if (next.nodeType === 8 && (next as Comment).data === '/$') {
					next.remove();
					foundMarker = true;
					break;
				}
				next.remove();
				next = following;
			}

			// Skip swap if marker wasn't found — malformed streaming output
			if (!foundMarker) continue;

			// Insert content children before the template position
			while (content.firstChild) {
				parent.insertBefore(content.firstChild, template);
			}

			// Clean up the template and hidden div
			template.remove();
			content.remove();
			swapCount++;
		}

		if (swapCount > 0) {
			this._log('Resolved streamed content:', swapCount, 'suspense boundaries');
		}
	}

	/**
	 * Replace a shadow DOM host element with a div containing its shadow content.
	 * Custom elements (tag names with hyphens) would re-initialize when inserted
	 * into a live DOM, recreating their shadow roots and hiding the content.
	 */
	private replaceShadowHost(el: Element, shadowHtml: string, doc: Document): void {
		const fragment = parseHTML(doc, shadowHtml);
		if (el.tagName.includes('-')) {
			// Custom element — replace with a div to prevent re-initialization
			const div = doc.createElement('div');
			div.appendChild(fragment);
			el.parentNode?.replaceChild(div, el);
		} else {
			el.textContent = '';
			el.appendChild(fragment);
		}
	}

	/**
	 * Resolve relative URLs in an HTML string
	 */
	private resolveContentUrls(html: string): string {
		const baseUrl = this.options.url || this.doc.URL;
		if (!baseUrl) return html;

		const container = this.doc.createElement('div');
		container.appendChild(parseHTML(this.doc, html));
		this.resolveRelativeUrls(container);
		return serializeHTML(container);
	}

	private _extractSchemaOrgData(doc: Document): any {
		const schemaScripts = doc.querySelectorAll('script[type="application/ld+json"]');
		const rawSchemaItems: any[] = [];

		schemaScripts.forEach(script => {
			let jsonContent = script.textContent || '';
			
			try {
				jsonContent = jsonContent
					.replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, '')
					.replace(/^\s*<!\[CDATA\[([\s\S]*?)\]\]>\s*$/, '$1')
					.replace(/^\s*(\*\/|\/\*)\s*|\s*(\*\/|\/\*)\s*$/g, '')
					.trim();
					
				const jsonData = JSON.parse(jsonContent);

				if (jsonData['@graph'] && Array.isArray(jsonData['@graph'])) {
					rawSchemaItems.push(...jsonData['@graph']);
				} else {
					rawSchemaItems.push(jsonData);
				}
			} catch (error) {
				console.error('Defuddle: Error parsing schema.org data:', error);
				if (this.debug) {
					console.error('Defuddle: Problematic JSON content:', jsonContent);
				}
			}
		});

		const decodeStringsInObject = (item: any): any => {
			if (typeof item === 'string') {
				return this._decodeHTMLEntities(item);
			} else if (Array.isArray(item)) {
				return item.map(decodeStringsInObject);
			} else if (typeof item === 'object' && item !== null) {
				const newItem: { [key: string]: any } = {};
				for (const key in item) {
					if (Object.prototype.hasOwnProperty.call(item, key)) {
						newItem[key] = decodeStringsInObject(item[key]);
					}
				}
				return newItem;
			}
			return item;
		};

		return rawSchemaItems.map(decodeStringsInObject);
	}

	private _collectMetaTags(): MetaTagItem[] {
		const pageMetaTags: MetaTagItem[] = [];
		this.doc.querySelectorAll('meta').forEach(meta => {
			const name = meta.getAttribute('name');
			const property = meta.getAttribute('property');
			let content = meta.getAttribute('content');
			if (content) {
				pageMetaTags.push({ name, property, content: this._decodeHTMLEntities(content) });
			}
		});
		return pageMetaTags;
	}

	private _decodeHTMLEntities(text: string): string {
		return decodeHTMLEntities(this.doc, text);
	}

	/**
	 * Build a DefuddleResponse from an extractor result with metadata
	 */
	private buildExtractorResponse(
		extracted: { contentHtml: string; variables?: { [key: string]: string } },
		metadata: ReturnType<typeof MetadataExtractor.extract>,
		startTime: number,
		extractor: BaseExtractor,
		pageMetaTags: MetaTagItem[]
	): DefuddleResponse {
		const contentHtml = this._sanitizeExtractorHtml(extracted.contentHtml);
		const variables = this.getExtractorVariables(extracted.variables);
		return {
			content: contentHtml,
			title: extracted.variables?.title || metadata.title,
			description: extracted.variables?.description || metadata.description,
			domain: metadata.domain,
			favicon: metadata.favicon,
			image: metadata.image,
			language: extracted.variables?.language || metadata.language,
			published: extracted.variables?.published || metadata.published,
			author: extracted.variables?.author || metadata.author,
			site: extracted.variables?.site || metadata.site,
			schemaOrgData: metadata.schemaOrgData,
			wordCount: this.countHtmlWords(contentHtml),
			parseTime: Math.round(Date.now() - startTime),
			extractorType: extractor.constructor.name.replace('Extractor', '').toLowerCase(),
			metaTags: pageMetaTags,
			...(variables ? { variables } : {}),
		};
	}

	/**
	 * Sanitize and finalize HTML produced by site extractors.
	 *
	 * Extractors build their output from template-literal strings, so unlike the
	 * main pipeline their output never passes through the DOM-based attribute
	 * sanitizer. Attacker-controlled attribute values (e.g. an image `alt` or
	 * `src` read straight off the page) could otherwise close an attribute and
	 * inject an event handler or a `javascript:` URL. Parsing the output into a
	 * DOM and running the same `_stripUnsafeElements` pass used elsewhere
	 * neutralizes any such injection regardless of which extractor produced it.
	 *
	 * Relative-URL resolution runs on the same parsed DOM so extractor output is
	 * parsed and serialized only once (resolveRelativeUrls no-ops without a URL).
	 *
	 * Extractors lift message and comment bodies straight out of the page, so the
	 * result also needs the cleanup the main pipeline gets from standardizeContent
	 * (attribute stripping and empty-spacer removal). standardizeExtractorOutput
	 * applies the subset of those steps that fits already-built markup.
	 *
	 * It runs after the passes above so they still see `href`/`src`/`srcdoc` intact,
	 * and after resolveRelativeUrls so no element is dropped before its URLs are
	 * rewritten.
	 */
	private _sanitizeExtractorHtml(html: string): string {
		if (!html) return html;
		const container = this.doc.createElement('div');
		container.appendChild(parseHTML(this.doc, html));
		this._stripUnsafeElements(container);
		this.resolveRelativeUrls(container);
		standardizeExtractorOutput(container, this.debug);
		return serializeHTML(container);
	}

	/**
	 * Filter extractor variables to only include custom ones
	 * (exclude standard fields that are already mapped to top-level properties)
	 */
	private getExtractorVariables(variables?: { [key: string]: string }): { [key: string]: string } | undefined {
		if (!variables) return undefined;
		const custom: { [key: string]: string } = {};
		let hasCustom = false;
		for (const [key, value] of Object.entries(variables)) {
			if (!STANDARD_VARIABLE_KEYS.has(key)) {
				custom[key] = value;
				hasCustom = true;
			}
		}
		return hasCustom ? custom : undefined;
	}

}

SHA-256: e18944ee113627e136266b3711b51bcc004682c77820dcdc6ba30fb005613de3