/** * @module research/extractor/html-to-content/extract-content/extract-content-readability * @description Research library module. */ import { parseHTML } from "linkedom"; interface Candidate { score: number; elem: any; // linkedom Element } /** * ### HTML-to-Main-Content Extractor #1 * The function extracts main content with regex patterns, cleaning HTML, scoring nodes * based on content indicators like paragraphs and id/class names, selecting * the top candidate, extracting it, and cleaning up content around it. * * * 1. Define regular expressions: * - Various regex patterns are defined to identify content and non-content areas. * * 2. Define helper functions: * - normalizeSpaces: Normalizes whitespace in a string. * - stripTags: Removes all HTML tags from a string. * - getTextLength: Calculates the length of text after stripping tags. * - calculateLinkDensity: Calculates the ratio of link text to total text. * * 3. Clean HTML: * - Remove unlikely candidates (e.g., ads, sidebars) from the HTML. * * 4. Define scoring function: * - scoreNode: Assigns a score to an HTML node based on content and attributes. * - Increases score for positive indicators (e.g., article, body, content tags). * - Decreases score for negative indicators (e.g., hidden, footer, sidebar tags). * - Adds to score based on paragraph tags and text length. * * 5. Find and score candidate nodes: * - Identify potential content nodes in the cleaned HTML. * - Score each node using the scoreNode function. * * 6. Select top candidate: * - Sort candidates by score and select the highest-scoring node. * * 7. Extract content: * - Use regex to extract content around the top candidate node. * * 8. Clean up extracted content: * - Remove script and style tags and their contents. * - Process anchor tags based on content density. * - Keep only specific HTML tags (a, p, img, h1-h6, ul, ol, li). * - Remove excess whitespace from the final content. * * [Article Extraction Benchmark](https://trafilatura.readthedocs.io/en/latest/evaluation.html) * * @example * var url = "https://www.nytimes.com/2024/08/28/business/telegram-ceo-pavel-durov-charged.html" * const html = await (await fetch(url)).text(); * var articleContent = extractMainContentFromHTML(html); * @param {Object} [options] * @param {number} options.minContentLength default=140 - Minimum length of content to be considered valid * @param {number} options.minScore default=20 - Minimum score for content to be considered valid * @param {number} options.minTextLength default=25 - Minimum length of text to be considered valid * @param {number} options.retryLength default=250 - Length to retry content extraction if initial attempt fails * @returns {string} Extracted HTML string of main content * @author [vtempest (2025)](https://github.com/vtempest) * Based on [Mozilla Readability (2015)](https://github.com/mozilla/readability) * @category Extract */ export function extractMainContentFromHTML( html: string, options: { minContentLength?: number; minScore?: number; minTextLength?: number; retryLength?: number; } = {}, ): string { const { minContentLength = 140, minScore = 20, minTextLength = 25, retryLength = 250, } = options; // Define regular expressions for content identification const positiveRe = /article|body|content|entry|hentry|main|page|pagination|post|text|blog|story/i; const negativeRe = /button|combx|comment|com-|contact|figure|foot|footer|footnote|form|input|masthead|media|meta|outbrain|promo|related|scroll|shoutbox|sidebar|sponsor|shopping|tags|tool|widget/i; const videoRe = /https?:\/\/(?:www\.)?(?:youtube|vimeo)\.com/i; // Parse the HTML string into a document const doc = parseHTML(html)?.document; if (!doc) return ""; // Remove script and style tags doc.querySelectorAll("script, style").forEach((elem) => elem.remove()); // Clean HTML by removing unlikely candidates for (const elem of doc.querySelectorAll("*")) { const attrs = (elem.getAttribute("class") || "") + " " + (elem.getAttribute("id") || ""); if (attrs.length < 2) continue; //test unlikely candidates if ( !["body", "html"].includes(elem.tagName.toLowerCase()) && /combx|comment|community|disqus|extra|foot|header|menu|remark|rss|shoutbox|sidebar|sponsor|ad-break|agegate|pagination|pager|popup|tweet|twitter/i.test( attrs, ) && !/and|article|body|column|main|shadow/i.test(attrs) ) { elem.remove(); } } // Convert divs to paragraphs if they don't contain block elements const divs = doc.getElementsByTagName("div"); for (const elem of divs) { if ( !/<(?:a|blockquote|dl|div|img|ol|p|pre|table|ul)/i.test( elem?.innerHTML?.replace(/\s+/, " "), ) ) { const newElem = doc.createElement("p"); for (const attr of elem.attributes) { newElem.setAttribute(attr.name, attr.value); } while (elem.firstChild) { newElem.appendChild(elem.firstChild); } elem.parentNode?.replaceChild(newElem, elem); } } // Score nodes const candidates: Record = {}; const elems = Array.from(doc.querySelectorAll("p, pre, td")); for (const elem of elems) { const parentNode = elem.parentNode; const grandParentNode = parentNode ? parentNode.parentNode : null; const innerText = (elem.textContent || "").trim(); const innerTextLen = innerText.length; if (innerTextLen < minTextLength) continue; const pKey = String(parentNode); const gpKey = String(grandParentNode); // Score parent and grandparent nodes if (!candidates[pKey]) { candidates[pKey] = scoreNode(parentNode as any, positiveRe, negativeRe); } if (grandParentNode && !candidates[gpKey]) { candidates[gpKey] = scoreNode( grandParentNode as any, positiveRe, negativeRe, ); } // Calculate score based on text content let score = 1; score += innerText.split(",").length; score += Math.min(innerTextLen / 100, 3); candidates[pKey].score += score; if (grandParentNode) candidates[gpKey].score += score / 2; } // Adjust scores based on link density for (const candidate of Object.values(candidates)) { if (candidate && candidate.elem) { candidate.score *= 1 - getLinkDensity(candidate.elem); } } // Find the best candidate const sortedCandidates = Object.values(candidates).sort( (a, b) => b.score - a.score, ); const bestCandidate = sortedCandidates[0]; let article: any; let cleanedArticle: any; if (bestCandidate) { // Extract content from the best candidate and its siblings const siblingScoreThreshold = Math.max(10, bestCandidate.score * 0.2); article = doc.createElement("div"); const parent = bestCandidate.elem.parentNode; const siblings = parent ? Array.from(parent.children) : [bestCandidate.elem]; for (let sibling of siblings) { let append = false; if ( sibling === bestCandidate.elem || (candidates[String(sibling)] && candidates[String(sibling)].score >= siblingScoreThreshold) ) { append = true; } else if (sibling.tagName === "P") { const linkDensity = getLinkDensity(sibling as any); const nodeContent = sibling.textContent || ""; const nodeLength = nodeContent.length; if ( (nodeLength > 80 && linkDensity < 0.25) || (nodeLength <= 80 && linkDensity === 0 && /\.( |$)/.test(nodeContent)) ) { append = true; } } if (append) article.innerHTML += sibling.innerHTML; } cleanedArticle = sanitize( article, candidates, videoRe, positiveRe, negativeRe, minTextLength, ); // var articleLength = cleanedArticle ? cleanedArticle.textContent.length : 0; } else { // If no best candidate, use the body or entire document article = doc.querySelector("body") || doc; cleanedArticle = sanitize( article, candidates, videoRe, positiveRe, negativeRe, minTextLength, ); } return cleanedArticle ? cleanedArticle.innerHTML : ""; } /** * Calculates the link density of an element. * @param {Element} elem - The element to calculate link density for * @returns {number} The link density (ratio of link text length to total text length) */ export function getLinkDensity(elem: any): number { if (!elem || !elem.textContent) { return 0; } const links = elem.querySelectorAll("a"); const textLength = elem.textContent.trim().length; const linkLength = (Array.from(links) as any[]).reduce( (total: number, link: any) => total + link.textContent.trim().length, 0, ); return textLength > 0 ? linkLength / textLength : 0; } /** * Calculates the weight of an element based on its class and id attributes. * @param {Element} elem - The element to calculate weight for * @param {RegExp} positiveRe - Regular expression for positive indicators * @param {RegExp} negativeRe - Regular expression for negative indicators * @returns {number} The calculated weight */ export function classWeight( elem: any, positiveRe: RegExp, negativeRe: RegExp, ): number { let weight = 0; if (!elem || !elem.getAttribute) return weight; if (elem.getAttribute("class")) { if (negativeRe.test(elem.getAttribute("class"))) weight -= 25; if (positiveRe.test(elem.getAttribute("class"))) weight += 25; } if (elem.getAttribute("id")) { if (negativeRe.test(elem.getAttribute("id"))) weight -= 25; if (positiveRe.test(elem.getAttribute("id"))) weight += 25; } return weight; } /** * Scores a node based on its tag name and attributes. * @param {Element} elem - The element to score * @param {RegExp} positiveRe - Regular expression for positive indicators * @param {RegExp} negativeRe - Regular expression for negative indicators * @returns {Object} An object containing the score and the element */ export function scoreNode( elem: any, positiveRe: RegExp, negativeRe: RegExp, ): Candidate { if (!elem || !elem.tagName) return { score: 0, elem }; const DIV_SCORES = new Set(["div", "article"]); const BLOCK_SCORES = new Set(["pre", "td", "blockquote"]); const BAD_ELEM_SCORES = new Set([ "address", "ol", "ul", "dl", "dd", "dt", "li", "form", "aside", ]); const STRUCTURE_SCORES = new Set([ "h1", "h2", "h3", "h4", "h5", "h6", "th", "header", "footer", "nav", ]); let score = classWeight(elem, positiveRe, negativeRe); const name = elem.tagName.toLowerCase(); if (DIV_SCORES.has(name)) score += 5; else if (BLOCK_SCORES.has(name)) score += 3; else if (BAD_ELEM_SCORES.has(name)) score -= 3; else if (STRUCTURE_SCORES.has(name)) score -= 5; return { score, elem }; } /** * Sanitizes the content by removing unwanted elements and cleaning remaining elements. * @param {Element} node - The node to sanitize * @param {Object} candidates - Object containing scored candidates * @param {RegExp} videoRe - Regular expression for video URLs * @param {RegExp} positiveRe - Regular expression for positive indicators * @param {RegExp} negativeRe - Regular expression for negative indicators * @param {number} minTextLength - Minimum text length to consider * @returns {Element} The sanitized node */ export function sanitize( node: any, candidates: Record, videoRe: RegExp, positiveRe: RegExp, negativeRe: RegExp, minTextLength: number, ): any { const DIV_TO_P_ELEMS = new Set([ "a", "blockquote", "dl", "div", "img", "ol", "p", "pre", "table", "ul", ]); // Remove unwanted elements for (let elem of node.querySelectorAll( "h1, h2, h3, h4, h5, h6, form, textarea, iframe", )) { if (elem.tagName === "IFRAME" && videoRe.test(elem.src)) { elem.textContent = "VIDEO"; } else { elem.remove(); } } // Clean remaining elements const allowed = new Set(); for (let elem of ( Array.from( node.querySelectorAll("table, ul, div, aside, header, footer, section"), ) as any[] ).reverse()) { if (allowed.has(elem)) continue; const weight = classWeight(elem, positiveRe, negativeRe); const score = candidates[String(elem)] ? candidates[String(elem)].score : 0; if (weight + score < 0) { elem.remove(); } else if (elem.textContent.split(",").length < 10) { // Count various elements within the current element const counts = { p: elem.querySelectorAll("p").length, img: elem.querySelectorAll("img").length, li: Math.max(0, elem.querySelectorAll("li").length - 100), input: elem.querySelectorAll("input").length - elem.querySelectorAll("input[type=hidden]").length, a: elem.querySelectorAll("a").length, embed: elem.querySelectorAll("embed").length, }; let textContent = elem?.textContent || ""; textContent = textContent.trim(); const contentLength = (textContent || "").replace(/\s+/g, " ").length; const linkDensity = getLinkDensity(elem); // Remove element if it meets certain criteria if ( counts.img > 1 + counts.p * 1.3 || (counts.li > counts.p && elem.tagName !== "UL" && elem.tagName !== "OL") || counts.input > counts.p / 3 || (contentLength < minTextLength && counts.img === 0) || (weight < 25 && linkDensity > 0.2) || (weight >= 25 && linkDensity > 0.5) || (counts.embed === 1 && contentLength < 75) || counts.embed > 1 ) { elem.remove(); } } } return node; }