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/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
- const UA =
17
- "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";
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
- async function get(url: string, signal?: AbortSignal, post?: string): Promise<string> {
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
- "User-Agent": UA,
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
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
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
- out.push({ title: stripTags(m[2]) || url, url, snippet: "", engine: "brave" });
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 Set<string>();
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,120})\]\((https?:\/\/[^)]+)\)/g)) {
208
- const title = decodeEntities(m[1].replace(/[*_`]/g, "")).trim();
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 || seen.has(url)) continue;
319
+ if (!url) continue;
211
320
  if (/duckduckgo\.com(?!\/l\/)|\/y\.js|bing\.com|\.svg/.test(url)) continue;
212
- seen.add(url);
213
- // date trails the URL line on html variant ("… 2026-08-20T00:00:00.0000000")
214
- const tail = md.slice(m.index, m.index + 1200);
215
- const d = tail.match(/\b(20[12]\d-\d{2}-\d{2})(?:T[0-9:.]+)?/);
216
- const date = d ? d[1] : undefined;
217
- out.push({ title, url, snippet: "", engine: "ddg-jina", ...(date ? { date } : {}) });
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
- export async function ddgSearch(
223
- query: string,
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
- // 0. jina proxy (different IP pool works when our IP is rate-limited)
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 results = parseJinaDdg(await jinaGet(`https://html.duckduckgo.com/html/?${enc}`, signal));
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
- } catch {
238
- /* try next */
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
- // 1. html GET
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 = parseDdgHtml(await get(`https://html.duckduckgo.com/html/?${enc}`, signal));
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
- directFailed = true;
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
- // 2. lite GET
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 = parseDdgLite(await get(`https://lite.duckduckgo.com/lite/?${enc}`, signal));
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
- /* try next */
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
- // 3. html POST (often bypasses GET challenges)
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
- const results = parseDdgHtml(await get("https://html.duckduckgo.com/html/", signal, enc));
261
- if (results.length > 0) return results.slice(0, maxResults);
414
+ return await ddgDirect(query, maxResults, recency, signal, lang);
262
415
  } catch {
263
- /* fall to jina */
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
- const results = parseBrave(await get(`https://search.brave.com/search?${params}`, signal));
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)) date = new Date(ts).toISOString().slice(0, 10);
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
- /** Run several engines in parallel; RRF-fuse, dedupe by URL, consensus ranks first. */
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
- ): Promise<{ results: SearchResult[]; engines: string[]; errors: string[] }> {
335
- const attempts: Array<{ name: string; fn: () => Promise<SearchResult[]> }> = [
336
- { name: "ddg", fn: () => ddgSearch(query, 15, recency, signal) },
337
- { name: "brave", fn: () => braveSearch(query, 15, recency, signal) },
338
- { name: "bing", fn: () => bingRssSearch(query, 15, recency, signal) },
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
- if (r.status === "fulfilled" && r.value.length > 0) {
346
- namedBuckets.push({ name: attempts[i].name, rows: r.value });
347
- engines.push(attempts[i].name);
348
- } else if (r.status === "rejected") {
349
- errors.push(`${attempts[i].name}: ${(r.reason as Error)?.message ?? r.reason}`);
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
- // reciprocal rank fusion (RRF, k=60) — consensus hits across engines rank higher, dedupe by normalized URL
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 function cacheGet(key: string): SearchResult[] | null {
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
- return Array.isArray(hit) && hit.length > 0 ? (hit as SearchResult[]) : null;
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: SearchResult[]) {
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