pi-wsearch 0.3.2 → 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/package.json +10 -6
- package/src/dom.ts +328 -0
- package/src/scrape.ts +3 -99
- package/src/tools.ts +18 -10
- package/src/transport.ts +5 -2
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",
|
|
@@ -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": {
|
|
@@ -59,7 +59,11 @@
|
|
|
59
59
|
"oxlint": "^1.86.0",
|
|
60
60
|
"typescript": "^5.9.3"
|
|
61
61
|
},
|
|
62
|
-
"dependencies": {
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"htmlparser2": "^12.0.0",
|
|
64
|
+
"parse5": "^8.0.1",
|
|
65
|
+
"parse5-htmlparser2-tree-adapter": "^8.0.1"
|
|
66
|
+
},
|
|
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,7 +4,7 @@
|
|
|
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';
|
|
7
|
+
import { keyHint, keyText, rawKeyHint } from '@earendil-works/pi-coding-agent';
|
|
8
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';
|
|
@@ -363,17 +363,18 @@ class WrappedSearchText implements Component {
|
|
|
363
363
|
render(width: number): string[] {
|
|
364
364
|
const wrapped = wrapAll(this.raw, Math.max(40, width));
|
|
365
365
|
const lines = wrapped.split('\n');
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if (!this.expanded && lines.length > SEARCH_PREVIEW_LINES) {
|
|
369
|
-
shown = lines.slice(0, SEARCH_PREVIEW_LINES);
|
|
370
|
-
hint = moreLinesHint(shown.length, lines.length, this.theme);
|
|
371
|
-
}
|
|
366
|
+
const expanded = this.expanded || lines.length <= SEARCH_PREVIEW_LINES;
|
|
367
|
+
const shown = expanded ? lines : lines.slice(0, SEARCH_PREVIEW_LINES);
|
|
372
368
|
const colored = italicizeRanks(shown.join('\n'), this.theme)
|
|
373
369
|
.split('\n')
|
|
374
370
|
.map((l) => colorLine(l, this.theme))
|
|
375
371
|
.join('\n');
|
|
376
|
-
|
|
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;
|
|
377
378
|
}
|
|
378
379
|
}
|
|
379
380
|
|
|
@@ -410,13 +411,20 @@ function resultText(result: { content: { type: string; text?: string }[] }): str
|
|
|
410
411
|
function moreLinesHint(shown: number, total: number, theme: Theme): string {
|
|
411
412
|
const remaining = total - shown;
|
|
412
413
|
if (remaining <= 0) return '';
|
|
413
|
-
|
|
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', ')');
|
|
414
421
|
}
|
|
415
422
|
|
|
416
423
|
function previewWithHint(lines: string[], theme: Theme, maxLines = PREVIEW_LINES): string {
|
|
417
424
|
const shown = lines.slice(0, maxLines);
|
|
418
425
|
const text = shown.map((l) => theme.fg('toolOutput', replaceTabs(l))).join('\n');
|
|
419
|
-
|
|
426
|
+
const hint = moreLinesHint(shown.length, lines.length, theme);
|
|
427
|
+
return hint ? `${text}\n${hint}` : text;
|
|
420
428
|
}
|
|
421
429
|
|
|
422
430
|
function replaceTabs(text: string): string {
|
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);
|