pi-wsearch 0.2.1 → 0.3.1

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 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 how
11
- many: `← 3 engines agree` means three separate indexes ranked that page.
12
- Agreement is the spam defense; generative-SEO can stuff one index, not three.
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
@@ -42,7 +44,11 @@ Every cut is marked — nothing is silently lost.
42
44
  **Reads are free.** Page parsing is built in, nothing billed per read, no key
43
45
  required. Real headings become the map, TOC echoes get dropped, so reads come
44
46
  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.
47
+ JavaScript heavy pages or when the local read fails. Reader output is
48
+ laundered too: standalone photo credits (REUTERS/, AP Photo/, Getty), video
49
+ durations, carousel markers and relative-time chips are stripped from news
50
+ pages (whole-line rules only — prose is untouched), and a reader that returns
51
+ a 404-page template is skipped so the chain moves on to the next backend.
46
52
 
47
53
  **Tracking links cache as one page.** `?utm_source=…` variants of the same URL
48
54
  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.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Pi extension: web.search + web.fetch",
5
5
  "license": "MIT",
6
6
  "type": "module",
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 chosen = selectEngines(opts).filter((def) => !deadEngines.has(def.id));
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
- const error = wanted?.length
402
- ? `no configured engines match the requested set (${wanted.join(', ')})`
403
- : 'no search engine keys configured; run /websearch to add one';
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 && depth <= 4)
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 && depth <= 4) outlineNames.push(name.replace(/\s+/g, ' ').slice(0, 90));
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);
@@ -713,25 +655,27 @@ async function archivedFetch(
713
655
  maxChars: number,
714
656
  ): Promise<ScrapeResult | undefined> {
715
657
  if (signal?.aborted) return undefined;
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);
716
662
  try {
717
- const { ctrl, done } = withTimeout('archive lookup', 10_000, signal);
718
- let snapshotUrl: string | undefined;
719
- try {
720
- const res = await fetch(`https://archive.org/wayback/available?url=${encodeURIComponent(url.toString())}`, {
721
- signal: ctrl.signal,
722
- headers: { Accept: 'application/json' },
723
- });
724
- if (res.ok) {
725
- const d = (await res.json()) as {
726
- archived_snapshots?: { closest?: { url?: string; status?: string; timestamp?: string } };
727
- };
728
- const snap = d.archived_snapshots?.closest;
729
- if (snap?.url && snap.status === '200') snapshotUrl = snap.url;
730
- var snapTs = snap?.timestamp;
731
- }
732
- } finally {
733
- 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;
734
674
  }
675
+ } finally {
676
+ done();
677
+ }
678
+ try {
735
679
  if (!snapshotUrl || signal?.aborted) return undefined;
736
680
  const { body, contentType } = await localFetch(new URL(snapshotUrl), signal);
737
681
  if (!/html|text/.test(contentType)) return undefined;
@@ -785,8 +729,10 @@ export async function scrape(urlRaw: string, opts: ScrapeOptions = {}): Promise<
785
729
  }
786
730
 
787
731
  const usable = (markdown: string): boolean => {
788
- if (markdown.replace(/\s+/g, '').length < 100) return false;
789
- const { sections } = sliceSections(markdown, DEFAULT_SECTION_CAP);
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);
790
736
  return sections.length > 0;
791
737
  };
792
738
 
@@ -829,6 +775,43 @@ export async function scrape(urlRaw: string, opts: ScrapeOptions = {}): Promise<
829
775
  return fromChain(url, chainResult.doc, sectionCap, maxChars);
830
776
  }
831
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
+
832
815
  /** Compose outline + sections; the cap is honest (marker included). */
