pi-webfind 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -2
- package/extensions/web-search.ts +162 -73
- package/lib/adapters.ts +295 -117
- package/lib/apis.ts +2 -0
- package/lib/cache.ts +42 -16
- package/lib/engine.ts +489 -91
- package/lib/extract.ts +377 -61
- package/lib/fetcher.ts +443 -217
- package/lib/net.ts +93 -0
- package/lib/rank.ts +89 -16
- package/lib/safe.ts +94 -0
- package/lib/version.ts +1 -1
- package/package.json +20 -16
- package/themes/claude-dark.json +80 -0
package/lib/engine.ts
CHANGED
|
@@ -12,9 +12,63 @@
|
|
|
12
12
|
* (JSON snapshot under ~/.pi/agent/cache/webfind, 10 min TTL).
|
|
13
13
|
*/
|
|
14
14
|
import { createDiskBackedCache } from "./cache.ts";
|
|
15
|
+
import { hostCooldownUntil, setHostCooldown, jinaAuth } from "./net.ts";
|
|
16
|
+
import { tokenize } from "./rank.ts";
|
|
17
|
+
|
|
18
|
+
// ------------------------------------------------------- anti-block hygiene
|
|
19
|
+
|
|
20
|
+
const USER_AGENTS = [
|
|
21
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
|
22
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
|
23
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",
|
|
24
|
+
"Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0",
|
|
25
|
+
];
|
|
26
|
+
const uaByHost = new Map<string, string>();
|
|
27
|
+
/** Sticky per-host UA: one pick per host for the process lifetime (consistent fingerprint). */
|
|
28
|
+
export function pickUA(host: string): string {
|
|
29
|
+
let ua = uaByHost.get(host);
|
|
30
|
+
if (!ua) {
|
|
31
|
+
ua = USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)]!;
|
|
32
|
+
uaByHost.set(host, ua);
|
|
33
|
+
}
|
|
34
|
+
return ua;
|
|
35
|
+
}
|
|
15
36
|
|
|
16
|
-
|
|
17
|
-
|
|
37
|
+
/** Name/value-only cookie jar (session-scoped preference cookies; no RFC 6265 semantics). */
|
|
38
|
+
const cookieJar = new Map<string, Map<string, string>>();
|
|
39
|
+
export const cookieHeaderFor = (host: string): string | undefined => {
|
|
40
|
+
const jar = cookieJar.get(host);
|
|
41
|
+
return jar && jar.size > 0 ? [...jar].map(([k, v]) => `${k}=${v}`).join("; ") : undefined;
|
|
42
|
+
};
|
|
43
|
+
export function storeCookies(host: string, res: Response): void {
|
|
44
|
+
const set = typeof res.headers.getSetCookie === "function" ? res.headers.getSetCookie() : [];
|
|
45
|
+
if (set.length === 0) return;
|
|
46
|
+
const jar = cookieJar.get(host) ?? new Map<string, string>();
|
|
47
|
+
for (const sc of set) {
|
|
48
|
+
const eq = sc.indexOf("=");
|
|
49
|
+
const semi = sc.indexOf(";");
|
|
50
|
+
if (eq > 0) jar.set(sc.slice(0, eq).trim(), sc.slice(eq + 1, semi > eq ? semi : undefined).trim());
|
|
51
|
+
}
|
|
52
|
+
cookieJar.set(host, jar);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function browserHeaders(host: string, opts: { acceptLanguage?: string; accept?: string } = {}): Record<string, string> {
|
|
56
|
+
const h: Record<string, string> = {
|
|
57
|
+
"User-Agent": pickUA(host),
|
|
58
|
+
Accept: opts.accept ?? "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
59
|
+
"Accept-Language": opts.acceptLanguage ?? "en-US,en;q=0.9",
|
|
60
|
+
"Sec-Fetch-Dest": "document",
|
|
61
|
+
"Sec-Fetch-Mode": "navigate",
|
|
62
|
+
"Sec-Fetch-Site": "none",
|
|
63
|
+
"Sec-Fetch-User": "?1",
|
|
64
|
+
"Upgrade-Insecure-Requests": "1",
|
|
65
|
+
};
|
|
66
|
+
const cookie = cookieHeaderFor(host);
|
|
67
|
+
if (cookie) h["Cookie"] = cookie;
|
|
68
|
+
return h;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const UA = USER_AGENTS[0]!;
|
|
18
72
|
// r.jina.ai blocks fake browser UAs but allows honest tool UAs (opposite of most sites)
|
|
19
73
|
import { TOOL_UA } from "./version.ts";
|
|
20
74
|
const TIMEOUT_MS = 15_000;
|
|
@@ -26,6 +80,18 @@ export interface SearchResult {
|
|
|
26
80
|
engine: string;
|
|
27
81
|
/** Publication date when the source surface provides one (ISO or human-readable). */
|
|
28
82
|
date?: string;
|
|
83
|
+
/** "indexed" = crawl stamp (e.g. Bing pubDate within 3 days of now), not a publication date */
|
|
84
|
+
dateKind?: "published" | "indexed";
|
|
85
|
+
/** every engine that returned this URL (set by fuse) */
|
|
86
|
+
engines?: string[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface SearchOutcome {
|
|
90
|
+
results: SearchResult[];
|
|
91
|
+
engines: string[];
|
|
92
|
+
errors: string[];
|
|
93
|
+
/** per engine: rows returned → rows that passed the relevance gate */
|
|
94
|
+
stats: Record<string, { got: number; kept: number }>;
|
|
29
95
|
}
|
|
30
96
|
|
|
31
97
|
// ---------------------------------------------------------------- utilities
|
|
@@ -82,7 +148,27 @@ function unwrapRedirect(href: string): string | null {
|
|
|
82
148
|
return null;
|
|
83
149
|
}
|
|
84
150
|
|
|
85
|
-
|
|
151
|
+
// ------------------------------------------------------- locale mapping
|
|
152
|
+
|
|
153
|
+
/** DDG `kl=` codes are country-language and don't follow one formula — table + fallback. */
|
|
154
|
+
const DDG_KL: Record<string, string> = {
|
|
155
|
+
"es-ES": "es-es", "de-DE": "de-de", "fr-FR": "fr-fr", "it-IT": "it-it", "pt-PT": "pt-pt",
|
|
156
|
+
"pt-BR": "br-pt", "ja-JP": "jp-jp", "ko-KR": "kr-kr", "ru-RU": "ru-ru", "nl-NL": "nl-nl",
|
|
157
|
+
"en-US": "us-en", "en-GB": "uk-en", "en-AU": "au-en", "en-CA": "ca-en",
|
|
158
|
+
};
|
|
159
|
+
export function toDdgKl(lang: string): string {
|
|
160
|
+
if (DDG_KL[lang]) return DDG_KL[lang];
|
|
161
|
+
const [language, region] = lang.split("-");
|
|
162
|
+
return region ? `${region.toLowerCase()}-${language.toLowerCase()}` : `${language}-${language}`; // approximation
|
|
163
|
+
}
|
|
164
|
+
export const toBraveCountry = (lang: string) => (lang.split("-")[1] ?? lang).toUpperCase();
|
|
165
|
+
export const toBingMkt = (lang: string): string => {
|
|
166
|
+
const [language, region] = lang.split("-");
|
|
167
|
+
return region ? `${language.toLowerCase()}-${region.toUpperCase()}` : lang;
|
|
168
|
+
};
|
|
169
|
+
export const toAcceptLanguage = (lang: string): string => `${lang},${lang.split("-")[0]};q=0.9,en;q=0.5`;
|
|
170
|
+
|
|
171
|
+
async function get(url: string, signal?: AbortSignal, post?: string, acceptLanguage?: string): Promise<string> {
|
|
86
172
|
const host = new URL(url).host;
|
|
87
173
|
await throttle(host, signal);
|
|
88
174
|
const timeout = AbortSignal.timeout(TIMEOUT_MS);
|
|
@@ -90,22 +176,31 @@ async function get(url: string, signal?: AbortSignal, post?: string): Promise<st
|
|
|
90
176
|
const res = await fetch(url, {
|
|
91
177
|
method: post ? "POST" : "GET",
|
|
92
178
|
headers: {
|
|
93
|
-
|
|
94
|
-
Accept: "text/html,application/xhtml+xml",
|
|
95
|
-
"Accept-Language": "en-US,en;q=0.9",
|
|
179
|
+
...browserHeaders(host, { acceptLanguage }),
|
|
96
180
|
...(post ? { "Content-Type": "application/x-www-form-urlencoded" } : {}),
|
|
97
181
|
},
|
|
98
182
|
body: post,
|
|
99
183
|
signal: combined,
|
|
100
184
|
redirect: "follow",
|
|
101
185
|
});
|
|
102
|
-
|
|
186
|
+
storeCookies(host, res);
|
|
187
|
+
if (!res.ok) {
|
|
188
|
+
// 202 = DDG's anomaly/challenge wall — the body IS the challenge page, hand it
|
|
189
|
+
// to the parser so it can throw the structured 'ddg challenge' error
|
|
190
|
+
if (res.status === 202 && (res.headers.get("content-type") ?? "").includes("html")) {
|
|
191
|
+
return res.text();
|
|
192
|
+
}
|
|
193
|
+
const err = new Error(`HTTP ${res.status}`) as Error & { status?: number };
|
|
194
|
+
err.status = res.status;
|
|
195
|
+
throw err;
|
|
196
|
+
}
|
|
103
197
|
return res.text();
|
|
104
198
|
}
|
|
105
199
|
|
|
106
200
|
// ------------------------------------------------------------------ engines
|
|
107
201
|
|
|
108
|
-
function parseDdgHtml(page: string): SearchResult[] {
|
|
202
|
+
export function parseDdgHtml(page: string): SearchResult[] { // exported for tests
|
|
203
|
+
if (/anomaly-modal|g-recaptcha/.test(page)) throw new Error("ddg challenge");
|
|
109
204
|
const out: SearchResult[] = [];
|
|
110
205
|
const snippets = [...page.matchAll(/<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g)].map(
|
|
111
206
|
(m) => stripTags(m[1]),
|
|
@@ -151,7 +246,16 @@ function parseBrave(page: string): SearchResult[] {
|
|
|
151
246
|
const url = unwrapRedirect(m[1]);
|
|
152
247
|
if (!url || seen.has(url) || /search\.brave\.com|brave\.com\/search/.test(url)) continue;
|
|
153
248
|
seen.add(url);
|
|
154
|
-
|
|
249
|
+
// title: inner element with class~="title" when present; else the last
|
|
250
|
+
// breadcrumb segment of the anchor text ("Site › Path › Page Title")
|
|
251
|
+
const inner = m[2];
|
|
252
|
+
const tm = inner.match(/<[^>]*class="[^"]*\btitle[^"]*"[^>]*>([\s\S]*?)<\/[^>]+>/);
|
|
253
|
+
const rawText = stripTags(inner);
|
|
254
|
+
const fallbackTitle = rawText.includes("›") ? rawText.split("›").pop()!.trim() : rawText;
|
|
255
|
+
const title = (tm ? stripTags(tm[1]) : fallbackTitle).slice(0, 120) || url;
|
|
256
|
+
// the rest of the anchor text after the title is the snippet
|
|
257
|
+
const snippet = (tm ? rawText.replace(stripTags(tm[1]), "").trim() : "").slice(0, 200);
|
|
258
|
+
out.push({ title, url, snippet, engine: "brave" });
|
|
155
259
|
}
|
|
156
260
|
// Fallback: any external anchor with heading-like content
|
|
157
261
|
if (out.length === 0) {
|
|
@@ -166,7 +270,7 @@ function parseBrave(page: string): SearchResult[] {
|
|
|
166
270
|
)
|
|
167
271
|
continue;
|
|
168
272
|
seen.add(url);
|
|
169
|
-
out.push({ title, url, snippet: "", engine: "brave" });
|
|
273
|
+
out.push({ title: title.slice(0, 120), url, snippet: "", engine: "brave" });
|
|
170
274
|
}
|
|
171
275
|
}
|
|
172
276
|
return out;
|
|
@@ -182,7 +286,7 @@ export type Recency = "d" | "w" | "m" | "y" | undefined;
|
|
|
182
286
|
* and can relay search-engine HTML when our direct requests get walled.
|
|
183
287
|
* Rate limit (keyless): ~20 req/min per IP — only used as fallback.
|
|
184
288
|
*/
|
|
185
|
-
const JINA_RATE_MS = 3_500;
|
|
289
|
+
const JINA_RATE_MS = process.env.JINA_API_KEY ? 300 : 3_500; // key: ~20 → ~200 rpm
|
|
186
290
|
let lastJina = 0;
|
|
187
291
|
|
|
188
292
|
async function jinaGet(url: string, signal?: AbortSignal): Promise<string> {
|
|
@@ -192,81 +296,125 @@ async function jinaGet(url: string, signal?: AbortSignal): Promise<string> {
|
|
|
192
296
|
const timeout = AbortSignal.timeout(30_000);
|
|
193
297
|
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
194
298
|
const res = await fetch(`https://r.jina.ai/${url}`, {
|
|
195
|
-
headers: { "User-Agent": TOOL_UA, Accept: "text/plain" },
|
|
299
|
+
headers: { "User-Agent": TOOL_UA, Accept: "text/plain", ...jinaAuth() },
|
|
196
300
|
signal: combined,
|
|
197
301
|
});
|
|
198
302
|
if (!res.ok) throw new Error(`jina HTTP ${res.status}`);
|
|
199
303
|
return res.text();
|
|
200
304
|
}
|
|
201
305
|
|
|
306
|
+
/** Bare domain/path echo of the URL (favicon-adjacent link in relayed DDG), not prose. */
|
|
307
|
+
function isUrlEcho(s: string): boolean {
|
|
308
|
+
return /^(?:[a-z0-9-]+\.)+[a-z]{2,6}(?:\/\S*)?$/i.test(s);
|
|
309
|
+
}
|
|
310
|
+
|
|
202
311
|
/** Parse r.jina.ai's markdown output of a DDG html/lite page into results. */
|
|
203
|
-
function parseJinaDdg(md: string): SearchResult[] {
|
|
312
|
+
export function parseJinaDdg(md: string): SearchResult[] { // exported for tests
|
|
204
313
|
const out: SearchResult[] = [];
|
|
205
|
-
const seen = new
|
|
314
|
+
const seen = new Map<string, SearchResult>();
|
|
206
315
|
// markdown links: [title](https://duckduckgo.com/l/?uddg=ENCODED ...) or direct links
|
|
207
|
-
for (const m of md.matchAll(/\[([^\]]{4,
|
|
208
|
-
const
|
|
316
|
+
for (const m of md.matchAll(/\[([^\]]{4,400})\]\((https?:\/\/[^)]+)\)/g)) {
|
|
317
|
+
const text = decodeEntities(m[1].replace(/[*_`]/g, "")).trim();
|
|
209
318
|
const url = unwrapRedirect(m[2]);
|
|
210
|
-
if (!url
|
|
319
|
+
if (!url) continue;
|
|
211
320
|
if (/duckduckgo\.com(?!\/l\/)|\/y\.js|bing\.com|\.svg/.test(url)) continue;
|
|
212
|
-
seen.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
321
|
+
const row = seen.get(url);
|
|
322
|
+
if (!row) {
|
|
323
|
+
// date trails the URL line on html variant ("… 2026-08-20T00:00:00.0000000")
|
|
324
|
+
const tail = md.slice(m.index, m.index + 1200);
|
|
325
|
+
const d = tail.match(/\b(20[12]\d-\d{2}-\d{2})(?:T[0-9:.]+)?/);
|
|
326
|
+
const date = d ? d[1] : undefined;
|
|
327
|
+
const r: SearchResult = { title: text, url, snippet: "", engine: "ddg-jina", ...(date ? { date } : {}) };
|
|
328
|
+
seen.set(url, r);
|
|
329
|
+
out.push(r);
|
|
330
|
+
} else if (row.snippet === "" && !isUrlEcho(text)) {
|
|
331
|
+
// the result__snippet anchor renders as a second [text](same uddg url) link
|
|
332
|
+
// right after the title link — jina drops the class info, we recover it here.
|
|
333
|
+
// (a bare domain/path echo of the URL is the favicon-adjacent link, not prose)
|
|
334
|
+
row.snippet = text.slice(0, 300);
|
|
335
|
+
}
|
|
218
336
|
}
|
|
219
337
|
return out;
|
|
220
338
|
}
|
|
221
339
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
maxResults: number,
|
|
225
|
-
recency: Recency,
|
|
226
|
-
signal?: AbortSignal,
|
|
227
|
-
): Promise<SearchResult[]> {
|
|
340
|
+
/** DDG without the jina relay: html GET → lite GET → html POST. */
|
|
341
|
+
async function ddgDirect(query: string, maxResults: number, recency: Recency, signal?: AbortSignal, lang?: string): Promise<SearchResult[]> {
|
|
228
342
|
const params = new URLSearchParams({ q: query });
|
|
229
343
|
if (recency) params.set("df", recency);
|
|
344
|
+
if (lang) params.set("kl", toDdgKl(lang));
|
|
345
|
+
const al = lang ? toAcceptLanguage(lang) : undefined;
|
|
230
346
|
const enc = params.toString();
|
|
231
|
-
let directFailed = false;
|
|
232
347
|
|
|
233
|
-
//
|
|
348
|
+
// 1. html GET — a challenge wall (202/403) means this IP is flagged: the POST
|
|
349
|
+
// retry below would only work around a *soft* block, and lite/POST serve the
|
|
350
|
+
// same wall from the same IP, so give up at once and let jina's IP pool try.
|
|
351
|
+
let softEmpty = false;
|
|
234
352
|
try {
|
|
235
|
-
const
|
|
353
|
+
const page = await get(`https://html.duckduckgo.com/html/?${enc}`, signal, undefined, al);
|
|
354
|
+
const results = parseDdgHtml(page);
|
|
236
355
|
if (results.length > 0) return results.slice(0, maxResults);
|
|
237
|
-
|
|
238
|
-
|
|
356
|
+
softEmpty = true; // 200 but zero rows — bot-wall serving empty markup
|
|
357
|
+
} catch (err) {
|
|
358
|
+
const msg = String((err as Error)?.message ?? err);
|
|
359
|
+
const st = (err as Error & { status?: number }).status;
|
|
360
|
+
if (msg === "ddg challenge" || st === 202 || st === 403) {
|
|
361
|
+
throw new Error("ddg challenge (rate-limited; jina relay may still work)");
|
|
362
|
+
}
|
|
239
363
|
}
|
|
240
364
|
|
|
241
|
-
//
|
|
365
|
+
// 2. lite GET (cheap retry on transient network errors — a challenge wall never
|
|
366
|
+
// reaches this point; that case threw above)
|
|
242
367
|
try {
|
|
243
|
-
const results =
|
|
368
|
+
const results = parseDdgLite(await get(`https://lite.duckduckgo.com/lite/?${enc}`, signal, undefined, al));
|
|
244
369
|
if (results.length > 0) return results.slice(0, maxResults);
|
|
245
|
-
directFailed = true;
|
|
246
370
|
} catch {
|
|
247
|
-
|
|
371
|
+
/* try next */
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// 3. html POST (often bypasses soft GET blocks)
|
|
375
|
+
if (softEmpty) {
|
|
376
|
+
try {
|
|
377
|
+
const results = parseDdgHtml(await get("https://html.duckduckgo.com/html/", signal, enc, al));
|
|
378
|
+
if (results.length > 0) return results.slice(0, maxResults);
|
|
379
|
+
} catch {
|
|
380
|
+
/* fall to jina */
|
|
381
|
+
}
|
|
248
382
|
}
|
|
383
|
+
throw new Error("no results from duckduckgo (direct)");
|
|
384
|
+
}
|
|
249
385
|
|
|
250
|
-
|
|
386
|
+
/** DDG via the jina relay (different IP pool): html first, then lite. */
|
|
387
|
+
async function ddgJina(query: string, maxResults: number, recency: Recency, signal?: AbortSignal, lang?: string): Promise<SearchResult[]> {
|
|
388
|
+
const params = new URLSearchParams({ q: query });
|
|
389
|
+
if (recency) params.set("df", recency);
|
|
390
|
+
if (lang) params.set("kl", toDdgKl(lang)); // baked into the relayed URL
|
|
391
|
+
const enc = params.toString();
|
|
251
392
|
try {
|
|
252
|
-
const results =
|
|
393
|
+
const results = parseJinaDdg(await jinaGet(`https://html.duckduckgo.com/html/?${enc}`, signal));
|
|
253
394
|
if (results.length > 0) return results.slice(0, maxResults);
|
|
254
|
-
} catch {
|
|
255
|
-
|
|
395
|
+
} catch (err) {
|
|
396
|
+
// the relay itself failed (rate limit / network) — the lite relay hits the
|
|
397
|
+
// same r.jina.ai host and will fail the same way; don't pay the jina gap twice
|
|
398
|
+
throw new Error(`no results from duckduckgo (jina relay: ${(err as Error)?.message ?? err})`);
|
|
256
399
|
}
|
|
400
|
+
// html relay answered but zero rows parsed (challenge relayed, or genuinely empty).
|
|
401
|
+
// A second relayed request pays the 3.5s jina gap and relays the same challenge — stop here.
|
|
402
|
+
throw new Error("no results from duckduckgo (jina relay)");
|
|
403
|
+
}
|
|
257
404
|
|
|
258
|
-
|
|
405
|
+
/** DDG with the jina relay as fallback — kept for `engine:'ddg'`. */
|
|
406
|
+
export async function ddgSearch(
|
|
407
|
+
query: string,
|
|
408
|
+
maxResults: number,
|
|
409
|
+
recency: Recency,
|
|
410
|
+
signal?: AbortSignal,
|
|
411
|
+
lang?: string,
|
|
412
|
+
): Promise<SearchResult[]> {
|
|
259
413
|
try {
|
|
260
|
-
|
|
261
|
-
if (results.length > 0) return results.slice(0, maxResults);
|
|
414
|
+
return await ddgDirect(query, maxResults, recency, signal, lang);
|
|
262
415
|
} catch {
|
|
263
|
-
|
|
416
|
+
return ddgJina(query, maxResults, recency, signal, lang);
|
|
264
417
|
}
|
|
265
|
-
|
|
266
|
-
// 4. jina relay of lite endpoint (last resort for search)
|
|
267
|
-
const results = parseJinaDdg(await jinaGet(`https://lite.duckduckgo.com/lite/?${enc}`, signal));
|
|
268
|
-
if (results.length === 0) throw new Error("no results from duckduckgo (direct + jina proxy)");
|
|
269
|
-
return results.slice(0, maxResults);
|
|
270
418
|
}
|
|
271
419
|
|
|
272
420
|
export async function braveSearch(
|
|
@@ -274,13 +422,27 @@ export async function braveSearch(
|
|
|
274
422
|
maxResults: number,
|
|
275
423
|
recency: Recency,
|
|
276
424
|
signal?: AbortSignal,
|
|
425
|
+
lang?: string,
|
|
277
426
|
): Promise<SearchResult[]> {
|
|
427
|
+
const cooldown = hostCooldownUntil("search.brave.com");
|
|
428
|
+
if (cooldown > Date.now()) {
|
|
429
|
+
throw new Error(`brave: cooling down (${Math.ceil((cooldown - Date.now()) / 1000)}s)`);
|
|
430
|
+
}
|
|
278
431
|
const params = new URLSearchParams({ q: query });
|
|
279
432
|
if (recency) {
|
|
280
433
|
const map: Record<string, string> = { d: "pd", w: "pw", m: "pm", y: "py" };
|
|
281
434
|
params.set("tf", map[recency]);
|
|
282
435
|
}
|
|
283
|
-
|
|
436
|
+
if (lang) params.set("country", toBraveCountry(lang));
|
|
437
|
+
let page: string;
|
|
438
|
+
try {
|
|
439
|
+
page = await get(`https://search.brave.com/search?${params}`, signal);
|
|
440
|
+
} catch (err) {
|
|
441
|
+
const msg = String((err as Error)?.message ?? err);
|
|
442
|
+
if (msg === "HTTP 429") setHostCooldown("search.brave.com", 60_000);
|
|
443
|
+
throw err;
|
|
444
|
+
}
|
|
445
|
+
const results = parseBrave(page);
|
|
284
446
|
if (results.length === 0) throw new Error("no results from brave");
|
|
285
447
|
return results.slice(0, maxResults);
|
|
286
448
|
}
|
|
@@ -290,16 +452,21 @@ function parseRssItems(xml: string, engine: string): SearchResult[] {
|
|
|
290
452
|
for (const m of xml.matchAll(/<item>([\s\S]*?)<\/item>/g)) {
|
|
291
453
|
const item = m[1];
|
|
292
454
|
const title = stripTags(item.match(/<title>([\s\S]*?)<\/title>/)?.[1] ?? "");
|
|
293
|
-
const url = (item.match(/<link>([\s\S]*?)<\/link>/)?.[1] ?? "").trim();
|
|
455
|
+
const url = decodeEntities(item.match(/<link>([\s\S]*?)<\/link>/)?.[1] ?? "").trim();
|
|
294
456
|
const snippet = stripTags(item.match(/<description>([\s\S]*?)<\/description>/)?.[1] ?? "");
|
|
295
457
|
if (!title || !/^https?:\/\//.test(url)) continue;
|
|
296
458
|
const pubRaw = (item.match(/<pubDate>([\s\S]*?)<\/pubDate>/)?.[1] ?? "").trim();
|
|
297
459
|
let date: string | undefined;
|
|
460
|
+
let dateKind: SearchResult["dateKind"];
|
|
298
461
|
if (pubRaw) {
|
|
299
462
|
const ts = Date.parse(pubRaw);
|
|
300
|
-
if (!Number.isNaN(ts))
|
|
463
|
+
if (!Number.isNaN(ts)) {
|
|
464
|
+
date = new Date(ts).toISOString().slice(0, 10);
|
|
465
|
+
// a pubDate within 3 days of now is a crawl stamp, not a publication date
|
|
466
|
+
dateKind = Date.now() - ts < 3 * 86_400_000 ? "indexed" : "published";
|
|
467
|
+
}
|
|
301
468
|
}
|
|
302
|
-
out.push({ title, url, snippet, engine, ...(date ? { date } : {}) });
|
|
469
|
+
out.push({ title, url, snippet, engine, ...(date ? { date, dateKind } : {}) });
|
|
303
470
|
}
|
|
304
471
|
return out;
|
|
305
472
|
}
|
|
@@ -310,13 +477,14 @@ export async function bingRssSearch(
|
|
|
310
477
|
maxResults: number,
|
|
311
478
|
recency: Recency,
|
|
312
479
|
signal?: AbortSignal,
|
|
480
|
+
lang?: string,
|
|
313
481
|
): Promise<SearchResult[]> {
|
|
314
482
|
const params = new URLSearchParams({
|
|
315
483
|
q: query,
|
|
316
484
|
format: "rss",
|
|
317
485
|
count: String(Math.max(maxResults, 15)),
|
|
318
|
-
setmkt: "en-US",
|
|
319
|
-
setlang: "en",
|
|
486
|
+
setmkt: lang ? toBingMkt(lang) : "en-US",
|
|
487
|
+
setlang: lang ? (lang.split("-")[0] ?? "en") : "en",
|
|
320
488
|
});
|
|
321
489
|
if (recency) params.set("qdr", recency); // bing supports freshness via qdr on html; harmless on rss
|
|
322
490
|
const xml = await get(`https://www.bing.com/search?${params}`, signal);
|
|
@@ -325,48 +493,271 @@ export async function bingRssSearch(
|
|
|
325
493
|
return results.slice(0, maxResults);
|
|
326
494
|
}
|
|
327
495
|
|
|
328
|
-
|
|
496
|
+
// ---------------------------------------------------------------- relevance
|
|
497
|
+
|
|
498
|
+
const STOP_TOKENS = new Set([
|
|
499
|
+
"how", "to", "in", "vs", "the", "a", "an", "of", "for", "and", "or", "is", "what", "with", "on", "does", "do",
|
|
500
|
+
]);
|
|
501
|
+
|
|
502
|
+
/** Query tokens used by the relevance gate (deduped, stopwords + ≤2-char words removed). */
|
|
503
|
+
function gateTokens(query: string): string[] {
|
|
504
|
+
return [...new Set(tokenize(query))].filter((t) => t.length > 2 && !STOP_TOKENS.has(t));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Does `doc` contain a hit for query token `q`? Exact or prefix match when either side is ≥ 5 chars. */
|
|
508
|
+
function tokenHit(q: string, docTokens: Set<string>): boolean {
|
|
509
|
+
if (docTokens.has(q)) return true;
|
|
510
|
+
if (q.length >= 5) {
|
|
511
|
+
for (const d of docTokens) if (d.startsWith(q) || (d.length >= 5 && q.startsWith(d))) return true;
|
|
512
|
+
}
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Drop rows that share almost no vocabulary with the query. Runs before fusion
|
|
518
|
+
* on every engine's bucket (including single-engine modes). Non-Latin queries
|
|
519
|
+
* (tokenizer yields nothing) pass everything through.
|
|
520
|
+
*/
|
|
521
|
+
export function relevanceGate(query: string, rows: SearchResult[]): SearchResult[] {
|
|
522
|
+
const q = gateTokens(query);
|
|
523
|
+
if (q.length === 0) return rows;
|
|
524
|
+
const minHits = Math.min(2, q.length);
|
|
525
|
+
return rows.filter((r) => {
|
|
526
|
+
const path = (() => {
|
|
527
|
+
try {
|
|
528
|
+
return decodeURIComponent(new URL(r.url).pathname).replace(/[-_/.]+/g, " ");
|
|
529
|
+
} catch {
|
|
530
|
+
return r.url;
|
|
531
|
+
}
|
|
532
|
+
})();
|
|
533
|
+
const docTokens = new Set(tokenize(`${r.title} ${r.snippet} ${path}`));
|
|
534
|
+
let hits = 0;
|
|
535
|
+
for (const t of q) if (tokenHit(t, docTokens)) hits++;
|
|
536
|
+
return hits >= minHits;
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// --------------------------------------------------------------- url normalize
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Canonical key for cross-engine URL dedupe: scheme/hash stripped, tracking
|
|
544
|
+
* params dropped, www/mobile/amp hosts and paths collapsed, SO question slugs
|
|
545
|
+
* trimmed, params sorted.
|
|
546
|
+
*/
|
|
547
|
+
export function normalizeUrl(u: string): string {
|
|
548
|
+
let url: URL;
|
|
549
|
+
try {
|
|
550
|
+
url = new URL(u);
|
|
551
|
+
} catch {
|
|
552
|
+
return u.trim().toLowerCase();
|
|
553
|
+
}
|
|
554
|
+
const host = url.hostname
|
|
555
|
+
.toLowerCase()
|
|
556
|
+
.replace(/^(www|m|amp|mobile)\./, "")
|
|
557
|
+
.replace(/^([a-z]{2,3})\.m\./, "$1.");
|
|
558
|
+
let path = url.pathname.replace(/\/+$/, "").replace(/^\/amp(?=\/)/, "").replace(/\/amp$/, "");
|
|
559
|
+
// /index.{html,htm,php} and hashbang paths fold to the directory
|
|
560
|
+
if (url.hash.startsWith("#!/")) path = url.hash.slice(2);
|
|
561
|
+
path = path.replace(/\/index\.(html?|php)$/i, "");
|
|
562
|
+
if (/(^|\.)(stackoverflow|superuser|serverfault)\.com$|\.stackexchange\.com$/.test(host)) {
|
|
563
|
+
path = path.replace(/^(\/questions\/\d+)\/.*/, "$1");
|
|
564
|
+
}
|
|
565
|
+
const params = [...url.searchParams]
|
|
566
|
+
.filter(([k]) => !/^(utm_\w+|fbclid|gclid|ref|ref_src|si)$/i.test(k))
|
|
567
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
568
|
+
return `${host}${path || "/"}${params.length ? "?" + params.map(([k, v]) => `${k}=${v}`).join("&") : ""}`;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ------------------------------------------------------------------- fusion
|
|
572
|
+
|
|
573
|
+
const WEIGHT: Record<string, number> = { ddg: 1, "ddg-lite": 1, "ddg-jina": 0.9, brave: 1, bing: 0.5 };
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Reciprocal-rank fusion across engine buckets (k=60, per-engine weights),
|
|
577
|
+
* field-merging rows that share a normalized URL: longest snippet, shortest
|
|
578
|
+
* meaningful title, earliest non-crawl date, union of engine names.
|
|
579
|
+
*/
|
|
580
|
+
export function fuse(buckets: Array<{ name: string; rows: SearchResult[] }>, maxResults: number): SearchResult[] { // exported for tests
|
|
581
|
+
const K = 60;
|
|
582
|
+
const scored = new Map<string, { r: SearchResult; s: number; engines: Set<string> }>();
|
|
583
|
+
for (const { name, rows } of buckets) {
|
|
584
|
+
rows.forEach((r, i) => {
|
|
585
|
+
const key = normalizeUrl(r.url);
|
|
586
|
+
const cur = scored.get(key) ?? { r: { ...r }, s: 0, engines: new Set<string>() };
|
|
587
|
+
cur.s += (WEIGHT[name] ?? 1) / (K + i + 1);
|
|
588
|
+
cur.engines.add(name);
|
|
589
|
+
if (r.snippet.length > cur.r.snippet.length) cur.r.snippet = r.snippet;
|
|
590
|
+
if (r.title.length >= 8 && r.title.length < cur.r.title.length) cur.r.title = r.title;
|
|
591
|
+
if (r.date && r.dateKind !== "indexed" && (!cur.r.date || cur.r.dateKind === "indexed" || r.date < cur.r.date)) {
|
|
592
|
+
cur.r.date = r.date;
|
|
593
|
+
cur.r.dateKind = "published";
|
|
594
|
+
}
|
|
595
|
+
scored.set(key, cur);
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
return [...scored.values()]
|
|
599
|
+
.sort((a, b) => b.s - a.s)
|
|
600
|
+
.slice(0, maxResults)
|
|
601
|
+
.map((e) => ({ ...e.r, engines: [...e.engines] }));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// --------------------------------------------------------------------- race
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Default engine: DDG-direct and Bing RSS in parallel, gated, RRF-fused.
|
|
608
|
+
* Fast path returns as soon as one bucket has ≥5 kept rows (300 ms grace for
|
|
609
|
+
* the other); Brave (cooldown-aware) then jina-relayed DDG only when both
|
|
610
|
+
* primary legs came up empty.
|
|
611
|
+
*/
|
|
612
|
+
export async function searchRace(
|
|
613
|
+
query: string,
|
|
614
|
+
maxResults: number,
|
|
615
|
+
recency: Recency,
|
|
616
|
+
signal?: AbortSignal,
|
|
617
|
+
lang?: string,
|
|
618
|
+
): Promise<SearchOutcome> {
|
|
619
|
+
const stats: SearchOutcome["stats"] = {};
|
|
620
|
+
const errors: string[] = [];
|
|
621
|
+
const buckets: Array<{ name: string; rows: SearchResult[] }> = [];
|
|
622
|
+
const leg = (name: string, p: Promise<SearchResult[]>) =>
|
|
623
|
+
p.then(
|
|
624
|
+
(rows) => {
|
|
625
|
+
const kept = relevanceGate(query, rows);
|
|
626
|
+
stats[name] = { got: rows.length, kept: kept.length };
|
|
627
|
+
if (kept.length) buckets.push({ name, rows: kept });
|
|
628
|
+
},
|
|
629
|
+
(e) => {
|
|
630
|
+
errors.push(`${name}: ${(e as Error)?.message ?? e}`);
|
|
631
|
+
},
|
|
632
|
+
);
|
|
633
|
+
const ddg = leg("ddg", ddgDirect(query, 15, recency, signal, lang));
|
|
634
|
+
const bing = leg("bing", bingRssSearch(query, 15, recency, signal, lang));
|
|
635
|
+
await Promise.race([ddg, bing]);
|
|
636
|
+
if (buckets.some((b) => b.rows.length >= 5)) await Promise.race([Promise.all([ddg, bing]), sleep(300, signal)]);
|
|
637
|
+
else await Promise.all([ddg, bing]);
|
|
638
|
+
const challenged = errors.some((e) => e.startsWith("ddg: ddg challenge"));
|
|
639
|
+
if (buckets.length === 0 && !challenged && hostCooldownUntil("search.brave.com") <= Date.now()) {
|
|
640
|
+
await leg("brave", braveSearch(query, 15, recency, signal, lang));
|
|
641
|
+
}
|
|
642
|
+
if (buckets.length === 0) await leg("ddg-jina", ddgJina(query, 15, recency, signal, lang));
|
|
643
|
+
return { results: fuse(buckets, maxResults), engines: buckets.map((b) => b.name), errors, stats };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ------------------------------------------------- optional accelerator keys
|
|
647
|
+
|
|
648
|
+
/** Brave's official API (JSON) — free tier with BRAVE_API_KEY; throws when unset. */
|
|
649
|
+
export async function braveApiSearch(
|
|
650
|
+
query: string,
|
|
651
|
+
maxResults: number,
|
|
652
|
+
recency: Recency,
|
|
653
|
+
signal?: AbortSignal,
|
|
654
|
+
lang?: string,
|
|
655
|
+
): Promise<SearchResult[]> {
|
|
656
|
+
const key = process.env.BRAVE_API_KEY;
|
|
657
|
+
if (!key) throw new Error("BRAVE_API_KEY not set");
|
|
658
|
+
const params = new URLSearchParams({ q: query, count: String(Math.min(maxResults, 20)) });
|
|
659
|
+
if (recency) {
|
|
660
|
+
const map: Record<string, string> = { d: "pd", w: "pw", m: "pm", y: "py" };
|
|
661
|
+
params.set("tf", map[recency]!);
|
|
662
|
+
}
|
|
663
|
+
if (lang) params.set("country", toBraveCountry(lang));
|
|
664
|
+
const res = await fetch(`https://api.search.brave.com/res/v1/web/search?${params}`, {
|
|
665
|
+
headers: { Accept: "application/json", "X-Subscription-Token": key },
|
|
666
|
+
signal: AbortSignal.any(signal ? [signal, AbortSignal.timeout(TIMEOUT_MS)] : [AbortSignal.timeout(TIMEOUT_MS)]),
|
|
667
|
+
});
|
|
668
|
+
if (!res.ok) throw new Error(`brave api HTTP ${res.status}`);
|
|
669
|
+
const data = (await res.json()) as { web?: { results?: Array<{ title: string; url: string; description?: string; age?: string }> } };
|
|
670
|
+
return (data.web?.results ?? []).slice(0, maxResults).map((r) => ({
|
|
671
|
+
title: decodeEntities(r.title), url: r.url, snippet: decodeEntities(r.description ?? ""), engine: "brave-api",
|
|
672
|
+
}));
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** Tavily search API — free tier with TAVILY_API_KEY; throws when unset. */
|
|
676
|
+
export async function tavilySearch(
|
|
677
|
+
query: string,
|
|
678
|
+
maxResults: number,
|
|
679
|
+
recency: Recency,
|
|
680
|
+
signal?: AbortSignal,
|
|
681
|
+
): Promise<SearchResult[]> {
|
|
682
|
+
const key = process.env.TAVILY_API_KEY;
|
|
683
|
+
if (!key) throw new Error("TAVILY_API_KEY not set");
|
|
684
|
+
const body: Record<string, unknown> = { query, max_results: Math.min(maxResults, 20) };
|
|
685
|
+
if (recency) body.topic = recency === "d" ? "news" : "general";
|
|
686
|
+
const res = await fetch("https://api.tavily.com/search", {
|
|
687
|
+
method: "POST",
|
|
688
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
|
|
689
|
+
body: JSON.stringify(body),
|
|
690
|
+
signal: AbortSignal.any(signal ? [signal, AbortSignal.timeout(TIMEOUT_MS)] : [AbortSignal.timeout(TIMEOUT_MS)]),
|
|
691
|
+
});
|
|
692
|
+
if (!res.ok) throw new Error(`tavily HTTP ${res.status}`);
|
|
693
|
+
const data = (await res.json()) as { results?: Array<{ title: string; url: string; content?: string }> };
|
|
694
|
+
return (data.results ?? []).slice(0, maxResults).map((r) => ({
|
|
695
|
+
title: decodeEntities(r.title), url: r.url, snippet: decodeEntities(r.content ?? ""), engine: "tavily",
|
|
696
|
+
}));
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Jina reader-based search API — free tier with JINA_API_KEY; throws when unset. */
|
|
700
|
+
export async function jinaSearchApi(
|
|
701
|
+
query: string,
|
|
702
|
+
maxResults: number,
|
|
703
|
+
signal?: AbortSignal,
|
|
704
|
+
): Promise<SearchResult[]> {
|
|
705
|
+
const key = process.env.JINA_API_KEY;
|
|
706
|
+
if (!key) throw new Error("JINA_API_KEY not set");
|
|
707
|
+
const res = await fetch("https://s.jina.ai/", {
|
|
708
|
+
method: "POST",
|
|
709
|
+
headers: {
|
|
710
|
+
"Content-Type": "application/json",
|
|
711
|
+
Accept: "application/json",
|
|
712
|
+
...jinaAuth(),
|
|
713
|
+
},
|
|
714
|
+
body: JSON.stringify({ query: [query] }),
|
|
715
|
+
signal: AbortSignal.any(signal ? [signal, AbortSignal.timeout(30_000)] : [AbortSignal.timeout(30_000)]),
|
|
716
|
+
});
|
|
717
|
+
if (!res.ok) throw new Error(`jina search HTTP ${res.status}`);
|
|
718
|
+
const data = (await res.json()) as { data?: Array<{ title?: string; url: string; description?: string; content?: string }> };
|
|
719
|
+
return (data.data ?? []).slice(0, maxResults).map((r) => ({
|
|
720
|
+
title: decodeEntities(r.title ?? r.url), url: r.url,
|
|
721
|
+
snippet: decodeEntities((r.description ?? r.content ?? "").slice(0, 300)), engine: "jina-search",
|
|
722
|
+
}));
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** Run several engines in parallel; gate, RRF-fuse by normalized URL, merge fields. */
|
|
329
726
|
export async function multiSearch(
|
|
330
727
|
query: string,
|
|
331
728
|
maxResults: number,
|
|
332
729
|
recency: Recency,
|
|
333
730
|
signal?: AbortSignal,
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
731
|
+
lang?: string,
|
|
732
|
+
): Promise<SearchOutcome> {
|
|
733
|
+
const attempts: Array<{ name: string; fn: () => Promise<SearchResult[]> }> = [];
|
|
734
|
+
if (process.env.TAVILY_API_KEY) attempts.push({ name: "tavily", fn: () => tavilySearch(query, 15, recency, signal) });
|
|
735
|
+
if (process.env.JINA_API_KEY) attempts.push({ name: "jina-search", fn: () => jinaSearchApi(query, 15, signal) });
|
|
736
|
+
if (process.env.BRAVE_API_KEY) attempts.push({ name: "brave-api", fn: () => braveApiSearch(query, 15, recency, signal, lang) });
|
|
737
|
+
attempts.push(
|
|
738
|
+
{ name: "ddg", fn: () => ddgSearch(query, 15, recency, signal, lang) },
|
|
739
|
+
{ name: "brave", fn: () => braveSearch(query, 15, recency, signal, lang) },
|
|
740
|
+
{ name: "bing", fn: () => bingRssSearch(query, 15, recency, signal, lang) },
|
|
741
|
+
);
|
|
340
742
|
const settled = await Promise.allSettled(attempts.map((a) => a.fn()));
|
|
341
743
|
const namedBuckets: Array<{ name: string; rows: SearchResult[] }> = [];
|
|
342
744
|
const engines: string[] = [];
|
|
343
745
|
const errors: string[] = [];
|
|
746
|
+
const stats: SearchOutcome["stats"] = {};
|
|
344
747
|
settled.forEach((r, i) => {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
748
|
+
const name = attempts[i]!.name;
|
|
749
|
+
if (r.status === "fulfilled") {
|
|
750
|
+
const kept = relevanceGate(query, r.value);
|
|
751
|
+
stats[name] = { got: r.value.length, kept: kept.length };
|
|
752
|
+
if (kept.length > 0) {
|
|
753
|
+
namedBuckets.push({ name, rows: kept });
|
|
754
|
+
engines.push(name);
|
|
755
|
+
}
|
|
756
|
+
} else {
|
|
757
|
+
errors.push(`${name}: ${(r.reason as Error)?.message ?? r.reason}`);
|
|
350
758
|
}
|
|
351
759
|
});
|
|
352
|
-
|
|
353
|
-
const K = 60;
|
|
354
|
-
const norm = (u: string) => u.replace(/\/+$/, "").replace(/^https?:\/\/www\./, "http://");
|
|
355
|
-
const scored = new Map<string, { r: SearchResult; s: number; engines: Set<string> }>();
|
|
356
|
-
for (const bucket of namedBuckets) {
|
|
357
|
-
bucket.rows.forEach((r, i) => {
|
|
358
|
-
const key = norm(r.url);
|
|
359
|
-
const e = scored.get(key) ?? { r, s: 0, engines: new Set<string>() };
|
|
360
|
-
e.s += 1 / (K + i + 1);
|
|
361
|
-
e.engines.add(bucket.name);
|
|
362
|
-
scored.set(key, e);
|
|
363
|
-
});
|
|
364
|
-
}
|
|
365
|
-
const merged = [...scored.values()]
|
|
366
|
-
.sort((a, b) => b.s - a.s)
|
|
367
|
-
.slice(0, maxResults)
|
|
368
|
-
.map((e) => ({ ...e.r, engines: [...e.engines] }) as SearchResult & { engines?: string[] });
|
|
369
|
-
if (merged.length > 0) return { results: merged, engines, errors };
|
|
760
|
+
return { results: fuse(namedBuckets, maxResults), engines, errors, stats };
|
|
370
761
|
}
|
|
371
762
|
|
|
372
763
|
// -------------------------------------------------------------------- cache
|
|
@@ -374,13 +765,20 @@ export async function multiSearch(
|
|
|
374
765
|
const CACHE_TTL = 10 * 60 * 1000;
|
|
375
766
|
const cache = createDiskBackedCache({ name: "search", maxEntries: 256, ttlMs: CACHE_TTL });
|
|
376
767
|
|
|
377
|
-
export
|
|
768
|
+
export interface CachedSearch {
|
|
769
|
+
results: SearchResult[];
|
|
770
|
+
engines: string[];
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
export function cacheGet(key: string): CachedSearch | null {
|
|
378
774
|
const hit = cache.get(key);
|
|
379
|
-
|
|
775
|
+
if (Array.isArray(hit)) return hit.length > 0 ? { results: hit as SearchResult[], engines: [] } : null; // legacy bare-array shape
|
|
776
|
+
const c = hit as CachedSearch | null;
|
|
777
|
+
return c && c.results.length > 0 ? c : null;
|
|
380
778
|
}
|
|
381
779
|
|
|
382
|
-
export function cacheSet(key: string, value:
|
|
383
|
-
if (value.length > 0) cache.set(key, value);
|
|
780
|
+
export function cacheSet(key: string, value: CachedSearch) {
|
|
781
|
+
if (value.results.length > 0) cache.set(key, value);
|
|
384
782
|
}
|
|
385
783
|
|
|
386
784
|
// ----------------------------------------------------- page text extraction
|