pi-webfind 0.5.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 ADDED
@@ -0,0 +1,320 @@
1
+ /**
2
+ * HTML → markdown extractor with density-scored article detection.
3
+ *
4
+ * Stage 1: score candidate blocks by text/link density (Readability-lite)
5
+ * and pick the best content container.
6
+ * Stage 2: convert the chosen block to markdown — headings, links, code
7
+ * fences, lists, tables survive so the model can read structure.
8
+ *
9
+ * Falls back to the simple flattener (lib/engine.ts htmlToText) when the
10
+ * output looks like junk. Zero dependencies.
11
+ */
12
+
13
+ import { decodeEntities } from "./engine.ts";
14
+
15
+ interface Node {
16
+ tag: string; // lowercase; "#text" for text, "#root" for root
17
+ attrs: Record<string, string>;
18
+ children: Node[];
19
+ text?: string;
20
+ }
21
+
22
+ const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
23
+ const DROP_TAGS = new Set(["script", "style", "noscript", "template", "svg", "iframe", "form", "select", "button", "nav", "aside", "link", "meta", "base", "area", "track", "param", "annotation", "semantics"]);
24
+
25
+ /** Tolerant HTML parser → shallow node tree. Never throws. */
26
+ export function parseHtml(html: string): Node {
27
+ const root: Node = { tag: "#root", attrs: {}, children: [] };
28
+ const stack: Node[] = [root];
29
+ let i = 0;
30
+ const len = html.length;
31
+ while (i < len) {
32
+ const lt = html.indexOf("<", i);
33
+ if (lt === -1) break;
34
+ // text before this tag
35
+ if (lt > i) {
36
+ const t = html.slice(i, lt);
37
+ if (/\S/.test(t)) stack[stack.length - 1].children.push({ tag: "#text", attrs: {}, children: [], text: decodeEntities(t) });
38
+ }
39
+ if (html.startsWith("<!--", lt)) {
40
+ const end = html.indexOf("-->", lt);
41
+ i = end === -1 ? len : end + 3;
42
+ continue;
43
+ }
44
+ if (html[lt + 1] === "/") {
45
+ const gt = html.indexOf(">", lt);
46
+ const tag = html.slice(lt + 2, gt === -1 ? len : gt).trim().toLowerCase();
47
+ for (let s = stack.length - 1; s > 0; s--) {
48
+ if (stack[s].tag === tag) {
49
+ stack.length = s;
50
+ break;
51
+ }
52
+ }
53
+ i = gt === -1 ? len : gt + 1;
54
+ continue;
55
+ }
56
+ if (html[lt + 1] === "!" || html[lt + 1] === "?") {
57
+ const gt = html.indexOf(">", lt);
58
+ i = gt === -1 ? len : gt + 1;
59
+ continue;
60
+ }
61
+ const gt = html.indexOf(">", lt);
62
+ if (gt === -1) break;
63
+ const tagSrc = html.slice(lt + 1, gt);
64
+ const tagName = (tagSrc.match(/^[a-zA-Z][a-zA-Z0-9-]*/) ?? [""])[0].toLowerCase();
65
+ if (!tagName) {
66
+ i = gt + 1;
67
+ continue;
68
+ }
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;
73
+ continue;
74
+ }
75
+ 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] ?? "");
78
+ }
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));
85
+ i = close ? gt + close.index + close[0].length : len;
86
+ continue;
87
+ }
88
+ if (!selfClosing) stack.push(node);
89
+ i = gt + 1;
90
+ }
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) });
94
+ }
95
+ return root;
96
+ }
97
+
98
+ // ------------------------------------------------------------------ stats
99
+
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);
108
+ }
109
+
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;
114
+ }
115
+
116
+ function rawText(node: Node): string {
117
+ let s = node.text ?? "";
118
+ for (const c of node.children) s += rawText(c);
119
+ return s;
120
+ }
121
+
122
+ 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;
124
+
125
+ /** Pick the best content container (Readability-lite scoring). */
126
+ function pickBest(root: Node): Node {
127
+ const candidates: Array<{ node: Node; score: number; textLenOf: number }> = [];
128
+ const walk = (node: Node, depth: number) => {
129
+ 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 });
144
+ }
145
+ for (const c of node.children) if (c.tag !== "#text") walk(c, depth + 1);
146
+ };
147
+ walk(root, 0);
148
+ if (candidates.length === 0) return root;
149
+ let best = candidates[0];
150
+ 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;
155
+ }
156
+
157
+ // ------------------------------------------------------ markdown rendering
158
+
159
+ function absUrl(href: string | undefined, base: URL): string | null {
160
+ const t = (href ?? "").trim();
161
+ if (!t || t.startsWith("#") || /^(javascript|mailto|tel|data):/i.test(t)) return null;
162
+ try {
163
+ return new URL(t, base).href;
164
+ } catch {
165
+ return null;
166
+ }
167
+ }
168
+
169
+ function inlineToMd(node: Node, base: URL): string {
170
+ if (node.tag === "#text") return (node.text ?? "").replace(/\s+/g, " ");
171
+ let kids = "";
172
+ for (const c of node.children) kids += inlineToMd(c, base);
173
+ switch (node.tag) {
174
+ case "br":
175
+ return "\n";
176
+ case "b":
177
+ case "strong":
178
+ return `**${kids.trim()}**`;
179
+ case "em":
180
+ case "i":
181
+ return `_${kids.trim()}_`;
182
+ case "code":
183
+ return `\`${kids.trim()}\``;
184
+ case "a": {
185
+ const href = absUrl(node.attrs.href, base);
186
+ const text = kids.trim();
187
+ if (!href || !text) return text;
188
+ return `[${text}](${href})`;
189
+ }
190
+ case "img": {
191
+ const alt = (node.attrs.alt ?? "").trim();
192
+ const src = absUrl(node.attrs.src, base);
193
+ return alt && src ? `![${alt}](${src})` : "";
194
+ }
195
+ default:
196
+ return kids;
197
+ }
198
+ }
199
+
200
+ function renderTable(node: Node, base: URL): string {
201
+ const trs: Node[] = [];
202
+ const walk = (n: Node) => {
203
+ if (n.tag === "tr") trs.push(n);
204
+ for (const c of n.children) walk(c);
205
+ };
206
+ walk(node);
207
+ const rows = trs
208
+ .map((tr) =>
209
+ tr.children
210
+ .filter((c) => c.tag === "td" || c.tag === "th")
211
+ .map((cell) => inlineToMd(cell, base).replace(/\|/g, "\\|").replace(/\n+/g, " ").trim().slice(0, 120)),
212
+ )
213
+ .filter((r) => r.length > 0);
214
+ if (rows.length === 0) return "";
215
+ const width = Math.min(Math.max(...rows.map((r) => r.length)), 8);
216
+ const pad = (cells: string[]) => {
217
+ const fixed = cells.slice(0, width);
218
+ while (fixed.length < width) fixed.push("");
219
+ return `| ${fixed.join(" | ")} |`;
220
+ };
221
+ const out = [pad(rows[0]), `| ${Array(width).fill("---").join(" | ")} |`];
222
+ for (const r of rows.slice(1, 30)) out.push(pad(r));
223
+ return `\n${out.join("\n")}\n`;
224
+ }
225
+
226
+ function renderBlock(node: Node, base: URL, depth: number): string {
227
+ const tag = node.tag;
228
+ if (tag === "#text") return (node.text ?? "").replace(/\s+/g, " ");
229
+ if (tag === "sup" && /reference|noprint|cite/i.test(node.attrs.class ?? "")) return ""; // [n] ref markers
230
+ if (tag === "table" && /infobox|vertical-navbox|metadata|sidebar|toc|ambox/i.test(node.attrs.class ?? "")) return "";
231
+ if (tag === "style" || tag === "script") return "";
232
+ if (/^h[1-6]$/.test(tag)) {
233
+ const text = inlineToMd(node, base).replace(/\s*\[(\d+)\]\s*/g, "").trim();
234
+ return text ? `\n${"#".repeat(Number(tag[1]))} ${text}\n` : "";
235
+ }
236
+ if (tag === "p") {
237
+ const text = inlineToMd(node, base).trim();
238
+ return text ? `\n${text}\n` : "";
239
+ }
240
+ if (tag === "pre") {
241
+ const codeEl = node.children.find((c) => c.tag === "code") ?? node;
242
+ const lang = /language-([\w+-]+)/.exec(`${node.attrs.class ?? ""} ${codeEl.attrs.class ?? ""}`)?.[1] ?? "";
243
+ return `\n\`\`\`${lang}\n${rawText(codeEl).replace(/\n+$/, "")}\n\`\`\`\n`;
244
+ }
245
+ if (tag === "blockquote") {
246
+ const inner = renderChildren(node, base, depth + 1).trim();
247
+ return inner ? `\n${inner.split("\n").map((l) => `> ${l}`).join("\n")}\n` : "";
248
+ }
249
+ if (tag === "ul" || tag === "ol") {
250
+ const items: string[] = [];
251
+ let idx = 1;
252
+ for (const c of node.children) {
253
+ if (c.tag !== "li") continue;
254
+ const inner = renderChildren(c, base, depth + 1).trim();
255
+ if (!inner) continue;
256
+ const b = tag === "ol" ? `${idx++}. ` : "- ";
257
+ const lines = inner.split("\n");
258
+ items.push([`${b}${lines[0]}`, ...lines.slice(1).map((l) => ` ${l}`)].join("\n"));
259
+ }
260
+ return items.length ? `\n${items.join("\n")}\n` : "";
261
+ }
262
+ if (tag === "table") return renderTable(node, base);
263
+ 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);
267
+ }
268
+ return renderChildren(node, base, depth + 1);
269
+ }
270
+
271
+ function renderChildren(node: Node, base: URL, depth: number): string {
272
+ if (depth > 40) return rawText(node);
273
+ let s = "";
274
+ for (const c of node.children) s += renderBlock(c, base, depth);
275
+ return s;
276
+ }
277
+
278
+ const LANG_RE = /^(afrikaans|albanian|amharic|arabic|armenian|asturian|assamese|avaric|aymara|azerbaijani|bashkir|basque|belarusian|bengali|bosnian|breton|bulgarian|burmese|catalan|cebuano|chamorro|cherokee|chichewa|chinese|corsican|cree|croatian|czech|danish|dutch|dzongkha|english|esperanto|estonian|ewe|faroese|fijian|filipino|finnish|french|fula|galician|georgian|german|greek|guarani|gujarati|haitian|hausa|hausa|hebrew|herero|hindi|hiri motu|hungarian|icelandic|ido|igbo|indonesian|interlingua|inuktitut|irish|italian|japanese|javanese|kannada|kanuri|kazakh|khmer|kikuyu|kinyarwanda|kirundi|korean|kurdish|kyrgyz|lao|latin|latvian|limburgish|lingala|lithuanian|luxembourgish|macedonian|malagasy|malay|malayalam|maltese|manx|maori|marathi|mongolian|nauru|navajo|ndonga|nepali|norwegian|occitan|ojibwe|oromo|ossetian|pashto|persian|polish|portuguese|punjabi|quechua|romanian|russian|samoan|sango|sanskrit|sardinian|scots|serbian|sesotho|shona|sindhi|sinhala|slovak|slovenian|somali|spanish|sundanese|swahili|swedish|tagalog|tahitian|tajik|tamil|tatar|telugu|thai|tigrinya|tok pisin|tongan|tsonga|tswana|turkish|turkmen|twi|ukrainian|urdu|uyghur|uzbek|vietnamese|walloon|welsh|wolof|xhosa|yiddish|yoruba|zulu)$/i;
279
+
280
+ function isLanguageName(word: string): boolean {
281
+ return LANG_RE.test(word.trim());
282
+ }
283
+
284
+ /** Extract the best content block as markdown. Returns "" when output looks like junk. */
285
+ export function htmlToMarkdown(page: string, baseUrl: string, maxChars: number): { text: string; truncated: boolean } {
286
+ const root = parseHtml(page);
287
+ const title = decodeEntities(rawText(findFirst(root, (n) => n.tag === "title") ?? { tag: "#text", attrs: {}, children: [] })).trim();
288
+ const best = pickBest(root);
289
+ let body = renderChildren(best, new URL(baseUrl), 0)
290
+ .replace(/\n{3,}/g, "\n\n")
291
+ .replace(/[ \t]+\n/g, "\n")
292
+ .replace(/([a-z,;)\"\]])\n(?=[a-z])/, "$1 ") // unwrap block elements that render run-on prose
293
+ .trim();
294
+ body = body
295
+ .split("\n")
296
+ .filter((l) => {
297
+ const t = l.trim();
298
+ 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;
299
+ if (/^- [^\d][\w '’-]{1,25}$/.test(t) && isLanguageName(t.slice(2))) return false;
300
+ if (/^\d+ languages$/i.test(t)) return false;
301
+ return true;
302
+ })
303
+ .join("\n")
304
+ .replace(/\n{3,}/g, "\n\n")
305
+ .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` : "";
309
+ const full = `${head}${body}`;
310
+ return { text: full.slice(0, maxChars), truncated: full.length > maxChars };
311
+ }
312
+
313
+ function findFirst(node: Node, pred: (n: Node) => boolean): Node | null {
314
+ if (pred(node)) return node;
315
+ for (const c of node.children) {
316
+ const hit = findFirst(c, pred);
317
+ if (hit) return hit;
318
+ }
319
+ return null;
320
+ }