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/README.md +14 -2
- package/extensions/web-search.ts +162 -73
- package/lib/adapters.ts +295 -117
- package/lib/apis.ts +2 -0
- package/lib/cache.ts +42 -16
- package/lib/engine.ts +489 -91
- package/lib/extract.ts +377 -61
- package/lib/fetcher.ts +443 -217
- package/lib/net.ts +93 -0
- package/lib/rank.ts +89 -16
- package/lib/safe.ts +94 -0
- package/lib/version.ts +1 -1
- package/package.json +20 -16
- package/themes/claude-dark.json +80 -0
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
|
-
*
|
|
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 (
|
|
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
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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_:.]*)
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
93
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
return
|
|
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
|
-
|
|
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 === "
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
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
|
+
}
|