|
| 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 | +// Every search below remembers where it last found its needle and only searches |
| 5 | +// again once the scan has passed that point, so hostile pages (thousands of |
| 6 | +// unclosed tags, a stray `<` with no `>`) stay linear in the page size. |
| 7 | + |
| 8 | +/** indexOf for a forward scan: repeated calls with growing `from` cost O(n) in total. */ |
| 9 | +function forwardFinder(haystack: string, needle: string): (from: number) => number { |
| 10 | + let found = -2; // -2: not searched yet; -1: absent from `searchedFrom` on |
| 11 | + let searchedFrom = 0; |
| 12 | + return (from: number) => { |
| 13 | + if (found === -1 && from >= searchedFrom) return -1; |
| 14 | + if (found >= from && from >= searchedFrom) return found; |
| 15 | + searchedFrom = from; |
| 16 | + found = haystack.indexOf(needle, from); |
| 17 | + return found; |
| 18 | + }; |
| 19 | +} |
| 20 | + |
| 21 | +const STRIP_TAGS = new Set([ |
| 22 | + "script", "style", "template", "noscript", "nav", "footer", "header", "aside", "form", "svg", "iframe", "button", |
| 23 | +]); |
| 24 | +const ROLE_STRIP = new Set(["banner", "navigation", "complementary", "search", "contentinfo"]); |
| 25 | +const CLASS_HINT = /(?:^|[\s_-])(?:cookie|consent|banner|navbar|navigation|nav|menu|sidebar|header|footer|subscribe|newsletter|social|share|paywall)(?:$|[\s_-])/i; |
| 26 | +const CONTENT_HINT = /(?:^|[\s_-])(?:article|content|story|post|entry|main|body|reader)(?:$|[\s_-])/i; |
| 27 | +const CONTENT_TAGS = new Set(["article", "main", "section", "div"]); |
| 28 | + |
| 29 | +export function decodeEntities(text: string): string { |
| 30 | + return text |
| 31 | + .replace(/ /gi, " ") |
| 32 | + .replace(/&/gi, "&") |
| 33 | + .replace(/"/gi, "\"") |
| 34 | + .replace(/'/gi, "'") |
| 35 | + .replace(/</gi, "<") |
| 36 | + .replace(/>/gi, ">") |
| 37 | + .replace(/&#x([0-9a-f]+);/gi, (_, hex: string) => fromCodePoint(hex, 16)) |
| 38 | + .replace(/&#(\d+);/g, (_, dec: string) => fromCodePoint(dec, 10)); |
| 39 | +} |
| 40 | + |
| 41 | +function fromCodePoint(raw: string, radix: number): string { |
| 42 | + const code = parseInt(raw, radix); |
| 43 | + // Surrogates and values past Unicode's last code point throw from fromCodePoint. |
| 44 | + if (!Number.isInteger(code) || code < 0 || code > 0x10FFFF || (code >= 0xD800 && code <= 0xDFFF)) return ""; |
| 45 | + return String.fromCodePoint(code); |
| 46 | +} |
| 47 | + |
| 48 | +function readAttr(tag: string, attr: string): string | undefined { |
| 49 | + const match = tag.match(new RegExp(`\\b${attr}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i")); |
| 50 | + return match?.[1] ?? match?.[2]; |
| 51 | +} |
| 52 | + |
| 53 | +function parseOpenTag(tag: string): { name: string; closing: boolean; selfClosing: boolean } | null { |
| 54 | + const match = /^<\s*(\/)?\s*([a-zA-Z][\w:-]*)/.exec(tag); |
| 55 | + if (!match) return null; |
| 56 | + return { |
| 57 | + name: match[2].toLowerCase(), |
| 58 | + closing: Boolean(match[1]), |
| 59 | + selfClosing: /\/\s*>$/.test(tag), |
| 60 | + }; |
| 61 | +} |
| 62 | + |
| 63 | +export function getMeta(html: string, names: string[]): string | undefined { |
| 64 | + const wanted = new Set(names.map((name) => name.toLowerCase())); |
| 65 | + const lower = html.toLowerCase(); |
| 66 | + const nextMeta = forwardFinder(lower, "<meta"); |
| 67 | + const nextGt = forwardFinder(html, ">"); |
| 68 | + let i = 0; |
| 69 | + while (i < html.length) { |
| 70 | + const start = nextMeta(i); |
| 71 | + if (start === -1) return undefined; |
| 72 | + const next = lower[start + 5]; |
| 73 | + if (next && /[a-z0-9]/.test(next)) { |
| 74 | + i = start + 5; |
| 75 | + continue; |
| 76 | + } |
| 77 | + const end = nextGt(start); |
| 78 | + // No `>` left means no complete tag left. |
| 79 | + if (end === -1) return undefined; |
| 80 | + // An overlong tag is skipped in one step. Searching it with a regex retried the whole page. |
| 81 | + if (end - start > 4000) { |
| 82 | + i = start + 5; |
| 83 | + continue; |
| 84 | + } |
| 85 | + const tag = html.slice(start, end + 1); |
| 86 | + const name = (readAttr(tag, "property") || readAttr(tag, "name") || "").toLowerCase(); |
| 87 | + const content = readAttr(tag, "content"); |
| 88 | + if (name && content !== undefined && wanted.has(name)) return decodeEntities(content.trim()); |
| 89 | + i = end + 1; |
| 90 | + } |
| 91 | + return undefined; |
| 92 | +} |
| 93 | + |
| 94 | +export function getTitle(html: string, fallback: string): string { |
| 95 | + const meta = getMeta(html, ["og:title", "twitter:title"]); |
| 96 | + if (meta) return meta; |
| 97 | + const lower = html.toLowerCase(); |
| 98 | + const start = lower.indexOf("<title"); |
| 99 | + if (start === -1) return fallback; |
| 100 | + const openEnd = html.indexOf(">", start); |
| 101 | + const close = openEnd === -1 ? -1 : lower.indexOf("</title>", openEnd); |
| 102 | + if (openEnd === -1 || close === -1 || close - openEnd > 2000) return fallback; |
| 103 | + const text = decodeEntities(html.slice(openEnd + 1, close).replace(/\s+/g, " ").trim()); |
| 104 | + return text || fallback; |
| 105 | +} |
| 106 | + |
| 107 | +function shouldStrip(name: string, tag: string): boolean { |
| 108 | + if (STRIP_TAGS.has(name)) return true; |
| 109 | + if (name !== "div" && name !== "section") return false; |
| 110 | + const role = readAttr(tag, "role")?.toLowerCase(); |
| 111 | + if (role && ROLE_STRIP.has(role)) return true; |
| 112 | + const idClass = `${readAttr(tag, "id") || ""} ${readAttr(tag, "class") || ""}`; |
| 113 | + return CLASS_HINT.test(idClass); |
| 114 | +} |
| 115 | + |
| 116 | +function isContentBlock(name: string, tag: string): boolean { |
| 117 | + if (!CONTENT_TAGS.has(name)) return false; |
| 118 | + if (name === "article" || name === "main") return true; |
| 119 | + const idClass = `${readAttr(tag, "id") || ""} ${readAttr(tag, "class") || ""}`; |
| 120 | + return CONTENT_HINT.test(idClass); |
| 121 | +} |
| 122 | + |
| 123 | +/** Index just after the matching close tag, or -1 when this element is never closed. */ |
| 124 | +function matchingClose(html: string, lower: string, tag: string, from: number, absentAfter: Map<string, number>): number { |
| 125 | + const known = absentAfter.get(tag); |
| 126 | + if (known !== undefined && from >= known) return -1; |
| 127 | + const openNeedle = `<${tag}`; |
| 128 | + const closeNeedle = `</${tag}`; |
| 129 | + // Each needle is searched once per occurrence, not once per nesting level: |
| 130 | + // re-running indexOf for the far close tag at every level was quadratic. |
| 131 | + const findOpen = forwardFinder(lower, openNeedle); |
| 132 | + const findClose = forwardFinder(lower, closeNeedle); |
| 133 | + let depth = 1; |
| 134 | + let i = from; |
| 135 | + while (depth > 0) { |
| 136 | + const nextOpen = findOpen(i); |
| 137 | + const nextClose = findClose(i); |
| 138 | + if (nextClose === -1) { |
| 139 | + absentAfter.set(tag, from); |
| 140 | + return -1; |
| 141 | + } |
| 142 | + const openIsTag = nextOpen !== -1 && !/[a-z0-9]/i.test(lower[nextOpen + openNeedle.length] || ""); |
| 143 | + if (openIsTag && nextOpen < nextClose) { |
| 144 | + depth += 1; |
| 145 | + i = nextOpen + openNeedle.length; |
| 146 | + continue; |
| 147 | + } |
| 148 | + depth -= 1; |
| 149 | + i = nextClose + closeNeedle.length; |
| 150 | + } |
| 151 | + const end = html.indexOf(">", i - closeNeedle.length); |
| 152 | + return end === -1 ? html.length : end + 1; |
| 153 | +} |
| 154 | + |
| 155 | +export function stripHtml(html: string): string { |
| 156 | + const lower = html.toLowerCase(); |
| 157 | + const absentAfter = new Map<string, number>(); |
| 158 | + const candidates: string[] = []; |
| 159 | + const nextGt = forwardFinder(html, ">"); |
| 160 | + let cleaned = ""; |
| 161 | + let i = 0; |
| 162 | + while (i < html.length) { |
| 163 | + const start = html.indexOf("<", i); |
| 164 | + if (start === -1) { |
| 165 | + cleaned += html.slice(i); |
| 166 | + break; |
| 167 | + } |
| 168 | + cleaned += html.slice(i, start); |
| 169 | + if (lower.startsWith("<!--", start)) { |
| 170 | + const end = html.indexOf("-->", start + 4); |
| 171 | + cleaned += " "; |
| 172 | + i = end === -1 ? html.length : end + 3; |
| 173 | + continue; |
| 174 | + } |
| 175 | + const end = nextGt(start + 1); |
| 176 | + if (end === -1) { |
| 177 | + // No `>` left, so nothing after this point can be a tag. |
| 178 | + cleaned += html.slice(start); |
| 179 | + break; |
| 180 | + } |
| 181 | + if (end - start > 8000) { |
| 182 | + cleaned += "<"; |
| 183 | + i = start + 1; |
| 184 | + continue; |
| 185 | + } |
| 186 | + const tag = html.slice(start, end + 1); |
| 187 | + const parsed = parseOpenTag(tag); |
| 188 | + if (!parsed || parsed.closing || parsed.selfClosing) { |
| 189 | + cleaned += tag; |
| 190 | + i = end + 1; |
| 191 | + continue; |
| 192 | + } |
| 193 | + if (shouldStrip(parsed.name, tag)) { |
| 194 | + const close = matchingClose(html, lower, parsed.name, end + 1, absentAfter); |
| 195 | + cleaned += " "; |
| 196 | + i = close === -1 ? end + 1 : close; |
| 197 | + continue; |
| 198 | + } |
| 199 | + if (isContentBlock(parsed.name, tag) && candidates.length < 8) { |
| 200 | + const close = matchingClose(html, lower, parsed.name, end + 1, absentAfter); |
| 201 | + if (close !== -1) candidates.push(html.slice(end + 1, close)); |
| 202 | + } |
| 203 | + cleaned += tag; |
| 204 | + i = end + 1; |
| 205 | + } |
| 206 | + |
| 207 | + const textCandidates = [...candidates, cleaned] |
| 208 | + .map((candidate) => htmlToText(candidate)) |
| 209 | + .filter((text) => text.split(/\s+/).filter(Boolean).length >= 20); |
| 210 | + return textCandidates.sort((a, b) => scoreReadableText(b) - scoreReadableText(a))[0] || htmlToText(cleaned); |
| 211 | +} |
| 212 | + |
| 213 | +function htmlToText(html: string): string { |
| 214 | + return decodeEntities(html |
| 215 | + .replace(/<\/(p|div|section|article|main|h[1-6]|li|blockquote|tr)>/gi, "\n") |
| 216 | + .replace(/<br\s*\/?>/gi, "\n") |
| 217 | + // `[^<>]` keeps each attempt from rescanning to the end of a page full of stray `<`. |
| 218 | + .replace(/<[^<>]+>/g, " ") |
| 219 | + .replace(/[ \t]+/g, " ") |
| 220 | + .replace(/\n{3,}/g, "\n\n") |
| 221 | + .trim()); |
| 222 | +} |
| 223 | + |
| 224 | +function scoreReadableText(text: string): number { |
| 225 | + const words = text.split(/\s+/).filter(Boolean).length; |
| 226 | + const paragraphs = text.split(/\n{2,}/).filter((part) => part.trim().split(/\s+/).length >= 12).length; |
| 227 | + const punctuation = (text.match(/[.!?]/g) || []).length; |
| 228 | + const navPenalty = (text.match(/\b(cookie|subscribe|sign in|advertisement|privacy policy|all rights reserved)\b/gi) || []).length * 30; |
| 229 | + return words + paragraphs * 80 + punctuation * 4 - navPenalty; |
| 230 | +} |
0 commit comments