pi-webfind 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/extract.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * HTML → markdown extractor with density-scored article detection.
3
3
  *
4
- * Stage 1: score candidate blocks by text/link density (Readability-lite)
5
- * and pick the best content container.
4
+ * Stage 1: score candidate blocks by text/link density (Readability-lite),
5
+ * subtracting text inside junk-classed subtrees (comments, sidebars, navs)
6
+ * so a container holding article + comments does not beat the article alone.
7
+ * Docs-site profiles (PostgreSQL, Docusaurus, MDN, react.dev, Nextra) hint
8
+ * the content root and fall back to density scoring when they miss.
6
9
  * Stage 2: convert the chosen block to markdown — headings, links, code
7
10
  * fences, lists, tables survive so the model can read structure.
8
11
  *
@@ -21,20 +24,79 @@ interface Node {
21
24
 
22
25
  const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
23
26
  const DROP_TAGS = new Set(["script", "style", "noscript", "template", "svg", "iframe", "form", "select", "button", "nav", "aside", "link", "meta", "base", "area", "track", "param", "annotation", "semantics"]);
27
+ // tags whose open implies closing an open <p> above (through inline wrappers)
28
+ const CLOSES_P = new Set(["address", "article", "aside", "blockquote", "details", "div", "dl", "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hr", "main", "menu", "nav", "ol", "p", "pre", "section", "table", "ul"]);
29
+ // elements that participate in inline formatting — p-close scans down through these only
30
+ const INLINE = new Set(["a", "b", "i", "em", "strong", "span", "code", "small", "sub", "sup", "u", "s", "mark", "abbr", "time", "cite", "q", "kbd", "label", "img", "br", "wbr"]);
31
+ // sibling auto-close: opening X closes an open Y above the nearest Z
32
+ const SIBLING: Record<string, { closes: string[]; within: string[] }> = {
33
+ li: { closes: ["li"], within: ["ul", "ol", "menu"] },
34
+ dt: { closes: ["dt", "dd"], within: ["dl"] },
35
+ dd: { closes: ["dt", "dd"], within: ["dl"] },
36
+ td: { closes: ["td", "th"], within: ["tr"] },
37
+ th: { closes: ["td", "th"], within: ["tr"] },
38
+ tr: { closes: ["tr"], within: ["table", "tbody", "thead", "tfoot"] },
39
+ tbody: { closes: ["tbody", "thead", "tfoot"], within: ["table"] },
40
+ thead: { closes: ["tbody", "thead", "tfoot"], within: ["table"] },
41
+ tfoot: { closes: ["tbody", "thead", "tfoot"], within: ["table"] },
42
+ };
43
+ // block-level tags used by the whitespace-collapsing policy when rendering children
44
+ const BLOCK = new Set(["p", "div", "section", "article", "main", "ul", "ol", "li", "table", "pre", "blockquote", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "header", "footer", "figure", "figcaption", "details", "summary", "dl", "dt", "dd", "address", "fieldset"]);
45
+ // block containers that get blank-line separators between siblings when rendered
46
+ const SEP = new Set(["div", "section", "article", "main", "li", "dd", "dt", "figure", "figcaption", "details", "summary", "dl", "address", "fieldset"]);
47
+
48
+ /** HTML5-ish implicit close: unclosed <p>, <li>, <td>, <tr>, ... before a new block/row starts. */
49
+ function implicitClose(stack: Node[], tag: string): void {
50
+ const sib = SIBLING[tag];
51
+ if (sib) {
52
+ for (let s = stack.length - 1; s > 0; s--) {
53
+ const t = stack[s].tag;
54
+ if (sib.closes.includes(t)) {
55
+ stack.length = s;
56
+ break;
57
+ }
58
+ if (sib.within.includes(t)) break;
59
+ }
60
+ }
61
+ if (CLOSES_P.has(tag)) {
62
+ for (let s = stack.length - 1; s > 0; s--) {
63
+ const t = stack[s].tag;
64
+ if (t === "p") {
65
+ stack.length = s;
66
+ break;
67
+ }
68
+ if (!INLINE.has(t)) break;
69
+ }
70
+ }
71
+ }
72
+
73
+ /** Index just past the matching close tag of a dropped element (depth-counted for nesting). */
74
+ function skipDropped(html: string, tagName: string, gt: number): number {
75
+ const re = new RegExp(`<(/)${tagName}\\b[^>]*>`, "gi");
76
+ re.lastIndex = gt;
77
+ let depth = 1;
78
+ for (let m = re.exec(html); m; m = re.exec(html)) {
79
+ depth += m[1] ? -1 : 1;
80
+ if (depth === 0) return m.index + m[0].length;
81
+ }
82
+ return html.length;
83
+ }
24
84
 
25
85
  /** Tolerant HTML parser → shallow node tree. Never throws. */
26
86
  export function parseHtml(html: string): Node {
27
87
  const root: Node = { tag: "#root", attrs: {}, children: [] };
28
88
  const stack: Node[] = [root];
89
+ let preDepth = 0;
29
90
  let i = 0;
30
91
  const len = html.length;
31
92
  while (i < len) {
32
93
  const lt = html.indexOf("<", i);
33
94
  if (lt === -1) break;
34
- // text before this tag
95
+ // text before this tag — kept even when whitespace-only (token spans, inline gaps);
96
+ // collapsing to one space happens at render time
35
97
  if (lt > i) {
36
98
  const t = html.slice(i, lt);
37
- if (/\S/.test(t)) stack[stack.length - 1].children.push({ tag: "#text", attrs: {}, children: [], text: decodeEntities(t) });
99
+ if (t.length) stack[stack.length - 1].children.push({ tag: "#text", attrs: {}, children: [], text: decodeEntities(t) });
38
100
  }
39
101
  if (html.startsWith("<!--", lt)) {
40
102
  const end = html.indexOf("-->", lt);
@@ -44,6 +106,7 @@ export function parseHtml(html: string): Node {
44
106
  if (html[lt + 1] === "/") {
45
107
  const gt = html.indexOf(">", lt);
46
108
  const tag = html.slice(lt + 2, gt === -1 ? len : gt).trim().toLowerCase();
109
+ if (tag === "pre") preDepth = Math.max(0, preDepth - 1);
47
110
  for (let s = stack.length - 1; s > 0; s--) {
48
111
  if (stack[s].tag === tag) {
49
112
  stack.length = s;
@@ -66,51 +129,109 @@ export function parseHtml(html: string): Node {
66
129
  i = gt + 1;
67
130
  continue;
68
131
  }
69
- // raw-text elements: consume until the literal close tag
70
- if (tagName === "script" || tagName === "style") {
71
- const close = new RegExp(`</${tagName}\\s*>`, "i").exec(html.slice(gt));
72
- i = close ? gt + close.index + close[0].length : len;
132
+ // dropped elements never enter the tree (and never reach content statistics);
133
+ // void members and self-closing forms just skip the tag itself
134
+ if (DROP_TAGS.has(tagName)) {
135
+ const selfClosed = /\/\s*$/.test(tagSrc);
136
+ i = VOID_TAGS.has(tagName) || selfClosed ? gt + 1 : skipDropped(html, tagName, gt);
73
137
  continue;
74
138
  }
75
139
  const attrs: Record<string, string> = {};
76
- for (const m of tagSrc.matchAll(/([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g)) {
77
- attrs[m[1].toLowerCase()] = decodeEntities(m[3] ?? m[4] ?? m[5] ?? "");
140
+ for (const m of tagSrc.matchAll(/([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))|(?=[\s\/]|$))/g)) {
141
+ attrs[m[1].toLowerCase()] = m[2] === undefined ? "" : decodeEntities(m[3] ?? m[4] ?? m[5] ?? "");
78
142
  }
79
- const node: Node = { tag: tagName, attrs, children: [] };
80
- stack[stack.length - 1].children.push(node);
81
- const selfClosing = tagSrc.endsWith("/") || VOID_TAGS.has(tagName);
82
- // MediaWiki Parsoid annotation wrappers: drop the element AND its content
83
- if (/mw:Transclusion|mw:Param/.test(attrs["typeof"] ?? "")) {
84
- const close = new RegExp(`</${tagName}\s*>`, "i").exec(html.slice(gt));
143
+ // hidden subtrees never enter the tree display:none blocks (search overlays,
144
+ // dropdown panels, hidden dialogs) are not part of the readable page
145
+ if (attrs.hidden !== undefined || /display:\s*none/.test(attrs.style ?? "")) {
146
+ const selfClosed = /\/\s*$/.test(tagSrc) || VOID_TAGS.has(tagName);
147
+ i = selfClosed ? gt + 1 : skipDropped(html, tagName, gt);
148
+ continue;
149
+ }
150
+ // MediaWiki Parsoid annotation wrappers: drop the element AND its content.
151
+ // Covers mw:Transclusion/mw:Param and extension content like mw:Extension/math
152
+ // (the <math> markup lives inside those wrappers on Parsoid pages).
153
+ if (/mw:Transclusion|mw:Param|mw:Extension/.test(attrs["typeof"] ?? "")) {
154
+ const close = new RegExp(`</${tagName}\\s*>`, "i").exec(html.slice(gt));
85
155
  i = close ? gt + close.index + close[0].length : len;
86
156
  continue;
87
157
  }
158
+ implicitClose(stack, tagName);
159
+ const node: Node = { tag: tagName, attrs, children: [] };
160
+ stack[stack.length - 1].children.push(node);
161
+ // `<a href=/>` is an unquoted value, not self-closing; `<br/>`, `<img src="x"/>`, `<div />` are
162
+ const selfClosing = /(^[a-zA-Z][a-zA-Z0-9-]*|["'\s])\/$/.test(tagSrc) || VOID_TAGS.has(tagName);
163
+ if (tagName === "pre" && !selfClosing) preDepth++;
88
164
  if (!selfClosing) stack.push(node);
89
165
  i = gt + 1;
90
166
  }
91
- // trailing text
92
- if (i < len && /\S/.test(html.slice(i))) {
93
- stack[stack.length - 1].children.push({ tag: "#text", attrs: {}, children: [], text: html.slice(i) });
167
+ // trailing text — verbatim inside pre, otherwise only when non-whitespace
168
+ if (i < len) {
169
+ const t = html.slice(i);
170
+ if (preDepth > 0 || /\S/.test(t)) {
171
+ stack[stack.length - 1].children.push({ tag: "#text", attrs: {}, children: [], text: decodeEntities(t) });
172
+ }
94
173
  }
95
174
  return root;
96
175
  }
97
176
 
98
177
  // ------------------------------------------------------------------ stats
99
178
 
100
- function collectStats(node: Node, inLink: boolean, stats: { textLen: number; linkTextLen: number }): void {
101
- if (node.tag === "#text") {
102
- const n = (node.text ?? "").replace(/\s+/g, " ").trim().length;
103
- stats.textLen += n;
104
- if (inLink) stats.linkTextLen += n;
105
- return;
106
- }
107
- for (const c of node.children) collectStats(c, inLink || node.tag === "a", stats);
179
+ interface NodeStats {
180
+ /** collapsed visible text length of the subtree */
181
+ text: number;
182
+ /** portion of that text which sits inside <a> */
183
+ link: number;
184
+ /** text inside BAD_CLASS / footer subtrees (subtracted from text when scoring) */
185
+ bad: number;
186
+ p: number;
187
+ li: number;
188
+ pre: number;
189
+ /** raw (uncollapsed) text length — proxy for subtree size in the innermost rule */
190
+ raw: number;
108
191
  }
109
192
 
110
- function countTag(node: Node, tag: string): number {
111
- let n = node.tag === tag ? 1 : 0;
112
- for (const c of node.children) n += countTag(c, tag);
113
- return n;
193
+ /** One bottom-up pass: per-node text/link/bad lengths + p/li/pre counts. */
194
+ function statsOf(root: Node): Map<Node, NodeStats> {
195
+ const map = new Map<Node, NodeStats>();
196
+ const walk = (node: Node, inBad: boolean): NodeStats => {
197
+ const st: NodeStats = { text: 0, link: 0, bad: 0, p: 0, li: 0, pre: 0, raw: 0 };
198
+ if (node.tag === "#text") {
199
+ const t = node.text ?? "";
200
+ st.raw = t.length;
201
+ const n = t.replace(/\s+/g, " ").trim().length;
202
+ st.text = n;
203
+ if (inBad) st.bad = n;
204
+ } else {
205
+ const cls = `${node.attrs.id ?? ""} ${node.attrs.class ?? ""}`.toLowerCase();
206
+ const selfBad = inBad || node.tag === "footer" || BAD_CLASS.test(cls);
207
+ for (const c of node.children) {
208
+ const cs = walk(c, selfBad);
209
+ st.text += cs.text;
210
+ st.link += cs.link;
211
+ st.bad += cs.bad;
212
+ st.p += cs.p;
213
+ st.li += cs.li;
214
+ st.pre += cs.pre;
215
+ st.raw += cs.raw;
216
+ if (c.tag === "a") st.link += cs.text;
217
+ else if (c.tag === "p") st.p += 1;
218
+ else if (c.tag === "li") st.li += 1;
219
+ else if (c.tag === "pre") st.pre += 1;
220
+ }
221
+ if (selfBad) {
222
+ st.bad = st.text;
223
+ // junk subtrees contribute no structure bonuses either — otherwise a
224
+ // container wrapping article + N comments still beats the article alone
225
+ st.p = 0;
226
+ st.li = 0;
227
+ st.pre = 0;
228
+ }
229
+ }
230
+ map.set(node, st);
231
+ return st;
232
+ };
233
+ walk(root, false);
234
+ return map;
114
235
  }
115
236
 
116
237
  function rawText(node: Node): string {
@@ -120,27 +241,32 @@ function rawText(node: Node): string {
120
241
  }
121
242
 
122
243
  const GOOD_CLASS = /content|article|post|entry|body|main|markdown|docs/i;
123
- const BAD_CLASS = /comment|sidebar|footer|nav|related|share|promo|advert|ads?-|cookie|banner|menu|social|newsletter|widget|mw-portlet|vector-menu/i;
244
+ // bare "menu" only when it reads as a menu container — NOT when embedded in
245
+ // feature-flag classes like "vector-feature-main-menu-pref-enabled" (which would
246
+ // mark the whole <html> subtree bad on Vector 2022 skins)
247
+ const BAD_CLASS = /comment|sidebar|footer|nav|related|share|promo|advert|ads?-|cookie|banner|social|newsletter|widget|mw-portlet|vector-menu|(^|[-_])menu(s)?($|[-_](item|container|list|bar|wrapper|toggle))/i;
124
248
 
125
249
  /** Pick the best content container (Readability-lite scoring). */
126
250
  function pickBest(root: Node): Node {
127
- const candidates: Array<{ node: Node; score: number; textLenOf: number }> = [];
251
+ const stats = statsOf(root);
252
+ const candidates: Array<{ node: Node; score: number; eff: number; size: number }> = [];
128
253
  const walk = (node: Node, depth: number) => {
129
254
  if (node.tag === "#text" || depth > 25) return;
130
- const stats = { textLen: 0, linkTextLen: 0 };
131
- collectStats(node, false, stats);
132
- if (stats.textLen > 200) {
133
- const linkDensity = stats.linkTextLen / stats.textLen;
134
- const cls = `${node.attrs.id ?? ""} ${node.attrs.class ?? ""}`.toLowerCase();
135
- let score =
136
- stats.textLen * (1 - linkDensity) + 25 * (countTag(node, "p") + countTag(node, "li") + countTag(node, "pre"));
137
- if (/^(article|main)$/.test(node.tag)) score *= 1.5;
138
- if (GOOD_CLASS.test(cls)) score *= 1.25;
139
- if (BAD_CLASS.test(cls)) score *= 0.4;
140
- if (linkDensity > 0.6) score *= 0.3;
141
- // MediaWiki parser output is a known content island — skip generic shells
142
- if (node.attrs.class?.includes("mw-parser-output")) score *= 4;
143
- candidates.push({ node, score, textLenOf: stats.textLen });
255
+ const st = stats.get(node);
256
+ if (st) {
257
+ const eff = st.text - st.bad;
258
+ if (eff > 200) {
259
+ const linkDensity = st.link / Math.max(st.text, 1);
260
+ const cls = `${node.attrs.id ?? ""} ${node.attrs.class ?? ""}`.toLowerCase();
261
+ let score = eff * (1 - linkDensity) + 25 * (st.p + st.li + st.pre);
262
+ if (/^(article|main)$/.test(node.tag)) score *= 1.5;
263
+ if (GOOD_CLASS.test(cls)) score *= 1.25;
264
+ if (BAD_CLASS.test(cls)) score *= 0.4;
265
+ if (linkDensity > 0.6) score *= 0.3;
266
+ // MediaWiki parser output is a known content island — skip generic shells
267
+ if (node.attrs.class?.includes("mw-parser-output")) score *= 4;
268
+ candidates.push({ node, score, eff, size: st.raw });
269
+ }
144
270
  }
145
271
  for (const c of node.children) if (c.tag !== "#text") walk(c, depth + 1);
146
272
  };
@@ -148,10 +274,46 @@ function pickBest(root: Node): Node {
148
274
  if (candidates.length === 0) return root;
149
275
  let best = candidates[0];
150
276
  for (const c of candidates) if (c.score > best.score) best = c;
151
- // innermost candidate within 80% of the best score trims sidebars/nav shells
152
- const nearBest = candidates.filter((c) => c.score >= best.score * 0.8);
153
- const innermost = nearBest.reduce((a, b) => (b.textLenOf < a.textLenOf ? b : a));
154
- return innermost.node;
277
+ // innermost candidate scoring near the best AND holding 90 % of its effective
278
+ // text trims shells (comments under main) without dropping header/lede siblings
279
+ const nearBest = candidates.filter((c) => c.score >= best.score * 0.8 && c.eff >= best.eff * 0.9);
280
+ if (nearBest.length === 0) return best.node;
281
+ return nearBest.reduce((a, b) => (b.size < a.size ? b : a)).node;
282
+ }
283
+
284
+ // ------------------------------------------------------- docs-site profiles
285
+
286
+ interface Profile {
287
+ match: (url: URL | null, root: Node) => boolean;
288
+ root: (root: Node) => Node | null;
289
+ }
290
+ const hasGenerator = (root: Node, re: RegExp) =>
291
+ !!findFirst(root, (n) => n.tag === "meta" && (n.attrs.name ?? "") === "generator" && re.test(n.attrs.content ?? ""));
292
+ const byId =
293
+ (id: string) =>
294
+ (root: Node): Node | null =>
295
+ findFirst(root, (n) => (n.attrs.id ?? "") === id);
296
+ const byClass =
297
+ (cls: string) =>
298
+ (root: Node): Node | null =>
299
+ findFirst(root, (n) => (n.attrs.class ?? "").split(/\s+/).includes(cls));
300
+
301
+ /** Hints for known docs hosts — a hint that misses falls back to density scoring. */
302
+ const PROFILES: Profile[] = [
303
+ { match: (u) => !!u && /(^|\.)postgresql\.org$/.test(u.hostname), root: byId("docContent") },
304
+ { match: (_u, r) => hasGenerator(r, /docusaurus/i), root: (r) => byClass("theme-doc-markdown")(r) ?? findFirst(r, (n) => n.tag === "article") },
305
+ { match: (_u, r) => hasGenerator(r, /nextra/i), root: (r) => findFirst(r, (n) => n.tag === "main")?.children.find((c) => c.tag === "article") ?? null },
306
+ { match: (u) => !!u && /(^|\.)developer\.mozilla\.org$/.test(u.hostname), root: byClass("main-page-content") },
307
+ { match: (u) => !!u && /(^|\.)react\.dev$/.test(u.hostname), root: (r) => findFirst(r, (n) => n.tag === "article") },
308
+ ];
309
+
310
+ function profileRoot(url: URL | null, root: Node): Node | null {
311
+ for (const p of PROFILES) {
312
+ if (!p.match(url, root)) continue;
313
+ const r = p.root(root);
314
+ if (r && rawText(r).replace(/\s+/g, " ").trim().length >= 200) return r;
315
+ }
316
+ return null;
155
317
  }
156
318
 
157
319
  // ------------------------------------------------------ markdown rendering
@@ -240,7 +402,28 @@ function renderBlock(node: Node, base: URL, depth: number): string {
240
402
  if (tag === "pre") {
241
403
  const codeEl = node.children.find((c) => c.tag === "code") ?? node;
242
404
  const lang = /language-([\w+-]+)/.exec(`${node.attrs.class ?? ""} ${codeEl.attrs.class ?? ""}`)?.[1] ?? "";
243
- return `\n\`\`\`${lang}\n${rawText(codeEl).replace(/\n+$/, "")}\n\`\`\`\n`;
405
+ let code: string;
406
+ if (codeEl.children.some((c) => c.tag === "div" || c.tag === "br")) {
407
+ // markup-based code blocks (react.dev sandpack cm-line divs): every block
408
+ // child is a line, every <br> a line break — rawText would fuse them.
409
+ // A div whose content already ends with <br> adds no extra newline.
410
+ const line = (n: Node, last: boolean): string => {
411
+ if (n.tag === "#text") return n.text ?? "";
412
+ if (n.tag === "br") return "\n";
413
+ if (n.tag === "div") {
414
+ let inner = "";
415
+ for (const c of n.children) inner += line(c, false);
416
+ return inner.endsWith("\n") ? inner : inner + (last ? "" : "\n");
417
+ }
418
+ let out = "";
419
+ for (const c of n.children) out += line(c, last);
420
+ return out;
421
+ };
422
+ code = codeEl.children.map((c, i) => line(c, i === codeEl.children.length - 1)).join("");
423
+ } else {
424
+ code = rawText(codeEl);
425
+ }
426
+ return `\n\`\`\`${lang}\n${code.replace(/^\n/, "").replace(/\n+$/, "")}\n\`\`\`\n`;
244
427
  }
245
428
  if (tag === "blockquote") {
246
429
  const inner = renderChildren(node, base, depth + 1).trim();
@@ -261,9 +444,12 @@ function renderBlock(node: Node, base: URL, depth: number): string {
261
444
  }
262
445
  if (tag === "table") return renderTable(node, base);
263
446
  if (tag === "hr") return "\n---\n";
264
- if (tag === "header" || tag === "footer") {
265
- // keep header content (may hold the H1) but drop footer boilerplate
266
- return tag === "footer" ? "" : renderChildren(node, base, depth + 1);
447
+ if (tag === "footer") return ""; // boilerplate
448
+ if (tag === "header") return renderChildren(node, base, depth + 1); // may hold the H1
449
+ // block containers get paragraph separators so sibling divs don't run together
450
+ if (SEP.has(tag)) {
451
+ const inner = renderChildren(node, base, depth + 1);
452
+ return inner.trim() ? `\n${inner}\n` : "";
267
453
  }
268
454
  return renderChildren(node, base, depth + 1);
269
455
  }
@@ -271,7 +457,20 @@ function renderBlock(node: Node, base: URL, depth: number): string {
271
457
  function renderChildren(node: Node, base: URL, depth: number): string {
272
458
  if (depth > 40) return rawText(node);
273
459
  let s = "";
274
- for (const c of node.children) s += renderBlock(c, base, depth);
460
+ const kids = node.children;
461
+ for (let i = 0; i < kids.length; i++) {
462
+ const c = kids[i];
463
+ // whitespace-only text between block siblings (or at either end) is layout
464
+ // noise; between inline siblings it is the word separator and survives
465
+ if (c.tag === "#text" && !/\S/.test(c.text ?? "")) {
466
+ const prev = kids[i - 1];
467
+ const next = kids[i + 1];
468
+ if (!prev || !next || BLOCK.has(prev.tag) || BLOCK.has(next.tag)) continue;
469
+ s += " ";
470
+ continue;
471
+ }
472
+ s += renderBlock(c, base, depth);
473
+ }
275
474
  return s;
276
475
  }
277
476
 
@@ -285,17 +484,26 @@ function isLanguageName(word: string): boolean {
285
484
  export function htmlToMarkdown(page: string, baseUrl: string, maxChars: number): { text: string; truncated: boolean } {
286
485
  const root = parseHtml(page);
287
486
  const title = decodeEntities(rawText(findFirst(root, (n) => n.tag === "title") ?? { tag: "#text", attrs: {}, children: [] })).trim();
288
- const best = pickBest(root);
487
+ let url: URL | null = null;
488
+ try {
489
+ url = new URL(baseUrl);
490
+ } catch {
491
+ /* profile matching just skips host checks */
492
+ }
493
+ const best = profileRoot(url, root) ?? pickBest(root);
289
494
  let body = renderChildren(best, new URL(baseUrl), 0)
290
495
  .replace(/\n{3,}/g, "\n\n")
291
496
  .replace(/[ \t]+\n/g, "\n")
292
- .replace(/([a-z,;)\"\]])\n(?=[a-z])/, "$1 ") // unwrap block elements that render run-on prose
293
497
  .trim();
294
498
  body = body
295
499
  .split("\n")
296
500
  .filter((l) => {
297
501
  const t = l.trim();
298
502
  if (/^(skip to (main )?content|toggle (the )?table of contents|jump to (content|nav)|advertisement|cookie (notice|settings)?|edit links|views|actions|print\/export|in other projects|appearance|move to sidebar|tools)\b/i.test(t)) return false;
503
+ // arXiv subject classification chip and its breadcrumb sibling — page chrome,
504
+ // not paper content (the real title follows immediately)
505
+ if (/^# Computer Science >/.test(t)) return false;
506
+ if (/^arXiv:\d{4}\.\d{4,5}( \(cs\))?$/.test(t)) return false;
299
507
  if (/^- [^\d][\w '’-]{1,25}$/.test(t) && isLanguageName(t.slice(2))) return false;
300
508
  if (/^\d+ languages$/i.test(t)) return false;
301
509
  return true;
@@ -303,9 +511,21 @@ export function htmlToMarkdown(page: string, baseUrl: string, maxChars: number):
303
511
  .join("\n")
304
512
  .replace(/\n{3,}/g, "\n\n")
305
513
  .trim();
306
- const letters = (body.match(/[a-zA-Z]/g) ?? []).length;
307
- if (body.length < 120 || letters / body.length < 0.35) return { text: "", truncated: false };
308
- const head = title && !body.toLowerCase().startsWith(title.toLowerCase()) ? `# ${title}\n\n` : "";
514
+ // junk gate: unicode-letter aware (Latin, Cyrillic, CJK, …); CJK pages carry
515
+ // wide punctuation and Latin link markup, so their bar is lower
516
+ const letters = (body.match(/\p{L}/gu) ?? []).length;
517
+ const cjk = (body.match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu) ?? []).length;
518
+ const minRatio = cjk / Math.max(letters, 1) > 0.3 ? 0.25 : 0.35;
519
+ if (body.length < 120 || letters / body.length < minRatio) return { text: "", truncated: false };
520
+ // title H1: emit only when the body has no heading of its own and nothing that
521
+ // duplicates the title; the site suffix ("— Example Blog") is redundant with the URL header
522
+ const norm = (s: string) => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
523
+ const siteless = title.replace(/\s+[-–—|·»:]\s+[^-–—|·»:]{2,60}$/, "").trim();
524
+ const window = body.slice(0, 300);
525
+ const firstHeading = window.match(/^#{1,6}\s+(.+)$/m)?.[1] ?? "";
526
+ const hasOwnH1 = /^# |\n# /.test(window);
527
+ const dup = !!firstHeading && (norm(firstHeading) === norm(siteless) || norm(firstHeading) === norm(title));
528
+ const head = title && !hasOwnH1 && !dup ? `# ${siteless}\n\n` : "";
309
529
  const full = `${head}${body}`;
310
530
  return { text: full.slice(0, maxChars), truncated: full.length > maxChars };
311
531
  }
@@ -318,3 +538,99 @@ function findFirst(node: Node, pred: (n: Node) => boolean): Node | null {
318
538
  }
319
539
  return null;
320
540
  }
541
+
542
+ // ------------------------------------------------------------------ dates
543
+
544
+ function attrValue(tag: string, name: string): string | undefined {
545
+ const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, "i"));
546
+ return m ? (m[2] ?? m[3]) : undefined;
547
+ }
548
+
549
+ const toIsoDate = (raw?: string): string | undefined => {
550
+ if (!raw) return undefined;
551
+ const s = raw.trim();
552
+ // date-only strings (2025-05-12, 2025/05/12) carry no time zone — use them
553
+ // as-is instead of shifting through a local-midnight Date
554
+ const dateOnly = s.match(/^(20\d{2})[-/](\d{1,2})[-/](\d{1,2})$/);
555
+ if (dateOnly) {
556
+ return `${dateOnly[1]}-${dateOnly[2]!.padStart(2, "0")}-${dateOnly[3]!.padStart(2, "0")}`;
557
+ }
558
+ const ts = Date.parse(s);
559
+ return Number.isNaN(ts) ? undefined : new Date(ts).toISOString().slice(0, 10);
560
+ };
561
+
562
+ function jsonLdDate(json: string): string | undefined {
563
+ try {
564
+ const data = JSON.parse(json);
565
+ for (const n of Array.isArray(data) ? data : (data["@graph"] ?? [data])) {
566
+ const iso = toIsoDate(n?.datePublished) ?? toIsoDate(n?.dateModified);
567
+ if (iso) return iso;
568
+ }
569
+ } catch {
570
+ /* malformed JSON-LD is common; ignore */
571
+ }
572
+ return undefined;
573
+ }
574
+
575
+ /**
576
+ * Best-effort page publication date (YYYY-MM-DD). Priority:
577
+ * article:published_time / og:updated_time → JSON-LD datePublished/dateModified
578
+ * → <time datetime> → date/dc.date/pubdate metas → /2026/08/10/ URL path.
579
+ */
580
+ export function extractDate(html: string, pageUrl: string): string | undefined {
581
+ const metas = new Map<string, string>();
582
+ for (const tag of html.match(/<meta\b[^>]*>/gi) ?? []) {
583
+ const key = (attrValue(tag, "property") ?? attrValue(tag, "name"))?.toLowerCase();
584
+ const content = attrValue(tag, "content");
585
+ if (key && content && !metas.has(key)) metas.set(key, content);
586
+ }
587
+ for (const k of ["article:published_time", "og:updated_time"]) {
588
+ const iso = toIsoDate(metas.get(k));
589
+ if (iso) return iso;
590
+ }
591
+ for (const m of html.matchAll(/<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)) {
592
+ const iso = jsonLdDate(m[1]!);
593
+ if (iso) return iso;
594
+ }
595
+ const timeIso = toIsoDate(html.match(/<time\b[^>]*\bdatetime\s*=\s*["']([^"']+)["']/i)?.[1]);
596
+ if (timeIso) return timeIso;
597
+ for (const k of ["date", "dc.date", "pubdate"]) {
598
+ const iso = toIsoDate(metas.get(k));
599
+ if (iso) return iso;
600
+ }
601
+ const path = pageUrl.match(/\/(20\d{2})\/(\d{2})\/(\d{2})(?:\/|$)/);
602
+ if (path) return `${path[1]}-${path[2]}-${path[3]}`;
603
+ // citation_date / citation_publication_date (arxiv, journals, CMS blogs)
604
+ for (const k of ["citation_publication_date", "citation_date"]) {
605
+ const iso = toIsoDate(metas.get(k));
606
+ if (iso) return iso;
607
+ }
608
+ // plain-text stamps: "[Submitted on 12 Jun 2017" (day-first) and
609
+ // "Published March 3, 2025" (US month-first)
610
+ const MONTHS: Record<string, string> = {
611
+ jan: "01", feb: "02", mar: "03", apr: "04", may: "05", jun: "06",
612
+ jul: "07", aug: "08", sep: "09", oct: "10", nov: "11", dec: "12",
613
+ };
614
+ const dayFirst = html.match(/(?:Submitted on|Published on|posted on)\s+(\d{1,2})\s+(\w{3,9})\.?,?\s+(20\d{2})/i);
615
+ if (dayFirst) {
616
+ const mo = MONTHS[dayFirst[2]!.slice(0, 3).toLowerCase()];
617
+ if (mo) return `${dayFirst[3]}-${mo}-${dayFirst[1]!.padStart(2, "0")}`;
618
+ }
619
+ const usFirst = html.match(/(?:Submitted on|Published on|Published|posted on)\s+(\w{3,9})\.?\s+(\d{1,2}),?\s+(20\d{2})/i);
620
+ if (usFirst) {
621
+ const mo = MONTHS[usFirst[1]!.slice(0, 3).toLowerCase()];
622
+ if (mo) return `${usFirst[3]}-${mo}-${usFirst[2]!.padStart(2, "0")}`;
623
+ }
624
+ // bare "Apr 24, 2024" — either in the page-header zone (first 3000 chars) or in
625
+ // a structural metadata element (dt/dd "Last Updated"/"Published")
626
+ const metadata = html.match(/(?:Last [Uu]pdated|[Dd]ate|[Pp]ublished)<\/dt>\s*<dd>\s*(\w{3})\s+(\d{1,2}),\s+(20\d{2})/);
627
+ if (metadata && MONTHS[metadata[1]!.toLowerCase()]) {
628
+ return `${metadata[3]}-${MONTHS[metadata[1]!.toLowerCase()]}-${metadata[2]!.padStart(2, "0")}`;
629
+ }
630
+ const head = html.slice(0, 3000);
631
+ const bare = head.match(/\b(\w{3})\s+(\d{1,2}),\s+(20\d{2})\b/);
632
+ if (bare && MONTHS[bare[1]!.toLowerCase()]) {
633
+ return `${bare[3]}-${MONTHS[bare[1]!.toLowerCase()]}-${bare[2]!.padStart(2, "0")}`;
634
+ }
635
+ return undefined;
636
+ }