export interface HtmlExtraction {
title?: string;
markdown: string;
}
const ENTITY_MAP: Record = {
" ": " ",
"&": "&",
"<": "<",
">": ">",
""": '"',
"'": "'",
"'": "'",
"…": "…",
"—": "—",
"–": "–",
"«": "«",
"»": "»",
"©": "©",
"®": "®",
"™": "™",
};
function decodeEntities(text: string): string {
let out = text;
for (const [k, v] of Object.entries(ENTITY_MAP)) out = out.split(k).join(v);
out = out.replace(/(\d+);/g, (_, n) => String.fromCodePoint(Number(n)));
out = out.replace(/([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)));
return out;
}
const CHROME_CLASS_EXACT = [
"navbox",
"sidebar",
"infobox",
"hatnote",
"shortdescription",
"noprint",
"thumb",
"thumbcaption",
"vertical-navbox",
"mw-editsection",
"mw-jump-link",
"mw-cite-backlink",
"reflist",
"catlinks",
"printfooter",
"mw-indicator",
"mw-empty-elt",
"toc",
"cookie-banner",
"cookies-banner",
"newsletter-signup",
"share-buttons",
"social-share",
"breadcrumbs",
"pagination",
"site-header",
"site-footer",
];
function stripChromeBlocks(html: string): string {
const classRe = new RegExp(
`<(table|div|aside|section|nav|ul|ol|figure)\\b[^>]*class=["'][^"']*(?]*>`,
"i",
);
let out = html;
let safety = 0;
while (safety++ < 500) {
const match = classRe.exec(out);
if (!match) break;
const tag = match[1].toLowerCase();
const start = match.index;
const openLen = match[0].length;
const openRe = new RegExp(`<${tag}\\b[^>]*>`, "ig");
const closeRe = new RegExp(`${tag}\\s*>`, "ig");
openRe.lastIndex = start + openLen;
closeRe.lastIndex = start + openLen;
let depth = 1;
let cursor = start + openLen;
while (depth > 0) {
openRe.lastIndex = cursor;
closeRe.lastIndex = cursor;
const o = openRe.exec(out);
const c = closeRe.exec(out);
if (!c) break;
if (o && o.index < c.index) {
depth++;
cursor = o.index + o[0].length;
} else {
depth--;
cursor = c.index + c[0].length;
if (depth === 0) {
out = out.slice(0, start) + out.slice(cursor);
break;
}
}
}
if (depth !== 0) {
out = out.slice(0, start) + out.slice(start + openLen);
}
}
return out;
}
function stripRoleNavigation(html: string): string {
return html.replace(/<(div|section|nav|aside)\b[^>]*role=["']navigation["'][^>]*>[\s\S]*?<\/\1>/gi, "");
}
export function htmlToMarkdown(html: string): HtmlExtraction {
const title = html.match(/]*>([\s\S]*?)<\/title>/i)?.[1]?.replace(/\s+/g, " ").trim();
let main = html.match(/]*>([\s\S]*?)<\/main>/i)?.[1]
?? html.match(/]*>([\s\S]*?)<\/article>/i)?.[1]
?? html.match(/]*>([\s\S]*?)<\/body>/i)?.[1]
?? html;
main = stripRoleNavigation(main);
main = stripChromeBlocks(main);
let body = main
.replace(/