pi-wsearch 0.2.2 → 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 +10 -4
- package/package.json +1 -1
- 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 +29 -19
- package/src/transport.ts +51 -22
- package/src/urls.ts +104 -11
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
|
|
@@ -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
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
|
@@ -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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
:
|
|
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
|
|
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);
|
|
@@ -366,10 +368,6 @@ interface FetchResult {
|
|
|
366
368
|
isError?: boolean;
|
|
367
369
|
}
|
|
368
370
|
|
|
369
|
-
function fmtSize(chars: number): string {
|
|
370
|
-
return chars >= 1024 ? `${(chars / 1024).toFixed(1)} KB` : `${chars} chars`;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
371
|
const PREVIEW_LINES = 10;
|
|
374
372
|
|
|
375
373
|
/** Display-only: the model-facing markers (`indices …`, `- *N*`) render as
|
|
@@ -400,6 +398,18 @@ function colorizedResult(text: string, theme: Theme): string {
|
|
|
400
398
|
.join('\n');
|
|
401
399
|
}
|
|
402
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
|
+
|
|
403
413
|
function resultText(result: { content: { type: string; text?: string }[] }): string {
|
|
404
414
|
return (result.content[0] as { type: string; text: string } | undefined)?.text ?? '';
|
|
405
415
|
}
|
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
|
+
}
|