833
816
  export function joinSections(
834
817
  outline: string[],
package/src/tools.ts CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  import { ABORT_ERROR, DEFAULT_MAX_RESULTS } from './constants';
22
22
  import { cacheKey, openCache, CACHE_TTL_HOURS } from './cache';
23
23
  import { clearDeadEngines, runEngines } from './engines';
24
- import { fuse, diversifyByHost } from './fuse';
24
+ import { fuse, diversifyByHost, type FusedHit } from './fuse';
25
25
  import { hostOf, normalizeUrl } from './urls';
26
26
  import { scrape } from './scrape';
27
27
 
@@ -83,8 +83,6 @@ function fmtSnippet(s: string, max: number): string {
83
83
  return t.length > max ? t.slice(0, max) + '…' : t;
84
84
  }
85
85
 
86
- const ABORT_ERROR_LOCAL = ABORT_ERROR;
87
-
88
86
  function dropRarestTerm(query: string): string {
89
87
  const words = query.split(/\s+/).filter((w) => w.length > 2 && !/^(?:site|inurl|intitle|filetype):/i.test(w));
90
88
  if (words.length < 2) return '';
@@ -115,19 +113,18 @@ function normalizeHitDate(raw: string | undefined): string | undefined {
115
113
  if (rel) {
116
114
  const n = parseInt(rel[1], 10);
117
115
  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
116
+ // 'mo' must win over 'm' BEFORE the minutes branch: the regex captures
117
+ // 'months' verbatim, so a bare startsWith('m') misclassifies it ("3
118
+ // months ago" used to render as ~today). Divide by unit, in months>…>ms.
119
+ const ms = unit.startsWith('h')
120
+ ? 3_600_000
121
+ : unit.startsWith('mo')
122
+ ? 2_592_000_000
123
+ : unit.startsWith('m')
124
+ ? 60_000
123
125
  : unit.startsWith('d')
124
126
  ? 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;
127
+ : 604_800_000;
131
128
  return new Date(Date.now() - n * ms).toISOString().slice(0, 10);
132
129
  }
133
130
  if (/^yesterday$/i.test(raw.trim())) return new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
@@ -231,7 +228,7 @@ export function registerWebTools(pi: ExtensionAPI) {
231
228
  });
232
229
  const title = fmtSnippet(stripSiteSuffix(cleanSnippet(r.title), hostOf(r.url)), 100) || r.url;
233
230
  const snippet = fmtSnippet(cleanSnippet(r.snippet), snip);
234
- const via = r.engines.length > 1 ? ` ← ${r.engines.length} engines agree` : ` ← ${r.engines[0]}`;
231
+ const via = formatEvidence(r);
235
232
  const when = normalizeHitDate(r.date);
236
233
  text += `${i + 1}. [${title}](${r.url})${when ? ` — ${when}` : ''}${via}\n`;
237
234
  if (snippet) text += ` ${snippet}\n`;
@@ -252,6 +249,7 @@ export function registerWebTools(pi: ExtensionAPI) {
252
249
  url: r.url,
253
250
  title: r.title,
254
251
  engines: r.engines,
252
+ ranks: r.engineRanks,
255
253
  score: r.finalScore,
256
254
  })),
257
255
  },
@@ -286,6 +284,9 @@ export function registerWebTools(pi: ExtensionAPI) {
286
284
  },
287
285
 
288
286
  async execute(_id, params, signal) {
287
+ // Abort = throw: a stale signal must never START a fetch, and an abort
288
+ // mid-flight must never be masked by the stale-if-error reserve below.
289
+ if (signal?.aborted) throw new Error(ABORT_ERROR);
289
290
  const cfg = getConfig();
290
291
  const key = cacheKey(['fetch', 'v3', normalizeUrl(params.url)]);
291
292
  const ranked = lastSearch.urlRank.get(normalizeUrl(params.url));
@@ -313,6 +314,7 @@ export function registerWebTools(pi: ExtensionAPI) {
313
314
  signal,
314
315
  validators: stale?.value?.validators,
315
316
  });
317
+ if (signal?.aborted) throw new Error(ABORT_ERROR); // mid-flight abort: never a payload, never stale
316
318
  if (res.error === 'not-modified' && stale?.value) {
317
319
  cache.set(key, stale.value, CACHE_TTL_HOURS * 3600_000);
318
320
  return makeFetchResult(stale.value, params);
@@ -333,9 +335,10 @@ export function registerWebTools(pi: ExtensionAPI) {
333
335
  return makeFetchResult(res, params);
334
336
  },
335
337
  renderResult(result, options, theme) {
336
- const lines = resultText(result).split('\n');
338
+ const raw = italicizeIndices(resultText(result), theme);
339
+ const lines = raw.split('\n');
337
340
  if (!options.expanded) return new Text(previewWithHint(lines, theme), 0, 0);
338
- return new Text(colorizedResult(resultText(result), theme), 0, 0);
341
+ return new Text(colorizedResult(raw, theme), 0, 0);
339
342
  },
340
343
  });
341
344
  }
