pi-wsearch 0.1.0 → 0.2.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/CHANGELOG.md +62 -0
- package/README.md +4 -4
- package/package.json +13 -2
- package/src/cache.ts +10 -11
- package/src/config.ts +2 -0
- package/src/engines.ts +33 -4
- package/src/fuse.ts +33 -0
- package/src/keys.ts +4 -1
- package/src/scrape.ts +395 -38
- package/src/tools.ts +159 -13
- package/src/transport.ts +229 -16
- package/src/urls.ts +20 -1
- package/justfile +0 -31
package/src/tools.ts
CHANGED
|
@@ -6,8 +6,8 @@ import { Type } from 'typebox';
|
|
|
6
6
|
import type { ExtensionAPI, ExtensionCommandContext, Theme } from '@earendil-works/pi-coding-agent';
|
|
7
7
|
import { keyHint } from '@earendil-works/pi-coding-agent';
|
|
8
8
|
import { Text } from '@earendil-works/pi-tui';
|
|
9
|
-
import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
|
10
|
-
import { dirname } from 'node:path';
|
|
9
|
+
import { appendFileSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
11
|
import {
|
|
12
12
|
getConfig,
|
|
13
13
|
PROVIDERS,
|
|
@@ -15,13 +15,14 @@ import {
|
|
|
15
15
|
resetConfigCache,
|
|
16
16
|
keyOrigins,
|
|
17
17
|
disabledFlagPath,
|
|
18
|
+
wsearchDir,
|
|
18
19
|
type ProviderInfo,
|
|
19
20
|
} from './config';
|
|
20
21
|
import { ABORT_ERROR, DEFAULT_MAX_RESULTS } from './constants';
|
|
21
22
|
import { cacheKey, openCache, CACHE_TTL_HOURS } from './cache';
|
|
22
|
-
import { runEngines } from './engines';
|
|
23
|
+
import { clearDeadEngines, runEngines } from './engines';
|
|
23
24
|
import { fuse, diversifyByHost } from './fuse';
|
|
24
|
-
import { normalizeUrl } from './urls';
|
|
25
|
+
import { hostOf, normalizeUrl } from './urls';
|
|
25
26
|
import { scrape } from './scrape';
|
|
26
27
|
|
|
27
28
|
const SEARCH_PARAMS = Type.Object({
|
|
@@ -38,6 +39,7 @@ const SEARCH_PARAMS = Type.Object({
|
|
|
38
39
|
const SEARCH_OUTPUT = Type.Object({
|
|
39
40
|
query: Type.String(),
|
|
40
41
|
engines_used: Type.Array(Type.String()),
|
|
42
|
+
engines_errored: Type.Optional(Type.Array(Type.Object({ engine: Type.String(), error: Type.String() }))),
|
|
41
43
|
results: Type.Array(
|
|
42
44
|
Type.Object({
|
|
43
45
|
url: Type.String(),
|
|
@@ -81,6 +83,57 @@ function fmtSnippet(s: string, max: number): string {
|
|
|
81
83
|
return t.length > max ? t.slice(0, max) + '…' : t;
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
const ABORT_ERROR_LOCAL = ABORT_ERROR;
|
|
87
|
+
|
|
88
|
+
function dropRarestTerm(query: string): string {
|
|
89
|
+
const words = query.split(/\s+/).filter((w) => w.length > 2 && !/^(?:site|inurl|intitle|filetype):/i.test(w));
|
|
90
|
+
if (words.length < 2) return '';
|
|
91
|
+
const rarest = words.reduce((a, b) => (b.length > a.length ? b : a));
|
|
92
|
+
return query
|
|
93
|
+
.replace(rarest, ' ')
|
|
94
|
+
.replace(/\s{2,}/g, ' ')
|
|
95
|
+
.trim();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function stripSiteSuffix(title: string, host: string): string {
|
|
99
|
+
const m = /^(.{3,})\s+[|–—-]\s+([^|–—-]{2,40})$/.exec(title.trim());
|
|
100
|
+
if (!m) return title;
|
|
101
|
+
const suffix = m[2].trim();
|
|
102
|
+
const head = m[1].trim();
|
|
103
|
+
const hostBase = host.replace(/^www\./, '').split('.')[0];
|
|
104
|
+
if (host && (suffix.toLowerCase().includes(hostBase) || suffix.length <= 25)) return head;
|
|
105
|
+
return title;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normalizeHitDate(raw: string | undefined): string | undefined {
|
|
109
|
+
if (!raw) return undefined;
|
|
110
|
+
const t = Date.parse(raw);
|
|
111
|
+
if (!Number.isNaN(t)) return new Date(t).toISOString().slice(0, 10);
|
|
112
|
+
const rel = /^(\d+)\s*(m|min|minute|minutes|h|hour|hours|d|day|days|w|week|weeks|mo|month|months)\b/i.exec(
|
|
113
|
+
raw.trim(),
|
|
114
|
+
);
|
|
115
|
+
if (rel) {
|
|
116
|
+
const n = parseInt(rel[1], 10);
|
|
117
|
+
const unit = rel[2].toLowerCase();
|
|
118
|
+
const ms =
|
|
119
|
+
unit.startsWith('m') && unit !== 'mo'
|
|
120
|
+
? 60_000
|
|
121
|
+
: unit.startsWith('h')
|
|
122
|
+
? 3_600_000
|
|
123
|
+
: unit.startsWith('d')
|
|
124
|
+
? 86_400_000
|
|
125
|
+
: unit.startsWith('w')
|
|
126
|
+
? 604_800_000
|
|
127
|
+
: unit.startsWith('mo')
|
|
128
|
+
? 2_592_000_000
|
|
129
|
+
: 604_800_000;
|
|
130
|
+
void ms;
|
|
131
|
+
return new Date(Date.now() - n * ms).toISOString().slice(0, 10);
|
|
132
|
+
}
|
|
133
|
+
if (/^yesterday$/i.test(raw.trim())) return new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
84
137
|
function cleanSnippet(s: string): string {
|
|
85
138
|
return s
|
|
86
139
|
.replace(/\\([_*`[\]()#>~.-])/g, '$1')
|
|
@@ -94,6 +147,8 @@ function cleanSnippet(s: string): string {
|
|
|
94
147
|
export function registerWebTools(pi: ExtensionAPI) {
|
|
95
148
|
const cache = openCache(getConfig().cacheDir + '/cache.json');
|
|
96
149
|
const exposure = existsSync(disabledFlagPath()) ? 'hidden' : 'direct';
|
|
150
|
+
const lastSearch = { urlRank: new Map<string, { rank: number; engines: string[]; query: string }>() };
|
|
151
|
+
const tracePath = join(wsearchDir(), 'fetchtrace.jsonl');
|
|
97
152
|
|
|
98
153
|
const namespace = {
|
|
99
154
|
name: 'web',
|
|
@@ -121,18 +176,47 @@ export function registerWebTools(pi: ExtensionAPI) {
|
|
|
121
176
|
maxResults: params.max_results ?? DEFAULT_MAX_RESULTS,
|
|
122
177
|
signal,
|
|
123
178
|
});
|
|
179
|
+
const errored = outcomes
|
|
180
|
+
.filter((o) => o.error && o.engine !== 'none')
|
|
181
|
+
.map((o) => ({ engine: o.engine, error: o.error! }));
|
|
124
182
|
// signal may have fired mid-round: still an abort, never a results payload
|
|
125
183
|
if (signal?.aborted) throw new Error(ABORT_ERROR);
|
|
126
|
-
|
|
127
|
-
|
|
184
|
+
let fused = fuse(outcomes, { query: params.query });
|
|
185
|
+
let candidates = diversifyByHost(
|
|
128
186
|
fused.filter((r) => !r.junk),
|
|
129
187
|
2,
|
|
130
188
|
).slice(0, Math.max((params.max_results ?? DEFAULT_MAX_RESULTS) * 2, DEFAULT_MAX_RESULTS));
|
|
189
|
+
let retryNote = '';
|
|
190
|
+
if (!candidates.length && !signal?.aborted) {
|
|
191
|
+
const hasOps = /(?:^|\s)(?:site:|intitle:|inurl:|filetype:|-"|"[^"]+"|(?:^|\s)-\w+)/i.test(params.query);
|
|
192
|
+
const stripped = hasOps
|
|
193
|
+
? params.query
|
|
194
|
+
.replace(/(?:^|\s)(?:site:\S+|intitle:\S+|inurl:\S+|filetype:\S+)|"[^"]*"|(?:^|\s)-\w+/gi, ' ')
|
|
195
|
+
.replace(/\s{2,}/g, ' ')
|
|
196
|
+
.trim()
|
|
197
|
+
: '';
|
|
198
|
+
const relaxed = stripped && stripped !== params.query ? stripped : dropRarestTerm(params.query);
|
|
199
|
+
if (relaxed && relaxed !== params.query) {
|
|
200
|
+
const retryOutcomes = await runEngines({
|
|
201
|
+
query: relaxed,
|
|
202
|
+
maxResults: params.max_results ?? DEFAULT_MAX_RESULTS,
|
|
203
|
+
signal,
|
|
204
|
+
});
|
|
205
|
+
if (!signal?.aborted) {
|
|
206
|
+
fused = fuse(retryOutcomes, { query: relaxed });
|
|
207
|
+
candidates = diversifyByHost(
|
|
208
|
+
fused.filter((r) => !r.junk),
|
|
209
|
+
2,
|
|
210
|
+
).slice(0, Math.max((params.max_results ?? DEFAULT_MAX_RESULTS) * 2, DEFAULT_MAX_RESULTS));
|
|
211
|
+
if (candidates.length) retryNote = ` (retried as "${relaxed}" after zero results)`;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
131
215
|
const visible = candidates.slice(0, params.max_results ?? DEFAULT_MAX_RESULTS);
|
|
132
216
|
const enginesUsed = [...new Set(fused.flatMap((r) => r.engines))];
|
|
133
217
|
const snip = 240;
|
|
134
218
|
|
|
135
|
-
let text = `${visible.length} result${visible.length === 1 ? '' : 's'} for "${params.query}": ${enginesUsed.length} engine${enginesUsed.length === 1 ? '' : 's'} contributed (${enginesUsed.join(', ')})\n\n`;
|
|
219
|
+
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`;
|
|
136
220
|
if (!visible.length) {
|
|
137
221
|
const fatal = outcomes.find((o) => o.engine === 'none' && o.error);
|
|
138
222
|
text = fatal
|
|
@@ -140,13 +224,22 @@ export function registerWebTools(pi: ExtensionAPI) {
|
|
|
140
224
|
: `No results for "${params.query}". Broaden the query.\n`;
|
|
141
225
|
}
|
|
142
226
|
visible.forEach((r, i) => {
|
|
143
|
-
|
|
227
|
+
lastSearch.urlRank.set(normalizeUrl(r.url), {
|
|
228
|
+
rank: i + 1,
|
|
229
|
+
engines: r.engines,
|
|
230
|
+
query: params.query,
|
|
231
|
+
});
|
|
232
|
+
const title = fmtSnippet(stripSiteSuffix(cleanSnippet(r.title), hostOf(r.url)), 100) || r.url;
|
|
144
233
|
const snippet = fmtSnippet(cleanSnippet(r.snippet), snip);
|
|
145
234
|
const via = r.engines.length > 1 ? ` ← ${r.engines.length} engines agree` : ` ← ${r.engines[0]}`;
|
|
146
|
-
|
|
235
|
+
const when = normalizeHitDate(r.date);
|
|
236
|
+
text += `${i + 1}. [${title}](${r.url})${when ? ` — ${when}` : ''}${via}\n`;
|
|
147
237
|
if (snippet) text += ` ${snippet}\n`;
|
|
148
238
|
text += '\n';
|
|
149
239
|
});
|
|
240
|
+
if (errored.length) {
|
|
241
|
+
text += `⚠ engines: ${errored.map((e) => `${e.engine} (${e.error.slice(0, 80)})`).join('; ')}\n`;
|
|
242
|
+
}
|
|
150
243
|
|
|
151
244
|
return {
|
|
152
245
|
content: [{ type: 'text', text }],
|
|
@@ -154,6 +247,7 @@ export function registerWebTools(pi: ExtensionAPI) {
|
|
|
154
247
|
structuredContent: {
|
|
155
248
|
query: params.query,
|
|
156
249
|
engines_used: enginesUsed,
|
|
250
|
+
engines_errored: errored,
|
|
157
251
|
results: visible.map((r) => ({
|
|
158
252
|
url: r.url,
|
|
159
253
|
title: r.title,
|
|
@@ -193,14 +287,49 @@ export function registerWebTools(pi: ExtensionAPI) {
|
|
|
193
287
|
|
|
194
288
|
async execute(_id, params, signal) {
|
|
195
289
|
const cfg = getConfig();
|
|
196
|
-
const key = cacheKey(['fetch', normalizeUrl(params.url)]);
|
|
290
|
+
const key = cacheKey(['fetch', 'v3', normalizeUrl(params.url)]);
|
|
291
|
+
const ranked = lastSearch.urlRank.get(normalizeUrl(params.url));
|
|
292
|
+
if (ranked && !cache.get(key)) {
|
|
293
|
+
try {
|
|
294
|
+
mkdirSync(dirname(tracePath), { recursive: true });
|
|
295
|
+
appendFileSync(
|
|
296
|
+
tracePath,
|
|
297
|
+
`${JSON.stringify({
|
|
298
|
+
ts: Date.now(),
|
|
299
|
+
q: ranked.query,
|
|
300
|
+
rank: ranked.rank,
|
|
301
|
+
engines: ranked.engines,
|
|
302
|
+
url: normalizeUrl(params.url),
|
|
303
|
+
})}\n`,
|
|
304
|
+
{ flag: 'a', mode: 0o600 },
|
|
305
|
+
);
|
|
306
|
+
} catch {}
|
|
307
|
+
}
|
|
197
308
|
const cached = cache.get(key) as Awaited<ReturnType<typeof scrape>> | undefined;
|
|
198
309
|
if (cached) return makeFetchResult(cached, params);
|
|
310
|
+
const stale = cache.peekStale(key) as { value: Awaited<ReturnType<typeof scrape>>; ageHours: number } | undefined;
|
|
199
311
|
const res = await scrape(params.url, {
|
|
200
312
|
allowPrivate: cfg.allowPrivate,
|
|
201
313
|
signal,
|
|
314
|
+
validators: stale?.value?.validators,
|
|
202
315
|
});
|
|
203
|
-
if (
|
|
316
|
+
if (res.error === 'not-modified' && stale?.value) {
|
|
317
|
+
cache.set(key, stale.value, CACHE_TTL_HOURS * 3600_000);
|
|
318
|
+
return makeFetchResult(stale.value, params);
|
|
319
|
+
}
|
|
320
|
+
if (!res.error) {
|
|
321
|
+
cache.set(key, res.validators ? { ...res, validators: res.validators } : res, CACHE_TTL_HOURS * 3600_000);
|
|
322
|
+
return makeFetchResult(res, params);
|
|
323
|
+
}
|
|
324
|
+
if (stale?.value && !stale.value.error) {
|
|
325
|
+
return makeFetchResult(
|
|
326
|
+
{
|
|
327
|
+
...stale.value,
|
|
328
|
+
title: `${stale.value.title} [STALE — ~${Math.round(stale.ageHours)}h old, live fetch failed]`,
|
|
329
|
+
},
|
|
330
|
+
params,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
204
333
|
return makeFetchResult(res, params);
|
|
205
334
|
},
|
|
206
335
|
renderResult(result, options, theme) {
|
|
@@ -246,7 +375,12 @@ function colorizedResult(text: string, theme: Theme): string {
|
|
|
246
375
|
return text
|
|
247
376
|
.split('\n')
|
|
248
377
|
.map((l) => {
|
|
249
|
-
if (
|
|
378
|
+
if (
|
|
379
|
+
l.includes('…[section truncated') ||
|
|
380
|
+
l.includes('…(continues at outline index') ||
|
|
381
|
+
l.startsWith('…[clipped by tool]') ||
|
|
382
|
+
l.startsWith('…[truncated]')
|
|
383
|
+
)
|
|
250
384
|
return theme.fg('warning', l);
|
|
251
385
|
if (l.startsWith('## [')) return theme.fg('accent', l);
|
|
252
386
|
if (l.startsWith('- ')) return theme.fg('dim', l);
|
|
@@ -316,7 +450,7 @@ function buildFetchResult(res: Awaited<ReturnType<typeof scrape>>, sections: num
|
|
|
316
450
|
if (res.renderer && res.renderer !== 'local') text += `\n\n[rendered via ${res.renderer}]`;
|
|
317
451
|
const clipped = text.length > MAX_FETCH_TEXT ? text.slice(0, MAX_FETCH_TEXT) + '\n…[clipped by tool]' : text;
|
|
318
452
|
const missing = sections.filter((i) => i >= res.sections.length);
|
|
319
|
-
const truncatedCount = want.filter((idx) => res.
|
|
453
|
+
const truncatedCount = want.filter((idx) => res.continued?.includes(idx)).length;
|
|
320
454
|
const details = {
|
|
321
455
|
url: res.url,
|
|
322
456
|
title: res.title,
|
|
@@ -327,6 +461,8 @@ function buildFetchResult(res: Awaited<ReturnType<typeof scrape>>, sections: num
|
|
|
327
461
|
sections_requested: [...new Set(sections)],
|
|
328
462
|
sections_missing: [...new Set(missing)],
|
|
329
463
|
sections_truncated: truncatedCount,
|
|
464
|
+
...(res.publishedAt ? { publishedAt: res.publishedAt } : {}),
|
|
465
|
+
...(res.paywalled ? { paywalled: true } : {}),
|
|
330
466
|
...(res.error ? { error: res.error } : {}),
|
|
331
467
|
};
|
|
332
468
|
if (res.error) return fetchErrorResult(details);
|
|
@@ -407,6 +543,7 @@ async function handleLogin(ctx: ExtensionCommandContext, name?: string): Promise
|
|
|
407
543
|
}
|
|
408
544
|
writeKey(provider, trimmed);
|
|
409
545
|
resetConfigCache();
|
|
546
|
+
clearDeadEngines();
|
|
410
547
|
ctx.ui.notify(`${provider.id} ✓ saved to wsearch/env (${isCmd ? 'command' : 'key'})`, 'info');
|
|
411
548
|
}
|
|
412
549
|
|
|
@@ -453,9 +590,12 @@ async function handleLogout(ctx: ExtensionCommandContext, name?: string): Promis
|
|
|
453
590
|
if (!ok) return;
|
|
454
591
|
removeKey(provider);
|
|
455
592
|
resetConfigCache();
|
|
593
|
+
clearDeadEngines();
|
|
456
594
|
ctx.ui.notify(`${provider.id} removed from wsearch/env`, 'info');
|
|
457
595
|
}
|
|
458
596
|
|
|
597
|
+
const SEARCH_TOOL_NAMES = ['web.search', 'web.fetch'] as const;
|
|
598
|
+
|
|
459
599
|
function toggleSearchTools(pi: ExtensionAPI, disabled: boolean): void {
|
|
460
600
|
const flag = disabledFlagPath();
|
|
461
601
|
if (disabled) {
|
|
@@ -465,6 +605,12 @@ function toggleSearchTools(pi: ExtensionAPI, disabled: boolean): void {
|
|
|
465
605
|
rmSync(flag, { force: true });
|
|
466
606
|
}
|
|
467
607
|
registerWebTools(pi);
|
|
608
|
+
const active = new Set(pi.getActiveTools());
|
|
609
|
+
for (const name of SEARCH_TOOL_NAMES) {
|
|
610
|
+
if (disabled) active.delete(name);
|
|
611
|
+
else active.add(name);
|
|
612
|
+
}
|
|
613
|
+
pi.setActiveTools([...active]);
|
|
468
614
|
}
|
|
469
615
|
|
|
470
616
|
export function registerWebCommand(pi: ExtensionAPI) {
|
package/src/transport.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { getConfig, sanitizeError } from './config';
|
|
6
6
|
import { ABORT_ERROR } from './constants';
|
|
7
7
|
|
|
8
|
-
export type Renderer = 'local' | 'firecrawl' | 'tavily' | 'exa' | 'jina';
|
|
8
|
+
export type Renderer = 'local' | 'firecrawl' | 'tavily' | 'exa' | 'jina' | 'kagi' | 'tinyfish' | 'serper' | 'you';
|
|
9
9
|
|
|
10
10
|
export type RemoteRenderer = Exclude<Renderer, 'local'>;
|
|
11
11
|
|
|
@@ -29,10 +29,14 @@ const READER_TIMEOUTS: Record<RemoteRenderer, number> = {
|
|
|
29
29
|
tavily: 30_000,
|
|
30
30
|
exa: 20_000,
|
|
31
31
|
jina: 20_000,
|
|
32
|
+
kagi: 30_000,
|
|
33
|
+
tinyfish: 25_000,
|
|
34
|
+
serper: 25_000,
|
|
35
|
+
you: 25_000,
|
|
32
36
|
};
|
|
33
37
|
|
|
34
38
|
function isBotBlocked(status: number, content: string): boolean {
|
|
35
|
-
if (status !== 403 && status !== 503) return false;
|
|
39
|
+
if (status !== 403 && status !== 503 && status !== 402) return false;
|
|
36
40
|
const lower = content.toLowerCase();
|
|
37
41
|
return (
|
|
38
42
|
lower.includes('cloudflare') ||
|
|
@@ -58,6 +62,11 @@ export function stripDataUriImages(markdown: string): string {
|
|
|
58
62
|
|
|
59
63
|
export function isLowQualityOutput(content: string): boolean {
|
|
60
64
|
const lower = content.toLowerCase();
|
|
65
|
+
if (
|
|
66
|
+
/\b(?:experiencing an access issue|unusual traffic|confirm you are (?:a )?(?:human|not a bot))\b/.test(lower) ||
|
|
67
|
+
(lower.includes('icanhazip.com') && lower.length < 2048)
|
|
68
|
+
)
|
|
69
|
+
return true;
|
|
61
70
|
const jsGated = [
|
|
62
71
|
'enable javascript',
|
|
63
72
|
'javascript required',
|
|
@@ -67,8 +76,25 @@ export function isLowQualityOutput(content: string): boolean {
|
|
|
67
76
|
];
|
|
68
77
|
if (content.length < 1024 && jsGated.some((t) => lower.includes(t))) return true;
|
|
69
78
|
const lines = content.split('\n').filter((l) => l.trim());
|
|
70
|
-
|
|
71
|
-
|
|
79
|
+
if (lines.length > 30) {
|
|
80
|
+
const seen = new Map<string, number>();
|
|
81
|
+
for (const l of lines) seen.set(l, (seen.get(l) ?? 0) + 1);
|
|
82
|
+
const dupChars = [...seen.entries()].reduce((n, [, c]) => (c > 1 ? n + c : n), 0);
|
|
83
|
+
if (dupChars / lines.length > 0.3) return true;
|
|
84
|
+
}
|
|
85
|
+
const words = lower.split(/\s+/).filter(Boolean);
|
|
86
|
+
if (words.length > 150) {
|
|
87
|
+
const grams = new Map<string, number>();
|
|
88
|
+
for (let i = 0; i <= words.length - 5; i++) {
|
|
89
|
+
const g = words.slice(i, i + 5).join(' ');
|
|
90
|
+
grams.set(g, (grams.get(g) ?? 0) + 1);
|
|
91
|
+
}
|
|
92
|
+
const repeatedGrams = [...grams.values()].filter((c) => c > 1).length;
|
|
93
|
+
if (repeatedGrams / grams.size > 0.2) return true;
|
|
94
|
+
}
|
|
95
|
+
const totalChars = lines.reduce((n, l) => n + l.trim().length, 0);
|
|
96
|
+
const shortChars = lines.reduce((n, l) => (l.trim().length < 40 ? n + l.trim().length : n), 0);
|
|
97
|
+
if (lines.length > 10 && totalChars > 0 && shortChars / totalChars > 0.6) return true;
|
|
72
98
|
return false;
|
|
73
99
|
}
|
|
74
100
|
|
|
@@ -101,16 +127,76 @@ function waitAbortable(ms: number, signal?: AbortSignal): Promise<void> {
|
|
|
101
127
|
});
|
|
102
128
|
}
|
|
103
129
|
|
|
130
|
+
const MOJIBAKE_RE = /[Ó†’éüï¬Åˆ]{2,}|â€[˜›”˜]/g;
|
|
131
|
+
|
|
132
|
+
function looksMojibake(text: string): boolean {
|
|
133
|
+
if (!text.includes('Ã') && !text.includes('â€') && !text.includes('Â')) return false;
|
|
134
|
+
const hits = text.match(MOJIBAKE_RE);
|
|
135
|
+
return !!hits && hits.length >= 2 && hits.length * 12 > text.length * 0.001;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const CP1252_REV = new Map([
|
|
139
|
+
[0x20ac, 0x80],
|
|
140
|
+
[0x201a, 0x82],
|
|
141
|
+
[0x192, 0x83],
|
|
142
|
+
[0x201e, 0x84],
|
|
143
|
+
[0x2026, 0x85],
|
|
144
|
+
[0x2020, 0x86],
|
|
145
|
+
[0x2021, 0x87],
|
|
146
|
+
[0x2c6, 0x88],
|
|
147
|
+
[0x2030, 0x89],
|
|
148
|
+
[0x160, 0x8a],
|
|
149
|
+
[0x2039, 0x8b],
|
|
150
|
+
[0x152, 0x8c],
|
|
151
|
+
[0x17d, 0x8e],
|
|
152
|
+
[0x2018, 0x91],
|
|
153
|
+
[0x2019, 0x92],
|
|
154
|
+
[0x201c, 0x93],
|
|
155
|
+
[0x201d, 0x94],
|
|
156
|
+
[0x2022, 0x95],
|
|
157
|
+
[0x2013, 0x96],
|
|
158
|
+
[0x2014, 0x97],
|
|
159
|
+
[0x2dc, 0x98],
|
|
160
|
+
[0x2122, 0x99],
|
|
161
|
+
[0x161, 0x9a],
|
|
162
|
+
[0x203a, 0x9b],
|
|
163
|
+
[0x152, 0x9c],
|
|
164
|
+
[0x17e, 0x9e],
|
|
165
|
+
[0x178, 0x9f],
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
function repairMojibake(text: string): string {
|
|
169
|
+
try {
|
|
170
|
+
const bytes = new Uint8Array(
|
|
171
|
+
[...text].map((c) => {
|
|
172
|
+
const cp = c.codePointAt(0)!;
|
|
173
|
+
return cp < 256 ? cp : (CP1252_REV.get(cp) ?? 0x3f);
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
const repaired = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
|
177
|
+
const before = (text.match(MOJIBAKE_RE) ?? []).length;
|
|
178
|
+
const after = (repaired.match(MOJIBAKE_RE) ?? []).length;
|
|
179
|
+
return after < before ? repaired : text;
|
|
180
|
+
} catch {
|
|
181
|
+
return text;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
104
185
|
function decodeBody(bytes: Uint8Array, contentType: string): string {
|
|
105
186
|
const label =
|
|
106
187
|
/charset\s*=\s*"?([\w-]+)"?/i.exec(contentType)?.[1] ??
|
|
107
188
|
/<meta[^>]+charset\s*=\s*["']?([\w-]+)/i.exec(new TextDecoder('latin1').decode(bytes.subarray(0, 2048)))?.[1];
|
|
189
|
+
let text: string;
|
|
108
190
|
if (label && !/^utf-?8$/i.test(label)) {
|
|
109
191
|
try {
|
|
110
|
-
|
|
111
|
-
} catch {
|
|
192
|
+
text = new TextDecoder(label).decode(bytes);
|
|
193
|
+
} catch {
|
|
194
|
+
text = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
|
195
|
+
}
|
|
196
|
+
} else {
|
|
197
|
+
text = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
|
112
198
|
}
|
|
113
|
-
return
|
|
199
|
+
return looksMojibake(text) ? repairMojibake(text) : text;
|
|
114
200
|
}
|
|
115
201
|
|
|
116
202
|
async function readBodyBounded(res: Response, maxBytes: number): Promise<Uint8Array> {
|
|
@@ -140,21 +226,38 @@ async function readBodyBounded(res: Response, maxBytes: number): Promise<Uint8Ar
|
|
|
140
226
|
return out;
|
|
141
227
|
}
|
|
142
228
|
|
|
143
|
-
export
|
|
229
|
+
export interface ConditionalValidators {
|
|
230
|
+
etag?: string;
|
|
231
|
+
lastModified?: string;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function localFetch(
|
|
235
|
+
url: URL,
|
|
236
|
+
signal?: AbortSignal,
|
|
237
|
+
validators?: ConditionalValidators,
|
|
238
|
+
): Promise<{ body: string; contentType: string; validators: ConditionalValidators; notModified: boolean }> {
|
|
144
239
|
let retried429 = false;
|
|
145
240
|
for (let attempt = 0; attempt < USER_AGENTS.length; attempt++) {
|
|
146
241
|
if (signal?.aborted) throw new Error(ABORT_ERROR);
|
|
147
242
|
const { ctrl, done } = withTimeout('local fetch', LOCAL_TIMEOUT_MS, signal);
|
|
148
243
|
try {
|
|
244
|
+
const condHeaders: Record<string, string> = {};
|
|
245
|
+
if (validators?.etag) condHeaders['If-None-Match'] = validators.etag;
|
|
246
|
+
if (validators?.lastModified) condHeaders['If-Modified-Since'] = validators.lastModified;
|
|
149
247
|
const res = await fetch(url, {
|
|
150
248
|
headers: {
|
|
151
249
|
'User-Agent': USER_AGENTS[attempt],
|
|
152
|
-
Accept: 'text/html,application/xhtml+xml,text/plain
|
|
250
|
+
Accept: 'text/markdown,text/html,application/xhtml+xml,text/plain;q=0.8,*/*;q=0.5',
|
|
153
251
|
'Accept-Language': 'en-US,en;q=0.5',
|
|
252
|
+
...condHeaders,
|
|
154
253
|
},
|
|
155
254
|
redirect: 'follow',
|
|
156
255
|
signal: ctrl.signal,
|
|
157
256
|
});
|
|
257
|
+
if (res.status === 304 && attempt === 0) {
|
|
258
|
+
done();
|
|
259
|
+
return { body: '', contentType: '', validators: validators ?? {}, notModified: true };
|
|
260
|
+
}
|
|
158
261
|
const ct = res.headers.get('content-type') ?? '';
|
|
159
262
|
const mime = ct.split(';')[0]?.trim().toLowerCase() ?? '';
|
|
160
263
|
if (
|
|
@@ -177,7 +280,15 @@ export async function localFetch(url: URL, signal?: AbortSignal): Promise<{ body
|
|
|
177
280
|
}
|
|
178
281
|
const bytes = await readBodyBounded(res, MAX_HTML_BYTES);
|
|
179
282
|
if (bytes.byteLength === 0) throw new Error('empty body');
|
|
180
|
-
return {
|
|
283
|
+
return {
|
|
284
|
+
body: decodeBody(bytes, ct),
|
|
285
|
+
contentType: ct,
|
|
286
|
+
validators: {
|
|
287
|
+
etag: res.headers.get('etag') ?? undefined,
|
|
288
|
+
lastModified: res.headers.get('last-modified') ?? undefined,
|
|
289
|
+
},
|
|
290
|
+
notModified: false,
|
|
291
|
+
};
|
|
181
292
|
} catch (e) {
|
|
182
293
|
if (signal?.aborted) throw new Error(ABORT_ERROR, { cause: e });
|
|
183
294
|
const clean = sanitizeError(e);
|
|
@@ -197,7 +308,11 @@ export async function localFetch(url: URL, signal?: AbortSignal): Promise<{ body
|
|
|
197
308
|
|
|
198
309
|
// Per-attempt timeout + pre-abort: an already-aborted signal never fires
|
|
199
310
|
// abort listeners, so the request must reject immediately on its own.
|
|
200
|
-
function withTimeout(
|
|
311
|
+
export function withTimeout(
|
|
312
|
+
label: string,
|
|
313
|
+
ms: number,
|
|
314
|
+
signal?: AbortSignal,
|
|
315
|
+
): { ctrl: AbortController; done: () => void } {
|
|
201
316
|
const ctrl = new AbortController();
|
|
202
317
|
const t = setTimeout(() => ctrl.abort(new Error(`${label} timeout`)), ms);
|
|
203
318
|
const onAbort = () => ctrl.abort(signal?.reason ?? new Error(ABORT_ERROR));
|
|
@@ -320,6 +435,97 @@ async function jinaRead(url: URL, signal?: AbortSignal): Promise<RemoteDoc> {
|
|
|
320
435
|
done();
|
|
321
436
|
}
|
|
322
437
|
}
|
|
438
|
+
async function kagiRead(url: URL, signal?: AbortSignal): Promise<RemoteDoc> {
|
|
439
|
+
const key = getConfig().kagiKey!;
|
|
440
|
+
const { ctrl, done } = withTimeout('kagi', READER_TIMEOUTS.kagi, signal);
|
|
441
|
+
try {
|
|
442
|
+
const d = await readJson<{
|
|
443
|
+
data?: { output?: string; references?: { title?: string; url?: string }[] };
|
|
444
|
+
}>(
|
|
445
|
+
'https://kagi.com/api/v0/summarize',
|
|
446
|
+
{
|
|
447
|
+
method: 'POST',
|
|
448
|
+
headers: { Authorization: `Bot ${key}`, 'Content-Type': 'application/json' },
|
|
449
|
+
body: JSON.stringify({ url: url.toString(), summary_type: 'takeaway' }),
|
|
450
|
+
},
|
|
451
|
+
ctrl.signal,
|
|
452
|
+
);
|
|
453
|
+
const output = d.data?.output?.trim() ?? '';
|
|
454
|
+
if (output.length < 100) throw new Error('kagi summarizer returned no usable summary');
|
|
455
|
+
const refs = (d.data?.references ?? []).filter((r) => r?.url);
|
|
456
|
+
const markdown = refs.length
|
|
457
|
+
? `${output}\n\nSources:\n${refs.map((r) => `- [${r.title ?? r.url}](${r.url})`).join('\n')}`
|
|
458
|
+
: output;
|
|
459
|
+
return { title: '', markdown };
|
|
460
|
+
} finally {
|
|
461
|
+
done();
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
async function tinyfishRead(url: URL, signal?: AbortSignal): Promise<RemoteDoc> {
|
|
465
|
+
const key = getConfig().tinyfishKey!;
|
|
466
|
+
const { ctrl, done } = withTimeout('tinyfish', READER_TIMEOUTS.tinyfish, signal);
|
|
467
|
+
try {
|
|
468
|
+
const d = await readJson<{ results?: { url?: string; title?: string; text?: string; markdown?: string }[] }>(
|
|
469
|
+
'https://api.fetch.tinyfish.ai',
|
|
470
|
+
{
|
|
471
|
+
method: 'POST',
|
|
472
|
+
headers: { 'X-API-Key': key, 'Content-Type': 'application/json' },
|
|
473
|
+
body: JSON.stringify({ urls: [url.toString()] }),
|
|
474
|
+
},
|
|
475
|
+
ctrl.signal,
|
|
476
|
+
);
|
|
477
|
+
const doc = (d.results ?? []).find((r) => r && !!r.text);
|
|
478
|
+
if (!doc?.text?.trim()) throw new Error('tinyfish fetch returned no content');
|
|
479
|
+
return { title: doc.title ?? '', markdown: doc.markdown ?? doc.text };
|
|
480
|
+
} finally {
|
|
481
|
+
done();
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
async function serperRead(url: URL, signal?: AbortSignal): Promise<RemoteDoc> {
|
|
485
|
+
const key = getConfig().serperKey!;
|
|
486
|
+
const { ctrl, done } = withTimeout('serper', READER_TIMEOUTS.serper, signal);
|
|
487
|
+
try {
|
|
488
|
+
const d = await readJson<Record<string, unknown>>(
|
|
489
|
+
'https://scrape.serper.dev/',
|
|
490
|
+
{
|
|
491
|
+
method: 'POST',
|
|
492
|
+
headers: { 'X-API-KEY': key, 'Content-Type': 'application/json' },
|
|
493
|
+
body: JSON.stringify({ url: url.toString(), includeMarkdown: true }),
|
|
494
|
+
},
|
|
495
|
+
ctrl.signal,
|
|
496
|
+
);
|
|
497
|
+
const data = (typeof d.data === 'object' && d.data ? (d.data as Record<string, unknown>) : {}) as Record<
|
|
498
|
+
string,
|
|
499
|
+
unknown
|
|
500
|
+
>;
|
|
501
|
+
const md = (typeof data.markdown === 'string' ? data.markdown : (d.markdown ?? d.text ?? '')) as string | undefined;
|
|
502
|
+
const markdown = (md ?? '').trim();
|
|
503
|
+
if (markdown.length < 100) throw new Error('serper scrape returned no usable content');
|
|
504
|
+
return { title: typeof d.title === 'string' ? d.title : '', markdown };
|
|
505
|
+
} finally {
|
|
506
|
+
done();
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
async function youRead(url: URL, signal?: AbortSignal): Promise<RemoteDoc> {
|
|
510
|
+
const key = getConfig().youKey!;
|
|
511
|
+
const { ctrl, done } = withTimeout('you', READER_TIMEOUTS.you, signal);
|
|
512
|
+
try {
|
|
513
|
+
const rows = await readJson<{ url?: string; title?: string; markdown?: string }[]>(
|
|
514
|
+
'https://ydc-index.io/v1/contents',
|
|
515
|
+
{
|
|
516
|
+
method: 'POST',
|
|
517
|
+
headers: { 'X-API-Key': key, 'Content-Type': 'application/json' },
|
|
518
|
+
body: JSON.stringify({ urls: [url.toString()], formats: ['markdown'] }),
|
|
519
|
+
},
|
|
520
|
+
ctrl.signal,
|
|
521
|
+
);
|
|
522
|
+
const doc = (rows ?? []).find((r) => r && !!r.markdown);
|
|
523
|
+
if (!doc?.markdown?.trim()) throw new Error('you contents returned no markdown');
|
|
524
|
+
return { title: doc.title ?? '', markdown: doc.markdown };
|
|
525
|
+
} finally {
|
|
526
|
+
done();
|
|
527
|
+
}
|
|
528
|
+
}
|
|
323
529
|
|
|
324
530
|
interface Reader {
|
|
325
531
|
available(): boolean;
|
|
@@ -330,10 +536,17 @@ const READERS: Record<RemoteRenderer, Reader> = {
|
|
|
330
536
|
firecrawl: { available: () => !!getConfig().firecrawlKey, read: firecrawlRead },
|
|
331
537
|
tavily: { available: () => !!getConfig().tavilyKey, read: tavilyRead },
|
|
332
538
|
exa: { available: () => !!getConfig().exaKey, read: exaRead },
|
|
333
|
-
jina: {
|
|
539
|
+
jina: {
|
|
540
|
+
available: () => !!getConfig().jinaKey || (getConfig().fetchChain ?? '').split(',').includes('jina'),
|
|
541
|
+
read: jinaRead,
|
|
542
|
+
},
|
|
543
|
+
kagi: { available: () => !!getConfig().kagiKey, read: kagiRead },
|
|
544
|
+
tinyfish: { available: () => !!getConfig().tinyfishKey, read: tinyfishRead },
|
|
545
|
+
serper: { available: () => !!getConfig().serperKey, read: serperRead },
|
|
546
|
+
you: { available: () => !!getConfig().youKey, read: youRead },
|
|
334
547
|
};
|
|
335
548
|
|
|
336
|
-
const REMOTE_ORDER: RemoteRenderer[] = ['firecrawl', 'tavily', 'exa', '
|
|
549
|
+
const REMOTE_ORDER: RemoteRenderer[] = ['jina', 'tinyfish', 'firecrawl', 'tavily', 'exa', 'serper', 'you', 'kagi'];
|
|
337
550
|
|
|
338
551
|
function readerOrder(): RemoteRenderer[] {
|
|
339
552
|
const raw = getConfig().fetchChain;
|
|
@@ -367,7 +580,7 @@ export async function remoteChain(
|
|
|
367
580
|
usable: (markdown: string) => boolean,
|
|
368
581
|
): Promise<ChainResult> {
|
|
369
582
|
const order = readerOrder();
|
|
370
|
-
|
|
583
|
+
const errors: string[] = [];
|
|
371
584
|
for (const id of order) {
|
|
372
585
|
if (signal?.aborted) return { ok: false, aborted: true, error: ABORT_ERROR };
|
|
373
586
|
const reader = READERS[id];
|
|
@@ -378,12 +591,12 @@ export async function remoteChain(
|
|
|
378
591
|
return { ok: true, doc: { ...doc, renderer: id } };
|
|
379
592
|
} catch (e) {
|
|
380
593
|
if (signal?.aborted) return { ok: false, aborted: true, error: ABORT_ERROR };
|
|
381
|
-
|
|
594
|
+
errors.push(`${id}: ${sanitizeError(e)}`);
|
|
382
595
|
}
|
|
383
596
|
}
|
|
384
597
|
return {
|
|
385
598
|
ok: false,
|
|
386
599
|
aborted: false,
|
|
387
|
-
error:
|
|
600
|
+
error: errors.length ? `reader chain exhausted: ${errors.join('; ')}` : 'no reader backend configured',
|
|
388
601
|
};
|
|
389
602
|
}
|