pi-wsearch 0.2.2 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -5
- package/package.json +3 -3
- package/src/cache.ts +4 -2
- package/src/engines.ts +10 -4
- package/src/fuse.ts +9 -0
- package/src/scrape.ts +65 -83
- package/src/tools.ts +145 -109
- package/src/transport.ts +51 -22
- package/src/urls.ts +104 -11
- package/src/view.ts +223 -0
package/README.md
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
**One search, many judges.** Most web tools hand you a single engine's
|
|
8
8
|
ranking. `web.search` runs every engine you've configured in parallel and
|
|
9
9
|
merges their results by reciprocal rank fusion (RRF). A hit only earns its
|
|
10
|
-
place by surviving several independent judgments. The response shows
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
place by surviving several independent judgments. The response shows the
|
|
11
|
+
corroboration in detail: `⇐ serper#1, exa#2, you#4` means three separate
|
|
12
|
+
indexes ranked that page — and where each one placed it. Single-engine hits
|
|
13
|
+
are labeled `single-engine: unverified`. Agreement is the spam defense;
|
|
14
|
+
generative-SEO can stuff one index, not three.
|
|
13
15
|
|
|
14
16
|
**Pick your engines.** Nine are supported; three or four is plenty. Use
|
|
15
17
|
whatever keys you have, even one, because what follows doesn't depend on how
|
|
@@ -37,12 +39,19 @@ and reads only those. No
|
|
|
37
39
|
full reads, no 40 KB dumps. Sections over 3,000 characters split into
|
|
38
40
|
addressable continuation entries (`Section (cont. 1)`), so no tail is ever
|
|
39
41
|
unreachable; pages that overflow are capped; the final response is clipped.
|
|
40
|
-
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`.
|
|
41
46
|
|
|
42
47
|
**Reads are free.** Page parsing is built in, nothing billed per read, no key
|
|
43
48
|
required. Real headings become the map, TOC echoes get dropped, so reads come
|
|
44
49
|
back as clean as a paid one at zero cost. Paid readers only step in for
|
|
45
|
-
JavaScript heavy pages or when the local read fails.
|
|
50
|
+
JavaScript heavy pages or when the local read fails. Reader output is
|
|
51
|
+
laundered too: standalone photo credits (REUTERS/, AP Photo/, Getty), video
|
|
52
|
+
durations, carousel markers and relative-time chips are stripped from news
|
|
53
|
+
pages (whole-line rules only — prose is untouched), and a reader that returns
|
|
54
|
+
a 404-page template is skipped so the chain moves on to the next backend.
|
|
46
55
|
|
|
47
56
|
**Tracking links cache as one page.** `?utm_source=…` variants of the same URL
|
|
48
57
|
share one 24 hour cache entry. Faster repeats, smaller disk.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-wsearch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.2",
|
|
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": {
|
|
@@ -57,7 +57,7 @@
|
|
|
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
61
|
},
|
|
62
62
|
"dependencies": {},
|
|
63
63
|
"main": "index.ts",
|
package/src/cache.ts
CHANGED
|
@@ -44,9 +44,11 @@ export class TtlCache {
|
|
|
44
44
|
/* cache writes are best-effort */
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
+
/** Entry valid while unexpired; non-numeric/NaN exp (corrupt store) must NOT
|
|
48
|
+
* count as "never expires" — treat it as expired so a poisoned entry dies. */
|
|
47
49
|
get(key: string): unknown | undefined {
|
|
48
50
|
const e = this.store.entries[key];
|
|
49
|
-
if (!e || e.exp < Date.now()) return undefined;
|
|
51
|
+
if (!e || typeof e.exp !== 'number' || !Number.isFinite(e.exp) || e.exp < Date.now()) return undefined;
|
|
50
52
|
const i = this.store.order.indexOf(key);
|
|
51
53
|
if (i >= 0) this.store.order.splice(i, 1);
|
|
52
54
|
this.store.order.push(key);
|
|
@@ -56,7 +58,7 @@ export class TtlCache {
|
|
|
56
58
|
/** Expired copy WITHOUT deleting it (stale-if-error reserve). */
|
|
57
59
|
peekStale(key: string): { value: unknown; ageHours: number } | undefined {
|
|
58
60
|
const e = this.store.entries[key];
|
|
59
|
-
if (!e) return undefined;
|
|
61
|
+
if (!e || typeof e.exp !== 'number' || !Number.isFinite(e.exp)) return undefined;
|
|
60
62
|
const ageHours = (Date.now() - e.exp) / 3600_000;
|
|
61
63
|
if (ageHours <= 0) return undefined;
|
|
62
64
|
return { value: e.value, ageHours };
|
package/src/engines.ts
CHANGED
|
@@ -395,12 +395,18 @@ function selectEngines(opts: SearchOptions): EngineDef[] {
|
|
|
395
395
|
|
|
396
396
|
export async function runEngines(opts: SearchOptions): Promise<EngineOutcome[]> {
|
|
397
397
|
const cfg = getConfig();
|
|
398
|
-
const
|
|
398
|
+
const selected = selectEngines(opts);
|
|
399
|
+
const chosen = selected.filter((def) => !deadEngines.has(def.id));
|
|
399
400
|
if (chosen.length === 0) {
|
|
400
401
|
const wanted = opts.engines ?? parseEngineList(getConfig().searchEngines);
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
402
|
+
// selected-but-dead means KEYS EXIST and the engines were temp-banned
|
|
403
|
+
// (stable HTTP 401/402/403): the old wording lied about the cause.
|
|
404
|
+
const error =
|
|
405
|
+
selected.length > 0
|
|
406
|
+
? 'all configured engines are temporarily unavailable (HTTP 401/402/403); try again later'
|
|
407
|
+
: wanted?.length
|
|
408
|
+
? `no configured engines match the requested set (${wanted.join(', ')})`
|
|
409
|
+
: 'no search engine keys configured; run /websearch to add one';
|
|
404
410
|
return [{ engine: 'none', hits: [], error, latencyMs: 0 }];
|
|
405
411
|
}
|
|
406
412
|
return Promise.all(
|
package/src/fuse.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { isJunk, normalizeUrl, hostOf } from './urls';
|
|
|
3
3
|
|
|
4
4
|
export interface FusedHit extends SearchHit {
|
|
5
5
|
engines: string[];
|
|
6
|
+
/** Per-engine rank of this hit: {serper: 1} = serper ranked it #1. */
|
|
7
|
+
engineRanks: { engine: string; rank: number }[];
|
|
6
8
|
rrfScore: number;
|
|
7
9
|
finalScore: number;
|
|
8
10
|
junk: boolean;
|
|
@@ -207,6 +209,12 @@ function collapseNearDuplicates(fused: FusedHit[]): FusedHit[] {
|
|
|
207
209
|
const twin = kept.find((k) => isNearDuplicate(k, h));
|
|
208
210
|
if (twin) {
|
|
209
211
|
twin.engines = [...new Set([...twin.engines, ...h.engines])];
|
|
212
|
+
const best = new Map(twin.engineRanks.map((r) => [r.engine, r]));
|
|
213
|
+
for (const r of h.engineRanks) {
|
|
214
|
+
const cur = best.get(r.engine);
|
|
215
|
+
if (!cur || r.rank < cur.rank) best.set(r.engine, r);
|
|
216
|
+
}
|
|
217
|
+
twin.engineRanks = [...best.values()];
|
|
210
218
|
if (h.snippet.length > twin.snippet.length) twin.snippet = h.snippet;
|
|
211
219
|
if (h.title.length > twin.title.length && !twin.title.includes(h.title)) twin.title = h.title;
|
|
212
220
|
continue;
|
|
@@ -250,6 +258,7 @@ export function fuse(outcomes: EngineOutcome[], opts: FuseOptions = {}): FusedHi
|
|
|
250
258
|
fused.push({
|
|
251
259
|
...hit,
|
|
252
260
|
engines: ranks.map((r) => r.engine),
|
|
261
|
+
engineRanks: [...ranks].toSorted((a, b) => a.rank - b.rank),
|
|
253
262
|
rrfScore: rrf,
|
|
254
263
|
finalScore: Math.round(score * 1e5) / 1e5,
|
|
255
264
|
junk,
|
package/src/scrape.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { getConfig, sanitizeError } from './config';
|
|
7
7
|
import { ABORT_ERROR } from './constants';
|
|
8
|
+
import { isPrivateHost, validateUrl } from './urls';
|
|
8
9
|
import {
|
|
9
10
|
localFetch,
|
|
10
11
|
looksLikeHtml,
|
|
@@ -33,6 +34,8 @@ export interface ScrapeResult {
|
|
|
33
34
|
error?: string;
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
export { isPrivateHost, validateUrl } from './urls'; // public surface unchanged; the gates live in the URL leaf
|
|
38
|
+
|
|
36
39
|
const DEFAULT_MAX_CHARS = 12000;
|
|
37
40
|
|
|
38
41
|
function stripToAscii(s: string): string {
|
|
@@ -91,66 +94,6 @@ export function decodeHtmlEntities(s: string): string {
|
|
|
91
94
|
});
|
|
92
95
|
}
|
|
93
96
|
|
|
94
|
-
function isPrivateIpv4(h: string): boolean {
|
|
95
|
-
const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h);
|
|
96
|
-
if (!ipv4) return false;
|
|
97
|
-
const [a, b] = [Number(ipv4[1]), Number(ipv4[2])];
|
|
98
|
-
if (a === 10) return true;
|
|
99
|
-
if (a === 127) return true;
|
|
100
|
-
if (a === 0) return true;
|
|
101
|
-
if (a === 169 && b === 254) return true;
|
|
102
|
-
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
103
|
-
if (a === 192 && b === 168) return true;
|
|
104
|
-
return false;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function numericIpv4(h: string): string | null {
|
|
108
|
-
if (!/^[0-9.]+$/.test(h) && !/^0[xX][0-9a-fA-F.]+$/.test(h)) return null;
|
|
109
|
-
const fields = h.split('.');
|
|
110
|
-
if (fields.length > 4) return null;
|
|
111
|
-
const parts: number[] = [];
|
|
112
|
-
for (const f of fields) {
|
|
113
|
-
if (/^0[xX][0-9a-fA-F]+$/.test(f)) parts.push(parseInt(f, 16));
|
|
114
|
-
else if (/^0[0-7]+$/.test(f)) parts.push(parseInt(f, 8));
|
|
115
|
-
else if (/^\d+$/.test(f)) parts.push(Number(f));
|
|
116
|
-
else return null;
|
|
117
|
-
}
|
|
118
|
-
const last = parts[parts.length - 1];
|
|
119
|
-
if (parts.length === 1) {
|
|
120
|
-
if (last >= 2 ** 32) return null;
|
|
121
|
-
return [(last >>> 24) & 0xff, (last >>> 16) & 0xff, (last >>> 8) & 0xff, last & 0xff].join('.');
|
|
122
|
-
}
|
|
123
|
-
if (parts.slice(0, -1).some((n) => n > 255)) return null;
|
|
124
|
-
if (last >= 2 ** (8 * (4 - parts.length + 1))) return null;
|
|
125
|
-
if (parts.length === 2) return [parts[0], 0, 0, last].join('.');
|
|
126
|
-
if (parts.length === 3) return [parts[0], parts[1], (last >>> 8) & 0xff, last & 0xff].join('.');
|
|
127
|
-
return parts.join('.');
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export function isPrivateHost(host: string): boolean {
|
|
131
|
-
const h = host.toLowerCase().replace(/^\[(.*)\]$/, '$1');
|
|
132
|
-
if (h === 'localhost' || h.endsWith('.local') || h.endsWith('.localhost')) return true;
|
|
133
|
-
const mapped = /:ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(h);
|
|
134
|
-
if (mapped) {
|
|
135
|
-
const [a, b] = [parseInt(mapped[1], 16), parseInt(mapped[2], 16)];
|
|
136
|
-
if (isPrivateIpv4(`${(a >> 8) & 0xff}.${a & 0xff}.${(b >> 8) & 0xff}.${b & 0xff}`)) return true;
|
|
137
|
-
}
|
|
138
|
-
const ipv4 = numericIpv4(h);
|
|
139
|
-
if (ipv4 && isPrivateIpv4(ipv4)) return true;
|
|
140
|
-
if (/^[0-9a-f:]+$/.test(h) && h.includes(':')) {
|
|
141
|
-
if (h.startsWith('::1') || h === '0:0:0:0:0:0:0:1') return true;
|
|
142
|
-
if (h.startsWith('fe80:') || h.startsWith('fc') || h.startsWith('fd')) return true;
|
|
143
|
-
}
|
|
144
|
-
return false;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
export function validateUrl(raw: string, allowPrivate = false): URL {
|
|
148
|
-
const u = new URL(raw);
|
|
149
|
-
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error(`unsupported protocol: ${u.protocol}`);
|
|
150
|
-
if (!allowPrivate && isPrivateHost(u.hostname)) throw new Error('private/loopback address rejected');
|
|
151
|
-
return u;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
97
|
function stripKatexSpans(h: string): string {
|
|
155
98
|
let out = '';
|
|
156
99
|
let i = 0;
|
|
@@ -468,8 +411,7 @@ export function sliceSections(
|
|
|
468
411
|
const depth = hm![1].length;
|
|
469
412
|
if (cur.trim()) sections.push(cur.trim());
|
|
470
413
|
cur = `${'#'.repeat(depth)} ${name}\n`;
|
|
471
|
-
if (outlineNames.length < OUTLINE_CAP
|
|
472
|
-
outlineNames.push(name.replace(/\s+/g, ' ').slice(0, 90));
|
|
414
|
+
if (outlineNames.length < OUTLINE_CAP) outlineNames.push(name.replace(/\s+/g, ' ').slice(0, 90));
|
|
473
415
|
continue;
|
|
474
416
|
}
|
|
475
417
|
seenParas.add(paraKey);
|
|
@@ -486,7 +428,7 @@ export function sliceSections(
|
|
|
486
428
|
const depth = hm[1].length;
|
|
487
429
|
const rest = (hm[3] ?? '').replace(/^[\n\s]+/, '');
|
|
488
430
|
cur = `${'#'.repeat(depth)} ${name}` + (rest ? `\n${rest}` : '') + '\n';
|
|
489
|
-
if (outlineNames.length < OUTLINE_CAP
|
|
431
|
+
if (outlineNames.length < OUTLINE_CAP) outlineNames.push(name.replace(/\s+/g, ' ').slice(0, 90));
|
|
490
432
|
continue;
|
|
491
433
|
}
|
|
492
434
|
cur += scrubNavBullets(b) + '\n\n';
|
|
@@ -685,7 +627,7 @@ function fromChain(
|
|
|
685
627
|
sectionCap: number,
|
|
686
628
|
maxChars: number,
|
|
687
629
|
): ScrapeResult {
|
|
688
|
-
let markdown = stripDataUriImages(doc.markdown);
|
|
630
|
+
let markdown = cleanReaderMarkdown(stripDataUriImages(doc.markdown));
|
|
689
631
|
let title = doc.title;
|
|
690
632
|
if (looksLikeHtml(markdown)) {
|
|
691
633
|
const extracted = extractHtml(markdown);
|
|
@@ -714,25 +656,26 @@ async function archivedFetch(
|
|
|
714
656
|
): Promise<ScrapeResult | undefined> {
|
|
715
657
|
if (signal?.aborted) return undefined;
|
|
716
658
|
if (isPrivateHost(url.hostname)) return undefined;
|
|
659
|
+
let snapshotUrl: string | undefined;
|
|
660
|
+
let snapTs: string | undefined;
|
|
661
|
+
const { ctrl, done } = withTimeout('archive lookup', 10_000, signal);
|
|
717
662
|
try {
|
|
718
|
-
const
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
const snap = d.archived_snapshots?.closest;
|
|
730
|
-
if (snap?.url && snap.status === '200') snapshotUrl = snap.url;
|
|
731
|
-
var snapTs = snap?.timestamp;
|
|
732
|
-
}
|
|
733
|
-
} finally {
|
|
734
|
-
done();
|
|
663
|
+
const res = await fetch(`https://archive.org/wayback/available?url=${encodeURIComponent(url.toString())}`, {
|
|
664
|
+
signal: ctrl.signal,
|
|
665
|
+
headers: { Accept: 'application/json' },
|
|
666
|
+
});
|
|
667
|
+
if (res.ok) {
|
|
668
|
+
const d = (await res.json()) as {
|
|
669
|
+
archived_snapshots?: { closest?: { url?: string; status?: string; timestamp?: string } };
|
|
670
|
+
};
|
|
671
|
+
const snap = d.archived_snapshots?.closest;
|
|
672
|
+
if (snap?.url && snap.status === '200') snapshotUrl = snap.url;
|
|
673
|
+
snapTs = snap?.timestamp;
|
|
735
674
|
}
|
|
675
|
+
} finally {
|
|
676
|
+
done();
|
|
677
|
+
}
|
|
678
|
+
try {
|
|
736
679
|
if (!snapshotUrl || signal?.aborted) return undefined;
|
|
737
680
|
const { body, contentType } = await localFetch(new URL(snapshotUrl), signal);
|
|
738
681
|
if (!/html|text/.test(contentType)) return undefined;
|
|
@@ -786,8 +729,10 @@ export async function scrape(urlRaw: string, opts: ScrapeOptions = {}): Promise<
|
|
|
786
729
|
}
|
|
787
730
|
|
|
788
731
|
const usable = (markdown: string): boolean => {
|
|
789
|
-
|
|
790
|
-
|
|
732
|
+
const md = cleanReaderMarkdown(markdown);
|
|
733
|
+
if (md.replace(/\s+/g, '').length < 100) return false;
|
|
734
|
+
if (notFoundDoc(md)) return false;
|
|
735
|
+
const { sections } = sliceSections(md, DEFAULT_SECTION_CAP);
|
|
791
736
|
return sections.length > 0;
|
|
792
737
|
};
|
|
793
738
|
|
|
@@ -830,6 +775,43 @@ export async function scrape(urlRaw: string, opts: ScrapeOptions = {}): Promise<
|
|
|
830
775
|
return fromChain(url, chainResult.doc, sectionCap, maxChars);
|
|
831
776
|
}
|
|
832
777
|
|
|
778
|
+
/** Whole-line news junk in reader output: photo credits, carousel markers,
|
|
779
|
+
* video durations, relative-time chips. Prose lines are untouched. */
|
|
780
|
+
const READER_JUNK_LINE_RE = [
|
|
781
|
+
// bare credits: "REUTERS/Sofiia Gatilova", "AP Photo/John Smith"
|
|
782
|
+
/^(?:reuters|ap photo|getty images|afp|epa)\/(?:[a-z0-9][\w.' -]{1,80})$/i,
|
|
783
|
+
// handout/file photo tags at line end: "…REUTERS/Rachel Wisniewski/File Photo"
|
|
784
|
+
/\/(?:file|handout)\s+photo\s*\.*$/i,
|
|
785
|
+
// carousel markers: "Item 1 of 15"
|
|
786
|
+
/^item\s+\d{1,3}\s+of\s+\d{1,3}\s*$/i,
|
|
787
|
+
// video durations: "0:48"
|
|
788
|
+
/^\d{1,3}:\d{2}$/,
|
|
789
|
+
// relative-time chips: "* 1 hour ago", "just now", "yesterday"
|
|
790
|
+
/^[*\-–—]?\s*(?:just now|today|yesterday|\d+\s*(?:m|min(?:ute)?s?|hrs?|h|hours?|d|days?|w|weeks?)\s*ago\.?)\s*$/i,
|
|
791
|
+
];
|
|
792
|
+
|
|
793
|
+
const NOT_FOUND_DOC_RE =
|
|
794
|
+
/(?:page|url|requested page|webpage)[^.]{0,40}(?:not found|not be found|cannot be found|does not exist|doesn't exist|no longer exists|error 404|404 error|404 not found)|we (?:couldn't|could not|can't) find(?: the)? (?:page|requested|article|that)|it looks like there isn't a page/i;
|
|
795
|
+
|
|
796
|
+
/** Best-effort scrub of reader markdown (news sites leak credits/captions). */
|
|
797
|
+
export function cleanReaderMarkdown(markdown: string): string {
|
|
798
|
+
const out: string[] = [];
|
|
799
|
+
let inFence = false;
|
|
800
|
+
for (const line of markdown.split('\n')) {
|
|
801
|
+
if (/^\s*(?:```|~~~)/.test(line)) inFence = !inFence;
|
|
802
|
+
const t = line.trim();
|
|
803
|
+
if (!inFence && t && t.length <= 200 && READER_JUNK_LINE_RE.some((re) => re.test(t))) continue;
|
|
804
|
+
out.push(line);
|
|
805
|
+
}
|
|
806
|
+
return out.join('\n').replace(/\n{3,}/g, '\n\n');
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** True for a 404-style reader stub; the chain skips it and tries the next backend. */
|
|
810
|
+
export function notFoundDoc(markdown: string): boolean {
|
|
811
|
+
if (markdown.replace(/\s+/g, '').length > 500) return false;
|
|
812
|
+
return NOT_FOUND_DOC_RE.test(markdown.slice(0, 300));
|
|
813
|
+
}
|
|
814
|
+
|
|
833
815
|
/** Compose outline + sections; the cap is honest (marker included). */
|
|
834
816
|
export function joinSections(
|
|
835
817
|
outline: string[],
|
package/src/tools.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
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
|
-
import { Text } from '@earendil-works/pi-tui';
|
|
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';
|
|
23
|
+
import { clearDeadEngines, runEngines, type EngineOutcome } from './engines';
|
|
24
24
|
import { fuse, diversifyByHost } from './fuse';
|
|
25
|
-
import {
|
|
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,21 +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
|
-
const ABORT_ERROR_LOCAL = ABORT_ERROR;
|
|
87
|
-
|
|
88
90
|
function dropRarestTerm(query: string): string {
|
|
89
91
|
const words = query.split(/\s+/).filter((w) => w.length > 2 && !/^(?:site|inurl|intitle|filetype):/i.test(w));
|
|
90
92
|
if (words.length < 2) return '';
|
|
@@ -95,55 +97,6 @@ function dropRarestTerm(query: string): string {
|
|
|
95
97
|
.trim();
|
|
96
98
|
}
|
|
97
99
|
|
|
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
|
-
|
|
137
|
-
function cleanSnippet(s: string): string {
|
|
138
|
-
return s
|
|
139
|
-
.replace(/\\([_*`[\]()#>~.-])/g, '$1')
|
|
140
|
-
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
|
141
|
-
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
142
|
-
.replace(/(^|\s)([*]{1,3}|_{1,2}|`)(\S(?:[^*`_]*\S)?)\2(?=\s|$|[.,;:!?)]|\b)/g, '$1$3')
|
|
143
|
-
.replace(/\s+/g, ' ')
|
|
144
|
-
.trim();
|
|
145
|
-
}
|
|
146
|
-
|
|
147
100
|
export function registerWebTools(pi: ExtensionAPI) {
|
|
148
101
|
const cache = openCache(getConfig().cacheDir + '/cache.json');
|
|
149
102
|
const exposure = existsSync(disabledFlagPath()) ? 'hidden' : 'direct';
|
|
@@ -169,6 +122,13 @@ export function registerWebTools(pi: ExtensionAPI) {
|
|
|
169
122
|
parameters: SEARCH_PARAMS,
|
|
170
123
|
outputSchema: SEARCH_OUTPUT,
|
|
171
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
|
+
|
|
172
132
|
async execute(_id, params, signal, _onUpdate, _ctx) {
|
|
173
133
|
if (signal?.aborted) throw new Error(ABORT_ERROR);
|
|
174
134
|
const outcomes = await runEngines({
|
|
@@ -208,38 +168,32 @@ export function registerWebTools(pi: ExtensionAPI) {
|
|
|
208
168
|
fused.filter((r) => !r.junk),
|
|
209
169
|
2,
|
|
210
170
|
).slice(0, Math.max((params.max_results ?? DEFAULT_MAX_RESULTS) * 2, DEFAULT_MAX_RESULTS));
|
|
211
|
-
if (candidates.length) retryNote = `
|
|
171
|
+
if (candidates.length) retryNote = `retried as "${relaxed}"`;
|
|
212
172
|
}
|
|
213
173
|
}
|
|
214
174
|
}
|
|
215
175
|
const visible = candidates.slice(0, params.max_results ?? DEFAULT_MAX_RESULTS);
|
|
216
176
|
const enginesUsed = [...new Set(fused.flatMap((r) => r.engines))];
|
|
217
|
-
const snip = 240;
|
|
218
|
-
|
|
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`;
|
|
220
|
-
if (!visible.length) {
|
|
221
|
-
const fatal = outcomes.find((o) => o.engine === 'none' && o.error);
|
|
222
|
-
text = fatal
|
|
223
|
-
? `No results for "${params.query}": ${fatal.error}\n`
|
|
224
|
-
: `No results for "${params.query}". Broaden the query.\n`;
|
|
225
|
-
}
|
|
226
177
|
visible.forEach((r, i) => {
|
|
227
178
|
lastSearch.urlRank.set(normalizeUrl(r.url), {
|
|
228
179
|
rank: i + 1,
|
|
229
180
|
engines: r.engines,
|
|
230
181
|
query: params.query,
|
|
231
182
|
});
|
|
232
|
-
const title = fmtSnippet(stripSiteSuffix(cleanSnippet(r.title), hostOf(r.url)), 100) || r.url;
|
|
233
|
-
const snippet = fmtSnippet(cleanSnippet(r.snippet), snip);
|
|
234
|
-
const via = r.engines.length > 1 ? ` ← ${r.engines.length} engines agree` : ` ← ${r.engines[0]}`;
|
|
235
|
-
const when = normalizeHitDate(r.date);
|
|
236
|
-
text += `${i + 1}. [${title}](${r.url})${when ? ` — ${when}` : ''}${via}\n`;
|
|
237
|
-
if (snippet) text += ` ${snippet}\n`;
|
|
238
|
-
text += '\n';
|
|
239
183
|
});
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
+
);
|
|
243
197
|
|
|
244
198
|
return {
|
|
245
199
|
content: [{ type: 'text', text }],
|
|
@@ -252,15 +206,14 @@ export function registerWebTools(pi: ExtensionAPI) {
|
|
|
252
206
|
url: r.url,
|
|
253
207
|
title: r.title,
|
|
254
208
|
engines: r.engines,
|
|
209
|
+
ranks: r.engineRanks,
|
|
255
210
|
score: r.finalScore,
|
|
256
211
|
})),
|
|
257
212
|
},
|
|
258
213
|
};
|
|
259
214
|
},
|
|
260
215
|
renderResult(result, options, theme) {
|
|
261
|
-
|
|
262
|
-
if (!options.expanded) return new Text(previewWithHint(lines, theme), 0, 0);
|
|
263
|
-
return new Text(colorizedResult(resultText(result), theme), 0, 0);
|
|
216
|
+
return new WrappedSearchText(resultText(result), theme, options.expanded);
|
|
264
217
|
},
|
|
265
218
|
});
|
|
266
219
|
|
|
@@ -282,10 +235,13 @@ export function registerWebTools(pi: ExtensionAPI) {
|
|
|
282
235
|
const url = typeof args.url === 'string' ? args.url : '…';
|
|
283
236
|
const s = Array.isArray(args.sections) ? args.sections.join(', ') : '…';
|
|
284
237
|
const tail = args.sections ? ` sections=[${s}]` : '';
|
|
285
|
-
return new Text(`${header} ${theme.fg('
|
|
238
|
+
return new Text(`${header} ${theme.fg('accent', url)}${theme.fg('muted', tail)}`, 0, 0);
|
|
286
239
|
},
|
|
287
240
|
|
|
288
241
|
async execute(_id, params, signal) {
|
|
242
|
+
// Abort = throw: a stale signal must never START a fetch, and an abort
|
|
243
|
+
// mid-flight must never be masked by the stale-if-error reserve below.
|
|
244
|
+
if (signal?.aborted) throw new Error(ABORT_ERROR);
|
|
289
245
|
const cfg = getConfig();
|
|
290
246
|
const key = cacheKey(['fetch', 'v3', normalizeUrl(params.url)]);
|
|
291
247
|
const ranked = lastSearch.urlRank.get(normalizeUrl(params.url));
|
|
@@ -313,6 +269,7 @@ export function registerWebTools(pi: ExtensionAPI) {
|
|
|
313
269
|
signal,
|
|
314
270
|
validators: stale?.value?.validators,
|
|
315
271
|
});
|
|
272
|
+
if (signal?.aborted) throw new Error(ABORT_ERROR); // mid-flight abort: never a payload, never stale
|
|
316
273
|
if (res.error === 'not-modified' && stale?.value) {
|
|
317
274
|
cache.set(key, stale.value, CACHE_TTL_HOURS * 3600_000);
|
|
318
275
|
return makeFetchResult(stale.value, params);
|
|
@@ -351,11 +308,13 @@ type FetchDetails = {
|
|
|
351
308
|
title: string;
|
|
352
309
|
renderer: string;
|
|
353
310
|
outline: string[];
|
|
311
|
+
sections: { index: number; heading: string; text: string }[];
|
|
354
312
|
section_count: number;
|
|
355
313
|
chars?: number;
|
|
356
314
|
sections_requested?: number[];
|
|
357
315
|
sections_missing?: number[];
|
|
358
316
|
sections_truncated?: number;
|
|
317
|
+
sections_clipped?: number[];
|
|
359
318
|
error?: string;
|
|
360
319
|
};
|
|
361
320
|
|
|
@@ -366,11 +325,8 @@ interface FetchResult {
|
|
|
366
325
|
isError?: boolean;
|
|
367
326
|
}
|
|
368
327
|
|
|
369
|
-
function fmtSize(chars: number): string {
|
|
370
|
-
return chars >= 1024 ? `${(chars / 1024).toFixed(1)} KB` : `${chars} chars`;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
328
|
const PREVIEW_LINES = 10;
|
|
329
|
+
const SEARCH_PREVIEW_LINES = 8;
|
|
374
330
|
|
|
375
331
|
/** Display-only: the model-facing markers (`indices …`, `- *N*`) render as
|
|
376
332
|
* genuine ANSI italics in the TUI; the content text itself is untouched. */
|
|
@@ -380,23 +336,70 @@ function italicizeIndices(text: string, theme: Theme): string {
|
|
|
380
336
|
.replace(/^- \*(\d+)\* /gm, (_, n: string) => `- ${theme.italic(n)} `);
|
|
381
337
|
}
|
|
382
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
|
+
let shown = lines;
|
|
367
|
+
let hint = '';
|
|
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
|
+
}
|
|
372
|
+
const colored = italicizeRanks(shown.join('\n'), this.theme)
|
|
373
|
+
.split('\n')
|
|
374
|
+
.map((l) => colorLine(l, this.theme))
|
|
375
|
+
.join('\n');
|
|
376
|
+
return replaceTabs(colored + hint).split('\n');
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function colorLine(l: string, theme: Theme): string {
|
|
381
|
+
const plain = l.replace(ANSI_SGR, '');
|
|
382
|
+
if (
|
|
383
|
+
plain.includes('…[section truncated') ||
|
|
384
|
+
plain.includes('…(continues at outline index') ||
|
|
385
|
+
plain.startsWith('…[clipped by tool]') ||
|
|
386
|
+
plain.startsWith('…[truncated]')
|
|
387
|
+
)
|
|
388
|
+
return theme.fg('warning', l);
|
|
389
|
+
if (plain.startsWith('## ') && plain.endsWith(READ_FIRST_TAG))
|
|
390
|
+
return theme.fg('accent', l.slice(0, l.length - READ_FIRST_TAG.length)) + theme.bold(READ_FIRST_TAG);
|
|
391
|
+
if (/^\s*· recommended first read$/.test(plain)) return theme.bold(l.trimEnd());
|
|
392
|
+
if (plain.startsWith('## ')) return theme.fg('accent', l);
|
|
393
|
+
if (plain.startsWith('- ')) return theme.fg('dim', l);
|
|
394
|
+
if (plain.startsWith('[rendered via')) return theme.fg('muted', l);
|
|
395
|
+
if (/^\s*\d+\. /.test(plain)) return theme.fg('dim', l);
|
|
396
|
+
return l;
|
|
397
|
+
}
|
|
398
|
+
|
|
383
399
|
function colorizedResult(text: string, theme: Theme): string {
|
|
384
400
|
return text
|
|
385
401
|
.split('\n')
|
|
386
|
-
.map((l) =>
|
|
387
|
-
if (
|
|
388
|
-
l.includes('…[section truncated') ||
|
|
389
|
-
l.includes('…(continues at outline index') ||
|
|
390
|
-
l.startsWith('…[clipped by tool]') ||
|
|
391
|
-
l.startsWith('…[truncated]')
|
|
392
|
-
)
|
|
393
|
-
return theme.fg('warning', l);
|
|
394
|
-
if (l.startsWith('## [')) return theme.fg('accent', l);
|
|
395
|
-
if (l.startsWith('- ')) return theme.fg('dim', l);
|
|
396
|
-
if (l.startsWith('[rendered via')) return theme.fg('muted', l);
|
|
397
|
-
if (/^\d+\. /.test(l)) return theme.fg('toolOutput', l);
|
|
398
|
-
return l;
|
|
399
|
-
})
|
|
402
|
+
.map((l) => colorLine(l, theme))
|
|
400
403
|
.join('\n');
|
|
401
404
|
}
|
|
402
405
|
|
|
@@ -404,13 +407,16 @@ function resultText(result: { content: { type: string; text?: string }[] }): str
|
|
|
404
407
|
return (result.content[0] as { type: string; text: string } | undefined)?.text ?? '';
|
|
405
408
|
}
|
|
406
409
|
|
|
407
|
-
function
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
+
function moreLinesHint(shown: number, total: number, theme: Theme): string {
|
|
411
|
+
const remaining = total - shown;
|
|
412
|
+
if (remaining <= 0) return '';
|
|
413
|
+
return theme.fg('muted', `\n… (${remaining} more lines, ${keyHint('app.tools.expand', 'to expand')})`);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function previewWithHint(lines: string[], theme: Theme, maxLines = PREVIEW_LINES): string {
|
|
417
|
+
const shown = lines.slice(0, maxLines);
|
|
410
418
|
const text = shown.map((l) => theme.fg('toolOutput', replaceTabs(l))).join('\n');
|
|
411
|
-
|
|
412
|
-
return text + theme.fg('muted', `\n… (${remaining} more lines, ${keyHint('app.tools.expand', 'to expand')})`);
|
|
413
|
-
return text;
|
|
419
|
+
return text + moreLinesHint(shown.length, lines.length, theme);
|
|
414
420
|
}
|
|
415
421
|
|
|
416
422
|
function replaceTabs(text: string): string {
|
|
@@ -429,7 +435,6 @@ function fetchErrorResult(details: FetchDetails): FetchResult {
|
|
|
429
435
|
|
|
430
436
|
function buildFetchResult(res: Awaited<ReturnType<typeof scrape>>, sections: number[]): FetchResult {
|
|
431
437
|
const want = [...new Set(sections.map((i) => Math.max(0, Math.min(res.sections.length - 1, i))))];
|
|
432
|
-
const chosen = want.map((i) => res.sections[i]).filter(Boolean);
|
|
433
438
|
const body = res.outline.length
|
|
434
439
|
? `indices - 0 - ${res.outline.length - 1} section${res.outline.length === 1 ? '' : 's'}\n${res.outline.map((h, i) => `- *${i}* ${h}`).join('\n')}\n\n`
|
|
435
440
|
: '';
|
|
@@ -461,17 +466,48 @@ function buildFetchResult(res: Awaited<ReturnType<typeof scrape>>, sections: num
|
|
|
461
466
|
if (res.renderer && res.renderer !== 'local') text += `\n\n[rendered via ${res.renderer}]`;
|
|
462
467
|
const clipped = text.length > MAX_FETCH_TEXT ? text.slice(0, MAX_FETCH_TEXT) + '\n…[clipped by tool]' : text;
|
|
463
468
|
const missing = sections.filter((i) => i >= res.sections.length);
|
|
469
|
+
// continuation cuts (res.continued) vs local budget clips (clippedSections)
|
|
464
470
|
const truncatedCount = want.filter((idx) => res.continued?.includes(idx)).length;
|
|
471
|
+
|
|
472
|
+
// same budget as the text view; headings and the '…' marker count against it
|
|
473
|
+
let budget = MAX_FETCH_TEXT;
|
|
474
|
+
const structuredSections: { index: number; heading: string; text: string }[] = [];
|
|
475
|
+
const clippedSections: number[] = [];
|
|
476
|
+
for (const idx of want) {
|
|
477
|
+
if (budget <= 0) {
|
|
478
|
+
clippedSections.push(idx);
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
const s = res.sections[idx];
|
|
482
|
+
if (s === undefined) continue;
|
|
483
|
+
const heading = res.outline[idx] ?? '';
|
|
484
|
+
const fullCost = heading.length + s.length;
|
|
485
|
+
if (fullCost <= budget) {
|
|
486
|
+
structuredSections.push({ index: idx, heading, text: s });
|
|
487
|
+
budget -= fullCost;
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
if (heading.length >= budget) {
|
|
491
|
+
structuredSections.push({ index: idx, heading: heading.slice(0, Math.max(0, budget - 1)) + '…', text: '' });
|
|
492
|
+
} else {
|
|
493
|
+
const textLen = Math.max(0, budget - heading.length - 1);
|
|
494
|
+
structuredSections.push({ index: idx, heading, text: s.slice(0, textLen) + (textLen < s.length ? '…' : '') });
|
|
495
|
+
}
|
|
496
|
+
clippedSections.push(idx);
|
|
497
|
+
budget = 0;
|
|
498
|
+
}
|
|
465
499
|
const details = {
|
|
466
500
|
url: res.url,
|
|
467
501
|
title: res.title,
|
|
468
502
|
renderer: res.renderer,
|
|
469
503
|
outline: res.outline,
|
|
470
|
-
|
|
504
|
+
sections: structuredSections,
|
|
505
|
+
section_count: structuredSections.length,
|
|
471
506
|
chars: clipped.length,
|
|
472
507
|
sections_requested: [...new Set(sections)],
|
|
473
508
|
sections_missing: [...new Set(missing)],
|
|
474
509
|
sections_truncated: truncatedCount,
|
|
510
|
+
sections_clipped: clippedSections,
|
|
475
511
|
...(res.publishedAt ? { publishedAt: res.publishedAt } : {}),
|
|
476
512
|
...(res.paywalled ? { paywalled: true } : {}),
|
|
477
513
|
...(res.error ? { error: res.error } : {}),
|
package/src/transport.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Transport for web.fetch: UA-rotating local HTTP + the key-gated reader
|
|
3
|
-
* backends. Text processing lives in scrape.ts (one-way import).
|
|
3
|
+
* backends. Text processing lives in scrape.ts (one-way import). SSRF: every
|
|
4
|
+
* redirect hop is re-validated against the private/loopback gates on the
|
|
5
|
+
* local path (see urls.isPrivateHost for the deliberate scope).
|
|
4
6
|
*/
|
|
5
7
|
import { getConfig, sanitizeError } from './config';
|
|
6
8
|
import { ABORT_ERROR } from './constants';
|
|
9
|
+
import { isPrivateHost } from './urls';
|
|
7
10
|
|
|
8
11
|
export type Renderer = 'local' | 'firecrawl' | 'tavily' | 'exa' | 'jina' | 'kagi' | 'tinyfish' | 'serper' | 'you';
|
|
9
12
|
|
|
@@ -24,6 +27,7 @@ const USER_AGENTS = [
|
|
|
24
27
|
const MAX_HTML_BYTES = 5 * 1024 * 1024;
|
|
25
28
|
const LOCAL_TIMEOUT_MS = 15_000;
|
|
26
29
|
const RETRY_AFTER_MAX_MS = 10_000;
|
|
30
|
+
const MAX_REDIRECTS = 8;
|
|
27
31
|
const READER_TIMEOUTS: Record<RemoteRenderer, number> = {
|
|
28
32
|
firecrawl: 45_000,
|
|
29
33
|
tavily: 30_000,
|
|
@@ -241,24 +245,44 @@ export async function localFetch(
|
|
|
241
245
|
if (signal?.aborted) throw new Error(ABORT_ERROR);
|
|
242
246
|
const { ctrl, done } = withTimeout('local fetch', LOCAL_TIMEOUT_MS, signal);
|
|
243
247
|
try {
|
|
248
|
+
const headers: Record<string, string> = {
|
|
249
|
+
'User-Agent': USER_AGENTS[attempt],
|
|
250
|
+
Accept: 'text/markdown,text/html,application/xhtml+xml,text/plain;q=0.8,*/*;q=0.5',
|
|
251
|
+
'Accept-Language': 'en-US,en;q=0.5',
|
|
252
|
+
};
|
|
244
253
|
const condHeaders: Record<string, string> = {};
|
|
245
254
|
if (validators?.etag) condHeaders['If-None-Match'] = validators.etag;
|
|
246
255
|
if (validators?.lastModified) condHeaders['If-Modified-Since'] = validators.lastModified;
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
256
|
+
// 'manual' + explicit per-hop re-validation: a hostile page must never
|
|
257
|
+
// aim the agent at its own machine via a Location header. Conditional
|
|
258
|
+
// headers describe the FIRST resource only, so they drop after hop 0.
|
|
259
|
+
let current = url;
|
|
260
|
+
let res: Response | undefined;
|
|
261
|
+
for (let hop = 0; ; hop++) {
|
|
262
|
+
if (signal?.aborted) throw new Error(ABORT_ERROR);
|
|
263
|
+
res = await fetch(current, {
|
|
264
|
+
redirect: 'manual',
|
|
265
|
+
headers: hop === 0 ? { ...headers, ...condHeaders } : headers,
|
|
266
|
+
signal: ctrl.signal,
|
|
267
|
+
});
|
|
268
|
+
if (!isRedirect(res.status)) break;
|
|
269
|
+
if (hop >= MAX_REDIRECTS) throw new Error(`too many redirects (HTTP ${res.status})`);
|
|
270
|
+
const loc = res.headers.get('location');
|
|
271
|
+
await res.body?.cancel().catch(() => {});
|
|
272
|
+
if (!loc) throw new Error(`redirect without Location (HTTP ${res.status})`);
|
|
273
|
+
const next = new URL(loc, current);
|
|
274
|
+
if (next.protocol !== 'http:' && next.protocol !== 'https:') {
|
|
275
|
+
throw new Error(`unsupported redirect protocol: ${next.protocol}`);
|
|
276
|
+
}
|
|
277
|
+
if (isPrivateHost(next.hostname)) throw new Error('private/loopback address rejected (redirect)');
|
|
278
|
+
current = next;
|
|
279
|
+
}
|
|
280
|
+
const final = res!;
|
|
281
|
+
if (final.status === 304 && attempt === 0) {
|
|
258
282
|
done();
|
|
259
283
|
return { body: '', contentType: '', validators: validators ?? {}, notModified: true };
|
|
260
284
|
}
|
|
261
|
-
const ct =
|
|
285
|
+
const ct = final.headers.get('content-type') ?? '';
|
|
262
286
|
const mime = ct.split(';')[0]?.trim().toLowerCase() ?? '';
|
|
263
287
|
if (
|
|
264
288
|
!/html|xml|text/.test(mime) &&
|
|
@@ -267,25 +291,25 @@ export async function localFetch(
|
|
|
267
291
|
) {
|
|
268
292
|
throw new Error(`not a readable page (${mime || '?'})`);
|
|
269
293
|
}
|
|
270
|
-
if (
|
|
294
|
+
if (final.status === 429 && !retried429) {
|
|
271
295
|
retried429 = true;
|
|
272
|
-
await waitAbortable(parseRetryAfter(
|
|
296
|
+
await waitAbortable(parseRetryAfter(final.headers.get('retry-after')), signal);
|
|
273
297
|
attempt--;
|
|
274
298
|
continue;
|
|
275
299
|
}
|
|
276
|
-
if (!
|
|
277
|
-
const sample = await
|
|
278
|
-
if (isBotBlocked(
|
|
279
|
-
throw new Error(`HTTP ${
|
|
300
|
+
if (!final.ok) {
|
|
301
|
+
const sample = await final.text().catch(() => '');
|
|
302
|
+
if (isBotBlocked(final.status, sample) && attempt < USER_AGENTS.length - 1) continue;
|
|
303
|
+
throw new Error(`HTTP ${final.status}`);
|
|
280
304
|
}
|
|
281
|
-
const bytes = await readBodyBounded(
|
|
305
|
+
const bytes = await readBodyBounded(final, MAX_HTML_BYTES);
|
|
282
306
|
if (bytes.byteLength === 0) throw new Error('empty body');
|
|
283
307
|
return {
|
|
284
308
|
body: decodeBody(bytes, ct),
|
|
285
309
|
contentType: ct,
|
|
286
310
|
validators: {
|
|
287
|
-
etag:
|
|
288
|
-
lastModified:
|
|
311
|
+
etag: final.headers.get('etag') ?? undefined,
|
|
312
|
+
lastModified: final.headers.get('last-modified') ?? undefined,
|
|
289
313
|
},
|
|
290
314
|
notModified: false,
|
|
291
315
|
};
|
|
@@ -306,6 +330,11 @@ export async function localFetch(
|
|
|
306
330
|
throw new Error('local fetch failed');
|
|
307
331
|
}
|
|
308
332
|
|
|
333
|
+
// 3xx redirect statuses; manual following must not treat these as content.
|
|
334
|
+
function isRedirect(status: number): boolean {
|
|
335
|
+
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
336
|
+
}
|
|
337
|
+
|
|
309
338
|
// Per-attempt timeout + pre-abort: an already-aborted signal never fires
|
|
310
339
|
// abort listeners, so the request must reject immediately on its own.
|
|
311
340
|
export function withTimeout(
|
package/src/urls.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
/** URL facts leaf: normalization (dedupe + cache keys), junk classification
|
|
1
|
+
/** URL facts leaf: normalization (dedupe + cache keys), junk classification,
|
|
2
|
+
* and the SSRF private-host gates (shared by scrape + transport). */
|
|
2
3
|
|
|
3
4
|
const JUNK_HOSTS = [
|
|
4
5
|
'quora.com',
|
|
@@ -19,6 +20,7 @@ const JUNK_HOSTS = [
|
|
|
19
20
|
'threadreaderapp.com',
|
|
20
21
|
'nitter.net',
|
|
21
22
|
'upd.dev',
|
|
23
|
+
'fb.watch',
|
|
22
24
|
];
|
|
23
25
|
const JUNK_TLDS = new Set(['xyz', 'top', 'loan', 'click', 'work', 'gq', 'icu', 'rest', 'cyou']);
|
|
24
26
|
|
|
@@ -47,28 +49,45 @@ export function hostOf(raw: string): string {
|
|
|
47
49
|
}
|
|
48
50
|
}
|
|
49
51
|
|
|
50
|
-
/** Keeps scheme+port deliberately (http vs https twins are different pages).
|
|
52
|
+
/** Keeps scheme+port deliberately (http vs https twins are different pages).
|
|
53
|
+
* Path CASE and query ENCODING are preserved verbatim: on case-sensitive hosts
|
|
54
|
+
* `/README` and `/readme` are different resources, and `?q=a%2Fb` is not
|
|
55
|
+
* `?q=a/b`. Folding them would collide fetch cache keys and merge distinct
|
|
56
|
+
* hits in fuse, so the raw query pairs are only filtered and sorted, never
|
|
57
|
+
* re-serialized (URLSearchParams would decode the encoding away). */
|
|
51
58
|
export function normalizeUrl(raw: string): string {
|
|
52
59
|
try {
|
|
53
60
|
const u = new URL(raw);
|
|
54
61
|
const host = u.hostname.toLowerCase().replace(/^www\./, '');
|
|
55
|
-
for (const p of Array.from(u.searchParams.keys())) {
|
|
56
|
-
if (TRACKING_PARAMS.has(p.toLowerCase())) u.searchParams.delete(p);
|
|
57
|
-
}
|
|
58
62
|
u.hash = '';
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
let q = '';
|
|
64
|
+
if (u.search) {
|
|
65
|
+
const kept = u.search
|
|
66
|
+
.slice(1)
|
|
67
|
+
.split('&')
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.filter((pair) => !TRACKING_PARAMS.has(decodeKey(pair)));
|
|
70
|
+
kept.sort((a, b) => (decodeKey(a) < decodeKey(b) ? -1 : decodeKey(a) > decodeKey(b) ? 1 : 0));
|
|
71
|
+
q = kept.join('&');
|
|
72
|
+
}
|
|
61
73
|
const port = u.port ? `:${u.port}` : '';
|
|
62
|
-
const path = u.pathname
|
|
63
|
-
.replace(/\/{2,}/g, '/')
|
|
64
|
-
.replace(/\/+$/, '')
|
|
65
|
-
.toLowerCase();
|
|
74
|
+
const path = u.pathname.replace(/\/{2,}/g, '/').replace(/\/+$/, '');
|
|
66
75
|
return `${u.protocol}//${host}${port}${path}${q ? '?' + q : ''}`;
|
|
67
76
|
} catch {
|
|
68
77
|
return raw.toLowerCase();
|
|
69
78
|
}
|
|
70
79
|
}
|
|
71
80
|
|
|
81
|
+
/** Decoded query KEY (for tracking-param matching); malformed stays raw. */
|
|
82
|
+
function decodeKey(pair: string): string {
|
|
83
|
+
const key = (pair.split('=')[0] ?? '').trim();
|
|
84
|
+
try {
|
|
85
|
+
return decodeURIComponent(key).toLowerCase();
|
|
86
|
+
} catch {
|
|
87
|
+
return key.toLowerCase();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
72
91
|
export function isJunk(raw: string): boolean {
|
|
73
92
|
const host = hostOf(raw);
|
|
74
93
|
if (JUNK_HOSTS.some((j) => host === j || host.endsWith('.' + j))) return true;
|
|
@@ -85,3 +104,77 @@ function pathOf(raw: string): string {
|
|
|
85
104
|
return '';
|
|
86
105
|
}
|
|
87
106
|
}
|
|
107
|
+
|
|
108
|
+
// ── SSRF gates: shared by scrape.ts and transport.ts; this leaf imports ──────
|
|
109
|
+
// ── nothing, so both consumers stay cycle-free. ───────────────────────────────
|
|
110
|
+
|
|
111
|
+
function isPrivateIpv4(ipv4: string): boolean {
|
|
112
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ipv4);
|
|
113
|
+
if (!m) return false;
|
|
114
|
+
const [a, b] = [Number(m[1]), Number(m[2])];
|
|
115
|
+
return (
|
|
116
|
+
a === 0 ||
|
|
117
|
+
a === 10 ||
|
|
118
|
+
a === 127 ||
|
|
119
|
+
(a === 169 && b === 254) ||
|
|
120
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
121
|
+
(a === 192 && b === 168)
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function numericIpv4(host: string): string | null {
|
|
126
|
+
if (!/^[0-9.]+$/.test(host) && !/^0[xX][0-9a-fA-F.]+$/.test(host)) return null;
|
|
127
|
+
const fields = host.split('.');
|
|
128
|
+
if (fields.length > 4) return null;
|
|
129
|
+
const parts: number[] = [];
|
|
130
|
+
for (const f of fields) {
|
|
131
|
+
if (/^0[xX][0-9a-fA-F]+$/.test(f)) parts.push(parseInt(f, 16));
|
|
132
|
+
else if (/^0[0-7]+$/.test(f)) parts.push(parseInt(f, 8));
|
|
133
|
+
else if (/^\d+$/.test(f)) parts.push(Number(f));
|
|
134
|
+
else return null;
|
|
135
|
+
}
|
|
136
|
+
const last = parts[parts.length - 1];
|
|
137
|
+
if (parts.length === 1) {
|
|
138
|
+
if (last >= 2 ** 32) return null;
|
|
139
|
+
return [(last >>> 24) & 0xff, (last >>> 16) & 0xff, (last >>> 8) & 0xff, last & 0xff].join('.');
|
|
140
|
+
}
|
|
141
|
+
if (parts.slice(0, -1).some((n) => n > 255)) return null;
|
|
142
|
+
if (last >= 2 ** (8 * (4 - parts.length + 1))) return null;
|
|
143
|
+
if (parts.length === 2) return [parts[0], 0, 0, last].join('.');
|
|
144
|
+
if (parts.length === 3) return [parts[0], parts[1], (last >>> 8) & 0xff, last & 0xff].join('.');
|
|
145
|
+
return parts.join('.');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** True for loopback/private/link-local numeric literals AND IPv6 forms that
|
|
149
|
+
* embed them (`::ffff:a.b.c.d`, and the `::aabb:ccdd` IPv4-compatible
|
|
150
|
+
* shorthand WHATWG serializes `::127.0.0.1` into). Scope is deliberate:
|
|
151
|
+
* hostnames that merely RESOLVE into private space are out of the threat
|
|
152
|
+
* model — the hostile web points the agent at literals, not at their DNS. */
|
|
153
|
+
export function isPrivateHost(host: string): boolean {
|
|
154
|
+
const h = host.toLowerCase().replace(/^\[(.*)\]$/, '$1');
|
|
155
|
+
if (h === 'localhost' || h.endsWith('.local') || h.endsWith('.localhost')) return true;
|
|
156
|
+
const mapped = /:ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(h);
|
|
157
|
+
if (mapped) {
|
|
158
|
+
const [a, b] = [parseInt(mapped[1], 16), parseInt(mapped[2], 16)];
|
|
159
|
+
if (isPrivateIpv4(`${(a >> 8) & 0xff}.${a & 0xff}.${(b >> 8) & 0xff}.${b & 0xff}`)) return true;
|
|
160
|
+
}
|
|
161
|
+
const compat = /^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(h);
|
|
162
|
+
if (compat) {
|
|
163
|
+
const [a, b] = [parseInt(compat[1], 16), parseInt(compat[2], 16)];
|
|
164
|
+
if (isPrivateIpv4(`${(a >> 8) & 0xff}.${a & 0xff}.${(b >> 8) & 0xff}.${b & 0xff}`)) return true;
|
|
165
|
+
}
|
|
166
|
+
const ipv4 = numericIpv4(h);
|
|
167
|
+
if (ipv4 && isPrivateIpv4(ipv4)) return true;
|
|
168
|
+
if (/^[0-9a-f:]+$/.test(h) && h.includes(':')) {
|
|
169
|
+
if (h.startsWith('::1') || h === '0:0:0:0:0:0:0:1') return true;
|
|
170
|
+
if (h.startsWith('fe80:') || h.startsWith('fc') || h.startsWith('fd')) return true;
|
|
171
|
+
}
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function validateUrl(raw: string, allowPrivate = false): URL {
|
|
176
|
+
const u = new URL(raw);
|
|
177
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error(`unsupported protocol: ${u.protocol}`);
|
|
178
|
+
if (!allowPrivate && isPrivateHost(u.hostname)) throw new Error('private/loopback address rejected');
|
|
179
|
+
return u;
|
|
180
|
+
}
|
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
|
+
}
|