Skip to content

Commit 12fb13f

Browse files
committed
Merge remote-tracking branch 'origin/main' into w179
2 parents 0974b59 + 1ad9329 commit 12fb13f

3 files changed

Lines changed: 306 additions & 80 deletions

File tree

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
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(/&nbsp;/gi, " ")
32+
.replace(/&amp;/gi, "&")
33+
.replace(/&quot;/gi, "\"")
34+
.replace(/&apos;/gi, "'")
35+
.replace(/&lt;/gi, "<")
36+
.replace(/&gt;/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+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { assertEquals } from "jsr:@std/assert@1.0.19";
2+
import { decodeEntities, getMeta, getTitle, stripHtml } from "./html-text.ts";
3+
4+
Deno.test("out-of-range numeric entities do not crash the import", () => {
5+
const html = `<article><p>${"word ".repeat(30)}&#99999999; &#x110000; &#xD800; &#38; more.</p></article>`;
6+
const text = stripHtml(html);
7+
assertEquals(text.includes("Invalid code point"), false);
8+
assertEquals(text.includes("&"), true);
9+
assertEquals(decodeEntities("&#99999999;"), "");
10+
assertEquals(decodeEntities("&#38;"), "&");
11+
assertEquals(decodeEntities("caf&#233;"), "café");
12+
});
13+
14+
Deno.test("article text is kept and a closed nav is dropped", () => {
15+
const words = "Public transport is cheaper than driving for most students in cities.";
16+
const html = `<nav><a>Home</a><a>Sign in</a></nav><article><p>${words} ${words}</p></article>`;
17+
const text = stripHtml(html);
18+
assertEquals(text.includes("Public transport"), true);
19+
assertEquals(text.includes("Home"), false);
20+
});
21+
22+
Deno.test("meta titles are read without retrying an unclosed tag", () => {
23+
const hostile = "<meta name='og:title' ".repeat(8000);
24+
const started = Date.now();
25+
assertEquals(getMeta(hostile, ["og:title"]), undefined);
26+
assertEquals(getTitle(`${hostile}<title>Voting age</title>`, "fallback"), "Voting age");
27+
const closed = `<meta property="og:title" content="Free buses &amp; trains">`;
28+
assertEquals(getMeta(closed, ["og:title"]), "Free buses & trains");
29+
if (Date.now() - started > 500) throw new Error("unclosed meta tags took too long");
30+
});
31+
32+
Deno.test("a long run of unclosed nav tags stays fast", () => {
33+
const html = "<nav class='menu'>".repeat(4000) + `<article><p>${"evidence ".repeat(40)}</p></article>`;
34+
const started = Date.now();
35+
const text = stripHtml(html);
36+
if (Date.now() - started > 500) throw new Error("unclosed nav tags took too long");
37+
assertEquals(text.includes("evidence"), true);
38+
});
39+
40+
// Pages up to the 1 MB import limit. Each case was quadratic in an earlier
41+
// version and took from seconds to many minutes at this size; linear code
42+
// takes well under 200 ms. The budget is loose so a busy machine does not flake.
43+
const PAGE = 1_000_000;
44+
const BUDGET_MS = 1500;
45+
const hostilePages: Record<string, string> = {
46+
"unclosed nav tags with one far close": "<nav>".repeat(PAGE / 5) + "</nav>",
47+
"stray < with no >": "<".repeat(PAGE),
48+
"unclosed meta tags with no >": "<meta ".repeat(PAGE / 6),
49+
"text full of '< a'": "< a ".repeat(PAGE / 4),
50+
"overlong tags": ("<" + "a".repeat(9000)).repeat(Math.ceil(PAGE / 9001)) + ">",
51+
};
52+
for (const [name, html] of Object.entries(hostilePages)) {
53+
Deno.test(`a 1 MB page of ${name} is parsed in linear time`, () => {
54+
const started = performance.now();
55+
stripHtml(html);
56+
getTitle(html, "fallback");
57+
getMeta(html, ["author"]);
58+
const elapsed = performance.now() - started;
59+
if (elapsed > BUDGET_MS) throw new Error(`${name}: ${Math.round(elapsed)} ms`);
60+
});
61+
}
62+
63+
Deno.test("a nested nav is dropped whole and the article survives the speed-ups", () => {
64+
const words = "Lowering the voting age raises turnout among first-time voters.";
65+
const html = `<nav><ul><li><nav>Inner</nav></li></ul>Outer</nav><article><p>${words} ${words} ${words}</p></article>`;
66+
const text = stripHtml(html);
67+
assertEquals(text.includes("Inner"), false);
68+
assertEquals(text.includes("Outer"), false);
69+
assertEquals(text.includes("voting age"), true);
70+
});
71+
72+
Deno.test("text after the last > is kept, with no tag parsing", () => {
73+
const html = `<p>${"word ".repeat(25)}</p> 3 < 5 and more words here`;
74+
assertEquals(stripHtml(html).includes("3 < 5 and more words here"), true);
75+
});

0 commit comments

Comments
 (0)