pi-wsearch 0.3.1 → 0.4.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 +4 -1
- package/package.json +12 -8
- package/src/dom.ts +328 -0
- package/src/scrape.ts +3 -99
- package/src/tools.ts +150 -116
- package/src/transport.ts +5 -2
- package/src/view.ts +223 -0
package/README.md
CHANGED
|
@@ -39,7 +39,10 @@ and reads only those. No
|
|
|
39
39
|
full reads, no 40 KB dumps. Sections over 3,000 characters split into
|
|
40
40
|
addressable continuation entries (`Section (cont. 1)`), so no tail is ever
|
|
41
41
|
unreachable; pages that overflow are capped; the final response is clipped.
|
|
42
|
-
Every cut is marked — nothing is silently lost.
|
|
42
|
+
Every cut is marked — nothing is silently lost. Codemode consumers get the same
|
|
43
|
+
view via structuredContent: per-section `{index, heading, text}`, continuation
|
|
44
|
+
cuts counted in `sections_truncated`, local budget cuts listed in
|
|
45
|
+
`sections_clipped`.
|
|
43
46
|
|
|
44
47
|
**Reads are free.** Page parsing is built in, nothing billed per read, no key
|
|
45
48
|
required. Real headings become the map, TOC echoes get dropped, so reads come
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-wsearch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Pi extension: web.search + web.fetch",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"check": "oxlint && oxfmt --check && tsc --noEmit && knip && bun test --jobs 1 test/",
|
|
37
37
|
"test": "bun test --jobs 1 test/",
|
|
38
38
|
"typecheck": "tsc --noEmit",
|
|
39
|
-
"knip": "knip",
|
|
39
|
+
"knip": "bun scripts/knip.mjs",
|
|
40
40
|
"coverage": "bun test --jobs 1 test/ --coverage"
|
|
41
41
|
},
|
|
42
42
|
"pi": {
|
|
@@ -45,10 +45,10 @@
|
|
|
45
45
|
]
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@earendil-works/pi-ai": "
|
|
49
|
-
"@earendil-works/pi-agent-core": "
|
|
50
|
-
"@earendil-works/pi-coding-agent": "
|
|
51
|
-
"@earendil-works/pi-tui": "
|
|
48
|
+
"@earendil-works/pi-ai": "^1.3.0",
|
|
49
|
+
"@earendil-works/pi-agent-core": "^1.3.0",
|
|
50
|
+
"@earendil-works/pi-coding-agent": "^1.3.0",
|
|
51
|
+
"@earendil-works/pi-tui": "^1.3.0",
|
|
52
52
|
"typebox": "*"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
@@ -57,9 +57,13 @@
|
|
|
57
57
|
"knip": "^6.39.0",
|
|
58
58
|
"oxfmt": "^0.71.0",
|
|
59
59
|
"oxlint": "^1.86.0",
|
|
60
|
-
"typescript": "^
|
|
60
|
+
"typescript": "^5.9.3"
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"htmlparser2": "^12.0.0",
|
|
64
|
+
"parse5": "^8.0.1",
|
|
65
|
+
"parse5-htmlparser2-tree-adapter": "^8.0.1"
|
|
61
66
|
},
|
|
62
|
-
"dependencies": {},
|
|
63
67
|
"main": "index.ts",
|
|
64
68
|
"files": [
|
|
65
69
|
"index.ts",
|
package/src/dom.ts
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { parse } from 'parse5';
|
|
2
|
+
import { adapter } from 'parse5-htmlparser2-tree-adapter';
|
|
3
|
+
import { parseDocument } from 'htmlparser2';
|
|
4
|
+
|
|
5
|
+
interface DomNode {
|
|
6
|
+
type: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
attribs?: Record<string, string>;
|
|
9
|
+
data?: string;
|
|
10
|
+
children?: DomNode[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const SKIP = new Set(['script', 'style', 'noscript', 'svg', 'nav', 'footer', 'aside', 'head', 'title']);
|
|
14
|
+
|
|
15
|
+
const BLOCK = new Set(['p', 'div', 'section', 'article', 'blockquote', 'pre']);
|
|
16
|
+
|
|
17
|
+
function isKatex(n: DomNode): boolean {
|
|
18
|
+
return (n.attribs?.['class'] ?? '').split(/\s+/).includes('katex');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function toDom(html: string): DomNode {
|
|
22
|
+
return parse(html, { treeAdapter: adapter }) as unknown as DomNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function inlineOf(n: DomNode): string {
|
|
26
|
+
const t = n.type;
|
|
27
|
+
if (t === 'text') return n.data ?? '';
|
|
28
|
+
if (t === 'comment' || t === 'directive') return '';
|
|
29
|
+
const name = n.name ?? '';
|
|
30
|
+
if (!name || SKIP.has(name) || isKatex(n)) return '';
|
|
31
|
+
if (name === 'math') {
|
|
32
|
+
const tex = annotationTex(n);
|
|
33
|
+
return tex ? `$${tex}$` : '';
|
|
34
|
+
}
|
|
35
|
+
if (name === 'br' || name === 'hr' || name === 'li') return ' ';
|
|
36
|
+
return (n.children ?? []).map(inlineOf).join('');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function annotationTex(n: DomNode): string | undefined {
|
|
40
|
+
const found: DomNode[] = [];
|
|
41
|
+
allDescendants(n, 'annotation', found);
|
|
42
|
+
for (const c of found) {
|
|
43
|
+
const enc = (c.attribs?.['encoding'] ?? '').toLowerCase();
|
|
44
|
+
if (enc === 'application/x-tex' || enc.includes('x-tex')) return inlineOf(c).trim();
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function allDescendants(n: DomNode, name: string, out: DomNode[]): void {
|
|
50
|
+
for (const c of n.children ?? []) {
|
|
51
|
+
if (c.type === 'tag') {
|
|
52
|
+
if (c.name === name) out.push(c);
|
|
53
|
+
allDescendants(c, name, out);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function listText(n: DomNode, out: string[]): void {
|
|
59
|
+
for (const c of n.children ?? []) {
|
|
60
|
+
if (c.type === 'tag' && c.name === 'li') {
|
|
61
|
+
newline(out);
|
|
62
|
+
out.push('• ');
|
|
63
|
+
liText(c, out);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function liText(n: DomNode, out: string[]): void {
|
|
69
|
+
for (const c of n.children ?? []) {
|
|
70
|
+
const t = c.type;
|
|
71
|
+
if (t === 'text') {
|
|
72
|
+
out.push((c.data ?? '').replace(/\s+/g, ' '));
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (t === 'comment' || t === 'directive') continue;
|
|
76
|
+
const name = c.name ?? '';
|
|
77
|
+
if (SKIP.has(name) || isKatex(c)) continue;
|
|
78
|
+
if (name === 'li') {
|
|
79
|
+
newline(out);
|
|
80
|
+
out.push('• ');
|
|
81
|
+
liText(c, out);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (name === 'ul' || name === 'ol') {
|
|
85
|
+
listText(c, out);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (name === 'br' || name === 'hr') {
|
|
89
|
+
out.push('\n');
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (name === 'pre') {
|
|
93
|
+
out.push('\n');
|
|
94
|
+
out.push(inlineOf(c).replace(/\n/g, '\uE000'));
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (name === 'math') {
|
|
98
|
+
const tex = annotationTex(c);
|
|
99
|
+
if (tex) out.push(`$${tex}$`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (BLOCK.has(name) || name === 'table') {
|
|
103
|
+
newline(out);
|
|
104
|
+
liText(c, out);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
out.push(inlineOf(c));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function renderTable(n: DomNode, out: string[]): void {
|
|
112
|
+
const rows: string[][] = [];
|
|
113
|
+
for (const tr of (() => {
|
|
114
|
+
const found: DomNode[] = [];
|
|
115
|
+
allDescendants(n, 'tr', found);
|
|
116
|
+
return found;
|
|
117
|
+
})()) {
|
|
118
|
+
const cells: string[] = [];
|
|
119
|
+
for (const cell of tr.children ?? []) {
|
|
120
|
+
if (cell.type === 'tag' && (cell.name === 'td' || cell.name === 'th')) {
|
|
121
|
+
cells.push(inlineOf(cell).replace(/\s+/g, ' ').trim().replace(/\|/g, '\\|'));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (cells.length) rows.push(cells);
|
|
125
|
+
}
|
|
126
|
+
newline(out);
|
|
127
|
+
if (rows.length < 2 || Math.max(...rows.map((r) => r.length)) < 2) {
|
|
128
|
+
out.push(rows.map((r) => r.join(' ')).join('\n'));
|
|
129
|
+
out.push('\n');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const cols = Math.max(...rows.map((r) => r.length));
|
|
133
|
+
const line = (r: string[]) => `| ${r.concat(Array(Math.max(0, cols - r.length)).fill(' ')).join(' | ')} |`;
|
|
134
|
+
out.push(
|
|
135
|
+
[line(rows[0]), `| ${Array.from({ length: cols }, () => '---').join(' | ')} |`, ...rows.slice(1).map(line)].join(
|
|
136
|
+
'\n',
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
out.push('\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function newline(out: string[]): void {
|
|
143
|
+
if (out.length > 0 && !out[out.length - 1].endsWith('\n')) out.push('\n');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function treeToMarkdownish(html: string): string {
|
|
147
|
+
const doc = toDom(html);
|
|
148
|
+
const out: string[] = [];
|
|
149
|
+
renderBlocks(doc.children ?? [], out);
|
|
150
|
+
return out.join('');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function renderBlocks(nodes: DomNode[], out: string[]): void {
|
|
154
|
+
for (const n of nodes) renderBlock(n, out);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function renderBlock(n: DomNode, out: string[]): void {
|
|
158
|
+
const t = n.type;
|
|
159
|
+
if (t === 'text') {
|
|
160
|
+
out.push(n.data ?? '');
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (t === 'comment' || t === 'directive' || t === 'style' || t === 'script') return;
|
|
164
|
+
const name = n.name ?? '';
|
|
165
|
+
if (!name || SKIP.has(name) || isKatex(n)) return;
|
|
166
|
+
switch (name) {
|
|
167
|
+
case 'h1':
|
|
168
|
+
case 'h2':
|
|
169
|
+
case 'h3':
|
|
170
|
+
case 'h4':
|
|
171
|
+
case 'h5':
|
|
172
|
+
case 'h6':
|
|
173
|
+
newline(out);
|
|
174
|
+
out.push(`${'#'.repeat(Number(name[1]))} ${inlineOf(n).trim()}\n`);
|
|
175
|
+
return;
|
|
176
|
+
case 'li':
|
|
177
|
+
newline(out);
|
|
178
|
+
out.push('• ');
|
|
179
|
+
liText(n, out);
|
|
180
|
+
return;
|
|
181
|
+
case 'ul':
|
|
182
|
+
case 'ol':
|
|
183
|
+
newline(out);
|
|
184
|
+
listText(n, out);
|
|
185
|
+
newline(out);
|
|
186
|
+
return;
|
|
187
|
+
case 'pre':
|
|
188
|
+
newline(out);
|
|
189
|
+
out.push(inlineOf(n).replace(/\n/g, '\uE000'));
|
|
190
|
+
newline(out);
|
|
191
|
+
return;
|
|
192
|
+
case 'math': {
|
|
193
|
+
const tex = annotationTex(n);
|
|
194
|
+
if (tex) out.push(` $${tex}$ `);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
case 'br':
|
|
198
|
+
case 'hr':
|
|
199
|
+
newline(out);
|
|
200
|
+
return;
|
|
201
|
+
case 'table':
|
|
202
|
+
renderTable(n, out);
|
|
203
|
+
return;
|
|
204
|
+
case 'p':
|
|
205
|
+
case 'div':
|
|
206
|
+
case 'section':
|
|
207
|
+
case 'article':
|
|
208
|
+
case 'blockquote':
|
|
209
|
+
newline(out);
|
|
210
|
+
renderBlocks(n.children ?? [], out);
|
|
211
|
+
newline(out);
|
|
212
|
+
return;
|
|
213
|
+
default:
|
|
214
|
+
renderBlocks(n.children ?? [], out);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const DATE_KEYWORDS = ['datePublished', 'dateCreated', 'uploadDate', 'publishedAt'];
|
|
219
|
+
const DATE_META_NAMES = [
|
|
220
|
+
'article:published_time',
|
|
221
|
+
'datePublished',
|
|
222
|
+
'publish-date',
|
|
223
|
+
'pubdate',
|
|
224
|
+
'parsely-pub-date',
|
|
225
|
+
'date',
|
|
226
|
+
];
|
|
227
|
+
|
|
228
|
+
function toIso(v: string | undefined): string | undefined {
|
|
229
|
+
if (!v) return undefined;
|
|
230
|
+
const ms = Date.parse(v.trim());
|
|
231
|
+
if (Number.isNaN(ms) || ms < 0) return undefined;
|
|
232
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function jsonLdDate(nodes: DomNode[]): string | undefined {
|
|
236
|
+
const pick = (value: unknown): string | undefined => {
|
|
237
|
+
if (typeof value === 'string') {
|
|
238
|
+
const iso = toIso(value);
|
|
239
|
+
if (iso) return iso;
|
|
240
|
+
}
|
|
241
|
+
return undefined;
|
|
242
|
+
};
|
|
243
|
+
const walk = (value: unknown): string | undefined => {
|
|
244
|
+
if (Array.isArray(value)) {
|
|
245
|
+
for (const v of value) {
|
|
246
|
+
const hit = walk(v);
|
|
247
|
+
if (hit) return hit;
|
|
248
|
+
}
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
if (typeof value === 'object' && value !== null) {
|
|
252
|
+
for (const key of DATE_KEYWORDS) {
|
|
253
|
+
const hit = pick((value as Record<string, unknown>)[key]);
|
|
254
|
+
if (hit) return hit;
|
|
255
|
+
}
|
|
256
|
+
for (const v of Object.values(value as Record<string, unknown>)) {
|
|
257
|
+
const hit = walk(v);
|
|
258
|
+
if (hit) return hit;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return undefined;
|
|
262
|
+
};
|
|
263
|
+
const visit = (n: DomNode): string | undefined => {
|
|
264
|
+
if (n.type === 'tag' && n.name === 'script') {
|
|
265
|
+
const type = (n.attribs?.['type'] ?? '').toLowerCase().trim();
|
|
266
|
+
if (type === 'application/ld+json' || type === 'application/json' || type === 'ld+json') {
|
|
267
|
+
try {
|
|
268
|
+
const hit = walk(JSON.parse(n.data ?? ''));
|
|
269
|
+
if (hit) return hit;
|
|
270
|
+
} catch {}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
for (const c of n.children ?? []) {
|
|
274
|
+
const hit = visit(c);
|
|
275
|
+
if (hit) return hit;
|
|
276
|
+
}
|
|
277
|
+
return undefined;
|
|
278
|
+
};
|
|
279
|
+
for (const n of nodes) {
|
|
280
|
+
const hit = visit(n);
|
|
281
|
+
if (hit) return hit;
|
|
282
|
+
}
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface HeadScan {
|
|
287
|
+
title: string;
|
|
288
|
+
publishedAt?: string;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function headScan(html: string): HeadScan {
|
|
292
|
+
let doc: DomNode;
|
|
293
|
+
try {
|
|
294
|
+
doc = parseDocument(html) as unknown as DomNode;
|
|
295
|
+
} catch {
|
|
296
|
+
const title = /<title[^>]*>([^<]*)<\/title>/i.exec(html)?.[1]?.trim()?.slice(0, 200) ?? '';
|
|
297
|
+
return { title };
|
|
298
|
+
}
|
|
299
|
+
const nodes = doc.children ?? [];
|
|
300
|
+
let title = '';
|
|
301
|
+
let ogTitle = '';
|
|
302
|
+
let metaDate: string | undefined;
|
|
303
|
+
let timeDate: string | undefined;
|
|
304
|
+
|
|
305
|
+
const visit = (n: DomNode): void => {
|
|
306
|
+
if (n.type !== 'tag') return;
|
|
307
|
+
const name = n.name ?? '';
|
|
308
|
+
const attribs = n.attribs ?? {};
|
|
309
|
+
if (name === 'title' && !title) {
|
|
310
|
+
title = (n.children ?? [])
|
|
311
|
+
.filter((c) => c.type === 'text')
|
|
312
|
+
.map((c) => c.data ?? '')
|
|
313
|
+
.join('')
|
|
314
|
+
.trim()
|
|
315
|
+
.slice(0, 200);
|
|
316
|
+
}
|
|
317
|
+
if (name === 'meta') {
|
|
318
|
+
const key = (attribs['property'] ?? attribs['name'] ?? '').toLowerCase().trim();
|
|
319
|
+
if (!ogTitle && key === 'og:title') ogTitle = (attribs['content'] ?? '').trim().slice(0, 200);
|
|
320
|
+
if (!metaDate && DATE_META_NAMES.includes(key)) metaDate = toIso(attribs['content']);
|
|
321
|
+
}
|
|
322
|
+
if (name === 'time' && !timeDate && attribs['datetime']) timeDate = toIso(attribs['datetime']);
|
|
323
|
+
for (const c of n.children ?? []) visit(c);
|
|
324
|
+
};
|
|
325
|
+
for (const n of nodes) visit(n);
|
|
326
|
+
|
|
327
|
+
return { title: title || ogTitle, publishedAt: jsonLdDate(nodes) ?? metaDate ?? timeDate };
|
|
328
|
+
}
|
package/src/scrape.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type RemoteDoc,
|
|
19
19
|
type Renderer,
|
|
20
20
|
} from './transport';
|
|
21
|
+
import { headScan, treeToMarkdownish } from './dom';
|
|
21
22
|
|
|
22
23
|
export interface ScrapeResult {
|
|
23
24
|
url: string;
|
|
@@ -94,35 +95,6 @@ export function decodeHtmlEntities(s: string): string {
|
|
|
94
95
|
});
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
function stripKatexSpans(h: string): string {
|
|
98
|
-
let out = '';
|
|
99
|
-
let i = 0;
|
|
100
|
-
for (;;) {
|
|
101
|
-
const start = /<span[^>]*class="[^"]*katex[^"]*"[^>]*>/i.exec(h.slice(i));
|
|
102
|
-
if (!start || start.index === undefined) {
|
|
103
|
-
out += h.slice(i);
|
|
104
|
-
break;
|
|
105
|
-
}
|
|
106
|
-
const abs = i + start.index;
|
|
107
|
-
out += h.slice(i, abs);
|
|
108
|
-
let depth = 1;
|
|
109
|
-
let j = abs + start[0].length;
|
|
110
|
-
while (depth > 0 && j < h.length) {
|
|
111
|
-
const next = /<span\b[^>]*>|<\/span>/gi;
|
|
112
|
-
next.lastIndex = j;
|
|
113
|
-
const tag = next.exec(h);
|
|
114
|
-
if (!tag) {
|
|
115
|
-
j = h.length;
|
|
116
|
-
break;
|
|
117
|
-
}
|
|
118
|
-
j = tag.index + tag[0].length;
|
|
119
|
-
depth += tag[0].startsWith('</') ? -1 : 1;
|
|
120
|
-
}
|
|
121
|
-
i = j;
|
|
122
|
-
}
|
|
123
|
-
return out;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
98
|
export function extractHtml(html: string): { title: string; text: string; publishedAt?: string; paywalled: boolean } {
|
|
127
99
|
const paywalled =
|
|
128
100
|
/class="[^"]*(?:fc-ab-root|metering-modal|paywall|pw-overlay|subscription-overlay|regwall)[^"]*"/i.test(html) ||
|
|
@@ -130,61 +102,8 @@ export function extractHtml(html: string): { title: string; text: string; publis
|
|
|
130
102
|
/<meta[^>]+(?:property|name)=["'](?:isAccessibleForFree|tunnel:[^"']*paywall)["'][^>]+content=["'](?:false|yes)["']/i.test(
|
|
131
103
|
html,
|
|
132
104
|
);
|
|
133
|
-
const title =
|
|
134
|
-
|
|
135
|
-
/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i.exec(html)?.[1]?.slice(0, 200) ||
|
|
136
|
-
'';
|
|
137
|
-
const publishedAt = extractPublishedDate(html);
|
|
138
|
-
let h = html
|
|
139
|
-
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
140
|
-
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
141
|
-
.replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
|
|
142
|
-
.replace(/<svg[\s\S]*?<\/svg>/gi, ' ')
|
|
143
|
-
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
144
|
-
.replace(/<nav[\s\S]*?<\/nav>/gi, ' ')
|
|
145
|
-
.replace(/<footer[\s\S]*?<\/footer>/gi, ' ')
|
|
146
|
-
.replace(/<aside[\s\S]*?<\/aside>/gi, ' ');
|
|
147
|
-
h = h.replace(/<pre[\s>][\s\S]*?<\/pre>/gi, (m) => m.replace(/\n/g, '\uE000'));
|
|
148
|
-
h = h.replace(/<table[\s>][\s\S]*?<\/table>/gi, (table) => {
|
|
149
|
-
const rows = [...table.matchAll(/<tr[\s>][\s\S]*?<\/tr>/gi)].map((r) =>
|
|
150
|
-
[...r[0].matchAll(/<t([hd])[\s>][\s\S]*?<\/t\1>/gi)].map((c) =>
|
|
151
|
-
c[0]
|
|
152
|
-
.replace(/<[^>]+>/g, ' ')
|
|
153
|
-
.replace(/\s+/g, ' ')
|
|
154
|
-
.replace(/\|/g, '\\|')
|
|
155
|
-
.trim(),
|
|
156
|
-
),
|
|
157
|
-
);
|
|
158
|
-
const grid = rows.filter((r) => r.length);
|
|
159
|
-
if (grid.length < 2) return table;
|
|
160
|
-
const cols = Math.max(...grid.map((g) => g.length));
|
|
161
|
-
if (cols < 2) return table;
|
|
162
|
-
const line = (g: string[]) => {
|
|
163
|
-
const cells = g.slice(0, cols);
|
|
164
|
-
while (cells.length < cols) cells.push(' ');
|
|
165
|
-
return `| ${cells.join(' | ')} |`;
|
|
166
|
-
};
|
|
167
|
-
const md = [line(grid[0]), `| ${Array.from({ length: cols }, () => '---').join(' | ')} |`];
|
|
168
|
-
for (const g of grid.slice(1)) md.push(line(g));
|
|
169
|
-
return `\n${md.join('\n')}\n`;
|
|
170
|
-
});
|
|
171
|
-
h = stripKatexSpans(h);
|
|
172
|
-
h = h.replace(/<math[\s>][\s\S]*?<\/math>/gi, (m) => {
|
|
173
|
-
const tex = /<annotation[^>]*encoding=["']application\/x-tex["'][^>]*>([\s\S]*?)<\/annotation>/i.exec(m)?.[1];
|
|
174
|
-
return tex?.trim() ? ` $${tex.trim()}$ ` : ' ';
|
|
175
|
-
});
|
|
176
|
-
h = h.replace(
|
|
177
|
-
/<h([1-6])[^>]*>(.*?)<\/h\1>/gis,
|
|
178
|
-
(_, n, inner) => `\n${'#'.repeat(Number(n))} ${inner.replace(/<[^>]+>/g, ' ').trim()}\n`,
|
|
179
|
-
);
|
|
180
|
-
h = h
|
|
181
|
-
.replace(/<li[^>]*>/gi, '\n• ')
|
|
182
|
-
.replace(/<\/(p|div|li|tr|section|article|blockquote|table|ul|ol|pre)>/gi, '\n')
|
|
183
|
-
.replace(/<(br|hr)[^>]*>/gi, '\n');
|
|
184
|
-
// strip tags first, THEN decode entities: escaped markup shown as literal
|
|
185
|
-
// text (`<limit...>`) must survive as text, not be stripped as a tag
|
|
186
|
-
h = h.replace(/<[^>]+>/g, ' ');
|
|
187
|
-
h = h.replace(/\uE000/g, '\n');
|
|
105
|
+
const { title, publishedAt } = headScan(html);
|
|
106
|
+
let h = treeToMarkdownish(html).replace(/\uE000/g, '\n');
|
|
188
107
|
h = stripToAscii(decodeHtmlEntities(h));
|
|
189
108
|
const lines = h
|
|
190
109
|
.split('\n')
|
|
@@ -230,21 +149,6 @@ export function extractHtml(html: string): { title: string; text: string; publis
|
|
|
230
149
|
return { title, publishedAt, paywalled, text: out.join('\n').replace(/\n{3,}/g, '\n\n') };
|
|
231
150
|
}
|
|
232
151
|
|
|
233
|
-
function extractPublishedDate(html: string): string | undefined {
|
|
234
|
-
const iso = (v: string | undefined) => {
|
|
235
|
-
if (!v) return undefined;
|
|
236
|
-
const t = Date.parse(v.trim());
|
|
237
|
-
return Number.isNaN(t) || t < 0 ? undefined : new Date(t).toISOString().slice(0, 10);
|
|
238
|
-
};
|
|
239
|
-
const ld = /"datePublished"\s*:\s*"([^"]+)"/.exec(html)?.[1];
|
|
240
|
-
const meta =
|
|
241
|
-
/<meta[^>]+(?:property|name)=["'](?:article:published_time|datePublished|publish-date|pubdate|parsely-pub-date|date)["'][^>]+content=["']([^"']+)["']/i.exec(
|
|
242
|
-
html,
|
|
243
|
-
)?.[1];
|
|
244
|
-
const timeEl = /<time[^>]+datetime=["']([^"']+)["']/i.exec(html)?.[1];
|
|
245
|
-
return iso(ld) ?? iso(meta) ?? iso(timeEl);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
152
|
const NAV_LABEL_RE =
|
|
249
153
|
/^(home|menu|about( us)?|docs?|documentation|downloads?|license|licence|support|purchase|buy|pricing|search|login|log ?in|sign ?(in|up|out)|register|account|blog|news|contact|careers?|jobs|store|shop|forum|community|tutorials?|api|guide|guides|reference|examples?|faq|help|terms|privacy|cookies?|sitemap|rss|changelog|releases?|twitter|facebook|github|discord|youtube|linkedin|mastodon|x)$/i;
|
|
250
154
|
|
package/src/tools.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { Type } from 'typebox';
|
|
6
6
|
import type { ExtensionAPI, ExtensionCommandContext, Theme } from '@earendil-works/pi-coding-agent';
|
|
7
|
-
import { keyHint } from '@earendil-works/pi-coding-agent';
|
|
8
|
-
import { Text } from '@earendil-works/pi-tui';
|
|
7
|
+
import { keyHint, keyText, rawKeyHint } from '@earendil-works/pi-coding-agent';
|
|
8
|
+
import { Text, type Component } from '@earendil-works/pi-tui';
|
|
9
9
|
import { appendFileSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
|
10
10
|
import { dirname, join } from 'node:path';
|
|
11
11
|
import {
|
|
@@ -20,9 +20,10 @@ import {
|
|
|
20
20
|
} from './config';
|
|
21
21
|
import { ABORT_ERROR, DEFAULT_MAX_RESULTS } from './constants';
|
|
22
22
|
import { cacheKey, openCache, CACHE_TTL_HOURS } from './cache';
|
|
23
|
-
import { clearDeadEngines, runEngines } from './engines';
|
|
24
|
-
import { fuse, diversifyByHost
|
|
25
|
-
import {
|
|
23
|
+
import { clearDeadEngines, runEngines, type EngineOutcome } from './engines';
|
|
24
|
+
import { fuse, diversifyByHost } from './fuse';
|
|
25
|
+
import { normalizeUrl } from './urls';
|
|
26
|
+
import { buildSearchText, READ_FIRST_TAG, wrapAll } from './view';
|
|
26
27
|
import { scrape } from './scrape';
|
|
27
28
|
|
|
28
29
|
const SEARCH_PARAMS = Type.Object({
|
|
@@ -70,19 +71,22 @@ const FETCH_OUTPUT = Type.Object({
|
|
|
70
71
|
title: Type.String(),
|
|
71
72
|
renderer: Type.String(),
|
|
72
73
|
outline: Type.Array(Type.String()),
|
|
74
|
+
sections: Type.Array(
|
|
75
|
+
Type.Object({
|
|
76
|
+
index: Type.Integer({ minimum: 0 }),
|
|
77
|
+
heading: Type.String(),
|
|
78
|
+
text: Type.String(),
|
|
79
|
+
}),
|
|
80
|
+
),
|
|
73
81
|
section_count: Type.Number(),
|
|
74
82
|
chars: Type.Number(),
|
|
75
83
|
sections_requested: Type.Optional(Type.Array(Type.Number())),
|
|
76
84
|
sections_missing: Type.Optional(Type.Array(Type.Number())),
|
|
77
85
|
sections_truncated: Type.Optional(Type.Number()),
|
|
86
|
+
sections_clipped: Type.Optional(Type.Array(Type.Integer({ minimum: 0 }))),
|
|
78
87
|
error: Type.Optional(Type.String()),
|
|
79
88
|
});
|
|
80
89
|
|
|
81
|
-
function fmtSnippet(s: string, max: number): string {
|
|
82
|
-
const t = s.replace(/\s+/g, ' ').trim();
|
|
83
|
-
return t.length > max ? t.slice(0, max) + '…' : t;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
90
|
function dropRarestTerm(query: string): string {
|
|
87
91
|
const words = query.split(/\s+/).filter((w) => w.length > 2 && !/^(?:site|inurl|intitle|filetype):/i.test(w));
|
|
88
92
|
if (words.length < 2) return '';
|
|
@@ -93,54 +97,6 @@ function dropRarestTerm(query: string): string {
|
|
|
93
97
|
.trim();
|
|
94
98
|
}
|
|
95
99
|
|
|
96
|
-
function stripSiteSuffix(title: string, host: string): string {
|
|
97
|
-
const m = /^(.{3,})\s+[|–—-]\s+([^|–—-]{2,40})$/.exec(title.trim());
|
|
98
|
-
if (!m) return title;
|
|
99
|
-
const suffix = m[2].trim();
|
|
100
|
-
const head = m[1].trim();
|
|
101
|
-
const hostBase = host.replace(/^www\./, '').split('.')[0];
|
|
102
|
-
if (host && (suffix.toLowerCase().includes(hostBase) || suffix.length <= 25)) return head;
|
|
103
|
-
return title;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function normalizeHitDate(raw: string | undefined): string | undefined {
|
|
107
|
-
if (!raw) return undefined;
|
|
108
|
-
const t = Date.parse(raw);
|
|
109
|
-
if (!Number.isNaN(t)) return new Date(t).toISOString().slice(0, 10);
|
|
110
|
-
const rel = /^(\d+)\s*(m|min|minute|minutes|h|hour|hours|d|day|days|w|week|weeks|mo|month|months)\b/i.exec(
|
|
111
|
-
raw.trim(),
|
|
112
|
-
);
|
|
113
|
-
if (rel) {
|
|
114
|
-
const n = parseInt(rel[1], 10);
|
|
115
|
-
const unit = rel[2].toLowerCase();
|
|
116
|
-
// 'mo' must win over 'm' BEFORE the minutes branch: the regex captures
|
|
117
|
-
// 'months' verbatim, so a bare startsWith('m') misclassifies it ("3
|
|
118
|
-
// months ago" used to render as ~today). Divide by unit, in months>…>ms.
|
|
119
|
-
const ms = unit.startsWith('h')
|
|
120
|
-
? 3_600_000
|
|
121
|
-
: unit.startsWith('mo')
|
|
122
|
-
? 2_592_000_000
|
|
123
|
-
: unit.startsWith('m')
|
|
124
|
-
? 60_000
|
|
125
|
-
: unit.startsWith('d')
|
|
126
|
-
? 86_400_000
|
|
127
|
-
: 604_800_000;
|
|
128
|
-
return new Date(Date.now() - n * ms).toISOString().slice(0, 10);
|
|
129
|
-
}
|
|
130
|
-
if (/^yesterday$/i.test(raw.trim())) return new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
|
|
131
|
-
return undefined;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function cleanSnippet(s: string): string {
|
|
135
|
-
return s
|
|
136
|
-
.replace(/\\([_*`[\]()#>~.-])/g, '$1')
|
|
137
|
-
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
|
138
|
-
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
139
|
-
.replace(/(^|\s)([*]{1,3}|_{1,2}|`)(\S(?:[^*`_]*\S)?)\2(?=\s|$|[.,;:!?)]|\b)/g, '$1$3')
|
|
140
|
-
.replace(/\s+/g, ' ')
|
|
141
|
-
.trim();
|
|
142
|
-
}
|
|
143
|
-
|
|
144
100
|
export function registerWebTools(pi: ExtensionAPI) {
|
|
145
101
|
const cache = openCache(getConfig().cacheDir + '/cache.json');
|
|
146
102
|
const exposure = existsSync(disabledFlagPath()) ? 'hidden' : 'direct';
|
|
@@ -166,6 +122,13 @@ export function registerWebTools(pi: ExtensionAPI) {
|
|
|
166
122
|
parameters: SEARCH_PARAMS,
|
|
167
123
|
outputSchema: SEARCH_OUTPUT,
|
|
168
124
|
|
|
125
|
+
renderCall(args, theme) {
|
|
126
|
+
const header = theme.fg('toolTitle', theme.bold('web.search'));
|
|
127
|
+
const q = typeof args.query === 'string' ? args.query : '…';
|
|
128
|
+
const tail = typeof args.max_results === 'number' ? theme.fg('muted', ` max=${args.max_results}`) : '';
|
|
129
|
+
return new Text(`${header} ${theme.fg('accent', q)}${tail}`, 0, 0);
|
|
130
|
+
},
|
|
131
|
+
|
|
169
132
|
async execute(_id, params, signal, _onUpdate, _ctx) {
|
|
170
133
|
if (signal?.aborted) throw new Error(ABORT_ERROR);
|
|
171
134
|
const outcomes = await runEngines({
|
|
@@ -205,38 +168,32 @@ export function registerWebTools(pi: ExtensionAPI) {
|
|
|
205
168
|
fused.filter((r) => !r.junk),
|
|
206
169
|
2,
|
|
207
170
|
).slice(0, Math.max((params.max_results ?? DEFAULT_MAX_RESULTS) * 2, DEFAULT_MAX_RESULTS));
|
|
208
|
-
if (candidates.length) retryNote = `
|
|
171
|
+
if (candidates.length) retryNote = `retried as "${relaxed}"`;
|
|
209
172
|
}
|
|
210
173
|
}
|
|
211
174
|
}
|
|
212
175
|
const visible = candidates.slice(0, params.max_results ?? DEFAULT_MAX_RESULTS);
|
|
213
176
|
const enginesUsed = [...new Set(fused.flatMap((r) => r.engines))];
|
|
214
|
-
const snip = 240;
|
|
215
|
-
|
|
216
|
-
let text = `${visible.length} result${visible.length === 1 ? '' : 's'} for "${params.query}": ${enginesUsed.length} engine${enginesUsed.length === 1 ? '' : 's'} contributed (${enginesUsed.join(', ')})${retryNote}\n\n`;
|
|
217
|
-
if (!visible.length) {
|
|
218
|
-
const fatal = outcomes.find((o) => o.engine === 'none' && o.error);
|
|
219
|
-
text = fatal
|
|
220
|
-
? `No results for "${params.query}": ${fatal.error}\n`
|
|
221
|
-
: `No results for "${params.query}". Broaden the query.\n`;
|
|
222
|
-
}
|
|
223
177
|
visible.forEach((r, i) => {
|
|
224
178
|
lastSearch.urlRank.set(normalizeUrl(r.url), {
|
|
225
179
|
rank: i + 1,
|
|
226
180
|
engines: r.engines,
|
|
227
181
|
query: params.query,
|
|
228
182
|
});
|
|
229
|
-
const title = fmtSnippet(stripSiteSuffix(cleanSnippet(r.title), hostOf(r.url)), 100) || r.url;
|
|
230
|
-
const snippet = fmtSnippet(cleanSnippet(r.snippet), snip);
|
|
231
|
-
const via = formatEvidence(r);
|
|
232
|
-
const when = normalizeHitDate(r.date);
|
|
233
|
-
text += `${i + 1}. [${title}](${r.url})${when ? ` — ${when}` : ''}${via}\n`;
|
|
234
|
-
if (snippet) text += ` ${snippet}\n`;
|
|
235
|
-
text += '\n';
|
|
236
183
|
});
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
184
|
+
const text = buildSearchText(
|
|
185
|
+
{
|
|
186
|
+
query: params.query,
|
|
187
|
+
hits: visible,
|
|
188
|
+
enginesUsed,
|
|
189
|
+
errored,
|
|
190
|
+
fatal: visible.length
|
|
191
|
+
? undefined
|
|
192
|
+
: outcomes.find((o): o is EngineOutcome & { error: string } => o.engine === 'none' && !!o.error),
|
|
193
|
+
retryNote,
|
|
194
|
+
},
|
|
195
|
+
{ wrap: terminalWrapWidth() },
|
|
196
|
+
);
|
|
240
197
|
|
|
241
198
|
return {
|
|
242
199
|
content: [{ type: 'text', text }],
|
|
@@ -256,9 +213,7 @@ export function registerWebTools(pi: ExtensionAPI) {
|
|
|
256
213
|
};
|
|
257
214
|
},
|
|
258
215
|
renderResult(result, options, theme) {
|
|
259
|
-
|
|
260
|
-
if (!options.expanded) return new Text(previewWithHint(lines, theme), 0, 0);
|
|
261
|
-
return new Text(colorizedResult(resultText(result), theme), 0, 0);
|
|
216
|
+
return new WrappedSearchText(resultText(result), theme, options.expanded);
|
|
262
217
|
},
|
|
263
218
|
});
|
|
264
219
|
|
|
@@ -280,7 +235,7 @@ export function registerWebTools(pi: ExtensionAPI) {
|
|
|
280
235
|
const url = typeof args.url === 'string' ? args.url : '…';
|
|
281
236
|
const s = Array.isArray(args.sections) ? args.sections.join(', ') : '…';
|
|
282
237
|
const tail = args.sections ? ` sections=[${s}]` : '';
|
|
283
|
-
return new Text(`${header} ${theme.fg('
|
|
238
|
+
return new Text(`${header} ${theme.fg('accent', url)}${theme.fg('muted', tail)}`, 0, 0);
|
|
284
239
|
},
|
|
285
240
|
|
|
286
241
|
async execute(_id, params, signal) {
|
|
@@ -353,11 +308,13 @@ type FetchDetails = {
|
|
|
353
308
|
title: string;
|
|
354
309
|
renderer: string;
|
|
355
310
|
outline: string[];
|
|
311
|
+
sections: { index: number; heading: string; text: string }[];
|
|
356
312
|
section_count: number;
|
|
357
313
|
chars?: number;
|
|
358
314
|
sections_requested?: number[];
|
|
359
315
|
sections_missing?: number[];
|
|
360
316
|
sections_truncated?: number;
|
|
317
|
+
sections_clipped?: number[];
|
|
361
318
|
error?: string;
|
|
362
319
|
};
|
|
363
320
|
|
|
@@ -369,6 +326,7 @@ interface FetchResult {
|
|
|
369
326
|
}
|
|
370
327
|
|
|
371
328
|
const PREVIEW_LINES = 10;
|
|
329
|
+
const SEARCH_PREVIEW_LINES = 8;
|
|
372
330
|
|
|
373
331
|
/** Display-only: the model-facing markers (`indices …`, `- *N*`) render as
|
|
374
332
|
* genuine ANSI italics in the TUI; the content text itself is untouched. */
|
|
@@ -378,49 +336,95 @@ function italicizeIndices(text: string, theme: Theme): string {
|
|
|
378
336
|
.replace(/^- \*(\d+)\* /gm, (_, n: string) => `- ${theme.italic(n)} `);
|
|
379
337
|
}
|
|
380
338
|
|
|
339
|
+
function italicizeRanks(text: string, theme: Theme): string {
|
|
340
|
+
return text.replace(/^(\s*)(\d+)\.(?=\s)/gm, (m, pad: string, n: string) => `${pad}${theme.italic(n)}.`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const ANSI_SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
|
|
344
|
+
|
|
345
|
+
const DEFAULT_WRAP_COLS = 90;
|
|
346
|
+
const MAX_WRAP_COLS = 240;
|
|
347
|
+
|
|
348
|
+
function terminalWrapWidth(): number {
|
|
349
|
+
const cols = process.stdout.columns ?? DEFAULT_WRAP_COLS;
|
|
350
|
+
return Math.min(MAX_WRAP_COLS, Math.max(60, cols - 4));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
class WrappedSearchText implements Component {
|
|
354
|
+
private raw: string;
|
|
355
|
+
private theme: Theme;
|
|
356
|
+
private expanded: boolean;
|
|
357
|
+
constructor(raw: string, theme: Theme, expanded: boolean) {
|
|
358
|
+
this.raw = raw;
|
|
359
|
+
this.theme = theme;
|
|
360
|
+
this.expanded = expanded;
|
|
361
|
+
}
|
|
362
|
+
invalidate(): void {}
|
|
363
|
+
render(width: number): string[] {
|
|
364
|
+
const wrapped = wrapAll(this.raw, Math.max(40, width));
|
|
365
|
+
const lines = wrapped.split('\n');
|
|
366
|
+
const expanded = this.expanded || lines.length <= SEARCH_PREVIEW_LINES;
|
|
367
|
+
const shown = expanded ? lines : lines.slice(0, SEARCH_PREVIEW_LINES);
|
|
368
|
+
const colored = italicizeRanks(shown.join('\n'), this.theme)
|
|
369
|
+
.split('\n')
|
|
370
|
+
.map((l) => colorLine(l, this.theme))
|
|
371
|
+
.join('\n');
|
|
372
|
+
const out = replaceTabs(colored).split('\n');
|
|
373
|
+
if (!expanded) {
|
|
374
|
+
const hint = moreLinesHint(shown.length, lines.length, this.theme);
|
|
375
|
+
if (hint) out.push(hint);
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function colorLine(l: string, theme: Theme): string {
|
|
382
|
+
const plain = l.replace(ANSI_SGR, '');
|
|
383
|
+
if (
|
|
384
|
+
plain.includes('…[section truncated') ||
|
|
385
|
+
plain.includes('…(continues at outline index') ||
|
|
386
|
+
plain.startsWith('…[clipped by tool]') ||
|
|
387
|
+
plain.startsWith('…[truncated]')
|
|
388
|
+
)
|
|
389
|
+
return theme.fg('warning', l);
|
|
390
|
+
if (plain.startsWith('## ') && plain.endsWith(READ_FIRST_TAG))
|
|
391
|
+
return theme.fg('accent', l.slice(0, l.length - READ_FIRST_TAG.length)) + theme.bold(READ_FIRST_TAG);
|
|
392
|
+
if (/^\s*· recommended first read$/.test(plain)) return theme.bold(l.trimEnd());
|
|
393
|
+
if (plain.startsWith('## ')) return theme.fg('accent', l);
|
|
394
|
+
if (plain.startsWith('- ')) return theme.fg('dim', l);
|
|
395
|
+
if (plain.startsWith('[rendered via')) return theme.fg('muted', l);
|
|
396
|
+
if (/^\s*\d+\. /.test(plain)) return theme.fg('dim', l);
|
|
397
|
+
return l;
|
|
398
|
+
}
|
|
399
|
+
|
|
381
400
|
function colorizedResult(text: string, theme: Theme): string {
|
|
382
401
|
return text
|
|
383
402
|
.split('\n')
|
|
384
|
-
.map((l) =>
|
|
385
|
-
if (
|
|
386
|
-
l.includes('…[section truncated') ||
|
|
387
|
-
l.includes('…(continues at outline index') ||
|
|
388
|
-
l.startsWith('…[clipped by tool]') ||
|
|
389
|
-
l.startsWith('…[truncated]')
|
|
390
|
-
)
|
|
391
|
-
return theme.fg('warning', l);
|
|
392
|
-
if (l.startsWith('## [')) return theme.fg('accent', l);
|
|
393
|
-
if (l.startsWith('- ')) return theme.fg('dim', l);
|
|
394
|
-
if (l.startsWith('[rendered via')) return theme.fg('muted', l);
|
|
395
|
-
if (/^\d+\. /.test(l)) return theme.fg('toolOutput', l);
|
|
396
|
-
return l;
|
|
397
|
-
})
|
|
403
|
+
.map((l) => colorLine(l, theme))
|
|
398
404
|
.join('\n');
|
|
399
405
|
}
|
|
400
406
|
|
|
401
|
-
/** Per-hit evidence line: "⇐ serper#1, exa#2"; singles are unverified. */
|
|
402
|
-
function formatEvidence(r: FusedHit): string {
|
|
403
|
-
if (!r.engineRanks?.length) return r.engines.length > 1 ? ` ← ${r.engines.length} engines` : '';
|
|
404
|
-
if (r.engineRanks.length === 1) return ` ⇐ ${r.engineRanks[0].engine} only (single-engine: unverified)`;
|
|
405
|
-
const shown = r.engineRanks
|
|
406
|
-
.slice(0, 6)
|
|
407
|
-
.map((e) => `${e.engine}#${e.rank}`)
|
|
408
|
-
.join(', ');
|
|
409
|
-
const rest = r.engineRanks.length > 6 ? `, +${r.engineRanks.length - 6} more` : '';
|
|
410
|
-
return ` ⇐ ${shown}${rest}`;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
407
|
function resultText(result: { content: { type: string; text?: string }[] }): string {
|
|
414
408
|
return (result.content[0] as { type: string; text: string } | undefined)?.text ?? '';
|
|
415
409
|
}
|
|
416
410
|
|
|
417
|
-
function
|
|
418
|
-
const
|
|
419
|
-
|
|
411
|
+
function moreLinesHint(shown: number, total: number, theme: Theme): string {
|
|
412
|
+
const remaining = total - shown;
|
|
413
|
+
if (remaining <= 0) return '';
|
|
414
|
+
let hint: string;
|
|
415
|
+
try {
|
|
416
|
+
hint = keyText('app.tools.expand') ? keyHint('app.tools.expand', 'to expand') : rawKeyHint('ctrl+o', 'to expand');
|
|
417
|
+
} catch {
|
|
418
|
+
hint = rawKeyHint('ctrl+o', 'to expand');
|
|
419
|
+
}
|
|
420
|
+
return theme.fg('muted', `… (${remaining} more lines,`) + ' ' + hint + theme.fg('muted', ')');
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function previewWithHint(lines: string[], theme: Theme, maxLines = PREVIEW_LINES): string {
|
|
424
|
+
const shown = lines.slice(0, maxLines);
|
|
420
425
|
const text = shown.map((l) => theme.fg('toolOutput', replaceTabs(l))).join('\n');
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
return text;
|
|
426
|
+
const hint = moreLinesHint(shown.length, lines.length, theme);
|
|
427
|
+
return hint ? `${text}\n${hint}` : text;
|
|
424
428
|
}
|
|
425
429
|
|
|
426
430
|
function replaceTabs(text: string): string {
|
|
@@ -439,7 +443,6 @@ function fetchErrorResult(details: FetchDetails): FetchResult {
|
|
|
439
443
|
|
|
440
444
|
function buildFetchResult(res: Awaited<ReturnType<typeof scrape>>, sections: number[]): FetchResult {
|
|
441
445
|
const want = [...new Set(sections.map((i) => Math.max(0, Math.min(res.sections.length - 1, i))))];
|
|
442
|
-
const chosen = want.map((i) => res.sections[i]).filter(Boolean);
|
|
443
446
|
const body = res.outline.length
|
|
444
447
|
? `indices - 0 - ${res.outline.length - 1} section${res.outline.length === 1 ? '' : 's'}\n${res.outline.map((h, i) => `- *${i}* ${h}`).join('\n')}\n\n`
|
|
445
448
|
: '';
|
|
@@ -471,17 +474,48 @@ function buildFetchResult(res: Awaited<ReturnType<typeof scrape>>, sections: num
|
|
|
471
474
|
if (res.renderer && res.renderer !== 'local') text += `\n\n[rendered via ${res.renderer}]`;
|
|
472
475
|
const clipped = text.length > MAX_FETCH_TEXT ? text.slice(0, MAX_FETCH_TEXT) + '\n…[clipped by tool]' : text;
|
|
473
476
|
const missing = sections.filter((i) => i >= res.sections.length);
|
|
477
|
+
// continuation cuts (res.continued) vs local budget clips (clippedSections)
|
|
474
478
|
const truncatedCount = want.filter((idx) => res.continued?.includes(idx)).length;
|
|
479
|
+
|
|
480
|
+
// same budget as the text view; headings and the '…' marker count against it
|
|
481
|
+
let budget = MAX_FETCH_TEXT;
|
|
482
|
+
const structuredSections: { index: number; heading: string; text: string }[] = [];
|
|
483
|
+
const clippedSections: number[] = [];
|
|
484
|
+
for (const idx of want) {
|
|
485
|
+
if (budget <= 0) {
|
|
486
|
+
clippedSections.push(idx);
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
const s = res.sections[idx];
|
|
490
|
+
if (s === undefined) continue;
|
|
491
|
+
const heading = res.outline[idx] ?? '';
|
|
492
|
+
const fullCost = heading.length + s.length;
|
|
493
|
+
if (fullCost <= budget) {
|
|
494
|
+
structuredSections.push({ index: idx, heading, text: s });
|
|
495
|
+
budget -= fullCost;
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (heading.length >= budget) {
|
|
499
|
+
structuredSections.push({ index: idx, heading: heading.slice(0, Math.max(0, budget - 1)) + '…', text: '' });
|
|
500
|
+
} else {
|
|
501
|
+
const textLen = Math.max(0, budget - heading.length - 1);
|
|
502
|
+
structuredSections.push({ index: idx, heading, text: s.slice(0, textLen) + (textLen < s.length ? '…' : '') });
|
|
503
|
+
}
|
|
504
|
+
clippedSections.push(idx);
|
|
505
|
+
budget = 0;
|
|
506
|
+
}
|
|
475
507
|
const details = {
|
|
476
508
|
url: res.url,
|
|
477
509
|
title: res.title,
|
|
478
510
|
renderer: res.renderer,
|
|
479
511
|
outline: res.outline,
|
|
480
|
-
|
|
512
|
+
sections: structuredSections,
|
|
513
|
+
section_count: structuredSections.length,
|
|
481
514
|
chars: clipped.length,
|
|
482
515
|
sections_requested: [...new Set(sections)],
|
|
483
516
|
sections_missing: [...new Set(missing)],
|
|
484
517
|
sections_truncated: truncatedCount,
|
|
518
|
+
sections_clipped: clippedSections,
|
|
485
519
|
...(res.publishedAt ? { publishedAt: res.publishedAt } : {}),
|
|
486
520
|
...(res.paywalled ? { paywalled: true } : {}),
|
|
487
521
|
...(res.error ? { error: res.error } : {}),
|
package/src/transport.ts
CHANGED
|
@@ -203,7 +203,7 @@ function decodeBody(bytes: Uint8Array, contentType: string): string {
|
|
|
203
203
|
return looksMojibake(text) ? repairMojibake(text) : text;
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
-
async function readBodyBounded(res: Response, maxBytes: number): Promise<Uint8Array> {
|
|
206
|
+
export async function readBodyBounded(res: Response, maxBytes: number): Promise<Uint8Array> {
|
|
207
207
|
const declared = Number(res.headers.get('content-length') ?? 0);
|
|
208
208
|
if (declared > maxBytes) throw new Error('page too large');
|
|
209
209
|
const reader = res.body?.getReader();
|
|
@@ -218,7 +218,10 @@ async function readBodyBounded(res: Response, maxBytes: number): Promise<Uint8Ar
|
|
|
218
218
|
const { done, value } = await reader.read();
|
|
219
219
|
if (done) break;
|
|
220
220
|
size += value.byteLength;
|
|
221
|
-
if (size > maxBytes)
|
|
221
|
+
if (size > maxBytes) {
|
|
222
|
+
await reader.cancel();
|
|
223
|
+
throw new Error('page too large');
|
|
224
|
+
}
|
|
222
225
|
chunks.push(value);
|
|
223
226
|
}
|
|
224
227
|
const out = new Uint8Array(size);
|
package/src/view.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { FusedHit } from './fuse';
|
|
2
|
+
import { hostOf } from './urls';
|
|
3
|
+
|
|
4
|
+
const SNIPPET_MAX = 240;
|
|
5
|
+
const CHAPTER_TITLE_MAX = 100;
|
|
6
|
+
const TOC_TITLE_MAX = 60;
|
|
7
|
+
const ERROR_SNIP = 40;
|
|
8
|
+
const DEFAULT_WRAP = 100;
|
|
9
|
+
const MAX_WRAP = 240;
|
|
10
|
+
|
|
11
|
+
export const READ_FIRST_TAG = ' · recommended first read';
|
|
12
|
+
|
|
13
|
+
interface ErroredEngine {
|
|
14
|
+
engine: string;
|
|
15
|
+
error: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SearchViewOpts {
|
|
19
|
+
wrap?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SearchView {
|
|
23
|
+
query: string;
|
|
24
|
+
hits: FusedHit[];
|
|
25
|
+
enginesUsed: string[];
|
|
26
|
+
errored: ErroredEngine[];
|
|
27
|
+
fatal?: { engine: string; error: string };
|
|
28
|
+
retryNote?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function fmtSnippet(s: string, max: number): string {
|
|
32
|
+
const t = s.replace(/\s+/g, ' ').trim();
|
|
33
|
+
return t.length > max ? t.slice(0, max) + '…' : t;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function cleanSnippet(s: string): string {
|
|
37
|
+
return s
|
|
38
|
+
.replace(/\\(.)/g, '$1')
|
|
39
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
|
40
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
41
|
+
.replace(/\[\[\d+\]\]\([^)]*\)\)?/g, '')
|
|
42
|
+
.replace(/\[\[\d+\]\]/g, '')
|
|
43
|
+
.replace(/\(https?:\/\/[^)]*\)/g, ' ')
|
|
44
|
+
.replace(/#{2,6}\s+/g, '')
|
|
45
|
+
.replace(/\|/g, ' ')
|
|
46
|
+
.replace(/(^|\s)-+(?=\s|$)/g, '$1')
|
|
47
|
+
.replace(/(^|[()?!:;])\s?\d{1,2}:\d{2}\s+/g, '$1 ')
|
|
48
|
+
.replace(/\(\s*\)/g, '')
|
|
49
|
+
.replace(/\((?=[0-9+\-–·\s]*[+\-–·])[0-9+\-–·\s]*\)/g, ' ')
|
|
50
|
+
.replace(/\d{1,4}[KM]?\s*•\s*\d{1,4}(?:mo|d|w|y|h)\s+ago/gi, ' ')
|
|
51
|
+
.replace(/\s+([?:!;])/g, '$1')
|
|
52
|
+
.replace(/(^|\s)([*]{1,3}|_{1,2}|`)(\S(?:[^*`_]*\S)?)\2(?=\s|$|[.,;:!?)]|\b)/g, '$1$3')
|
|
53
|
+
.replace(/\s+/g, ' ')
|
|
54
|
+
.trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function stripSiteSuffix(title: string, host: string): string {
|
|
58
|
+
const m = /^(.{3,})\s+[|–—-]\s+([^|–—-]{2,40})$/.exec(title.trim());
|
|
59
|
+
if (!m) return title;
|
|
60
|
+
const suffix = m[2].trim();
|
|
61
|
+
const head = m[1].trim();
|
|
62
|
+
const hostBase = host.replace(/^www\./, '').split('.')[0];
|
|
63
|
+
if (host && suffix.toLowerCase().includes(hostBase)) return head;
|
|
64
|
+
if (suffix.length <= 25 && !/\s/.test(suffix)) return head;
|
|
65
|
+
return title;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeHitDate(raw: string | undefined): string | undefined {
|
|
69
|
+
if (!raw) return undefined;
|
|
70
|
+
const t = Date.parse(raw);
|
|
71
|
+
if (!Number.isNaN(t)) return new Date(t).toISOString().slice(0, 10);
|
|
72
|
+
const rel = /^(\d+)\s*(m|min|minute|minutes|h|hour|hours|d|day|days|w|week|weeks|mo|month|months)\b/i.exec(
|
|
73
|
+
raw.trim(),
|
|
74
|
+
);
|
|
75
|
+
if (rel) {
|
|
76
|
+
const n = parseInt(rel[1], 10);
|
|
77
|
+
const unit = rel[2].toLowerCase();
|
|
78
|
+
const ms = unit.startsWith('h')
|
|
79
|
+
? 3_600_000
|
|
80
|
+
: unit.startsWith('mo')
|
|
81
|
+
? 2_592_000_000
|
|
82
|
+
: unit.startsWith('m')
|
|
83
|
+
? 60_000
|
|
84
|
+
: unit.startsWith('d')
|
|
85
|
+
? 86_400_000
|
|
86
|
+
: 604_800_000;
|
|
87
|
+
return new Date(Date.now() - n * ms).toISOString().slice(0, 10);
|
|
88
|
+
}
|
|
89
|
+
if (/^yesterday$/i.test(raw.trim())) return new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function formatEvidence(r: FusedHit): string {
|
|
94
|
+
if (!r.engineRanks?.length) return r.engines.length > 1 ? ` ← ${r.engines.length} engines` : '';
|
|
95
|
+
if (r.engineRanks.length === 1) return ` ⇐ ${r.engineRanks[0].engine} only (single-engine: unverified)`;
|
|
96
|
+
const shown = r.engineRanks
|
|
97
|
+
.slice(0, 6)
|
|
98
|
+
.map((e) => `${e.engine}#${e.rank}`)
|
|
99
|
+
.join(', ');
|
|
100
|
+
const rest = r.engineRanks.length > 6 ? `, +${r.engineRanks.length - 6} more` : '';
|
|
101
|
+
return ` ⇐ ${shown}${rest}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function cleanTitle(s: string, host: string): string {
|
|
105
|
+
return stripSiteSuffix(cleanSnippet(s), host).replace(/\s+[—–]\s+/g, ': ');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function tocTitle(r: FusedHit): string {
|
|
109
|
+
return fmtSnippet(cleanTitle(r.title, hostOf(r.url)), TOC_TITLE_MAX) || fmtSnippet(r.url, TOC_TITLE_MAX);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function chapterTitle(r: FusedHit): string {
|
|
113
|
+
return fmtSnippet(cleanTitle(r.title, hostOf(r.url)), CHAPTER_TITLE_MAX) || r.url;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function compactWarn(e: ErroredEngine): string {
|
|
117
|
+
const status = /HTTP\s+(\d{3})/i.exec(e.error)?.[1];
|
|
118
|
+
return status ? `${status}: ${e.engine}` : `${e.engine}: ${e.error.slice(0, ERROR_SNIP)}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function buildSearchText(v: SearchView, opts: SearchViewOpts = {}): string {
|
|
122
|
+
const count = v.hits.length;
|
|
123
|
+
const width = Math.max(40, Math.min(MAX_WRAP, opts.wrap ?? DEFAULT_WRAP));
|
|
124
|
+
const engineWord = v.enginesUsed.length === 1 ? 'engine' : 'engines';
|
|
125
|
+
if (!count) {
|
|
126
|
+
return v.fatal ? `No results: ${v.fatal.error}\n` : `No results. Broaden the query.\n`;
|
|
127
|
+
}
|
|
128
|
+
let text = `${v.enginesUsed.length} ${engineWord} [${v.enginesUsed.join(', ')}]`;
|
|
129
|
+
if (v.retryNote) text += ` · ${v.retryNote}`;
|
|
130
|
+
const warns = v.errored.map(compactWarn);
|
|
131
|
+
if (warns.length) text += ` ⚠ ${warns.join(' · ')}`;
|
|
132
|
+
text += '\n';
|
|
133
|
+
const rows = v.hits.map((r, i) => {
|
|
134
|
+
const host = hostOf(r.url);
|
|
135
|
+
return `${String(i + 1).padStart(2)}. ${tocTitle(r)}${host ? ` · ${host}` : ''}`;
|
|
136
|
+
});
|
|
137
|
+
text += `\n${rows.join('\n')}\n`;
|
|
138
|
+
v.hits.forEach((r, i) => {
|
|
139
|
+
const title = chapterTitle(r);
|
|
140
|
+
const snippet = fmtSnippet(cleanSnippet(r.snippet), SNIPPET_MAX);
|
|
141
|
+
const via = formatEvidence(r).trim();
|
|
142
|
+
const when = normalizeHitDate(r.date);
|
|
143
|
+
let meta = r.url;
|
|
144
|
+
if (when) meta += ` · ${when}`;
|
|
145
|
+
if (via) meta += ` ${via}`;
|
|
146
|
+
text += `\n## ${i + 1}. ${title}${i === 0 && count > 1 ? READ_FIRST_TAG : ''}\n`;
|
|
147
|
+
text += ` ${meta}\n`;
|
|
148
|
+
if (snippet) text += ` ${snippet}\n`;
|
|
149
|
+
});
|
|
150
|
+
return wrapAll(text, width);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function wrapAll(text: string, width: number): string {
|
|
154
|
+
return text
|
|
155
|
+
.split('\n')
|
|
156
|
+
.map((l) => wrapLine(l, width))
|
|
157
|
+
.join('\n');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function wrapLine(line: string, width: number): string {
|
|
161
|
+
if (line.length <= width) return line;
|
|
162
|
+
const hasTag = line.endsWith(READ_FIRST_TAG);
|
|
163
|
+
const body = hasTag ? line.slice(0, line.length - READ_FIRST_TAG.length) : line;
|
|
164
|
+
const m = /^(##\s*\d+\.\s+|\s*\d+\.\s+|\s*)/.exec(body);
|
|
165
|
+
const head = m?.[1] ?? '';
|
|
166
|
+
const indent = head.replace(/\S/g, ' ');
|
|
167
|
+
const words = body.slice(head.length).split(' ');
|
|
168
|
+
const limit = width - indent.length;
|
|
169
|
+
const out: string[] = [];
|
|
170
|
+
let cur = '';
|
|
171
|
+
for (const word of words) {
|
|
172
|
+
if (!word) continue;
|
|
173
|
+
const fits = cur ? cur.length + 1 + word.length <= limit : word.length <= limit;
|
|
174
|
+
if (fits) {
|
|
175
|
+
cur = cur ? `${cur} ${word}` : word;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (cur) {
|
|
179
|
+
out.push(cur);
|
|
180
|
+
cur = '';
|
|
181
|
+
}
|
|
182
|
+
if (word.length > limit) {
|
|
183
|
+
for (const chunk of breakToken(word, limit)) {
|
|
184
|
+
if (cur) {
|
|
185
|
+
out.push(cur);
|
|
186
|
+
cur = '';
|
|
187
|
+
}
|
|
188
|
+
cur = chunk;
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
cur = word;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (cur) out.push(cur);
|
|
195
|
+
const lines = out.map((l, i) => (i ? indent + l : head + l));
|
|
196
|
+
if (hasTag) {
|
|
197
|
+
const last = lines.length - 1;
|
|
198
|
+
if (last >= 0 && lines[last].length + READ_FIRST_TAG.length <= width) {
|
|
199
|
+
lines[last] += READ_FIRST_TAG;
|
|
200
|
+
} else {
|
|
201
|
+
lines.push(indent + READ_FIRST_TAG.trimStart());
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return lines.join('\n');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function breakToken(token: string, limit: number): string[] {
|
|
208
|
+
const out: string[] = [];
|
|
209
|
+
let from = 0;
|
|
210
|
+
while (token.length - from > limit) {
|
|
211
|
+
let cut = from + limit;
|
|
212
|
+
for (let i = cut - 1; i > from; i--) {
|
|
213
|
+
if (i < token.length && /[/\-_.?=&]/.test(token[i])) {
|
|
214
|
+
cut = i + 1;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
out.push(token.slice(from, cut));
|
|
219
|
+
from = cut;
|
|
220
|
+
}
|
|
221
|
+
out.push(token.slice(from));
|
|
222
|
+
return out;
|
|
223
|
+
}
|