|
| 1 | +// HTML cleanup for URL import. Tag removal is a single forward scan: the old |
| 2 | +// regular expressions retried unclosed `<meta` and `<nav` until the import |
| 3 | +// timed out, and an out-of-range numeric entity threw before any text was saved. |
| 4 | + |
| 5 | +const STRIP_TAGS = new Set([ |
| 6 | + "script", "style", "template", "noscript", "nav", "footer", "header", "aside", "form", "svg", "iframe", "button", |
| 7 | +]); |
| 8 | +const ROLE_STRIP = new Set(["banner", "navigation", "complementary", "search", "contentinfo"]); |
| 9 | +const CLASS_HINT = /(?:^|[\s_-])(?:cookie|consent|banner|navbar|navigation|nav|menu|sidebar|header|footer|subscribe|newsletter|social|share|paywall)(?:$|[\s_-])/i; |
| 10 | +const CONTENT_HINT = /(?:^|[\s_-])(?:article|content|story|post|entry|main|body|reader)(?:$|[\s_-])/i; |
| 11 | +const CONTENT_TAGS = new Set(["article", "main", "section", "div"]); |
| 12 | + |
| 13 | +export function decodeEntities(text: string): string { |
| 14 | + return text |
| 15 | + .replace(/ /gi, " ") |
| 16 | + .replace(/&/gi, "&") |
| 17 | + .replace(/"/gi, "\"") |
| 18 | + .replace(/'/gi, "'") |
| 19 | + .replace(/</gi, "<") |
| 20 | + .replace(/>/gi, ">") |
| 21 | + .replace(/&#x([0-9a-f]+);/gi, (_, hex: string) => fromCodePoint(hex, 16)) |
| 22 | + .replace(/&#(\d+);/g, (_, dec: string) => fromCodePoint(dec, 10)); |
| 23 | +} |
| 24 | + |
| 25 | +function fromCodePoint(raw: string, radix: number): string { |
| 26 | + const code = parseInt(raw, radix); |
| 27 | + // Surrogates and values past Unicode's last code point throw from fromCodePoint. |
| 28 | + if (!Number.isInteger(code) || code < 0 || code > 0x10FFFF || (code >= 0xD800 && code <= 0xDFFF)) return ""; |
| 29 | + return String.fromCodePoint(code); |
| 30 | +} |
| 31 | + |
| 32 | +function readAttr(tag: string, attr: string): string | undefined { |
| 33 | + const match = tag.match(new RegExp(`\\b${attr}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i")); |
| 34 | + return match?.[1] ?? match?.[2]; |
| 35 | +} |
| 36 | + |
| 37 | +function parseOpenTag(tag: string): { name: string; closing: boolean; selfClosing: boolean } | null { |
| 38 | + const match = /^<\s*(\/)?\s*([a-zA-Z][\w:-]*)/.exec(tag); |
| 39 | + if (!match) return null; |
| 40 | + return { |
| 41 | + name: match[2].toLowerCase(), |
| 42 | + closing: Boolean(match[1]), |
| 43 | + selfClosing: /\/\s*>$/.test(tag), |
| 44 | + }; |
| 45 | +} |
| 46 | + |
| 47 | +export function getMeta(html: string, names: string[]): string | undefined { |
| 48 | + const wanted = new Set(names.map((name) => name.toLowerCase())); |
| 49 | + const lower = html.toLowerCase(); |
| 50 | + let i = 0; |
| 51 | + while (i < html.length) { |
| 52 | + const start = lower.indexOf("<meta", i); |
| 53 | + if (start === -1) return undefined; |
| 54 | + const next = lower[start + 5]; |
| 55 | + if (next && /[a-z0-9]/.test(next)) { |
| 56 | + i = start + 5; |
| 57 | + continue; |
| 58 | + } |
| 59 | + const end = html.indexOf(">", start); |
| 60 | + // An unclosed tag is skipped in one step. Searching it with a regex retried the whole page. |
| 61 | + if (end === -1 || end - start > 4000) { |
| 62 | + i = start + 5; |
| 63 | + continue; |
| 64 | + } |
| 65 | + const tag = html.slice(start, end + 1); |
| 66 | + const name = (readAttr(tag, "property") || readAttr(tag, "name") || "").toLowerCase(); |
| 67 | + const content = readAttr(tag, "content"); |
| 68 | + if (name && content !== undefined && wanted.has(name)) return decodeEntities(content.trim()); |
| 69 | + i = end + 1; |
| 70 | + } |
| 71 | + return undefined; |
| 72 | +} |
| 73 | + |
| 74 | +export function getTitle(html: string, fallback: string): string { |
| 75 | + const meta = getMeta(html, ["og:title", "twitter:title"]); |
| 76 | + if (meta) return meta; |
| 77 | + const lower = html.toLowerCase(); |
| 78 | + const start = lower.indexOf("<title"); |
| 79 | + if (start === -1) return fallback; |
| 80 | + const openEnd = html.indexOf(">", start); |
| 81 | + const close = openEnd === -1 ? -1 : lower.indexOf("</title>", openEnd); |
| 82 | + if (openEnd === -1 || close === -1 || close - openEnd > 2000) return fallback; |
| 83 | + const text = decodeEntities(html.slice(openEnd + 1, close).replace(/\s+/g, " ").trim()); |
| 84 | + return text || fallback; |
| 85 | +} |
| 86 | + |
| 87 | +function shouldStrip(name: string, tag: string): boolean { |
| 88 | + if (STRIP_TAGS.has(name)) return true; |
| 89 | + if (name !== "div" && name !== "section") return false; |
| 90 | + const role = readAttr(tag, "role")?.toLowerCase(); |
| 91 | + if (role && ROLE_STRIP.has(role)) return true; |
| 92 | + const idClass = `${readAttr(tag, "id") || ""} ${readAttr(tag, "class") || ""}`; |
| 93 | + return CLASS_HINT.test(idClass); |
| 94 | +} |
| 95 | + |
| 96 | +function isContentBlock(name: string, tag: string): boolean { |
| 97 | + if (!CONTENT_TAGS.has(name)) return false; |
| 98 | + if (name === "article" || name === "main") return true; |
| 99 | + const idClass = `${readAttr(tag, "id") || ""} ${readAttr(tag, "class") || ""}`; |
| 100 | + return CONTENT_HINT.test(idClass); |
| 101 | +} |
| 102 | + |
| 103 | +/** Index just after the matching close tag, or -1 when this element is never closed. */ |
| 104 | +function matchingClose(html: string, lower: string, tag: string, from: number, absentAfter: Map<string, number>): number { |
| 105 | + const known = absentAfter.get(tag); |
| 106 | + if (known !== undefined && from >= known) return -1; |
| 107 | + const openNeedle = `<${tag}`; |
| 108 | + const closeNeedle = `</${tag}`; |
| 109 | + let depth = 1; |
| 110 | + let i = from; |
| 111 | + while (depth > 0) { |
| 112 | + const nextOpen = lower.indexOf(openNeedle, i); |
| 113 | + const nextClose = lower.indexOf(closeNeedle, i); |
| 114 | + if (nextClose === -1) { |
| 115 | + absentAfter.set(tag, from); |
| 116 | + return -1; |
| 117 | + } |
| 118 | + const openIsTag = nextOpen !== -1 && !/[a-z0-9]/i.test(lower[nextOpen + openNeedle.length] || ""); |
| 119 | + if (openIsTag && nextOpen < nextClose) { |
| 120 | + depth += 1; |
| 121 | + i = nextOpen + openNeedle.length; |
| 122 | + continue; |
| 123 | + } |
| 124 | + depth -= 1; |
| 125 | + i = nextClose + closeNeedle.length; |
| 126 | + } |
| 127 | + const end = html.indexOf(">", i - closeNeedle.length); |
| 128 | + return end === -1 ? html.length : end + 1; |
| 129 | +} |
| 130 | + |
| 131 | +export function stripHtml(html: string): string { |
| 132 | + const lower = html.toLowerCase(); |
| 133 | + const absentAfter = new Map<string, number>(); |
| 134 | + const candidates: string[] = []; |
| 135 | + let cleaned = ""; |
| 136 | + let i = 0; |
| 137 | + while (i < html.length) { |
| 138 | + const start = html.indexOf("<", i); |
| 139 | + if (start === -1) { |
| 140 | + cleaned += html.slice(i); |
| 141 | + break; |
| 142 | + } |
| 143 | + cleaned += html.slice(i, start); |
| 144 | + if (lower.startsWith("<!--", start)) { |
| 145 | + const end = html.indexOf("-->", start + 4); |
| 146 | + cleaned += " "; |
| 147 | + i = end === -1 ? html.length : end + 3; |
| 148 | + continue; |
| 149 | + } |
| 150 | + const end = html.indexOf(">", start + 1); |
| 151 | + if (end === -1 || end - start > 8000) { |
| 152 | + cleaned += "<"; |
| 153 | + i = start + 1; |
| 154 | + continue; |
| 155 | + } |
| 156 | + const tag = html.slice(start, end + 1); |
| 157 | + const parsed = parseOpenTag(tag); |
| 158 | + if (!parsed || parsed.closing || parsed.selfClosing) { |
| 159 | + cleaned += tag; |
| 160 | + i = end + 1; |
| 161 | + continue; |
| 162 | + } |
| 163 | + if (shouldStrip(parsed.name, tag)) { |
| 164 | + const close = matchingClose(html, lower, parsed.name, end + 1, absentAfter); |
| 165 | + cleaned += " "; |
| 166 | + i = close === -1 ? end + 1 : close; |
| 167 | + continue; |
| 168 | + } |
| 169 | + if (isContentBlock(parsed.name, tag) && candidates.length < 8) { |
| 170 | + const close = matchingClose(html, lower, parsed.name, end + 1, absentAfter); |
| 171 | + if (close !== -1) candidates.push(html.slice(end + 1, close)); |
| 172 | + } |
| 173 | + cleaned += tag; |
| 174 | + i = end + 1; |
| 175 | + } |
| 176 | + |
| 177 | + const textCandidates = [...candidates, cleaned] |
| 178 | + .map((candidate) => htmlToText(candidate)) |
| 179 | + .filter((text) => text.split(/\s+/).filter(Boolean).length >= 20); |
| 180 | + return textCandidates.sort((a, b) => scoreReadableText(b) - scoreReadableText(a))[0] || htmlToText(cleaned); |
| 181 | +} |
| 182 | + |
| 183 | +function htmlToText(html: string): string { |
| 184 | + return decodeEntities(html |
| 185 | + .replace(/<\/(p|div|section|article|main|h[1-6]|li|blockquote|tr)>/gi, "\n") |
| 186 | + .replace(/<br\s*\/?>/gi, "\n") |
| 187 | + .replace(/<[^>]+>/g, " ") |
| 188 | + .replace(/[ \t]+/g, " ") |
| 189 | + .replace(/\n{3,}/g, "\n\n") |
| 190 | + .trim()); |
| 191 | +} |
| 192 | + |
| 193 | +function scoreReadableText(text: string): number { |
| 194 | + const words = text.split(/\s+/).filter(Boolean).length; |
| 195 | + const paragraphs = text.split(/\n{2,}/).filter((part) => part.trim().split(/\s+/).length >= 12).length; |
| 196 | + const punctuation = (text.match(/[.!?]/g) || []).length; |
| 197 | + const navPenalty = (text.match(/\b(cookie|subscribe|sign in|advertisement|privacy policy|all rights reserved)\b/gi) || []).length * 30; |
| 198 | + return words + paragraphs * 80 + punctuation * 4 - navPenalty; |
| 199 | +} |
0 commit comments