@@ -365,12 +368,16 @@ interface FetchResult {
365
368
  isError?: boolean;
366
369
  }
367
370
 
368
- function fmtSize(chars: number): string {
369
- return chars >= 1024 ? `${(chars / 1024).toFixed(1)} KB` : `${chars} chars`;
370
- }
371
-
372
371
  const PREVIEW_LINES = 10;
373
372
 
373
+ /** Display-only: the model-facing markers (`indices …`, `- *N*`) render as
374
+ * genuine ANSI italics in the TUI; the content text itself is untouched. */
375
+ function italicizeIndices(text: string, theme: Theme): string {
376
+ return text
377
+ .replace(/^(indices - 0 - \d+ sections?)$/gm, (m) => theme.italic(m))
378
+ .replace(/^- \*(\d+)\* /gm, (_, n: string) => `- ${theme.italic(n)} `);
379
+ }
380
+
374
381
  function colorizedResult(text: string, theme: Theme): string {
375
382
  return text
376
383
  .split('\n')
@@ -391,6 +398,18 @@ function colorizedResult(text: string, theme: Theme): string {
391
398
  .join('\n');
392
399
  }
393
400
 
401
+ /** Per-hit evidence line: "⇐ serper#1, exa#2"; singles are unverified. */
402
+ function formatEvidence(r: FusedHit): string {
403
+ if (!r.engineRanks?.length) return r.engines.length > 1 ? ` ← ${r.engines.length} engines` : '';
404
+ if (r.engineRanks.length === 1) return ` ⇐ ${r.engineRanks[0].engine} only (single-engine: unverified)`;
405
+ const shown = r.engineRanks
406
+ .slice(0, 6)
407
+ .map((e) => `${e.engine}#${e.rank}`)
408
+ .join(', ');
409
+ const rest = r.engineRanks.length > 6 ? `, +${r.engineRanks.length - 6} more` : '';
410
+ return ` ⇐ ${shown}${rest}`;
411
+ }
412
+
394
413
  function resultText(result: { content: { type: string; text?: string }[] }): string {
395
414
  return (result.content[0] as { type: string; text: string } | undefined)?.text ?? '';
396
415
  }
@@ -421,7 +440,9 @@ function fetchErrorResult(details: FetchDetails): FetchResult {
421
440
  function buildFetchResult(res: Awaited<ReturnType<typeof scrape>>, sections: number[]): FetchResult {
422
441
  const want = [...new Set(sections.map((i) => Math.max(0, Math.min(res.sections.length - 1, i))))];
423
442
  const chosen = want.map((i) => res.sections[i]).filter(Boolean);
424
- const body = res.outline.length ? res.outline.map((h) => `- ${h}`).join('\n') + '\n\n' : '';
443
+ const body = res.outline.length
444
+ ? `indices - 0 - ${res.outline.length - 1} section${res.outline.length === 1 ? '' : 's'}\n${res.outline.map((h, i) => `- *${i}* ${h}`).join('\n')}\n\n`
445
+ : '';
425
446
  const parts: string[] = [];
426
447
  want.forEach((idx) => {
427
448
  const s = res.sections[idx];
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
- const res = await fetch(url, {
248
- headers: {
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
- ...condHeaders,
253
- },
254
- redirect: 'follow',
255
- signal: ctrl.signal,
256
- });
257
- if (res.status === 304 && attempt === 0) {
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 = res.headers.get('content-type') ?? '';
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 (res.status === 429 && !retried429) {
294
+ if (final.status === 429 && !retried429) {
271
295
  retried429 = true;
272
- await waitAbortable(parseRetryAfter(res.headers.get('retry-after')), signal);
296
+ await waitAbortable(parseRetryAfter(final.headers.get('retry-after')), signal);
273
297
  attempt--;
274
298
  continue;
275
299
  }
276
- if (!res.ok) {
277
- const sample = await res.text().catch(() => '');
278
- if (isBotBlocked(res.status, sample) && attempt < USER_AGENTS.length - 1) continue;
279
- throw new Error(`HTTP ${res.status}`);
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(res, MAX_HTML_BYTES);
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: res.headers.get('etag') ?? undefined,
288
- lastModified: res.headers.get('last-modified') ?? undefined,
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
- const pairs = Array.from(u.searchParams.entries()).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
60
- const q = pairs.map(([k, v]) => `${k}=${v}`).join('&');
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
+ }